From 8462619c75b80ff26559a6a4d9672575945ab2ba Mon Sep 17 00:00:00 2001 From: rootxrishabh Date: Wed, 10 May 2023 13:22:39 +0530 Subject: [PATCH 001/176] Tests added for remote sensing widget (#1739) * Tests added for remote sensing widget * Fixed flake8 * Flake8 fixed * Updated authors * AUTHORS updated * Fixed pytest errors * Issues resolved * Import order updated * Changes made * Update test_remotesensing.py * Data reorganized * Update test_remotesensing.py --- tests/_test_msui/test_remotesensing.py | 187 ++++++++++++++++++++++--- 1 file changed, 168 insertions(+), 19 deletions(-) diff --git a/tests/_test_msui/test_remotesensing.py b/tests/_test_msui/test_remotesensing.py index 67d8e508b..a44f3e0dd 100644 --- a/tests/_test_msui/test_remotesensing.py +++ b/tests/_test_msui/test_remotesensing.py @@ -24,8 +24,18 @@ See the License for the specific language governing permissions and limitations under the License. """ + + +import datetime +import sys + +from mock import Mock +from matplotlib.collections import LineCollection +from PyQt5 import QtWidgets +import pytest import skyfield_data from mslib.msui.remotesensing_dockwidget import RemoteSensingControlWidget +from mslib.msui import mpl_qtwidget as qt def test_skyfield_data_expiration(recwarn): @@ -33,25 +43,164 @@ def test_skyfield_data_expiration(recwarn): assert len(recwarn) == 0, [_x.message for _x in recwarn] -class TestAngles(object): +class Test_RemoteSensingControlWidget(object): """ - tests about angles + Tests about RemoteSensingControlWidget """ + def setup_method(self): + self.application = QtWidgets.QApplication(sys.argv) + self.view = Mock() + self.map = qt.TopViewPlotter() + self.map.init_map() + self.bmap = self.map.map + self.result_test_direction_coordinates = [([79.08, 79.06, 79.03, 79.01, 78.99, 78.97, 78.95, + 78.93, 78.9, 78.88, 78.86, 78.84, 78.82, + 78.73, 78.7, 78.68, 78.66, 78.64, 78.62, + 78.59, 78.57, 78.55, 78.79, 78.77, 78.75, + 78.53, 78.50, 78.48, 78.46, 78.44, 78.41, + 78.39, 78.37, 78.35, 78.32, 78.30, 78.28, + 78.25, 78.23, 78.21, 78.19, 78.16, 78.14, + 78.12, 78.09, 78.07, 78.05, 78.03, 78.00, + 77.98, 77.96, 77.93, 77.91, 77.89, 77.86, + 77.84, 77.82, 77.79, 77.77, 77.75, 77.72, + 77.70, 77.68, 77.65, 77.63, 77.60, 77.58, + 77.56, 77.53, 77.51, 77.49, 77.46, 77.44, + 77.41, 77.39, 77.37, 77.34, 77.32, 77.29, + 77.27, 77.24, 77.22, 77.20, 77.17, 77.15, + 77.12, 77.1], [21.15, 21.23, 21.32, 21.40, 21.49, 21.58, + 22.166, 22.75, 21.84, 22.92, 22.84, 23, + 23.36, 23.12, 23.58, 24.44, 24.52, 24.82, + 25.74, 25.28, 25.84, 25.57, 25.98, 26.06, + 26.15, 26.24, 26.32, 26.41, 26.49, 26.58, + 26.67, 26.75, 26.84, 26.93, 27.01, 27.1, + 27.18, 27.27, 27.36, 27.44, 27.53, 27.61, + 27.7, 27.79, 27.87, 27.96, 28.04, 28.13, + 28.22, 28.30, 28.39, 28.47, 28.56])] + + self.lon_lin = [79.08, 79.06, 79.04, 79.02, 78.99, 78.97, 78.95, 78.93, + 78.91, 78.89, 78.86, 78.84, 78.82, 78.80, 78.78, 78.75, + 78.75, 78.71, 78.69, 78.67, 78.64, 78.62, 78.60, 78.58, + 78.56, 78.53, 78.51, 78.49, 78.46, 78.44, 78.42, 78.40, + 78.37, 78.35, 78.33, 78.31, 78.28, 78.26, 78.24, 78.21, + 78.19, 78.17, 78.15, 78.12, 78.10, 78.08, 78.05, 78.05, + 78.03, 77.98, 77.96, 77.94, 77.91, 77.89, 77.87, 77.84, + 77.82, 77.80, 77.77, 77.75, 77.73, 77.70, 77.68, 77.66, + 77.61, 77.59, 77.58, 77.54, 77.51, 77.49, 77.47, 77.44, + 77.42, 77.39, 77.37, 77.34, 77.32, 77.30, 77.27, 77.25, + 77.20, 77.18, 77.16, 77.15, 77.13] + + self.lat_lin = [21.15, 21.23, 21.32, 21.4, 21.495, 21.58, 21.66, 21.75, + 21.84, 21.92, 22.01, 22.1, 22.186, 22.27, 22.35, 22.44, + 22.53, 22.61, 22.7, 22.79, 22.87, 22.96, 23.05, 23.13, + 23.22, 23.30, 23.39, 23.48, 23.56, 23.65, 23.74, 23.82, + 23.91, 23.99, 24.08, 24.17, 24.25, 24.34, 24.43, 24.51, + 24.60, 24.68, 24.77, 24.86, 24.94, 25.03, 25.12, 25.20, + 25.29, 25.37, 25.46, 25.55, 25.63, 25.72, 25.81, 25.89, + 25.98, 26.06, 26.15, 26.24, 26.32, 26.41, 26.49, 26.58, + 26.67, 26.75, 26.84, 26.93, 27.01, 27.10, 27.18, 27.27, + 27.36, 27.44, 27.53, 27.61, 27.70, 27.79, 27.87, 27.96, + 28.04, 28.13, 28.22, 28.30, 28.39, 28.47, 28.56] + + self.cut_height = 10.0 + self.result_test_tangent_point_coordinates = [(81.2, 21.62), (81.19, 21.65), (81.17, 21.79), + (81.11, 21.98), (81.12, 21.93), (81.1, 22.05), + (81.09, 22.08), (81.07, 22.17), (81.04, 22.3), + (80.98, 22.53), (81.01, 22.42), (80.98, 22.53), + (80.96, 22.63), (80.94, 22.73), (80.88, 22.96), + (80.95, 22.44), (80.75, 23.39), (80.86, 23.02), + (80.85, 23.11), (80.75, 23.46), (80.8, 23.28), + (80.78, 23.37), (80.75, 23.51), (80.74, 23.54), + (80.65, 23.89), (80.69, 23.71), (80.68, 23.8), + (80.58, 24.15), (80.63, 23.97), (80.61, 24.06), + (80.58, 24.2), (80.52, 24.42), (80.54, 24.37), + (80.53, 24.4), (80.51, 24.49), (80.42, 24.83), + (80.46, 24.66), (80.44, 24.75), (80.35, 25.09), + (80.39, 24.92), (80.37, 25.06), (80.36, 25.09), + (80.29, 25.36), (80.3, 25.31), (80.29, 25.35), + (80.22, 25.62), (80.29, 25.12), (80.25, 25.6), + (79.98, 26.3), (80.18, 25.77), (80.16, 25.86), + (80.07, 26.21), (80.11, 26.03), (80.1, 26.12), + (80.01, 26.47), (80.05, 26.29), (80.02, 26.43), + (79.96, 26.65), (79.98, 26.55), (79.96, 26.69), + (79.9, 26.91), (79.91, 26.86), (79.9, 26.89), + (79.69, 27.49), (79.83, 27.12), (79.85, 26.95), + (79.69, 27.6), (79.7, 27.58), (79.74, 27.41), + (79.71, 27.55), (79.65, 27.76), (79.68, 27.67), + (79.59, 28.01), (79.63, 27.84), (79.54, 28.18), + (79.58, 28.01), (79.56, 28.1), (79.48, 28.44), + (79.52, 28.27), (79.26, 28.95), (79.45, 28.44), + (79.43, 28.52), (79.45, 28.44), (79.41, 28.69)] + + self.wp_vertices = [(0, 0), (1, 4)] + self.wp_heights = [0, 1000] + self.coordinates = [[79.083, 21.15], [77.103, 28.566]] + self.heights = [0.0, 0.0] + self.times = [datetime.datetime(2023, 4, 15, 10, 9, 59, 174000), + datetime.datetime(2023, 4, 15, 11, 18, 27, 735581)] + self.solar_type = ('sun', 'total (horizon)') + self.remote_widget = RemoteSensingControlWidget(view=self.view) + + @pytest.mark.parametrize( + "lon0, lat0, h0, lon1, lat1, h1, obs_azi, expected", + [ + (0, 0, 0, 1, 0, 0, 0, (90.0, -1)), + (0, 0, 0, -1, 0, 0, 0, (270.0, -1)), + (0, 0, 0, 1, 0, 0, 90, (180.0, -1)), + (0, 0, 0, 0, 1, 0, 0, (0.0, -1)), + (0, 0, 0, 0, -1, 0, 0, (180.0, -1)), + ], + ) + def test_view_angles(self, lon0, lat0, h0, lon1, lat1, h1, obs_azi, expected): + compute_view_angles = self.remote_widget.compute_view_angles + angle = compute_view_angles(lon0, lat0, h0, lon1, lat1, h1, obs_azi, -1) + assert angle[0] == expected[0] + assert angle[1] == expected[1] + + @pytest.mark.parametrize("body, lat, lon, alt, expected_angle", [ + ("sun", 73.56, 78.01, 25.27, (106.71, -20.28)), + ("sun", 73.07, 77.78, 26.07, (106.35, -20.71)), + ("sun", 73.58, 77.56, 26.92, (105.96, -21.13)), + ("sun", 73.08, 77.33, 27.74, (105.57, -21.55)), + ("sun", 73.56, 78.01, 25.27, (106.71, -20.28)) + ]) + def test_body_angle(self, body, lat, lon, alt, expected_angle): + compute_body_angle = self.remote_widget.compute_body_angle + angle = compute_body_angle(body, lat, lon, alt) + assert angle[0] == pytest.approx(expected_angle[0], rel=1e-3) + assert angle[1] == pytest.approx(expected_angle[1], rel=1e-3) + + def test_direction_coordinates(self): + compute_direction_coordinates = self.remote_widget.direction_coordinates + coordinates = compute_direction_coordinates(self.result_test_direction_coordinates) + result = [[(round(x, 2), round(y, 2)) for x, y in inner_list] for inner_list in coordinates] + assert result == [[(78.1, 27.83), (79.18, 28.14)]] + + def test_compute_tangent_lines(self): + result = self.remote_widget.compute_tangent_lines(self.bmap, + self.wp_vertices, self.wp_heights) + assert isinstance(result, LineCollection) + assert len(result.get_segments()) == len(self.wp_heights) + + def test_compute_solar_lines(self): + result = self.remote_widget + result = result.compute_solar_lines(self.bmap, self.coordinates, self.heights, self.times, self.solar_type) + assert isinstance(result, LineCollection) + + def test_tangent_point_coordinates(self): + tangent_point_coordinates = self.remote_widget.tangent_point_coordinates + coordinates = tangent_point_coordinates(lon_lin=self.lon_lin, lat_lin=self.lat_lin, cut_height=self.cut_height) + result = [(round(x, 2), round(y, 2)) for x, y in coordinates] + assert result == self.result_test_tangent_point_coordinates - def test_view_angles(self): - compute_view_angles = RemoteSensingControlWidget.compute_view_angles - angle = compute_view_angles(0, 0, 0, 1, 0, 0, 0, -1) - assert angle[0] == 90.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, -1, 0, 0, 0, -1) - assert angle[0] == 270.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, 1, 0, 0, 90, -1) - assert angle[0] == 180.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, 0, 1, 0, 0, -1) - assert angle[0] == 0.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, 0, -1, 0, 0, -1) - assert angle[0] == 180.0 - assert angle[1] == -1 + @pytest.mark.parametrize("obs_azi, obs_ele, sol_azi, sol_ele, expected_rating", [ + (76.00, -1.0, 240.70, 58.33, 175.06), + (76.11, -1.0, 239.90, 60.03, 174.79), + (76.50, -1.0, 236.15, 66.92, 173.5), + ]) + def test_calc_view_rating(self, obs_azi, obs_ele, sol_azi, sol_ele, expected_rating): + height = 0.0 + difftype = "total (horizon)" + calc_view_rating = self.remote_widget.calc_view_rating + view_rating = calc_view_rating(obs_azi=obs_azi, obs_ele=obs_ele, sol_azi=sol_azi, + sol_ele=sol_ele, height=height, difftype=difftype) + assert round(view_rating, 2) == pytest.approx(expected_rating, rel=1e-3) From fc0fabd47cd79b29c027fe0f46fd9e7605d7056a Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Tue, 16 May 2023 09:09:20 +0200 Subject: [PATCH 002/176] Add an option to use pcolormesh instead of contourf for generic hsec plots Fix #1780 --- mslib/mswms/mpl_hsec_styles.py | 86 +++++++++++++++++++++++++--------- mslib/mswms/mpl_vsec_styles.py | 64 +++++++++++++++++-------- 2 files changed, 108 insertions(+), 42 deletions(-) diff --git a/mslib/mswms/mpl_hsec_styles.py b/mslib/mswms/mpl_hsec_styles.py index 33d475f3d..7e8c05bac 100644 --- a/mslib/mswms/mpl_hsec_styles.py +++ b/mslib/mswms/mpl_hsec_styles.py @@ -99,7 +99,10 @@ def _plot_style(self): cmin, cmax, clevs, cmap, norm, ticks = generics.get_style_parameters( self.dataname, self.style, cmin, cmax, show_data) - tc = bm.contourf(self.lonmesh, self.latmesh, show_data, levels=clevs, cmap=cmap, extend="both", norm=norm) + if self.use_pcolormesh: + tc = bm.pcolormesh(self.lonmesh, self.latmesh, show_data, cmap=cmap, norm=norm) + else: + tc = bm.contourf(self.lonmesh, self.latmesh, show_data, levels=clevs, cmap=cmap, extend="both", norm=norm) for cont_data, cont_levels, cont_colour, cont_label_colour, cont_style, cont_lw, pe in self.contours: cs_pv = ax.contour(self.lonmesh, self.latmesh, self.data[cont_data], cont_levels, @@ -124,47 +127,81 @@ def _plot_style(self): if not self.noframe: cbar = self.fig.colorbar(tc, fraction=0.05, pad=0.08, shrink=0.7, - label=cbar_label, format=cbar_format, ticks=ticks) + label=cbar_label, format=cbar_format, ticks=ticks, extend="both") cbar.set_ticks(clevs) cbar.set_ticklabels(clevs) else: axins1 = mpl_toolkits.axes_grid1.inset_locator.inset_axes( ax, width="3%", height="40%", loc=cbar_location) - self.fig.colorbar(tc, cax=axins1, orientation="vertical", format=cbar_format, ticks=ticks) + self.fig.colorbar(tc, cax=axins1, orientation="vertical", format=cbar_format, ticks=ticks, extend="both") axins1.yaxis.set_ticks_position(tick_pos) make_cbar_labels_readable(self.fig, axins1) def make_generic_class(name, standard_name, vert, add_data=None, add_contours=None, - fix_styles=None, add_styles=None, add_prepare=None): + fix_styles=None, add_styles=None, add_prepare=None, use_pcolormesh=False): """ - This function instantiates a plotting class and adds it to the global name space - of this module. + This function instantiates a plotting class and adds it to the global name + space of this module. Args: name (str): name of the class, under which it will be added to the module name space + standard_name (str): CF standard_name of the main plotting target. - This must be registered within the mslib.mswms.generics module. + This standard_name must be registered (by default or manually) + within the mslib.mswms.generics module. + vert (str): vertical level type, e.g. "pl" + add_data (list, optional): List of tuples adding data to be read in and - provide to the plotting class. E.g. [("pl", "ertel_potential_vorticity", "PVU")] - for ertel_potential_vorticity on pressure levels in PVU units. The vertical - level type must be the one specified by the vert variable or "sfc". - By default ertel_potential_vorticity in PVU is provide. - add_contours (list, optional): List of tuples specifying contour lines to be - plotted. E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] - cause PV to be plotted for 2, 4, 8, and 16 PVU with dashed green lines, - red labels, and line width 2. The last value defines wether a stroke effect - shall be applied. - fix_styles (list, optional): A list of plotting styles, which must be defined in the - mslib.mswms.generics.STYLES dictionary. Defaults to a list of standard styles - ("auto", "logauto", "default", "nonlinear") depending on which ranges and thresholds - are defined for the main variable in the generics module. - add_styles (list, optional): Similar to fix_styles, but *adds* the supplied styles to - the list of support styles instead of overwriting them. Defaults to None. - add_prepare (function, optional): a function to overwrite the _prepare_datafield method. + provide to the plotting class. + E.g. [("pl", "ertel_potential_vorticity", "PVU")] + for ertel_potential_vorticity on pressure levels in PVU units. + The vertical level type must be the one specified by the vert + variable or "sfc". + + By default ertel_potential_vorticity in PVU is selected. + + add_contours (list, optional): List of tuples specifying contour lines + to be plotted. + E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] + causes PV to be plotted at 2, 4, 8, and 16 PVU with dashed green + lines, red labels, and line width of 2. The last value defines + whether a stroke effect shall be applied. + + fix_styles (list, optional): A list of plotting styles, which must + be defined in the mslib.mswms.generics.STYLES dictionary. + Defaults to a list of standard styles + ("auto", "logauto", "default", "nonlinear") depending on which + ranges and thresholds are defined for the main variable in the + generics module. Further styles can be registered to that dict + if desired. + + add_styles (list, optional): Similar to fix_styles, but *adds* the + supplied styles to the list of support styles instead of + overwriting them. If both add_styles and fix_styles are supplied, + fix_styles takes precedence. Don't do this. + + Defaults to None. + + add_prepare (function, optional): a function to overwrite the + _prepare_datafield method. Use this to add derived quantities based + on those provided by the modes. For example 'horizontal_wind' could + be computed from U and V in here. + Defaults to None. + + use_pcolormesh (bool, optional): determines whether to use pcolormesh + uor plotting instead of the default "contourf" method. Use + pcolormesh for data that contains a lot of fill values or NaNs, + or to show the actual location of data. + + Defaults to False. + + Returns: + The generated class. (The class is also placed in this module under the + given name). """ if add_data is None: add_data = [(vert, "ertel_potential_vorticity", "PVU")] @@ -186,6 +223,7 @@ class fnord(HS_GenericStyle): required_datafields = [(vert, standard_name, units)] + add_data contours = add_contours + fnord.use_pcolormesh = use_pcolormesh fnord.__name__ = name fnord.styles = list(fnord.styles) if generics.get_thresholds(standard_name) is not None: @@ -203,6 +241,8 @@ class fnord(HS_GenericStyle): fnord._prepare_datafields = add_prepare globals()[name] = fnord + return fnord + # Generation of HS plotting layers for registered CF standard_names for vert in ["al", "ml", "pl", "tl"]: diff --git a/mslib/mswms/mpl_vsec_styles.py b/mslib/mswms/mpl_vsec_styles.py index 65a59ff85..fb58c0caf 100644 --- a/mslib/mswms/mpl_vsec_styles.py +++ b/mslib/mswms/mpl_vsec_styles.py @@ -115,33 +115,57 @@ def _plot_style(self): def make_generic_class(name, standard_name, vert, add_data=None, add_contours=None, fix_styles=None, add_styles=None, add_prepare=None): """ - This function instantiates a plotting class and adds it to the global name space - of this module. + This function instantiates a plotting class and adds it to the global name + space of this module. Args: - name (str): name of the class, under which it will be added to the module - name space + name (str): name of the class, under which it will be added to the + module name space + standard_name (str): CF standard_name of the main plotting target. This must be registered within the mslib.mswms.generics module. + vert (str): vertical level type, e.g. "pl" + add_data (list, optional): List of tuples adding data to be read in and - provide to the plotting class. E.g. [("pl", "ertel_potential_vorticity", "PVU")] - for ertel_potential_vorticity on pressure levels in PVU units. The vertical - level type must be the one specified by the vert variable or "sfc". + provide to the plotting class. + E.g. [("pl", "ertel_potential_vorticity", "PVU")] + for ertel_potential_vorticity on pressure levels in PVU units. + The vertical level type must be the one specified by the vert + variable or "sfc". + By default ertel_potential_vorticity in PVU is provide. - add_contours (list, optional): List of tuples specifying contour lines to be - plotted. E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] - cause PV to be plotted for 2, 4, 8, and 16 PVU with dashed green lines, - red labels, and line width 2. The last value defines wether a stroke effect - shall be applied. - fix_styles (list, optional): A list of plotting styles, which must be defined in the - mslib.mswms.generics.STYLES dictionary. Defaults to a list of standard styles - ("auto", "logauto", "default", "nonlinear") depending on which ranges and thresholds - are defined for the main variable in the generics module. - add_styles (list, optional): Similar to fix_styles, but *adds* the supplied styles to - the list of support styles instead of overwriting them. Defaults to None. - add_prepare (function, optional): a function to overwrite the _prepare_datafield method. + + add_contours (list, optional): List of tuples specifying contour + lines to be plotted. + E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] + cause PV to be plotted for 2, 4, 8, and 16 PVU with dashed green + lines, red labels, and line width of 2. The last value defines + wether a stroke effect shall be applied. + + fix_styles (list, optional): A list of plotting styles, which must be + defined in the mslib.mswms.generics.STYLES dictionary. Defaults + to a list of standard styles + ("auto", "logauto", "default", "nonlinear") depending on which + ranges and thresholds are defined for the main variable in the + generics module. + Defaults to None. + + add_styles (list, optional): Similar to fix_styles, but *adds* the + supplied styles to the list of support styles instead of + overwriting them. + + Defaults to None. + + add_prepare (function, optional): a function to overwrite the + _prepare_datafield method. + + Defaults to None. + + Returns: + The generated class. (The class is also placed in this module under the + given name). """ if add_data is None: add_data = [(vert, "ertel_potential_vorticity", "PVU")] @@ -181,6 +205,8 @@ class fnord(VS_GenericStyle): globals()[name] = fnord + return fnord + _ADD_DATA = { "al": [("al", "ertel_potential_vorticity", "PVU"), From 1abe9eec883c5767574145f6f08794a1ea72651e Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Tue, 16 May 2023 09:11:49 +0200 Subject: [PATCH 003/176] fix --- mslib/mswms/mpl_vsec_styles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/mswms/mpl_vsec_styles.py b/mslib/mswms/mpl_vsec_styles.py index fb58c0caf..1b20417b3 100644 --- a/mslib/mswms/mpl_vsec_styles.py +++ b/mslib/mswms/mpl_vsec_styles.py @@ -160,7 +160,7 @@ def make_generic_class(name, standard_name, vert, add_data=None, add_contours=No add_prepare (function, optional): a function to overwrite the _prepare_datafield method. - + Defaults to None. Returns: From 221ed172e99a1fae7da8765739e83667595511b4 Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Tue, 16 May 2023 17:15:21 +0200 Subject: [PATCH 004/176] fix typo --- mslib/mswms/mpl_hsec_styles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/mswms/mpl_hsec_styles.py b/mslib/mswms/mpl_hsec_styles.py index 7e8c05bac..2e43e9b4e 100644 --- a/mslib/mswms/mpl_hsec_styles.py +++ b/mslib/mswms/mpl_hsec_styles.py @@ -193,7 +193,7 @@ def make_generic_class(name, standard_name, vert, add_data=None, add_contours=No Defaults to None. use_pcolormesh (bool, optional): determines whether to use pcolormesh - uor plotting instead of the default "contourf" method. Use + or plotting instead of the default "contourf" method. Use pcolormesh for data that contains a lot of fill values or NaNs, or to show the actual location of data. From 050e7c75dfe02b478b594313be6980c26a626628 Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Tue, 16 May 2023 13:37:02 +0200 Subject: [PATCH 005/176] Revamp of mscolab operation menu and addition of "archive" operation Fix #1753 --- mslib/mscolab/server.py | 23 ++- mslib/msui/mscolab.py | 237 ++++++++++++++++++------- mslib/msui/msui.py | 15 +- mslib/msui/qt5/ui_mainwindow.py | 100 ++++++----- mslib/msui/qt5/ui_operation_archive.py | 47 +++++ mslib/msui/ui/ui_mainwindow.ui | 121 +++++++------ mslib/msui/ui/ui_operation_archive.ui | 65 +++++++ mslib/utils/config.py | 2 +- mslib/utils/qt.py | 1 + tests/_test_msui/test_mscolab.py | 20 ++- 10 files changed, 438 insertions(+), 193 deletions(-) create mode 100644 mslib/msui/qt5/ui_operation_archive.py create mode 100644 mslib/msui/ui/ui_operation_archive.ui diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index fab054bfe..2462a1af6 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -59,6 +59,8 @@ migrate = Migrate(APP, db, render_as_batch=True) auth = HTTPBasicAuth() +ARCHIVE_THRESHOLD = 30 + try: from mscolab_auth import mscolab_auth except ImportError as ex: @@ -489,13 +491,17 @@ def get_operation_details(): @verify_user def set_last_used(): op_id = request.form.get('op_id', None) + days_ago = int(request.form.get('days', 0)) operation = Operation.query.filter_by(id=int(op_id)).first() - operation.last_used = datetime.datetime.utcnow() + operation.last_used = datetime.datetime.utcnow() - datetime.timedelta(days=days_ago) temp_operation_active = operation.active - operation.active = True + if days_ago > ARCHIVE_THRESHOLD: + operation.active = False + else: + operation.active = True db.session.commit() # Reload Operation List - if not temp_operation_active: + if temp_operation_active != operation.active: token = request.args.get('token', request.form.get('token', False)) json_config = {"token": token} sockio.sm.update_operation_list(json_config) @@ -507,11 +513,12 @@ def set_last_used(): def update_last_used(): operations = Operation.query.filter().all() for operation in operations: - a = (datetime.datetime.utcnow() - operation.last_used).days - if a > 30: - operation.active = False - else: - operation.active = True + if operation.last_used is not None: + days_ago = (datetime.datetime.utcnow() - operation.last_used).days + if days_ago > ARCHIVE_THRESHOLD: + operation.active = False + else: + operation.active = True db.session.commit() return jsonify({"success": True}), 200 diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 35f4f28cd..7a4e6e32c 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -61,10 +61,61 @@ from mslib.utils.qt import ui_mscolab_merge_waypoints_dialog as merge_wp_ui from mslib.utils.qt import ui_mscolab_connect_dialog as ui_conn from mslib.utils.qt import ui_mscolab_profile_dialog as ui_profile +from mslib.utils.qt import ui_operation_archive as ui_opar from mslib.msui import constants from mslib.utils.config import config_loader, load_settings_qsettings, save_settings_qsettings, modify_config_file +class MSColab_OperationArchiveBrowser(QtWidgets.QDialog, ui_opar.Ui_OperationArchiveBrowser): + def __init__(self, parent=None, mscolab=None): + super().__init__(parent) + self.setupUi(self) + self.parent = parent + self.mscolab = mscolab + self.pbClose.clicked.connect(self.hide) + self.pbUnarchiveOperation.setEnabled(False) + self.pbUnarchiveOperation.clicked.connect(self.unarchive_operation) + self.listArchivedOperations.itemClicked.connect(self.select_archived_operation) + self.setModal(True) + + def select_archived_operation(self, item): + logging.debug('select_inactive_operation') + if item.access_level == "creator": + self.archived_op_id = item.op_id + self.pbUnarchiveOperation.setEnabled(True) + else: + self.archived_op_id = None + self.pbUnarchiveOperation.setEnabled(False) + + def unarchive_operation(self): + logging.debug('unarchive_operation') + if verify_user_token(self.mscolab.mscolab_server_url, self.mscolab.token): + # set last used date for operation + data = { + "token": self.mscolab.token, + "op_id": self.archived_op_id, + } + url = url_join(self.mscolab.mscolab_server_url, 'set_last_used') + try: + res = requests.post(url, data=data, timeout=(2, 10)) + except requests.exceptions.RequestException as e: + logging.debug(e) + show_popup(self.parent, "Error", "Some error occurred! Could not unarchive operation.") + else: + if res.text != "False": + res = res.json() + if res["success"]: + self.mscolab.reload_operations() + else: + show_popup(self.parent, "Error", "Some error occurred! Could not activate operation") + else: + show_popup(self.parent, "Error", "Session expired, new login required") + self.mscolab.logout() + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.mscolab.logout() + + class MSColab_ConnectDialog(QtWidgets.QDialog, ui_conn.Ui_MSColabConnectDialog): """MSColab connect window class. Provides user interface elements to connect/disconnect, login, add new user to an MSColab Server. Also implements HTTP Server Authentication prompt. @@ -75,7 +126,7 @@ def __init__(self, parent=None, mscolab=None): Arguments: parent -- Qt widget that is parent to this widget. """ - super(MSColab_ConnectDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.parent = parent self.mscolab = mscolab @@ -428,7 +479,7 @@ class MSUIMscolab(QtCore.QObject): """ name = "Mscolab" - signal_activate_operation = QtCore.Signal(int, name="signal_activate_operation") + signal_unarchive_operation = QtCore.Signal(int, name="signal_unarchive_operation") signal_operation_added = QtCore.Signal(int, str, name="signal_operation_added") signal_operation_removed = QtCore.Signal(int, name="signal_operation_removed") signal_login_mscolab = QtCore.Signal(str, str, name="signal_login_mscolab") @@ -441,16 +492,20 @@ def __init__(self, parent=None, data_dir=None): super(MSUIMscolab, self).__init__(parent) self.ui = parent + self.operation_archive_browser = MSColab_OperationArchiveBrowser(self.ui, self) + self.operation_archive_browser.hide() + self.ui.listInactiveOperationsMSC = self.operation_archive_browser.listArchivedOperations + # connect mscolab help action from help menu self.ui.actionMSColabHelp.triggered.connect(self.open_help_dialog) + self.ui.pbOpenOperationArchive.clicked.connect(self.open_operation_archive) # hide mscolab related widgets self.ui.usernameLabel.hide() self.ui.userOptionsTb.hide() self.ui.actionAddOperation.setEnabled(False) - self.ui.actionUnarchiveOperation.setEnabled(False) - self.hide_operation_options() self.ui.activeOperationDesc.setHidden(True) + self.hide_operation_options() # reset operation description label for flight tracks and open views self.ui.listFlightTracks.itemDoubleClicked.connect(self.listFlighttrack_itemDoubleClicked) @@ -464,13 +519,15 @@ def __init__(self, parent=None, data_dir=None): self.ui.actionManageUsers.triggered.connect(self.operation_options_handler) self.ui.actionDeleteOperation.triggered.connect(self.operation_options_handler) self.ui.actionLeaveOperation.triggered.connect(self.operation_options_handler) - self.ui.actionUpdateOperationDesc.triggered.connect(self.update_description_handler) + self.ui.actionChangeCategory.triggered.connect(self.change_category_handler) + self.ui.actionChangeDescription.triggered.connect(self.change_description_handler) self.ui.actionRenameOperation.triggered.connect(self.rename_operation_handler) - self.ui.actionUnarchiveOperation.triggered.connect(self.activate_operation) - self.ui.actionDescription.triggered.connect( - lambda: QtWidgets.QMessageBox.information(None, - "Operation Description", - f"{self.active_operation_desc}")) + self.ui.actionArchiveOperation.triggered.connect(self.archive_operation) + self.ui.actionViewDescription.triggered.connect( + lambda: QtWidgets.QMessageBox.information( + None, "Operation Description", + f"Category: {self.active_operation_category}\n" + f"{self.active_operation_description}")) self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) # connect slot for handling operation options combobox @@ -493,12 +550,12 @@ def __init__(self, parent=None, data_dir=None): self.new_op_id = None # int to store active pid self.active_op_id = None - # int to store selected inactive op_id - self.inactive_op_id = None # storing access_level to save network call self.access_level = None # storing operation_name to save network call self.active_operation_name = None + # storing operation category to save network call + self.active_operation_category = None # Storing operation list to pass to admin window self.operations = None # store active_flight_path here as object @@ -506,7 +563,7 @@ def __init__(self, parent=None, data_dir=None): # Store active operation's file path self.local_ftml_file = None # Store active_operation_description - self.active_operation_desc = None + self.active_operation_description = None # connection object to interact with sockets self.conn = None # operation window @@ -537,6 +594,9 @@ def __init__(self, parent=None, data_dir=None): self.data_dir = data_dir self.create_dir() + def open_operation_archive(self): + self.operation_archive_browser.show() + def create_dir(self): # ToDo this needs to be done earlier if '://' in self.data_dir: @@ -574,6 +634,7 @@ def open_connect_window(self): self.connect_window.exec_() def after_login(self, emailid, url, r): + logging.debug("after login %s %s", emailid, url) # emailid by direct call self.email = emailid self.connect_window.close() @@ -626,16 +687,12 @@ def after_login(self, emailid, url, r): # Show category list self.show_categories_to_ui() - # show operation_description self.ui.activeOperationDesc.setHidden(False) - # disable update operation description button - self.ui.actionUpdateOperationDesc.setEnabled(False) - # disable delete operation button + self.ui.actionChangeCategory.setEnabled(False) + self.ui.actionChangeDescription.setEnabled(False) self.ui.actionDeleteOperation.setEnabled(False) - # disable category change selector self.ui.filterCategoryCb.setEnabled(True) - # disable activate operation button - self.ui.actionUnarchiveOperation.setEnabled(False) + self.ui.actionViewDescription.setEnabled(False) self.signal_login_mscolab.emit(self.mscolab_server_url, self.token) @@ -1084,27 +1141,57 @@ def handle_leave_operation(self): self.logout() def set_operation_desc_label(self, op_desc): - self.active_operation_desc = op_desc - desc_count = len(str(self.active_operation_desc)) + self.active_operation_description = op_desc + desc_count = len(str(self.active_operation_description)) if desc_count < 95: self.ui.activeOperationDesc.setText( - self.ui.tr(f"{self.active_operation_name}: {self.active_operation_desc}")) + self.ui.tr(f"{self.active_operation_name}: {self.active_operation_description}")) else: self.ui.activeOperationDesc.setText( "Description is too long to show here, for long descriptions go " "to operations menu.") - def update_description_handler(self): + def change_category_handler(self): + # only after login + if verify_user_token(self.mscolab_server_url, self.token): + entered_operation_category, ok = QtWidgets.QInputDialog.getText( + self.ui, + self.ui.tr(f"{self.active_operation_name} - Change Category"), + self.ui.tr( + "You're about to change the operation category\n" + "Enter new operation category: " + ), + text=self.active_operation_category + ) + if ok: + data = { + "token": self.token, + "op_id": self.active_op_id, + "attribute": 'category', + "value": entered_operation_category + } + url = url_join(self.mscolab_server_url, 'update_operation') + r = requests.post(url, data=data, timeout=(2, 10)) + if r.text == "True": + self.active_operation_category = entered_operation_category + self.reload_operation_list() + self.error_dialog = QtWidgets.QErrorMessage() + self.error_dialog.showMessage("Description is updated successfully.") + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() + + def change_description_handler(self): # only after login if verify_user_token(self.mscolab_server_url, self.token): entered_operation_desc, ok = QtWidgets.QInputDialog.getText( self.ui, - self.ui.tr(f"{self.active_operation_name} - Update Description"), + self.ui.tr(f"{self.active_operation_name} - Change Description"), self.ui.tr( - "You're about to update the operation description" - "\nEnter new operation description: " + "You're about to change the operation description\n" + "Enter new operation description: " ), - text=self.active_operation_desc + text=self.active_operation_description ) if ok: data = { @@ -1151,7 +1238,7 @@ def rename_operation_handler(self): self.active_operation_name = entered_operation_name # Update active operation description - self.set_operation_desc_label(self.active_operation_desc) + self.set_operation_desc_label(self.active_operation_description) self.reload_operation_list() self.reload_windows_slot() # Update other user's operation list @@ -1341,7 +1428,7 @@ def render_new_permission(self, op_id, u_id): widgetItem.catgegory = operation["category"] widgetItem.operation_path = operation["path"] widgetItem.access_level = operation["access_level"] - widgetItem.active_operation_desc = operation["description"] + widgetItem.active_operation_description = operation["description"] self.ui.listOperationsMSC.addItem(widgetItem) self.signal_render_new_permission.emit(operation["op_id"], operation["path"]) if self.chat_window is not None: @@ -1478,13 +1565,13 @@ def add_operations_to_ui(self): logging.debug("adding operations to ui") operations = sorted(self.operations, key=lambda k: k["path"].lower()) self.ui.listOperationsMSC.clear() - self.ui.listInactiveOperationsMSC.clear() + self.operation_archive_browser.listArchivedOperations.clear() new_operation = None active_operation = None for operation in operations: operation_desc = f'{operation["path"]} - {operation["access_level"]}' widgetItem = QtWidgets.QListWidgetItem(operation_desc) - widgetItem.active_operation_desc = operation["description"] + widgetItem.active_operation_description = operation["description"] widgetItem.op_id = operation["op_id"] widgetItem.access_level = operation["access_level"] widgetItem.operation_path = operation["path"] @@ -1502,7 +1589,7 @@ def add_operations_to_ui(self): if widgetItem.op_id == self.new_op_id: new_operation = widgetItem else: - self.ui.listInactiveOperationsMSC.addItem(widgetItem) + self.operation_archive_browser.listArchivedOperations.addItem(widgetItem) if new_operation is not None: logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) self.ui.listOperationsMSC.itemActivated.emit(new_operation) @@ -1510,7 +1597,6 @@ def add_operations_to_ui(self): logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) self.ui.listOperationsMSC.itemActivated.emit(active_operation) self.ui.listOperationsMSC.itemActivated.connect(self.set_active_op_id) - self.ui.listInactiveOperationsMSC.itemClicked.connect(self.select_inactive_operation) self.new_op_id = None else: show_popup(self.ui, "Error", "Session expired, new login required") @@ -1519,34 +1605,36 @@ def add_operations_to_ui(self): show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() - def select_inactive_operation(self, item): - logging.debug('select_inactive_operation') - self.inactive_op_id = item.op_id - self.show_operation_options_in_inactivated_state(item.access_level) - def show_operation_options_in_inactivated_state(self, access_level): - self.ui.actionUnarchiveOperation.setEnabled(False) - if access_level == "creator": - self.ui.actionUnarchiveOperation.setEnabled(True) + pass - def activate_operation(self): - logging.debug('activate_operation') + def archive_operation(self): + logging.debug("handle_archive_operation") if verify_user_token(self.mscolab_server_url, self.token): - # set last used date for operation - data = { - "token": self.token, - "op_id": self.inactive_op_id, - } - res = requests.post(f'{self.mscolab_server_url}/set_last_used', data=data, timeout=(2, 10)) - if res.text != "False": - res = res.json() - if res["success"]: - self.reload_operations() + ret = QtWidgets.QMessageBox.warning( + self.ui, self.tr("Mission Support System"), + self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + if ret == QtWidgets.QMessageBox.Yes: + data = { + "token": self.token, + "op_id": self.active_op_id, + "days": 31, + } + url = url_join(self.mscolab_server_url, 'set_last_used') + try: + res = requests.post(url, data=data, timeout=(2, 10)) + except requests.exceptions.RequestException as e: + logging.debug(e) + show_popup(self.ui, "Error", "Some error occurred! Could not archive operation.") else: - show_popup(self.ui, "Error", "Some error occurred! Could not activate operation") - else: - show_popup(self.ui, "Error", "Session expired, new login required") - self.logout() + res.raise_for_status() + self.reload_operations() + self.signal_operation_removed.emit(self.active_op_id) + logging.debug("activate local") + self.ui.listFlightTracks.setCurrentRow(0) + self.ui.activate_selected_flight_track() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1571,7 +1659,7 @@ def set_active_op_id(self, item): self.ui.workLocallyCheckbox.blockSignals(False) # Disable Activate Operation Button - self.ui.actionUnarchiveOperation.setEnabled(False) + # self.ui.actionUnarchiveOperation.setEnabled(False) # set last used date for operation data = { @@ -1584,10 +1672,11 @@ def set_active_op_id(self, item): self.active_op_id = item.op_id self.access_level = item.access_level self.active_operation_name = item.operation_path - self.active_operation_desc = item.active_operation_desc + self.active_operation_description = item.active_operation_description + self.active_operation_category = item.operation_category self.waypoints_model = None - self.signal_activate_operation.emit(self.active_op_id) + self.signal_unarchive_operation.emit(self.active_op_id) self.inactive_op_id = None font = QtGui.QFont() @@ -1596,7 +1685,7 @@ def set_active_op_id(self, item): font.setBold(False) # Set active operation description - self.set_operation_desc_label(self.active_operation_desc) + self.set_operation_desc_label(self.active_operation_description) # set active flightpath here self.load_wps_from_server() # display working status @@ -1654,11 +1743,14 @@ def show_operation_options(self): self.ui.actionChat.setEnabled(False) self.ui.actionVersionHistory.setEnabled(False) self.ui.actionManageUsers.setEnabled(False) - self.ui.menuProperties.setEnabled(True) self.ui.actionRenameOperation.setEnabled(False) self.ui.actionLeaveOperation.setEnabled(True) self.ui.actionDeleteOperation.setEnabled(False) - self.ui.actionUpdateOperationDesc.setEnabled(False) + self.ui.actionChangeCategory.setEnabled(False) + self.ui.actionChangeDescription.setEnabled(False) + self.ui.actionArchiveOperation.setEnabled(False) + self.ui.actionViewDescription.setEnabled(True) + self.ui.menuProperties.setEnabled(True) if self.access_level == "viewer": self.ui.menuImportFlightTrack.setEnabled(False) @@ -1681,7 +1773,8 @@ def show_operation_options(self): if self.access_level in ["creator", "admin"]: self.ui.actionManageUsers.setEnabled(True) - self.ui.actionUpdateOperationDesc.setEnabled(True) + self.ui.actionChangeCategory.setEnabled(True) + self.ui.actionChangeDescription.setEnabled(True) self.ui.filterCategoryCb.setEnabled(True) else: if self.admin_window is not None: @@ -1691,6 +1784,7 @@ def show_operation_options(self): self.ui.actionDeleteOperation.setEnabled(True) self.ui.actionRenameOperation.setEnabled(True) self.ui.actionLeaveOperation.setEnabled(False) + self.ui.actionArchiveOperation.setEnabled(True) self.ui.menuImportFlightTrack.setEnabled(True) @@ -1698,8 +1792,15 @@ def hide_operation_options(self): self.ui.actionChat.setEnabled(False) self.ui.actionVersionHistory.setEnabled(False) self.ui.actionManageUsers.setEnabled(False) - self.ui.menuProperties.setEnabled(False) + self.ui.actionViewDescription.setEnabled(False) + self.ui.actionLeaveOperation.setEnabled(False) + self.ui.actionRenameOperation.setEnabled(False) + self.ui.actionArchiveOperation.setEnabled(False) + self.ui.actionChangeCategory.setEnabled(False) + self.ui.actionChangeDescription.setEnabled(False) + self.ui.actionDeleteOperation.setEnabled(False) self.ui.workLocallyCheckbox.setEnabled(False) + self.ui.menuProperties.setEnabled(False) self.ui.serverOptionsCb.hide() # change working status label self.ui.workingStatusLabel.setText(self.ui.tr("\n\nNo Operation Selected")) @@ -1859,7 +1960,7 @@ def logout(self): # clear operation listing self.ui.listOperationsMSC.clear() # clear inactive operation listing - self.ui.listInactiveOperationsMSC.clear() + self.operation_archive_browser.listArchivedOperations.clear() # clear mscolab url self.mscolab_server_url = None # clear operations list here @@ -1904,6 +2005,8 @@ def logout(self): self.ui.filterCategoryCb.setEnabled(False) self.signal_logout_mscolab.emit() + self.operation_archive_browser.hide() + # Don't try to activate local flighttrack while testing if "pytest" not in sys.modules: # activate first local flighttrack after logging out diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index adb93614f..8ebbfc81e 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -459,22 +459,13 @@ def __init__(self, mscolab_data_dir=None, *args): # deactivate vice versa selection of Operation, inactive operation or Flight Track - def deselecter(list_a, list_b, disable): - list_a.setCurrentItem(None) - list_b.setCurrentItem(None) - if disable: - self.mscolab.ui.actionUnarchiveOperation.setEnabled(False) - self.listFlightTracks.itemClicked.connect( - lambda: deselecter(self.listOperationsMSC, self.listInactiveOperationsMSC, True)) + lambda: self.listOperationsMSC.setCurrentItem(None)) self.listOperationsMSC.itemClicked.connect( - lambda: deselecter(self.listFlightTracks, self.listInactiveOperationsMSC, True)) - self.listInactiveOperationsMSC.itemClicked.connect( - lambda: deselecter(self.listFlightTracks, self.listOperationsMSC, False)) - + lambda: self.listFlightTracks.setCurrentItem(None)) # disable category until connected/login into mscolab self.filterCategoryCb.setEnabled(False) - self.mscolab.signal_activate_operation.connect(self.activate_operation_slot) + self.mscolab.signal_unarchive_operation.connect(self.activate_operation_slot) self.mscolab.signal_operation_added.connect(self.add_operation_slot) self.mscolab.signal_operation_removed.connect(self.remove_operation_slot) self.mscolab.signal_login_mscolab.connect(lambda d, t: self.signal_login_mscolab.emit(d, t)) diff --git a/mslib/msui/qt5/ui_mainwindow.py b/mslib/msui/qt5/ui_mainwindow.py index 9ecc88b89..890ae2112 100644 --- a/mslib/msui/qt5/ui_mainwindow.py +++ b/mslib/msui/qt5/ui_mainwindow.py @@ -111,43 +111,45 @@ def setupUi(self, MSUIMainWindow): self.gridLayout_3 = QtWidgets.QGridLayout(self.openOperationsGb) self.gridLayout_3.setContentsMargins(8, 8, 8, 8) self.gridLayout_3.setObjectName("gridLayout_3") + self.workingStatusLabel = QtWidgets.QLabel(self.openOperationsGb) + self.workingStatusLabel.setWordWrap(True) + self.workingStatusLabel.setObjectName("workingStatusLabel") + self.gridLayout_3.addWidget(self.workingStatusLabel, 6, 0, 1, 2) + self.activeOperationDesc = QtWidgets.QLabel(self.openOperationsGb) + self.activeOperationDesc.setObjectName("activeOperationDesc") + self.gridLayout_3.addWidget(self.activeOperationDesc, 1, 0, 1, 2) + self.activeOperationsLabel = QtWidgets.QLabel(self.openOperationsGb) + self.activeOperationsLabel.setObjectName("activeOperationsLabel") + self.gridLayout_3.addWidget(self.activeOperationsLabel, 2, 0, 1, 1) + self.workLocallyCheckbox = QtWidgets.QCheckBox(self.openOperationsGb) + self.workLocallyCheckbox.setObjectName("workLocallyCheckbox") + self.gridLayout_3.addWidget(self.workLocallyCheckbox, 10, 0, 1, 1) self.filterCategoryCb = QtWidgets.QComboBox(self.openOperationsGb) self.filterCategoryCb.setAutoFillBackground(False) self.filterCategoryCb.setEditable(False) self.filterCategoryCb.setObjectName("filterCategoryCb") self.filterCategoryCb.addItem("") - self.gridLayout_3.addWidget(self.filterCategoryCb, 10, 1, 1, 1) - self.categoryLabel = QtWidgets.QLabel(self.openOperationsGb) - self.categoryLabel.setObjectName("categoryLabel") - self.gridLayout_3.addWidget(self.categoryLabel, 10, 0, 1, 1) - self.inactiveOperationsLabel = QtWidgets.QLabel(self.openOperationsGb) - self.inactiveOperationsLabel.setObjectName("inactiveOperationsLabel") - self.gridLayout_3.addWidget(self.inactiveOperationsLabel, 5, 0, 1, 1) - self.listInactiveOperationsMSC = QtWidgets.QListWidget(self.openOperationsGb) - self.listInactiveOperationsMSC.setObjectName("listInactiveOperationsMSC") - self.gridLayout_3.addWidget(self.listInactiveOperationsMSC, 6, 0, 1, 2) - self.workingStatusLabel = QtWidgets.QLabel(self.openOperationsGb) - self.workingStatusLabel.setWordWrap(True) - self.workingStatusLabel.setObjectName("workingStatusLabel") - self.gridLayout_3.addWidget(self.workingStatusLabel, 7, 0, 1, 2) + self.gridLayout_3.addWidget(self.filterCategoryCb, 9, 1, 1, 1) self.serverOptionsCb = QtWidgets.QComboBox(self.openOperationsGb) self.serverOptionsCb.setObjectName("serverOptionsCb") self.serverOptionsCb.addItem("") self.serverOptionsCb.addItem("") self.serverOptionsCb.addItem("") - self.gridLayout_3.addWidget(self.serverOptionsCb, 11, 1, 1, 1) - self.workLocallyCheckbox = QtWidgets.QCheckBox(self.openOperationsGb) - self.workLocallyCheckbox.setObjectName("workLocallyCheckbox") - self.gridLayout_3.addWidget(self.workLocallyCheckbox, 11, 0, 1, 1) + self.gridLayout_3.addWidget(self.serverOptionsCb, 10, 1, 1, 1) + self.categoryLabel = QtWidgets.QLabel(self.openOperationsGb) + self.categoryLabel.setObjectName("categoryLabel") + self.gridLayout_3.addWidget(self.categoryLabel, 9, 0, 1, 1) self.listOperationsMSC = QtWidgets.QListWidget(self.openOperationsGb) self.listOperationsMSC.setObjectName("listOperationsMSC") self.gridLayout_3.addWidget(self.listOperationsMSC, 4, 0, 1, 2) - self.activeOperationDesc = QtWidgets.QLabel(self.openOperationsGb) - self.activeOperationDesc.setObjectName("activeOperationDesc") - self.gridLayout_3.addWidget(self.activeOperationDesc, 1, 0, 1, 2) - self.activeOperationsLabel = QtWidgets.QLabel(self.openOperationsGb) - self.activeOperationsLabel.setObjectName("activeOperationsLabel") - self.gridLayout_3.addWidget(self.activeOperationsLabel, 2, 0, 1, 1) + self.pbOpenOperationArchive = QtWidgets.QPushButton(self.openOperationsGb) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.pbOpenOperationArchive.sizePolicy().hasHeightForWidth()) + self.pbOpenOperationArchive.setSizePolicy(sizePolicy) + self.pbOpenOperationArchive.setObjectName("pbOpenOperationArchive") + self.gridLayout_3.addWidget(self.pbOpenOperationArchive, 11, 0, 1, 2) self.horizontalLayout.addWidget(self.openOperationsGb) self.verticalLayout_2.addLayout(self.horizontalLayout) self.gridLayout.addLayout(self.verticalLayout_2, 0, 0, 1, 2) @@ -221,16 +223,18 @@ def setupUi(self, MSUIMainWindow): self.actionAddOperation.setObjectName("actionAddOperation") self.actionSearch = QtWidgets.QAction(MSUIMainWindow) self.actionSearch.setObjectName("actionSearch") - self.actionDescription = QtWidgets.QAction(MSUIMainWindow) - self.actionDescription.setObjectName("actionDescription") - self.actionUpdateOperationDesc = QtWidgets.QAction(MSUIMainWindow) - self.actionUpdateOperationDesc.setObjectName("actionUpdateOperationDesc") + self.actionViewDescription = QtWidgets.QAction(MSUIMainWindow) + self.actionViewDescription.setObjectName("actionViewDescription") + self.actionChangeDescription = QtWidgets.QAction(MSUIMainWindow) + self.actionChangeDescription.setObjectName("actionChangeDescription") self.actionRenameOperation = QtWidgets.QAction(MSUIMainWindow) self.actionRenameOperation.setObjectName("actionRenameOperation") self.actionLeaveOperation = QtWidgets.QAction(MSUIMainWindow) self.actionLeaveOperation.setObjectName("actionLeaveOperation") - self.actionUnarchiveOperation = QtWidgets.QAction(MSUIMainWindow) - self.actionUnarchiveOperation.setObjectName("actionUnarchiveOperation") + self.actionArchiveOperation = QtWidgets.QAction(MSUIMainWindow) + self.actionArchiveOperation.setObjectName("actionArchiveOperation") + self.actionChangeCategory = QtWidgets.QAction(MSUIMainWindow) + self.actionChangeCategory.setObjectName("actionChangeCategory") self.menuNew.addAction(self.actionNewFlightTrack) self.menuNew.addAction(self.actionAddOperation) self.menuFile.addAction(self.menuNew.menuAction()) @@ -256,16 +260,17 @@ def setupUi(self, MSUIMainWindow): self.menuViews.addAction(self.actionSideView) self.menuViews.addAction(self.actionTableView) self.menuViews.addAction(self.actionLinearView) - self.menuProperties.addAction(self.actionDeleteOperation) - self.menuProperties.addAction(self.actionDescription) - self.menuProperties.addAction(self.actionUpdateOperationDesc) + self.menuProperties.addAction(self.actionChangeCategory) + self.menuProperties.addAction(self.actionChangeDescription) + self.menuProperties.addAction(self.actionManageUsers) self.menuProperties.addAction(self.actionRenameOperation) - self.menuProperties.addAction(self.actionLeaveOperation) + self.menuProperties.addAction(self.actionDeleteOperation) + self.menuProperties.addAction(self.actionArchiveOperation) self.menuOperation.addAction(self.actionChat) self.menuOperation.addAction(self.actionVersionHistory) - self.menuOperation.addAction(self.actionManageUsers) - self.menuOperation.addAction(self.actionUnarchiveOperation) + self.menuOperation.addAction(self.actionViewDescription) self.menuOperation.addSeparator() + self.menuOperation.addAction(self.actionLeaveOperation) self.menuOperation.addAction(self.menuProperties.menuAction()) self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuViews.menuAction()) @@ -298,22 +303,22 @@ def retranslateUi(self, MSUIMainWindow): "Save a flight track to name it.")) self.openViewsLabel.setText(_translate("MSUIMainWindow", "Open Views:")) self.listViews.setToolTip(_translate("MSUIMainWindow", "Double-click a view to bring it to the front.")) + self.workingStatusLabel.setText(_translate("MSUIMainWindow", "No operations selected")) + self.activeOperationDesc.setText(_translate("MSUIMainWindow", "Select Operation to View Description")) + self.activeOperationsLabel.setText(_translate("MSUIMainWindow", "Operations")) + self.workLocallyCheckbox.setToolTip(_translate("MSUIMainWindow", "Check to work asynchronously from the server")) + self.workLocallyCheckbox.setText(_translate("MSUIMainWindow", "Work Asynchronously")) self.filterCategoryCb.setWhatsThis(_translate("MSUIMainWindow", "filter by operation category")) self.filterCategoryCb.setCurrentText(_translate("MSUIMainWindow", "ANY")) self.filterCategoryCb.setItemText(0, _translate("MSUIMainWindow", "ANY")) - self.categoryLabel.setText(_translate("MSUIMainWindow", "Category:")) - self.inactiveOperationsLabel.setText(_translate("MSUIMainWindow", "Archived Operations")) - self.workingStatusLabel.setText(_translate("MSUIMainWindow", "No operations selected")) self.serverOptionsCb.setToolTip(_translate("MSUIMainWindow", "Fetch/Save Server options")) self.serverOptionsCb.setItemText(0, _translate("MSUIMainWindow", "Server Options")) self.serverOptionsCb.setItemText(1, _translate("MSUIMainWindow", "Fetch From Server")) self.serverOptionsCb.setItemText(2, _translate("MSUIMainWindow", "Save To Server")) - self.workLocallyCheckbox.setToolTip(_translate("MSUIMainWindow", "Check to work asynchronously from the server")) - self.workLocallyCheckbox.setText(_translate("MSUIMainWindow", "Work Asynchronously")) + self.categoryLabel.setText(_translate("MSUIMainWindow", "Category:")) self.listOperationsMSC.setToolTip(_translate("MSUIMainWindow", "List of mscolab operations.\n" "Double click a operation to activate and view its description.")) - self.activeOperationDesc.setText(_translate("MSUIMainWindow", "Select Operation to View Description")) - self.activeOperationsLabel.setText(_translate("MSUIMainWindow", "Operations")) + self.pbOpenOperationArchive.setText(_translate("MSUIMainWindow", "Operation Archive")) self.menuFile.setTitle(_translate("MSUIMainWindow", "&File")) self.menuImportFlightTrack.setTitle(_translate("MSUIMainWindow", "Import Flight Track")) self.menuExportActiveFlightTrack.setTitle(_translate("MSUIMainWindow", "Export Flight Track")) @@ -321,7 +326,7 @@ def retranslateUi(self, MSUIMainWindow): self.menuHelp.setTitle(_translate("MSUIMainWindow", "&Help")) self.menuViews.setTitle(_translate("MSUIMainWindow", "Views")) self.menuOperation.setTitle(_translate("MSUIMainWindow", "Operation")) - self.menuProperties.setTitle(_translate("MSUIMainWindow", "Properties")) + self.menuProperties.setTitle(_translate("MSUIMainWindow", "Maintenance")) self.actionSaveActiveFlightTrack.setText(_translate("MSUIMainWindow", "&Save Active Flight Track")) self.actionSaveActiveFlightTrack.setShortcut(_translate("MSUIMainWindow", "Ctrl+S")) self.actionSaveActiveFlightTrackAs.setText(_translate("MSUIMainWindow", "Save Active Flight Track As")) @@ -356,8 +361,9 @@ def retranslateUi(self, MSUIMainWindow): self.actionSearch.setText(_translate("MSUIMainWindow", "Search")) self.actionSearch.setToolTip(_translate("MSUIMainWindow", "Search for interactive text in the UI")) self.actionSearch.setShortcut(_translate("MSUIMainWindow", "Ctrl+F")) - self.actionDescription.setText(_translate("MSUIMainWindow", "View Description")) - self.actionUpdateOperationDesc.setText(_translate("MSUIMainWindow", "Update Description")) + self.actionViewDescription.setText(_translate("MSUIMainWindow", "View Description")) + self.actionChangeDescription.setText(_translate("MSUIMainWindow", "Change Description")) self.actionRenameOperation.setText(_translate("MSUIMainWindow", "Rename Operation")) self.actionLeaveOperation.setText(_translate("MSUIMainWindow", "&Leave Operation")) - self.actionUnarchiveOperation.setText(_translate("MSUIMainWindow", "Unarchive Operation")) + self.actionArchiveOperation.setText(_translate("MSUIMainWindow", "Archive Operation")) + self.actionChangeCategory.setText(_translate("MSUIMainWindow", "Change Category")) diff --git a/mslib/msui/qt5/ui_operation_archive.py b/mslib/msui/qt5/ui_operation_archive.py new file mode 100644 index 000000000..7b19f3474 --- /dev/null +++ b/mslib/msui/qt5/ui_operation_archive.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'mslib/msui/ui/ui_operation_archive.ui' +# +# Created by: PyQt5 UI code generator 5.15.7 +# +# 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_OperationArchiveBrowser(object): + def setupUi(self, OperationArchiveBrowser): + OperationArchiveBrowser.setObjectName("OperationArchiveBrowser") + OperationArchiveBrowser.resize(754, 393) + self.verticalLayout = QtWidgets.QVBoxLayout(OperationArchiveBrowser) + self.verticalLayout.setObjectName("verticalLayout") + self.label = QtWidgets.QLabel(OperationArchiveBrowser) + self.label.setObjectName("label") + self.verticalLayout.addWidget(self.label) + self.listArchivedOperations = QtWidgets.QListWidget(OperationArchiveBrowser) + self.listArchivedOperations.setObjectName("listArchivedOperations") + self.verticalLayout.addWidget(self.listArchivedOperations) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.pbUnarchiveOperation = QtWidgets.QPushButton(OperationArchiveBrowser) + self.pbUnarchiveOperation.setObjectName("pbUnarchiveOperation") + self.horizontalLayout.addWidget(self.pbUnarchiveOperation) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout.addItem(spacerItem) + self.pbClose = QtWidgets.QPushButton(OperationArchiveBrowser) + self.pbClose.setObjectName("pbClose") + self.horizontalLayout.addWidget(self.pbClose) + self.verticalLayout.addLayout(self.horizontalLayout) + + self.retranslateUi(OperationArchiveBrowser) + QtCore.QMetaObject.connectSlotsByName(OperationArchiveBrowser) + + def retranslateUi(self, OperationArchiveBrowser): + _translate = QtCore.QCoreApplication.translate + OperationArchiveBrowser.setWindowTitle(_translate("OperationArchiveBrowser", "Browse archived operations")) + self.label.setText(_translate("OperationArchiveBrowser", "Operation Archive")) + self.pbUnarchiveOperation.setToolTip(_translate("OperationArchiveBrowser", "

Becomes available when you have the right to unarchive an operation. Moves this operation to the list of current operations.

")) + self.pbUnarchiveOperation.setText(_translate("OperationArchiveBrowser", "Unarchive")) + self.pbClose.setText(_translate("OperationArchiveBrowser", "close")) diff --git a/mslib/msui/ui/ui_mainwindow.ui b/mslib/msui/ui/ui_mainwindow.ui index efb8e8fc3..46aec65a5 100644 --- a/mslib/msui/ui/ui_mainwindow.ui +++ b/mslib/msui/ui/ui_mainwindow.ui @@ -230,7 +230,41 @@ Save a flight track to name it. 8 - + + + + No operations selected + + + true + + + + + + + Select Operation to View Description + + + + + + + Operations + + + + + + + Check to work asynchronously from the server + + + Work Asynchronously + + + + filter by operation category @@ -254,34 +288,7 @@ Save a flight track to name it. - - - - Category: - - - - - - - Archived Operations - - - - - - - - - - No operations selected - - - true - - - - + Fetch/Save Server options @@ -303,13 +310,10 @@ Save a flight track to name it. - - - - Check to work asynchronously from the server - + + - Work Asynchronously + Category: @@ -321,17 +325,16 @@ Double click a operation to activate and view its description. - - - - Select Operation to View Description + + + + + 0 + 0 + - - - - - Operations + Operation Archive @@ -417,19 +420,20 @@ Double click a operation to activate and view its description. - Properties + Maintenance - - - + + + - + + - - + + @@ -584,14 +588,14 @@ Double click a operation to activate and view its description. Ctrl+F - + View Description - + - Update Description + Change Description @@ -604,9 +608,14 @@ Double click a operation to activate and view its description. &Leave Operation - + + + Archive Operation + + + - Unarchive Operation + Change Category diff --git a/mslib/msui/ui/ui_operation_archive.ui b/mslib/msui/ui/ui_operation_archive.ui new file mode 100644 index 000000000..62cf5e185 --- /dev/null +++ b/mslib/msui/ui/ui_operation_archive.ui @@ -0,0 +1,65 @@ + + + OperationArchiveBrowser + + + + 0 + 0 + 754 + 393 + + + + Browse archived operations + + + + + + Operation Archive + + + + + + + + + + + + <html><head/><body><p>Becomes available when you have the right to unarchive an operation. Moves this operation to the list of current operations.</p></body></html> + + + Unarchive + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + close + + + + + + + + + + diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 424cfc093..cc12cb0e4 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -132,7 +132,7 @@ class MSUIDefaultConfig(object): MSCOLAB_mailid = "" # category for MSC operations - MSCOLAB_category = "default" + MSCOLAB_category = "ANY" # list of MSC servers {"http://www.your-mscolab-server.de": "authuser", # "http://www.your-wms-server.de": "authuser"} diff --git a/mslib/utils/qt.py b/mslib/utils/qt.py index a32dab700..0db5ddaab 100644 --- a/mslib/utils/qt.py +++ b/mslib/utils/qt.py @@ -633,6 +633,7 @@ def emit(self, *args): "ui_topview_window", "ui_linearview_options", "ui_linearview_window", + "ui_operation_archive", "ui_wms_password_dialog", "ui_wms_capabilities", "ui_wms_dockwidget", diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 947bfc831..3a4e24ab1 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -553,11 +553,27 @@ def test_update_description(self, mockbox, mockpatch): assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None - self.window.actionUpdateOperationDesc.trigger() + self.window.actionChangeDescription.trigger() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None - assert self.window.mscolab.active_operation_desc == "new_desciption" + assert self.window.mscolab.active_operation_description == "new_desciption" + + @mock.patch("PyQt5.QtWidgets.QMessageBox.information") + @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_category", True)) + def test_update_category(self, mockbox, mockpatch): + self._connect_to_mscolab() + self._create_user("something", "something@something.org", "something") + self._create_operation("flight1234", "Description flight1234") + assert self.window.listOperationsMSC.model().rowCount() == 1 + assert self.window.mscolab.active_operation_category == "example" + self._activate_operation_at_index(0) + assert self.window.mscolab.active_op_id is not None + self.window.actionChangeCategory.trigger() + QtWidgets.QApplication.processEvents() + QtTest.QTest.qWait(0) + assert self.window.mscolab.active_op_id is not None + assert self.window.mscolab.active_operation_category == "new_category" def test_get_recent_op_id(self): self._connect_to_mscolab() From 388253ec69298e5858ae7942d57b5913aff5f5a1 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 17 May 2023 11:47:07 +0200 Subject: [PATCH 006/176] new tests --- conftest.py | 43 ++++++++- .../test_server_auth_required.py | 90 +++++++++++++++++++ tests/constants.py | 1 + 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/_test_mscolab/test_server_auth_required.py diff --git a/conftest.py b/conftest.py index 784cf82e3..0a485c5c5 100644 --- a/conftest.py +++ b/conftest.py @@ -188,10 +188,49 @@ class mscolab_settings(object): ROOT_FS.makedir('mscolab') with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: # windows needs \\ or / but mixed is terrible. *nix needs / - mscolab_fs.writetext('mscolab_settings.py', config_string.replace('\\', '/')) - path = fs.path.join(constants.ROOT_DIR, 'mscolab', 'mscolab_settings.py') + mscolab_fs.writetext(constants.MSCOLAB_CONFIG_FILE, config_string.replace('\\', '/')) + path = fs.path.join(constants.ROOT_DIR, 'mscolab', constants.MSCOLAB_CONFIG_FILE) parent_path = fs.path.join(constants.ROOT_DIR, 'mscolab') +if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_AUTH_FILE): + config_string = f'''# -*- coding: utf-8 -*- +""" + + mslib.mscolab.auth.py.example + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + config for mscolab auth. + + This file is part of MSS. + + :copyright: Copyright 2019 Shivashis Padhi + :copyright: Copyright 2019-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import hashlib + +class mscolab_auth(object): + password = "testvaluepassword" + allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] + ''' + ROOT_FS = fs.open_fs(constants.ROOT_DIR) + if not ROOT_FS.exists('mscolab'): + ROOT_FS.makedir('mscolab') + with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: + # windows needs \\ or / but mixed is terrible. *nix needs / + mscolab_fs.writetext(constants.MSCOLAB_AUTH_FILE, config_string.replace('\\', '/')) importlib.machinery.SourceFileLoader('mswms_settings', constants.SERVER_CONFIG_FILE_PATH).load_module() sys.path.insert(0, constants.SERVER_CONFIG_FS.root_path) diff --git a/tests/_test_mscolab/test_server_auth_required.py b/tests/_test_mscolab/test_server_auth_required.py new file mode 100644 index 000000000..e9d2670b3 --- /dev/null +++ b/tests/_test_mscolab/test_server_auth_required.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" + + tests._test_mscolab.test_server_auth_required + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + tests for server basics when auth is enabled + + This file is part of MSS. + + :copyright: Copyright 2020 Reimar Bauer + :copyright: Copyright 2020-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import os +import pytest + +from flask_testing import TestCase +from mslib.mscolab.conf import mscolab_settings + +mscolab_settings.enable_basic_http_authentication = True +try: + from mslib.mscolab.server import authfunc, verify_pw, initialize_managers, get_auth_token, register_user, APP +except ImportError: + pytest.mark.skip("this test runs only by an explicit call " + "e.g. pytest tests/_test_mscolab/test_server_auth_required.py") + +from mslib.mscolab.mscolab import handle_db_reset + + +@pytest.mark.skipif(os.name == "nt", + reason="multiprocessing needs currently start_method fork") +class Test_Server_Auth_Not_Valid(TestCase): + render_templates = False + + def create_app(self): + app = APP + app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI + app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR + app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config["TESTING"] = True + app.config['LIVESERVER_TIMEOUT'] = 10 + app.config['LIVESERVER_PORT'] = 0 + return app + + def setUp(self): + handle_db_reset() + self.userdata = 'UV10@uv10', 'UV10', 'uv10' + + def tearDown(self): + pass + + def test_initialize_managers(self): + app, sockio, cm, fm = initialize_managers(self.app) + + assert app.config['MSCOLAB_DATA_DIR'] == mscolab_settings.MSCOLAB_DATA_DIR + assert 'Create a Flask-SocketIO server.' in sockio.__doc__ + assert 'Class with handler functions for chat related functionalities' in cm.__doc__ + assert 'Class with handler functions for file related functionalities' in fm.__doc__ + + def test_authfunc(self): + mscolab_settings.enable_basic_http_authentication = True + assert authfunc("user", "testvaluepassword") + assert authfunc("user", "wrong") is False + + def test_verify_pw(self): + assert verify_pw("user", "testvaluepassword") + assert verify_pw("unknown", "unknow") is False + assert verify_pw("user", "wrong") is False + + def test_register_user(self): + r = register_user("test@test.io", "test", "pwdtest") + assert r.status_code == 401 + + def test_get_auth_token(self): + r = get_auth_token() + assert r.status_code == 401 diff --git a/tests/constants.py b/tests/constants.py index 7f92f7b08..be503bf8e 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -44,6 +44,7 @@ CACHED_CONFIG_FILE = None SERVER_CONFIG_FILE = "mswms_settings.py" MSCOLAB_CONFIG_FILE = "mscolab_settings.py" +MSCOLAB_AUTH_FILE = "mscolab_auth.py" ROOT_FS = TempFS(identifier=f"msui{SHA}") OSFS_URL = ROOT_FS.geturl("", purpose="fs") From 8a209380fbeded8f041a05d004af68d290327cda Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 17 May 2023 12:47:55 +0200 Subject: [PATCH 007/176] skip --- tests/_test_mscolab/test_server_auth_required.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/_test_mscolab/test_server_auth_required.py b/tests/_test_mscolab/test_server_auth_required.py index e9d2670b3..1528c0317 100644 --- a/tests/_test_mscolab/test_server_auth_required.py +++ b/tests/_test_mscolab/test_server_auth_required.py @@ -34,8 +34,8 @@ try: from mslib.mscolab.server import authfunc, verify_pw, initialize_managers, get_auth_token, register_user, APP except ImportError: - pytest.mark.skip("this test runs only by an explicit call " - "e.g. pytest tests/_test_mscolab/test_server_auth_required.py") + pytest.skip("this test runs only by an explicit call " + "e.g. pytest tests/_test_mscolab/test_server_auth_required.py", allow_module_level=True) from mslib.mscolab.mscolab import handle_db_reset From 600d7698977523b61b504f8f436dd5b35d1f6cb1 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 25 May 2023 12:44:26 +0200 Subject: [PATCH 008/176] MSColab authenticate/authorize restructuring (#1792) * added stuff * keyring tidy up * fixed some testcases * fixed keyring * added method to 'clear' keyring * split connect tests and added a '401' one for authorization denied case * adapted to comments * adapted to comments * renamed http_auth to mss_auth * removed misplaced http_basic_auth statment * updated keyring related documentation * updated status messages. * update * update --- conftest.py | 18 +- docs/usage.rst | 21 +- mslib/mscolab/server.py | 4 +- mslib/msui/mscolab.py | 261 ++++++-------------- mslib/msui/qt5/ui_mscolab_connect_dialog.py | 40 ++- mslib/msui/ui/ui_mscolab_connect_dialog.ui | 55 ++--- mslib/utils/config.py | 2 +- tests/_test_msui/test_mscolab.py | 92 +++---- tests/_test_utils/test_auth.py | 22 +- tests/_test_utils/test_migration.py | 4 +- 10 files changed, 200 insertions(+), 319 deletions(-) diff --git a/conftest.py b/conftest.py index 0a485c5c5..7b7887d9e 100644 --- a/conftest.py +++ b/conftest.py @@ -53,19 +53,31 @@ class TestKeyring(keyring.backend.KeyringBackend): """ priority = 1 + passwords = {} + + def reset(self): + self.passwords = {} + def set_password(self, servicename, username, password): - pass + self.passwords[servicename + username] = password def get_password(self, servicename, username): - return "password from TestKeyring" + return self.passwords.get(servicename + username, "password from TestKeyring") def delete_password(self, servicename, username): - pass + if servicename + username in self.passwords: + del self.passwords[servicename + username] + # set the keyring for keyring lib keyring.set_keyring(TestKeyring()) +@pytest.fixture(autouse=True) +def keyring_reset(): + keyring.get_keyring().reset() + + def pytest_addoption(parser): parser.addoption("--msui_settings", action="store") diff --git a/docs/usage.rst b/docs/usage.rst index 4c376f5eb..d347e81cd 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -114,29 +114,20 @@ You can setup which accounts are used to login into MSColab and used for authent When you use an old configuration having WMS_login, MSC_login, MSCOLAB_password defined on start of msui you get a hint that we can update your msui_settings.json file. We keep your old attributes in a bak file. - -A dictionary by Server-Url and username provide the username for an http-auth request -and the MSCOLAB_mailid is used to login by your credentials into the service. +A dictionary by Server-Url and username provide the username for logging into our services +(by http-auth request for WMS). .. code:: text "MSS_auth": { "http://www.your-server.de/forecasts": "authuser", - "http://www.your-mscolab-server.de": "authuser" + "http://www.your-mscolab-server.de": "your-email" }, - "MSCOLAB_mailid": "your-email" - - -By entering first time the passwords they are stored by using keyring. -You can also use the keyring app to set, change and delete passwords. -The following examples shows how to setup your individual MSColab account and to add -the common WWW-authentication to access the server. - -.. code:: text - (mssenv): keyring set MSCOLAB your-email your-password - (mssenv): keyring set http://www.your-mscolab-server.de authuser authpassword +All passwords are stored by using an OS-provided keyring after entering them +the first time. Also the token required for accessing the MSColab server will +be stored there. You can also use an OS-provided keyring app to set, change and delete passwords. MSUI Flight track import/export plugins diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 2462a1af6..704ba25ce 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -155,7 +155,6 @@ def check_login(emailid, password): return False -@conditional_decorator(auth.login_required, mscolab_settings.__dict__.get('enable_basic_http_authentication', False)) def register_user(email, password, username): user = User(email, username, password) is_valid_username = True if username.find("@") == -1 else False @@ -204,8 +203,8 @@ def home(): return render_template("/index.html") -# ToDo setup codes in return statements @APP.route("/status") +@conditional_decorator(auth.login_required, mscolab_settings.__dict__.get('enable_basic_http_authentication', False)) def hello(): return "Mscolab server" @@ -252,6 +251,7 @@ def authorized(): @APP.route("/register", methods=["POST"]) +@conditional_decorator(auth.login_required, mscolab_settings.__dict__.get('enable_basic_http_authentication', False)) def user_register_handler(): email = request.form['email'] password = request.form['password'] diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 32fc80a92..2f86e1ab6 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -51,9 +51,10 @@ from mslib.msui import mscolab_version_history as mvh from mslib.msui import socket_control as sc +import PyQt5 from PyQt5 import QtCore, QtGui, QtWidgets -from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring, get_auth_from_url_and_name +from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring from mslib.utils.verify_user_token import verify_user_token from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup from mslib.utils.qt import ui_mscolab_help_dialog as msc_help_dialog @@ -63,7 +64,7 @@ from mslib.utils.qt import ui_mscolab_profile_dialog as ui_profile from mslib.utils.qt import ui_operation_archive as ui_opar from mslib.msui import constants -from mslib.utils.config import config_loader, load_settings_qsettings, save_settings_qsettings, modify_config_file +from mslib.utils.config import config_loader, modify_config_file class MSColab_OperationArchiveBrowser(QtWidgets.QDialog, ui_opar.Ui_OperationArchiveBrowser): @@ -133,9 +134,10 @@ def __init__(self, parent=None, mscolab=None): # initialize server url as none self.mscolab_server_url = None + self.auth = None self.setFixedSize(self.size()) - self.stackedWidget.setCurrentWidget(self.loginPage) + self.stackedWidget.setCurrentWidget(self.httpAuthPage) # disable widgets in login frame self.loginEmailLe.setEnabled(False) @@ -145,6 +147,7 @@ def __init__(self, parent=None, mscolab=None): # add urls from settings to the combobox self.add_mscolab_urls() + self.mscolab_url_changed(self.urlCb.currentText()) # connect login, adduser, connect buttons self.connectBtn.clicked.connect(self.connect_handler) @@ -152,9 +155,11 @@ def __init__(self, parent=None, mscolab=None): self.addUserBtn.clicked.connect(lambda: self.stackedWidget.setCurrentWidget(self.newuserPage)) # enable login button only if email and password are entered - self.loginEmailLe.textChanged[str].connect(self.enable_login_btn) + self.loginEmailLe.textChanged[str].connect(self.mscolab_login_changed) self.loginPasswordLe.textChanged[str].connect(self.enable_login_btn) + self.urlCb.editTextChanged.connect(self.mscolab_url_changed) + # connect new user dialogbutton self.newUserBb.accepted.connect(self.new_user_handler) self.newUserBb.rejected.connect(lambda: self.stackedWidget.setCurrentWidget(self.loginPage)) @@ -162,23 +167,22 @@ def __init__(self, parent=None, mscolab=None): # connecting slot to clear all input widgets while switching tabs self.stackedWidget.currentChanged.connect(self.page_switched) - # fill value of mscolab url if found in QSettings storage - self.settings = load_settings_qsettings('mscolab', default_settings={'auth': {}, 'server_settings': {}}) + def mscolab_url_changed(self, text): + self.httpPasswordLe.setText( + get_password_from_keyring("MSCOLAB_AUTH_" + text, "mscolab")) - def page_switched(self, index): - # clear all text in all input - self.loginEmailLe.setText("") - self.loginPasswordLe.setText("") + def mscolab_login_changed(self, text): + self.loginPasswordLe.setText( + get_password_from_keyring(self.mscolab_server_url, text)) + def page_switched(self, index): + # clear all text in add user widget self.newUsernameLe.setText("") self.newEmailLe.setText("") self.newPasswordLe.setText("") self.newConfirmPasswordLe.setText("") - self.httpUsernameLe.setText("") - self.httpPasswordLe.setText("") - - if index == 2: + if index == 1: self.connectBtn.setEnabled(False) else: self.connectBtn.setEnabled(True) @@ -195,6 +199,7 @@ def set_status(self, _type="Error", msg=""): self.statusLabel.setStyleSheet("") msg = "ⓘ " + msg self.statusLabel.setText(msg) + logging.debug("set_status: %s", msg) QtWidgets.QApplication.processEvents() def add_mscolab_urls(self): @@ -209,9 +214,16 @@ def enable_login_btn(self): def connect_handler(self): try: url = str(self.urlCb.currentText()) - r = requests.get(url_join(url, 'status')) - if r.text == "Mscolab server": - self.set_status("Success", "Successfully connected to MSColab Server") + auth = "mscolab", self.httpPasswordLe.text() + s = requests.Session() + s.auth = auth + s.headers.update({'x-test': 'true'}) + r = s.get(url_join(url, 'status'), timeout=(2, 10)) + if r.status_code == 401: + self.set_status("Error", 'Server authentication data were incorrect.') + elif r.status_code == 200: + self.stackedWidget.setCurrentWidget(self.loginPage) + self.set_status("Success", "Successfully connected to MSColab server.") # disable url input self.urlCb.setEnabled(False) @@ -222,40 +234,49 @@ def connect_handler(self): self.loginPasswordLe.setEnabled(True) self.mscolab_server_url = url - # delete mscolab http_auth settings for the url - if self.mscolab_server_url in self.settings["auth"].keys(): - del self.settings["auth"][self.mscolab_server_url] - - if self.mscolab_server_url not in self.settings["server_settings"].keys(): - self.settings["server_settings"].update({self.mscolab_server_url: {}}) - save_settings_qsettings('mscolab', self.settings) + self.auth = auth + save_password_to_keyring("MSCOLAB_AUTH_" + url, auth[0], auth[1]) + + url_list = config_loader(dataset="default_MSCOLAB") + if self.mscolab_server_url not in url_list: + ret = PyQt5.QtWidgets.QMessageBox.question( + self, self.tr("Update Server List"), + self.tr("You are using a new MSColab server. " + "Should your settings file be updated by adding the new server?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) + if ret == QtWidgets.QMessageBox.Yes: + url_list = [self.mscolab_server_url] + url_list + modify_config_file({"default_MSCOLAB": url_list}) # Fill Email and Password fields from config - self.loginEmailLe.setText(config_loader(dataset="MSCOLAB_mailid")) - self.loginPasswordLe.setText(get_password_from_keyring(service_name="MSCOLAB", - username=config_loader(dataset="MSCOLAB_mailid"))) - self.enable_login_btn() + self.loginEmailLe.setText( + config_loader(dataset="MSS_auth").get(self.mscolab_server_url)) + self.mscolab_login_changed(self.loginEmailLe.text()) # Change connect button text and connect disconnect handler self.connectBtn.setText('Disconnect') - self.connectBtn.clicked.disconnect(self.connect_handler) + try: + self.connectBtn.clicked.disconnect(self.connect_handler) + except TypeError: + pass self.connectBtn.clicked.connect(self.disconnect_handler) else: + logging.error("Error %s", r) self.set_status("Error", "Some unexpected error occurred. Please try again.") except requests.exceptions.SSLError: logging.debug("Certificate Verification Failed") - self.set_status("Error", "Certificate Verification Failed") + self.set_status("Error", "Certificate Verification Failed.") except requests.exceptions.InvalidSchema: logging.debug("invalid schema of url") - self.set_status("Error", "Invalid Url Scheme!") + self.set_status("Error", "Invalid Url Scheme.") except requests.exceptions.InvalidURL: logging.debug("invalid url") - self.set_status("Error", "Invalid URL") + self.set_status("Error", "Invalid URL.") except requests.exceptions.ConnectionError: logging.debug("MSColab server isn't active") - self.set_status("Error", "MSColab server isn't active") + self.set_status("Error", "MSColab server isn't active.") except Exception as e: - logging.debug("Error %s", str(e)) + logging.error("Error %s %s", type(e), str(e)) self.set_status("Error", "Some unexpected error occurred. Please try again.") def disconnect_handler(self): @@ -268,132 +289,71 @@ def disconnect_handler(self): self.loginPasswordLe.setEnabled(False) # clear text - self.stackedWidget.setCurrentWidget(self.loginPage) + self.stackedWidget.setCurrentWidget(self.httpAuthPage) - # delete mscolab http_auth settings for the url - if self.mscolab_server_url in self.settings["auth"].keys(): - del self.settings["auth"][self.mscolab_server_url] - save_settings_qsettings('mscolab', self.settings) self.mscolab_server_url = None + self.auth = None self.connectBtn.setText('Connect') self.connectBtn.clicked.disconnect(self.disconnect_handler) self.connectBtn.clicked.connect(self.connect_handler) - self.set_status("Info", 'Disconnected from server') - - def authenticate(self, data, r, url): - if r.status_code == 401: - auth_username, auth_password = self.httpUsernameLe.text(), self.httpPasswordLe.text() - self.settings["auth"][self.mscolab_server_url] = (auth_username, auth_password) - s = requests.Session() - s.auth = (auth_username, auth_password) - s.headers.update({'x-test': 'true'}) - r = s.post(url, data=data, timeout=(2, 10)) - return r + self.set_status("Info", 'Disconnected from server.') def login_handler(self): - auth = get_auth_from_url_and_name(self.mscolab_server_url, config_loader(dataset="MSS_auth")) - emailid = self.loginEmailLe.text() - password = self.loginPasswordLe.text() data = { - "email": emailid, - "password": password + "email": self.loginEmailLe.text(), + "password": self.loginPasswordLe.text() } s = requests.Session() - s.auth = (auth[0], auth[1]) + s.auth = self.auth s.headers.update({'x-test': 'true'}) url = f'{self.mscolab_server_url}/token' url_recover_password = f'{self.mscolab_server_url}/reset_request' try: r = s.post(url, data=data, timeout=(2, 10)) + if r.status_code == 401: + raise requests.exceptions.ConnectionError except requests.exceptions.ConnectionError as ex: logging.error("unexpected error: %s %s %s", type(ex), url, ex) self.set_status( "Error", - "Failed to establish a new connection" f' to "{self.mscolab_server_url}". Try in a moment again.', + f'Failed to establish a new connection to "{self.mscolab_server_url}". Try again in a moment.', ) self.disconnect_handler() return if r.text == "False": # show status indicating about wrong credentials - self.set_status("Error", 'Oh no, you need to add a user account or ' - f'Recover Your Password') - elif r.text == "Unauthorized Access": - # Server auth required for logging in - self.login_data = [data, r, url] - self.connectBtn.setEnabled(False) - self.stackedWidget.setCurrentWidget(self.httpAuthPage) - try: - self.httpBb.accepted.disconnect() - except TypeError: - pass - try: - self.httpBb.rejected.disconnect() - except TypeError: - pass - self.httpBb.accepted.connect(self.login_server_auth) - self.httpBb.rejected.connect(lambda: self.stackedWidget.setCurrentWidget(self.loginPage)) + self.set_status("Error", 'Invalid credentials. Fix them, create a new user, or ' + f'recover your password.') else: - try: - save_password_to_keyring(service_name="MSCOLAB", username=emailid, password=password) - except (NoKeyringError, PasswordSetError, InitError) as ex: - logging.warning("Can't use Keyring on your system: %s" % ex) - self.mscolab.after_login(emailid, self.mscolab_server_url, r) + self.save_user_credentials_to_config_file(data["email"], data["password"]) + self.mscolab.after_login(data["email"], self.mscolab_server_url, r) def save_user_credentials_to_config_file(self, emailid, password): - data_to_save_in_config_file = { - "MSCOLAB_mailid": emailid - } - try: - save_password_to_keyring(service_name="MSCOLAB", username=emailid, password=password) + save_password_to_keyring(service_name=self.mscolab_server_url, username=emailid, password=password) except (NoKeyringError, PasswordSetError, InitError) as ex: logging.warning("Can't use Keyring on your system: %s" % ex) - exiting_mscolab_mailid = config_loader(dataset="MSCOLAB_mailid") - if exiting_mscolab_mailid != emailid: + mss_auth = config_loader(dataset="MSS_auth") + if mss_auth.get(self.mscolab_server_url) != emailid: ret = QtWidgets.QMessageBox.question( self, self.tr("Update Credentials"), self.tr("You are using new credentials. " "Should your settings file be updated with the new credentials?"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: - modify_config_file(data_to_save_in_config_file) - else: - modify_config_file(data_to_save_in_config_file) - - def login_server_auth(self): - data, r, url = self.login_data - emailid = data['email'] - password = data['password'] - if r.status_code == 401: - r = self.authenticate(data, r, url) - if r.status_code == 200: - # http auth was successful - self.save_auth_credentials_to_config_file() - if r.text not in ["False", "Unauthorized Access"]: - # user does not exist or password is wrong - self.save_user_credentials_to_config_file(emailid, password) - self.mscolab.after_login(emailid, self.mscolab_server_url, r) - else: - self.stackedWidget.setCurrentWidget(self.loginPage) - url_recover_password = f'{self.mscolab_server_url}/reset_request' - self.set_status("Error", 'Oh no, you need to add a user account or ' - f'Recover Your Password') - else: - self.set_status("Error", 'Oh no, server authentication were incorrect.') - self.stackedWidget.setCurrentWidget(self.loginPage) + mss_auth[self.mscolab_server_url] = emailid + modify_config_file({"MSS_auth": mss_auth}) def new_user_handler(self): # get mscolab /token http auth credentials from cache - auth = get_auth_from_url_and_name(self.mscolab_server_url, config_loader(dataset="MSS_auth")) - emailid = self.newEmailLe.text() password = self.newPasswordLe.text() re_password = self.newConfirmPasswordLe.text() username = self.newUsernameLe.text() if password != re_password: - self.set_status("Error", 'Oh no, your passwords don\'t match') + self.set_status("Error", 'Your passwords don\'t match.') return data = { @@ -402,7 +362,7 @@ def new_user_handler(self): "username": username } s = requests.Session() - s.auth = (auth[0], auth[1]) + s.auth = self.auth s.headers.update({'x-test': 'true'}) url = f'{self.mscolab_server_url}/register' try: @@ -411,13 +371,13 @@ def new_user_handler(self): logging.error("unexpected error: %s %s %s", type(ex), url, ex) self.set_status( "Error", - "Failed to establish a new connection" f' to "{self.mscolab_server_url}". Try in a moment again.', + f'Failed to establish a new connection to "{self.mscolab_server_url}". Try again in a moment.', ) self.disconnect_handler() return if r.status_code == 204: - self.set_status("Success", 'You are registered, confirm your email to log in.') + self.set_status("Success", 'You are registered, confirm your email before logging in.') self.save_user_credentials_to_config_file(emailid, password) self.stackedWidget.setCurrentWidget(self.loginPage) self.loginEmailLe.setText(emailid) @@ -428,19 +388,6 @@ def new_user_handler(self): self.loginEmailLe.setText(emailid) self.loginPasswordLe.setText(password) self.login_handler() - elif r.status_code == 401: - self.newuser_data = [data, r, url] - self.stackedWidget.setCurrentWidget(self.httpAuthPage) - try: - self.httpBb.accepted.disconnect() - except TypeError: - pass - try: - self.httpBb.rejected.disconnect() - except TypeError: - pass - self.httpBb.accepted.connect(self.newuser_server_auth) - self.httpBb.rejected.connect(lambda: self.stackedWidget.setCurrentWidget(self.newuserPage)) else: try: error_msg = json.loads(r.text)["message"] @@ -449,51 +396,6 @@ def new_user_handler(self): error_msg = "Unexpected error occured. Please try again." self.set_status("Error", error_msg) - def save_auth_credentials_to_config_file(self): - http_auth_login_data = config_loader(dataset="MSS_auth") - auth_username = self.settings["auth"][self.mscolab_server_url][0] - auth_password = self.settings["auth"][self.mscolab_server_url][1] - http_auth_login_data[self.mscolab_server_url] = auth_username - - data_to_save_in_config_file = { - "default_MSCOLAB": [self.mscolab_server_url], - "MSS_auth": http_auth_login_data - } - - modify_config_file(data_to_save_in_config_file) - try: - save_password_to_keyring(self.mscolab_server_url, auth_username, auth_password) - except (NoKeyringError, PasswordSetError, InitError) as ex: - logging.warning("Can't use Keyring on your system: %s" % ex) - - def newuser_server_auth(self): - data, r, url = self.newuser_data - r = self.authenticate(data, r, url) - if r.status_code == 201: - self.save_auth_credentials_to_config_file() - self.set_status("Success", "You are registered.") - self.save_user_credentials_to_config_file(data['email'], data['password']) - self.loginEmailLe.setText(data['email']) - self.loginPasswordLe.setText(data['password']) - self.login_handler() - elif r.status_code == 200: - try: - error_msg = json.loads(r.text)["message"] - except Exception as e: - logging.debug("Unexpected error occured %s", e) - error_msg = "Unexpected error occured. Please try again." - self.set_status("Error", error_msg) - elif r.status_code == 204: - self.save_auth_credentials_to_config_file() - self.set_status("Success", 'You are registered, confirm your email to log in.') - self.save_user_credentials_to_config_file(data['email'], data['password']) - self.stackedWidget.setCurrentWidget(self.loginPage) - self.loginEmailLe.setText(data['email']) - self.loginPasswordLe.setText(data['password']) - else: - self.set_status("Error", "Oh no, server authentication were incorrect.") - self.stackedWidget.setCurrentWidget(self.newuserPage) - class MSUIMscolab(QtCore.QObject): """ @@ -663,7 +565,6 @@ def after_login(self, emailid, url, r): self.connect_window = None QtWidgets.QApplication.processEvents() # fill value of mscolab url if found in QSettings storage - self.settings = load_settings_qsettings('mscolab', default_settings={'auth': {}, 'server_settings': {}}) _json = json.loads(r.text) self.token = _json["token"] @@ -675,7 +576,7 @@ def after_login(self, emailid, url, r): self.conn = sc.ConnectionManager(self.token, user=self.user, mscolab_server_url=self.mscolab_server_url) except Exception as ex: logging.debug("Couldn't create a socket connection: %s", ex) - show_popup(self.ui, "Error", "Couldn't create a socket connection. Maybe the mscolab server is too old." + show_popup(self.ui, "Error", "Couldn't create a socket connection. Maybe the MSColab server is too old. " "New Login required!") self.logout() else: @@ -1728,7 +1629,6 @@ def set_active_op_id(self, item): item.setFont(font) # set new waypoints model to open views - logging.debug("mscolab set wpm") for window in self.ui.get_active_views(): window.setFlightTrackModel(self.waypoints_model) if self.access_level == "viewer": @@ -2018,11 +1918,6 @@ def logout(self): # clear user email self.email = None - # delete mscolab http_auth settings for the url - if self.mscolab_server_url in self.settings["auth"].keys(): - del self.settings["auth"][self.mscolab_server_url] - save_settings_qsettings('mscolab', self.settings) - # disable category change selector self.ui.filterCategoryCb.setEnabled(False) self.signal_logout_mscolab.emit() diff --git a/mslib/msui/qt5/ui_mscolab_connect_dialog.py b/mslib/msui/qt5/ui_mscolab_connect_dialog.py index 328c70761..2f0061373 100644 --- a/mslib/msui/qt5/ui_mscolab_connect_dialog.py +++ b/mslib/msui/qt5/ui_mscolab_connect_dialog.py @@ -2,9 +2,10 @@ # Form implementation generated from reading ui file 'mslib/msui/ui/ui_mscolab_connect_dialog.ui' # -# Created by: PyQt5 UI code generator 5.12.3 +# Created by: PyQt5 UI code generator 5.15.7 # -# WARNING! All changes made in this file will be lost! +# 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 @@ -120,31 +121,28 @@ def setupUi(self, MSColabConnectDialog): self.verticalLayout_4.setContentsMargins(0, 0, 0, 0) self.verticalLayout_4.setObjectName("verticalLayout_4") self.httpTopicLabel = QtWidgets.QLabel(self.httpAuthPage) + self.httpTopicLabel.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.httpTopicLabel.sizePolicy().hasHeightForWidth()) + self.httpTopicLabel.setSizePolicy(sizePolicy) + self.httpTopicLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.httpTopicLabel.setObjectName("httpTopicLabel") self.verticalLayout_4.addWidget(self.httpTopicLabel) - self.httpInfoLabel = QtWidgets.QLabel(self.httpAuthPage) - self.httpInfoLabel.setObjectName("httpInfoLabel") - self.verticalLayout_4.addWidget(self.httpInfoLabel) self.gridLayout = QtWidgets.QGridLayout() + self.gridLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) self.gridLayout.setObjectName("gridLayout") - self.httpUsernameLabel = QtWidgets.QLabel(self.httpAuthPage) - self.httpUsernameLabel.setObjectName("httpUsernameLabel") - self.gridLayout.addWidget(self.httpUsernameLabel, 0, 0, 1, 1) self.httpPasswordLe = QtWidgets.QLineEdit(self.httpAuthPage) self.httpPasswordLe.setEchoMode(QtWidgets.QLineEdit.Password) self.httpPasswordLe.setObjectName("httpPasswordLe") - self.gridLayout.addWidget(self.httpPasswordLe, 1, 1, 1, 1) + self.gridLayout.addWidget(self.httpPasswordLe, 0, 1, 1, 1) self.httpPasswordLabel = QtWidgets.QLabel(self.httpAuthPage) self.httpPasswordLabel.setObjectName("httpPasswordLabel") - self.gridLayout.addWidget(self.httpPasswordLabel, 1, 0, 1, 1) - self.httpUsernameLe = QtWidgets.QLineEdit(self.httpAuthPage) - self.httpUsernameLe.setObjectName("httpUsernameLe") - self.gridLayout.addWidget(self.httpUsernameLe, 0, 1, 1, 1) + self.gridLayout.addWidget(self.httpPasswordLabel, 0, 0, 1, 1) self.verticalLayout_4.addLayout(self.gridLayout) - self.httpBb = QtWidgets.QDialogButtonBox(self.httpAuthPage) - self.httpBb.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) - self.httpBb.setObjectName("httpBb") - self.verticalLayout_4.addWidget(self.httpBb) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_4.addItem(spacerItem) self.stackedWidget.addWidget(self.httpAuthPage) self.verticalLayout.addWidget(self.stackedWidget) self.line_2 = QtWidgets.QFrame(MSColabConnectDialog) @@ -163,7 +161,7 @@ def setupUi(self, MSColabConnectDialog): self.verticalLayout.addLayout(self.statusHL) self.retranslateUi(MSColabConnectDialog) - self.stackedWidget.setCurrentIndex(0) + self.stackedWidget.setCurrentIndex(2) QtCore.QMetaObject.connectSlotsByName(MSColabConnectDialog) MSColabConnectDialog.setTabOrder(self.urlCb, self.connectBtn) MSColabConnectDialog.setTabOrder(self.connectBtn, self.loginEmailLe) @@ -174,8 +172,7 @@ def setupUi(self, MSColabConnectDialog): MSColabConnectDialog.setTabOrder(self.newUsernameLe, self.newEmailLe) MSColabConnectDialog.setTabOrder(self.newEmailLe, self.newPasswordLe) MSColabConnectDialog.setTabOrder(self.newPasswordLe, self.newConfirmPasswordLe) - MSColabConnectDialog.setTabOrder(self.newConfirmPasswordLe, self.httpUsernameLe) - MSColabConnectDialog.setTabOrder(self.httpUsernameLe, self.httpPasswordLe) + MSColabConnectDialog.setTabOrder(self.newConfirmPasswordLe, self.httpPasswordLe) def retranslateUi(self, MSColabConnectDialog): _translate = QtCore.QCoreApplication.translate @@ -202,9 +199,6 @@ def retranslateUi(self, MSColabConnectDialog): self.newUsernameLabel.setText(_translate("MSColabConnectDialog", "Username:")) self.newConfirmPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Confirm New Password")) self.httpTopicLabel.setText(_translate("MSColabConnectDialog", "HTTP Server Authentication")) - self.httpInfoLabel.setText(_translate("MSColabConnectDialog", "The server you are trying to connect requires a username and a password:")) - self.httpUsernameLabel.setText(_translate("MSColabConnectDialog", "Username:")) self.httpPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Server Auth Password")) self.httpPasswordLabel.setText(_translate("MSColabConnectDialog", "Password:")) - self.httpUsernameLe.setPlaceholderText(_translate("MSColabConnectDialog", "Server Auth Username")) self.statusLabel.setText(_translate("MSColabConnectDialog", "Status:")) diff --git a/mslib/msui/ui/ui_mscolab_connect_dialog.ui b/mslib/msui/ui/ui_mscolab_connect_dialog.ui index d740d2bc0..85d683571 100644 --- a/mslib/msui/ui/ui_mscolab_connect_dialog.ui +++ b/mslib/msui/ui/ui_mscolab_connect_dialog.ui @@ -73,7 +73,7 @@ - 0 + 2 @@ -272,28 +272,29 @@ + + true + + + + 0 + 0 + + HTTP Server Authentication - - - - - - The server you are trying to connect requires a username and a password: + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - Username: - - - - + + QLayout::SetDefaultConstraint + + QLineEdit::Password @@ -303,28 +304,27 @@ - + Password: - - - - Server Auth Username - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + Qt::Vertical - + + + 20 + 40 + + + @@ -367,7 +367,6 @@ newEmailLe newPasswordLe newConfirmPasswordLe - httpUsernameLe httpPasswordLe diff --git a/mslib/utils/config.py b/mslib/utils/config.py index cc12cb0e4..424cfc093 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -132,7 +132,7 @@ class MSUIDefaultConfig(object): MSCOLAB_mailid = "" # category for MSC operations - MSCOLAB_category = "ANY" + MSCOLAB_category = "default" # list of MSC servers {"http://www.your-mscolab-server.de": "authuser", # "http://www.your-wms-server.de": "authuser"} diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 3a4e24ab1..7d3313155 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -86,15 +86,31 @@ def teardown_method(self): def test_url_combo(self): assert self.window.urlCb.count() >= 1 + @pytest.mark.parametrize( + "exc", + [requests.exceptions.ConnectionError, requests.exceptions.InvalidSchema, + requests.exceptions.InvalidURL, requests.exceptions.SSLError, + Exception]) @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") - def test_connect(self, mockset): - for exc in [requests.exceptions.ConnectionError, requests.exceptions.InvalidSchema, - requests.exceptions.InvalidURL, requests.exceptions.SSLError, Exception("")]: - with mock.patch("requests.get", new=ExceptionMock(exc).raise_exc): - self.window.connect_handler() + def test_connect_except(self, mockset, exc): + with mock.patch("requests.Session.get", new=ExceptionMock(exc).raise_exc): + self.window.connect_handler() + assert mockset.call_args_list == [mock.call("color: red;")] - self._connect_to_mscolab() - assert mockset.call_args_list == [mock.call("color: red;") for _ in range(5)] + [mock.call("color: green;")] + @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") + def test_connect_denied(self, mockset): + with mock.patch("requests.Session.get", return_value=mock.Mock(status_code=401)): + self.window.connect_handler() + assert mockset.call_args_list == [mock.call("color: red;")] + + @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") + @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) + def test_connect_success(self, mockbox, mockset): + assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") != "fnord" + self._connect_to_mscolab(password="fnord") + + assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") == "fnord" + assert mockset.call_args_list == [mock.call("color: green;")] def test_disconnect(self): self._connect_to_mscolab() @@ -148,7 +164,7 @@ def test_logout(self): def test_add_user(self, mockmessage): self._connect_to_mscolab() self._create_user("something", "something@something.org", "something") - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" assert mslib.utils.auth.get_password_from_keyring("MSCOLAB", "something@something.org") == "password from TestKeyring" # assert self.window.stackedWidget.currentWidget() == self.window.newuserPage @@ -157,72 +173,42 @@ def test_add_user(self, mockmessage): @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) def test_add_users_without_updating_credentials_in_config_file(self, mockmessage): - create_msui_settings_file('{"MSCOLAB_mailid": "something@something.org"}') + create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') read_config_file() # check current settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" self._connect_to_mscolab() assert self.window.mscolab_server_url is not None - self._create_user("anand", "anand@something.org", "anand") + self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert mslib.utils.auth.get_password_from_keyring(service_name=self.url, + username="anand@something.org") == "anand_pass" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" # check user is logged in assert self.main_window.usernameLabel.text() == "anand" @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_add_users_with_updating_credentials_in_config_file(self, mockmessage): create_msui_settings_file('{"MSCOLAB_mailid": "something@something.org" }') - mslib.utils.auth.save_password_to_keyring(service_name="MSCOLAB", + create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') + mslib.utils.auth.save_password_to_keyring(service_name=self.url, username="something@something.org", password="something") read_config_file() # check current settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" - assert mslib.utils.auth.get_password_from_keyring("MSCOLAB", - "something@something.org") == "password from TestKeyring" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" self._connect_to_mscolab() assert self.window.mscolab_server_url is not None - self._create_user("anand", "anand@something.org", "anand") + self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings - assert config_loader(dataset="MSCOLAB_mailid") == "anand@something.org" - assert mslib.utils.auth.get_password_from_keyring(service_name="MSCOLAB", - username="anand@something.org") == "password from TestKeyring" + assert config_loader(dataset="MSS_auth").get(self.url) == "anand@something.org" + assert mslib.utils.auth.get_password_from_keyring(service_name=self.url, + username="anand@something.org") == "anand_pass" # check user is logged in assert self.main_window.usernameLabel.text() == "anand" - def test_failed_authorize(self): - class response: - def __init__(self, code, text): - self.status_code = code - self.text = text - - # case: connection error when trying to login after connecting to server - self._connect_to_mscolab() - with mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") as mockset: - with mock.patch("requests.Session.post", new=ExceptionMock(requests.exceptions.ConnectionError).raise_exc): - self._login() - mockset.assert_has_calls([mock.call("color: red;"), mock.call("")]) - - # case: when the credentials are incorrect for login - self._connect_to_mscolab() - with mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") as mockset: - with mock.patch("requests.Session.post", return_value=response(201, "False")): - self._login() - mockset.assert_has_calls([mock.call("color: red;")]) - - # case: when http auth fails - with mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") as mockset: - with mock.patch("requests.Session.post", return_value=response(401, "Unauthorized Access")): - self._login() - # check if switched to HTTP Auth Page - assert self.window.stackedWidget.currentIndex() == 2 - # press ok without entering server auth details - okWidget = self.window.httpBb.button(self.window.httpBb.Ok) - QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - mockset.assert_has_calls([mock.call("color: red;")]) - - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, password=""): self.window.urlCb.setEditText(self.url) + self.window.httpPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.connectBtn, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) diff --git a/tests/_test_utils/test_auth.py b/tests/_test_utils/test_auth.py index 54a839047..257e8f5fe 100644 --- a/tests/_test_utils/test_auth.py +++ b/tests/_test_utils/test_auth.py @@ -33,14 +33,18 @@ def test_keyring(): username = "something@something.org" - password = "x-*\\M#.U6R(HPNW2}" + password = "abcdef" auth.save_password_to_keyring(service_name="MSCOLAB", username=username, password=password) - assert auth.get_password_from_keyring(service_name="MSCOLAB", - username=username) == "password from TestKeyring" + assert auth.get_password_from_keyring( + service_name="MSCOLAB", username=username) == password + password = "123456" + auth.save_password_to_keyring(service_name="MSCOLAB", username=username, password=password) + assert auth.get_password_from_keyring( + service_name="MSCOLAB", username=username) == "123456" auth.del_password_from_keyring(service_name="MSCOLAB", username=username) # the testsetu returns the same string per definition - assert auth.get_password_from_keyring(service_name="MSCOLAB", - username=username) == "password from TestKeyring" + assert auth.get_password_from_keyring( + service_name="MSCOLAB", username=username) == "password from TestKeyring" def test_get_auth_from_url_and_name(): @@ -62,15 +66,15 @@ def test_get_auth_from_url_and_name(): # store a password auth.save_password_to_keyring(server_url, auth_username, "password") # return the test password - assert auth.get_password_from_keyring(server_url, auth_username) == 'password from TestKeyring' + assert auth.get_password_from_keyring(server_url, auth_username) == "password" assert constants.AUTH_LOGIN_CACHE == {} auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=False) # password is set but doesn't go into the login cache assert constants.AUTH_LOGIN_CACHE == {} # now we overwrite_login_cache=True data = auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=True) - assert data == (auth_username, 'password from TestKeyring') - assert constants.AUTH_LOGIN_CACHE[server_url] == (auth_username, 'password from TestKeyring') + assert data == (auth_username, 'password') + assert constants.AUTH_LOGIN_CACHE[server_url] == (auth_username, 'password') # restart and use a different url create_msui_settings_file(f'{{"MSS_auth": {{"http://example.com": "{auth_username}"}}}}') read_config_file() @@ -78,4 +82,4 @@ def test_get_auth_from_url_and_name(): assert data == (None, None) # check storage of MSCOLAB password auth.save_password_to_keyring('MSCOLAB', auth_username, "password") - assert auth.get_password_from_keyring("MSCOLAB", auth_username) == 'password from TestKeyring' + assert auth.get_password_from_keyring("MSCOLAB", auth_username) == 'password' diff --git a/tests/_test_utils/test_migration.py b/tests/_test_utils/test_migration.py index f26fd45c1..78e898d7c 100644 --- a/tests/_test_utils/test_migration.py +++ b/tests/_test_utils/test_migration.py @@ -91,8 +91,8 @@ def test_upgrade_json_file_to_version_eight(self): } # verify keyring data = auth.get_auth_from_url_and_name("http://www.your-server.de/forecasts", http_auth) - assert data == ("youruser", 'password from TestKeyring') - assert auth.get_password_from_keyring("MSCOLAB", "something@something.org") == 'password from TestKeyring' + assert data == ("youruser", 'yourpassword') + assert auth.get_password_from_keyring("MSCOLAB", "something@something.org") == mail_password # check removed old attributes with pytest.raises(KeyError): From 8563eb90addbbaa8f247456f4190bbb77f98bb91 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 25 May 2023 16:39:07 +0200 Subject: [PATCH 009/176] fill mswms_settings with default values. (#1794) * fill mswms_settings with default values. * update --- conftest.py | 176 ++++++------------ .../config/mscolab/mscolab_settings.py.sample | 90 +++++---- mslib/mscolab/conf.py | 105 ++++++----- mslib/mswms/wms.py | 98 +++++----- 4 files changed, 210 insertions(+), 259 deletions(-) diff --git a/conftest.py b/conftest.py index 7b7887d9e..7586141f7 100644 --- a/conftest.py +++ b/conftest.py @@ -107,94 +107,67 @@ def pytest_generate_tests(metafunc): examples.create_data() if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_CONFIG_FILE): - config_string = f'''# -*- coding: utf-8 -*- -""" - - mslib.mscolab.conf.py.example - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - config for mscolab. - - This file is part of MSS. - - :copyright: Copyright 2019 Shivashis Padhi - :copyright: Copyright 2019-2023 by the MSS team, see AUTHORS. - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + config_string = f''' +# SQLALCHEMY_DB_URI = 'mysql://user:pass@127.0.0.1/mscolab' +import os +import logging +import fs +import secrets +from werkzeug.urls import url_join + +ROOT_DIR = '{constants.ROOT_DIR}' +# directory where mss output files are stored +root_fs = fs.open_fs(ROOT_DIR) +if not root_fs.exists('colabTestData'): + root_fs.makedir('colabTestData') +BASE_DIR = ROOT_DIR +DATA_DIR = fs.path.join(ROOT_DIR, 'colabTestData') +# mscolab data directory +MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') + +# used to generate and parse tokens +SECRET_KEY = secrets.token_urlsafe(16) + +# used to generate the password token +SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) + +# mail settings +MAIL_SERVER = 'localhost' +MAIL_PORT = 25 +MAIL_USE_TLS = False +MAIL_USE_SSL = True + +# mail authentication +MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') +MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') + +# mail accounts +MAIL_DEFAULT_SENDER = 'MSS@localhost' + +# enable verification by Mail +MAIL_ENABLED = False + +SQLALCHEMY_DB_URI = 'sqlite:///' + url_join(DATA_DIR, 'mscolab.db') + +# mscolab file upload settings +UPLOAD_FOLDER = fs.path.join(DATA_DIR, 'uploads') +MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB + +# text to be written in new mscolab based ftml files. +STUB_CODE = """ + + + + + + + + + + """ -class mscolab_settings(object): - - # SQLALCHEMY_DB_URI = 'mysql://user:pass@127.0.0.1/mscolab' - import os - import logging - import fs - import secrets - from werkzeug.urls import url_join - - ROOT_DIR = '{constants.ROOT_DIR}' - # directory where mss output files are stored - root_fs = fs.open_fs(ROOT_DIR) - if not root_fs.exists('colabTestData'): - root_fs.makedir('colabTestData') - BASE_DIR = ROOT_DIR - DATA_DIR = fs.path.join(ROOT_DIR, 'colabTestData') - # mscolab data directory - MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') - - # used to generate and parse tokens - SECRET_KEY = secrets.token_urlsafe(16) - - # used to generate the password token - SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) - - # mail settings - MAIL_SERVER = 'localhost' - MAIL_PORT = 25 - MAIL_USE_TLS = False - MAIL_USE_SSL = True - - # mail authentication - MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') - MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') - - # mail accounts - MAIL_DEFAULT_SENDER = 'MSS@localhost' - - # enable verification by Mail - MAIL_ENABLED = False - - SQLALCHEMY_DB_URI = 'sqlite:///' + url_join(DATA_DIR, 'mscolab.db') - - # mscolab file upload settings - UPLOAD_FOLDER = fs.path.join(DATA_DIR, 'uploads') - MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB - - # text to be written in new mscolab based ftml files. - STUB_CODE = """ - - - - - - - - - - - """ - enable_basic_http_authentication = False - ''' +enable_basic_http_authentication = False +''' ROOT_FS = fs.open_fs(constants.ROOT_DIR) if not ROOT_FS.exists('mscolab'): ROOT_FS.makedir('mscolab') @@ -205,38 +178,13 @@ class mscolab_settings(object): parent_path = fs.path.join(constants.ROOT_DIR, 'mscolab') if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_AUTH_FILE): - config_string = f'''# -*- coding: utf-8 -*- -""" - - mslib.mscolab.auth.py.example - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - config for mscolab auth. - - This file is part of MSS. - - :copyright: Copyright 2019 Shivashis Padhi - :copyright: Copyright 2019-2023 by the MSS team, see AUTHORS. - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" + config_string = ''' import hashlib class mscolab_auth(object): password = "testvaluepassword" allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] - ''' +''' ROOT_FS = fs.open_fs(constants.ROOT_DIR) if not ROOT_FS.exists('mscolab'): ROOT_FS.makedir('mscolab') diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index 57f31b218..48a0bd1f8 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -25,49 +25,47 @@ limitations under the License. """ import os -import logging - -class mscolab_settings: - # Set which origins are allowed to communicate with your server - CORS_ORIGINS = ["*"] - - # Set base directory where you want to save Mscolab data - BASE_DIR = os.path.abspath(os.path.dirname(__file__)) - - # Directory in which all data related to Mscolab is stored - DATA_DIR = os.path.join(BASE_DIR, "colabdata") - - # Where mscolab project files are stored on the server - MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') - - # Directory where uploaded images and documents in the chat are stored - UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') - - # Max image/document upload size in mscolab chat (default 2MB) - MAX_UPLOAD_SIZE = 2 * 1024 * 1024 - - # Set your secret key for token generation - SECRET_KEY = 'MySecretKey' - - # Set the database connection string: - # Examples for different DBMS: - # MySQL: "mysql+pymysql://:@/?charset=utf8mb4" - # PostgreSQL: "postgresql://:@/" - # SQLite: "sqlite:///" - SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') - - enable_basic_http_authentication = False - - # text to be written in new mscolab based ftml files. - STUB_CODE = """ - - - - - - - - - - - """ + +# Set which origins are allowed to communicate with your server +CORS_ORIGINS = ["*"] + +# Set base directory where you want to save Mscolab data +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + +# Directory in which all data related to Mscolab is stored +DATA_DIR = os.path.join(BASE_DIR, "colabdata") + +# Where mscolab project files are stored on the server +MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') + +# Directory where uploaded images and documents in the chat are stored +UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') + +# Max image/document upload size in mscolab chat (default 2MB) +MAX_UPLOAD_SIZE = 2 * 1024 * 1024 + +# Set your secret key for token generation +SECRET_KEY = 'MySecretKey' + +# Set the database connection string: +# Examples for different DBMS: +# MySQL: "mysql+pymysql://:@/?charset=utf8mb4" +# PostgreSQL: "postgresql://:@/" +# SQLite: "sqlite:///" +SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') + +enable_basic_http_authentication = False + +# text to be written in new mscolab based ftml files. +STUB_CODE = """ + + + + + + + + + + +""" diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 8ecb2bd60..09325703f 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -24,72 +24,75 @@ See the License for the specific language governing permissions and limitations under the License. """ +import os import logging import secrets -try: - from mscolab_settings import mscolab_settings - logging.info("Using user defined settings") -except ImportError as ex: - logging.warning(u"Couldn't import mscolab_settings (ImportError:'%s'), using dummy config.", ex) - class mscolab_settings(object): - import os - import logging +class default_mscolab_settings(object): + # expire token in seconds + # EXPIRATION = 86400 + + # Which origins are allowed to communicate with your server + CORS_ORIGINS = ["*"] - # expire token in seconds - # EXPIRATION = 86400 + # dir where msui output files are stored + BASE_DIR = os.path.expanduser("~") - # Which origins are allowed to communicate with your server - CORS_ORIGINS = ["*"] + DATA_DIR = os.path.join(BASE_DIR, "colabdata") - # dir where msui output files are stored - BASE_DIR = os.path.expanduser("~") + # mscolab data directory + MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') - DATA_DIR = os.path.join(BASE_DIR, "colabdata") + # MYSQL CONNECTION STRING: "mysql+pymysql://:@:/?charset=utf8mb4" + SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') - # mscolab data directory - MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') + # mscolab file upload settings + UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') + MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB - # MYSQL CONNECTION STRING: "mysql+pymysql://:@:/?charset=utf8mb4" - SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') + # used to generate and parse tokens + SECRET_KEY = secrets.token_urlsafe(16) - # mscolab file upload settings - UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') - MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB + # used to generate the password token + SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) - # used to generate and parse tokens - SECRET_KEY = secrets.token_urlsafe(16) + STUB_CODE = """ + + + + + + + + + + + """ + enable_basic_http_authentication = False - # used to generate the password token - SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) + # enable verification by Mail + MAIL_ENABLED = False - STUB_CODE = """ - - - - - - - - - - - """ - enable_basic_http_authentication = False + # mail settings + # MAIL_SERVER = 'localhost' + # MAIL_PORT = 25 + # MAIL_USE_TLS = False + # MAIL_USE_SSL = True - # enable verification by Mail - MAIL_ENABLED = False + # mail authentication + # MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') + # MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') - # mail settings - # MAIL_SERVER = 'localhost' - # MAIL_PORT = 25 - # MAIL_USE_TLS = False - # MAIL_USE_SSL = True + # mail accounts + # MAIL_DEFAULT_SENDER = 'MSS@localhost' - # mail authentication - # MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') - # MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') - # mail accounts - # MAIL_DEFAULT_SENDER = 'MSS@localhost' +mscolab_settings = default_mscolab_settings() + +try: + import mscolab_settings as user_settings + logging.info("Using user defined settings") + mscolab_settings.__dict__.update(user_settings.__dict__) +except ImportError as ex: + logging.warning(u"Couldn't import mscolab_settings (ImportError:'%s'), using dummy config.", ex) diff --git a/mslib/mswms/wms.py b/mslib/mswms/wms.py index aecbd2f12..58227f38f 100644 --- a/mslib/mswms/wms.py +++ b/mslib/mswms/wms.py @@ -79,33 +79,40 @@ realm = 'Mission Support Web Map Service' app.config['realm'] = realm + +class default_mswms_settings(object): + base_dir = os.path.abspath(os.path.dirname(__file__)) + xml_template_location = os.path.join(base_dir, "xml_templates") + service_name = "OGC:WMS" + service_title = "Mission Support System Web Map Service" + service_abstract = "" + service_contact_person = "" + service_contact_organisation = "" + service_contact_position = "" + service_address_type = "" + service_address = "" + service_city = "" + service_state_or_province = "" + service_post_code = "" + service_country = "" + service_fees = "" + service_email = "" + service_access_constraints = "This service is intended for research purposes only." + register_horizontal_layers = [] + register_vertical_layers = [] + register_linear_layers = [] + data = {} + enable_basic_http_authentication = False + __file__ = None + + +mswms_settings = default_mswms_settings() + try: - import mswms_settings + import mswms_settings as user_settings + mswms_settings.__dict__.update(user_settings.__dict__) except ImportError as ex: - logging.warning("Couldn't import mswms_settings (ImportError:'%s'), creating dummy config.", ex) - - class mswms_settings(object): - base_dir = os.path.abspath(os.path.dirname(__file__)) - xml_template_location = os.path.join(base_dir, "xml_templates") - service_name = "OGC:WMS" - service_title = "Mission Support System Web Map Service" - service_abstract = "" - service_contact_person = "" - service_contact_organisation = "" - service_address_type = "" - service_address = "" - service_city = "" - service_state_or_province = "" - service_post_code = "" - service_country = "" - service_fees = "" - service_access_constraints = "This service is intended for research purposes only." - register_horizontal_layers = [] - register_vertical_layers = [] - register_linear_layers = [] - data = {} - enable_basic_http_authentication = False - __file__ = None + logging.warning("Couldn't import mswms_settings (ImportError:'%s'), Using dummy config.", ex) try: import mswms_auth @@ -117,7 +124,7 @@ class mswms_auth(object): ("add_new_user_here", "add_md5_digest_of_PASSWORD_here")] __file__ = None -if mswms_settings.__dict__.get('enable_basic_http_authentication', False): +if mswms_settings.enable_basic_http_authentication: logging.debug("Enabling basic HTTP authentication. Username and " "password required to access the service.") import hashlib @@ -145,9 +152,7 @@ def verify_pw(username, password): datefmt="%Y-%m-%d %H:%M:%S") # Chameleon XMl template -base_dir = os.path.abspath(os.path.dirname(__file__)) -xml_template_location = os.path.join(base_dir, "xml_templates") -templates = PageTemplateLoader(mswms_settings.__dict__.get("xml_template_location", xml_template_location)) +templates = PageTemplateLoader(mswms_settings.xml_template_location) def squash_multiple_images(imgs): @@ -617,26 +622,23 @@ def get_capabilities(self, query, server_url=None): continue lsec_layers.append((dataset, layer)) - settings = mswms_settings.__dict__ return_data = template(hsec_layers=hsec_layers, vsec_layers=vsec_layers, lsec_layers=lsec_layers, server_url=server_url, - service_name=settings.get("service_name", "OGC:WMS"), - service_title=settings.get("service_title", "Mission Support System Web Map Service"), - service_abstract=settings.get("service_abstract", ""), - service_contact_person=settings.get("service_contact_person", ""), - service_contact_organisation=settings.get("service_contact_organisation", ""), - service_contact_position=settings.get("service_contact_position", ""), - service_email=settings.get("service_email", ""), - service_address_type=settings.get("service_address_type", ""), - service_address=settings.get("service_address", ""), - service_city=settings.get("service_city", ""), - service_state_or_province=settings.get("service_state_or_province", ""), - service_post_code=settings.get("service_post_code", ""), - service_country=settings.get("service_country", ""), - service_fees=settings.get("service_fees", ""), - service_access_constraints=settings.get( - "service_access_constraints", - "This service is intended for research purposes only.")) + service_name=mswms_settings.service_name, + service_title=mswms_settings.service_title, + service_abstract=mswms_settings.service_abstract, + service_contact_person=mswms_settings.service_contact_person, + service_contact_organisation=mswms_settings.service_contact_organisation, + service_contact_position=mswms_settings.service_contact_position, + service_email=mswms_settings.service_email, + service_address_type=mswms_settings.service_address_type, + service_address=mswms_settings.service_address, + service_city=mswms_settings.service_city, + service_state_or_province=mswms_settings.service_state_or_province, + service_post_code=mswms_settings.service_post_code, + service_country=mswms_settings.service_country, + service_fees=mswms_settings.service_fees, + service_access_constraints=mswms_settings.service_access_constraints) return return_data.encode("utf-8"), "text/xml" def produce_plot(self, query, mode): @@ -949,7 +951,7 @@ def produce_plot(self, query, mode): @app.route('/') -@conditional_decorator(auth.login_required, mswms_settings.__dict__.get('enable_basic_http_authentication', False)) +@conditional_decorator(auth.login_required, mswms_settings.enable_basic_http_authentication) def application(): try: # Request info From b0c5a9a5b6f8dcb133ac06afd99f476a917d86c7 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 25 May 2023 20:49:21 +0200 Subject: [PATCH 010/176] preparing migration of json data to next major (#1798) * preparing migration of json data to next major * flake8 * further changes * type fixed * new test for new migration * sample files updated * test improved * syntax fixe in sample --- docs/mscolab.rst | 6 - .../config/msui/mssautoplot.json.sample | 3 +- .../config/msui/msui_settings.json.sample | 4 +- mslib/mscolab/seed.py | 4 - mslib/msui/msui.py | 20 +- mslib/utils/config.py | 5 - mslib/utils/migration/config_before_nine.py | 627 ++++++++++++++++++ .../update_json_file_to_version_nine.py | 94 +++ tests/_test_msui/test_mscolab.py | 1 - tests/_test_utils/test_config.py | 12 +- tests/_test_utils/test_migration.py | 60 +- tests/data/msui_settings.json | 4 +- 12 files changed, 797 insertions(+), 43 deletions(-) create mode 100644 mslib/utils/migration/config_before_nine.py create mode 100644 mslib/utils/migration/update_json_file_to_version_nine.py diff --git a/docs/mscolab.rst b/docs/mscolab.rst index c69cec70d..b97cb67e2 100644 --- a/docs/mscolab.rst +++ b/docs/mscolab.rst @@ -62,14 +62,8 @@ After executed you get informations to exchange with users. y Userdata: email suggested_username 30736d0350c9b886 - "MSCOLAB_mailid": "email", - "MSCOLAB_password": "30736d0350c9b886", - - Userdata: email2 suggested_username2 342434de34904303 - "MSCOLAB_mailid": "email2", - "MSCOLAB_password": "342434de34904303", Further options can be listed by `mscolab db -h` diff --git a/docs/samples/config/msui/mssautoplot.json.sample b/docs/samples/config/msui/mssautoplot.json.sample index 284db0987..a21327a4c 100644 --- a/docs/samples/config/msui/mssautoplot.json.sample +++ b/docs/samples/config/msui/mssautoplot.json.sample @@ -86,8 +86,7 @@ ], "automated_plotting_lsecs": [ ["", "", ""] - ] + ], - "MSCOLAB_mailid": "", "MSCOLAB_operations": [] } diff --git a/docs/samples/config/msui/msui_settings.json.sample b/docs/samples/config/msui/msui_settings.json.sample index 883c04e12..2f125181e 100644 --- a/docs/samples/config/msui/msui_settings.json.sample +++ b/docs/samples/config/msui/msui_settings.json.sample @@ -72,7 +72,5 @@ "MSS_auth": { "http://www.your-server.de/forecasts": "authuser", "http://www.your-mscolab-server.de": "authuser" - }, - - "MSCOLAB_mailid": "" + } } diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index 2eb08fccc..0f3003b1e 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -116,9 +116,6 @@ def add_user(email, username, password): app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - template = f""" - "MSCOLAB_mailid": "{email}", -""" with app.app_context(): user_email_exists = User.query.filter_by(emailid=str(email)).first() user_name_exists = User.query.filter_by(username=str(username)).first() @@ -128,7 +125,6 @@ def add_user(email, username, password): db.session.commit() db.session.close() logging.info("Userdata: %s %s %s", email, username, password) - logging.info(template) return True else: logging.info("%s already in db", user_name_exists) diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index 8ebbfc81e..c460a5625 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -1173,18 +1173,16 @@ def notify(QObject, QEvent): application.setApplicationDisplayName("MSUI") application.setAttribute(QtCore.Qt.AA_DisableWindowContextHelpButton) mainwindow = MSUIMainWindow() - if version.parse(__version__) >= version.parse('8.0.0') and version.parse(__version__) < version.parse('9.0.0'): - from mslib.utils.migration.config_before_eight import read_config_file as read_config_file_before_eight - from mslib.utils.migration.config_before_eight import config_loader as config_loader_before_eight - read_config_file_before_eight() - if config_loader_before_eight(dataset="WMS_login") or config_loader_before_eight( - dataset="MSC_login") or config_loader_before_eight(dataset="MSCOLAB_password"): + if version.parse(__version__) >= version.parse('9.0.0') and version.parse(__version__) < version.parse('10.0.0'): + from mslib.utils.migration.config_before_nine import read_config_file as read_config_file_before_nine + from mslib.utils.migration.config_before_nine import config_loader as config_loader_before_nine + read_config_file_before_nine() + if config_loader_before_nine(dataset="MSCOLAB_mailid"): text = """We can update your msui_settings.json file \n -We add the new attributes for the webserver authentication, see \n +We add the new attributes for the MSColab login, see \n https://mss.readthedocs.io/en/stable/usage.html#mscolab-login-and-www-authentication \n -The old attributes get removed: \n -WMS_login, MSC_login, MSCOLAB_password \n\n + A backup of the old file is stored. """ @@ -1193,8 +1191,8 @@ def notify(QObject, QEvent): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: - from mslib.utils.migration.update_json_file_to_version_eight import JsonConversion - if version.parse(__version__) >= version.parse('8.0.0'): + from mslib.utils.migration.update_json_file_to_version_nine import JsonConversion + if version.parse(__version__) >= version.parse('9.0.0'): new_version = JsonConversion() new_version.change_parameters() read_config_file() diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 424cfc093..f0f50a978 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -128,9 +128,6 @@ class MSUIDefaultConfig(object): "http://localhost:8083", ] - # mail address to sign in - MSCOLAB_mailid = "" - # category for MSC operations MSCOLAB_category = "default" @@ -238,7 +235,6 @@ class MSUIDefaultConfig(object): 'num_labels', 'num_interpolation_points', 'new_flighttrack_flightlevel', - 'MSCOLAB_mailid', 'MSCOLAB_category', 'mscolab_server_url', 'wms_cache', @@ -301,7 +297,6 @@ class MSUIDefaultConfig(object): "default_LSEC_WMS": "Documentation Required", "default_MSCOLAB": "Documentation Required", "MSS_auth": "Documentation Required", - "MSCOLAB_mailid": "Documentation Required", "WMS_request_timeout": "Documentation Required", "WMS_preload": "Documentation Required", "wms_cache": "Documentation Required", diff --git a/mslib/utils/migration/config_before_nine.py b/mslib/utils/migration/config_before_nine.py new file mode 100644 index 000000000..e38cf8ab8 --- /dev/null +++ b/mslib/utils/migration/config_before_nine.py @@ -0,0 +1,627 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.migration.config_before_nine + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Collection of functions all around config handling before version 9.0.0 + + This file is part of MSS. + + :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. + :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import sys +from PyQt5 import QtCore + +import copy +import json +import logging +import fs +import os +import tempfile + +from mslib.utils import FatalUserError +from mslib.msui import constants +from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType + + +class MSUIDefaultConfig(object): + """Central configuration for the Mission Support System User Interface + Application (msui). + + DESCRIPTION: + ============ + + This file includes configuration settings central to the entire + Mission Support User Interface (msui). Among others, define + -- available map projections + -- vertical section interpolation options + -- the lists of predefined web service URLs + -- predefined waypoints for the table view + in this file. + + Do not change any value for good reasons. + Your values can be set in your personal msui_settings.json file + """ + # this skips the verification of the user token on each mscolab request + mscolab_skip_verify_user_token = True + + # Default for general filepicker. Pick "default", "qt", or "fs" + filepicker_default = "default" + + # dir where msui output files are stored + data_dir = "~/mssdata" + + # layout of different views, with immutable they can't resized + layout = {"topview": [963, 702], + "sideview": [913, 557], + "linearview": [913, 557], + "tableview": [1236, 424], + "immutable": False} + + # Predefined map regions to be listed in the corresponding topview combobox + predefined_map_sections = { + "01 Europe (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": -15.0, "llcrnrlat": 35.0, + "urcrnrlon": 30.0, "urcrnrlat": 65.0}}, + "02 Germany (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": 5.0, "llcrnrlat": 45.0, + "urcrnrlon": 15.0, "urcrnrlat": 57.0}}, + "03 Global (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": -180.0, "llcrnrlat": -90.0, + "urcrnrlon": 180.0, "urcrnrlat": 90.0}}, + "04 Northern Hemisphere (stereo)": {"CRS": "MSS:stere,0,90,90", + "map": {"llcrnrlon": -45.0, "llcrnrlat": 0.0, + "urcrnrlon": 135.0, "urcrnrlat": 0.0}} + } + + # Side View. + # The following two parameters are passed to the WMS in the BBOX + # argument when a vertical cross section is requested. + + # Number of interpolation points used to interpolate the flight track + # to a great circle. + num_interpolation_points = 201 + + # Number of x-axis labels in the side view. + num_labels = 10 + + # Web Map Service Client. + # Settings for the WMS client. Set the URLs of WMS servers that appear + # by default in the WMS control (for examples, see + # http://external.opengis.org/twiki_public/bin/view/MetOceanDWG/MetocWMS_Servers). + # Also set the location of the image file cache and its size. + + # URLs of default WMS servers. + default_WMS = [ + "http://localhost:8081/", + "http://eumetview.eumetsat.int/geoserver/wms", + "https://apps.ecmwf.int/wms/?token=public" + ] + + default_VSEC_WMS = [ + "http://localhost:8081/" + ] + + default_LSEC_WMS = [ + "http://localhost:8081/" + ] + + # URLs of default mscolab servers + default_MSCOLAB = [ + "http://localhost:8083", + ] + + # mail address to sign in + MSCOLAB_mailid = "" + + # category for MSC operations + MSCOLAB_category = "default" + + # list of MSC servers {"http://www.your-mscolab-server.de": "authuser", + # "http://www.your-wms-server.de": "authuser"} + MSS_auth = {} + + # timeout of Url request + WMS_request_timeout = 30 + + WMS_preload = [] + + # WMS image cache settings: + # this changes on any start of msui, use ths msui_settings.json when you want a persistent path + wms_cache = os.path.join(tempfile.TemporaryDirectory().name, "msui_wms_cache") + + # Maximum size of the cache in bytes. + wms_cache_max_size_bytes = 20 * 1024 * 1024 + + # Maximum age of a cached file in seconds. + wms_cache_max_age_seconds = 5 * 86400 + + wms_prefetch = { + "validtime_fwd": 0, + "validtime_bck": 0, + "level_up": 0, + "level_down": 0 + } + + locations = { + "EDMO": [48.08, 11.28], + "Hannover": [52.37, 9.74], + "Hamburg": [53.55, 9.99], + "Juelich": [50.92, 6.36], + "Leipzig": [51.34, 12.37], + "Muenchen": [48.14, 11.57], + "Stuttgart": [48.78, 9.18], + "Wien": [48.20833, 16.373064], + "Zugspitze": [47.42, 10.98], + "Kiruna": [67.821, 20.336], + "Ny-Alesund": [78.928, 11.986], + "Zhukovsky": [55.6, 38.116], + "Paphos": [34.775, 32.425], + "Sharjah": [25.35, 55.65], + "Brindisi": [40.658, 17.947], + "Nagpur": [21.15, 79.083], + "Mumbai": [19.089, 72.868], + "Delhi": [28.566, 77.103], + } + + # Main application: Template for new flight tracks + # Flight track template that is used when a new flight track is + # created. Specify a list of place names that can be found in the + # "locations" dictionary defined above. + new_flighttrack_template = ["Nagpur", "Delhi"] + + # This configures the flight level for waypoints inserted by the + # flighttrack template + new_flighttrack_flightlevel = 0 + + # None is not wanted here + proxies = {} + + # ToDo configurable later + # mscolab server + mscolab_server_url = "http://localhost:8083" + # ToDo refactor to rename this to data_dir/mss_data_dir + # mss dir + mss_dir = "~/mss" + + # list of gravatar email ids to automatically fetch + gravatar_ids = [] + + # dictionary for export plugins, e.g. {"Text": ["txt", "mslib.plugins.io.text", "save_to_txt"] } + export_plugins = {} + + # dictionary for import plugins, e.g. { "FliteStar": ["txt", "mslib.plugins.io.flitestar", "load_from_flitestar"] } + import_plugins = {} + + # dictionary to make title, label and ticklabel sizes for topview and sideview configurable. + # You can put your default value here, whatever you want to give,it should be a number. + topview = {"plot_title_size": 10, + "axes_label_size": 10} + + sideview = {"plot_title_size": 10, + "axes_label_size": 10} + + linearview = {"plot_title_size": 10, + "axes_label_size": 10} + + automated_plotting_flights = [[]] + automated_plotting_hsecs = [[]] + automated_plotting_vsecs = [[]] + automated_plotting_lsecs = [[]] + + # Dictionary options with fixed key/value pairs + fixed_dict_options = ["layout", "wms_prefetch", "topview", "sideview", "linearview"] + + # Fixed key/value pair options + key_value_options = [ + 'mscolab_skip_verify_user_token', + 'filepicker_default', + 'mss_dir', + 'data_dir', + 'num_labels', + 'num_interpolation_points', + 'new_flighttrack_flightlevel', + 'MSCOLAB_mailid', + 'MSCOLAB_category', + 'mscolab_server_url', + 'wms_cache', + 'wms_cache_max_size_bytes', + 'wms_cache_max_age_seconds', + 'WMS_request_timeout', + ] + + # Dictionary options with predefined structure + dict_option_structure = { + "MSS_auth": {"http://www.your-wms-server.de": "authusername"}, + "predefined_map_sections": { + "new_map_section": { + "CRS": "crs_value", + "map": { + "llcrnrlon": 0.0, + "llcrnrlat": 0.0, + "urcrnrlon": 0.0, + "urcrnrlat": 0.0, + }, + } + }, + "locations": { + "new-location": [0.0, 0.0], + }, + "export_plugins": { + "plugin-name": ["extension", "module", "function", "default"], + }, + "import_plugins": { + "plugin-name": ["extension", "module", "function", "default"], + }, + "proxies": { + "https": "https://proxy.com", + }, + } + + # List options with predefined structure + list_option_structure = { + "default_WMS": ["https://wms-server-url.com"], + "default_VSEC_WMS": ["https://vsec-wms-server-url.com"], + "default_LSEC_WMS": ["https://lsec-wms-server-url.com"], + "default_MSCOLAB": ["https://mscolab-server-url.com"], + "new_flighttrack_template": ["new-location"], + "gravatar_ids": ["example@email.com"], + "WMS_preload": ["https://wms-preload-url.com"], + "automated_plotting_flights": [["", "", "", "", "", ""]], + "automated_plotting_hsecs": [["http://www.your-wms-server.de", "", "", ""]], + "automated_plotting_vsecs": [["http://www.your-wms-server.de", "", "", ""]], + "automated_plotting_lsecs": [["http://www.your-wms-server.de", "", ""]] + } + + config_descriptions = { + "filepicker_default": "Documentation Required", + "data_dir": "Documentation Required", + "predefined_map_sections": "Documentation Required", + "num_interpolation_points": "Documentation Required", + "num_labels": "Documentation Required", + "default_WMS": "Documentation Required", + "default_VSEC_WMS": "Documentation Required", + "default_LSEC_WMS": "Documentation Required", + "default_MSCOLAB": "Documentation Required", + "MSS_auth": "Documentation Required", + "MSCOLAB_mailid": "Documentation Required", + "WMS_request_timeout": "Documentation Required", + "WMS_preload": "Documentation Required", + "wms_cache": "Documentation Required", + "wms_cache_max_size_bytes": "Documentation Required", + "wms_cache_max_age_seconds": "Documentation Required", + "wms_prefetch": "Documentation Required", + "locations": "Documentation Required", + "new_flighttrack_template": "Documentation Required", + "new_flighttrack_flightlevel": "Documentation Required", + "proxies": "Documentation Required", + "mscolab_server_url": "Documentation Required", + "mss_dir": "Documentation Required", + "gravatar_ids": "Documentation Required", + "export_plugins": "Documentation Required", + "import_plugins": "Documentation Required", + "layout": "Documentation Required", + "topview": "Documentation Required", + "sideview": "Documentation Required", + "linearview": "Documentation Required", + } + + +# default options as dictionary +default_options = dict(MSUIDefaultConfig.__dict__) +for key in [ + "__module__", + "__doc__", + "__dict__", + "__weakref__", + "fixed_dict_options", + "dict_option_structure", + "list_option_structure", + "key_value_options", + "config_descriptions", +]: + del default_options[key] + + +# user options as dictionary +user_options = copy.deepcopy(default_options) + + +def read_config_file(path=constants.MSUI_SETTINGS): + """ + reads a config file and updates global user_options + + Args: + path: path of config file + + Note: + sole purpose of the path argument is to be able to test with example config files + """ + path = path.replace("\\", "/") + dir_name, file_name = fs.path.split(path) + json_file_data = {} + with fs.open_fs(dir_name) as _fs: + if _fs.exists(file_name): + file_content = _fs.readtext(file_name) + try: + json_file_data = json.loads(file_content, object_pairs_hook=dict_raise_on_duplicates_empty) + except json.JSONDecodeError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Unexpected error while loading config\n{e}" + raise FatalUserError(error_message) + except ValueError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Invalid keys detected in config\n{e}" + raise FatalUserError(error_message) + else: + error_message = f"MSS config File '{path}' not found" + raise FileNotFoundError(error_message) + + global user_options + if json_file_data: + user_options = merge_dict(copy.deepcopy(default_options), json_file_data) + logging.debug("Merged default and user settings") + else: + user_options = copy.deepcopy(default_options) + logging.debug("No user settings found, using default settings") + + +def modify_config_file(data, path=constants.MSUI_SETTINGS): + """ + modifies a config file + + Args: + data: data to be modified/written + path: path of config file + + Note: + sole purpose of the path argument is to be able to test with example config files + """ + path = path.replace("\\", "/") + dir_name, file_name = fs.path.split(path) + json_file_data = {} + with fs.open_fs(dir_name) as _fs: + if _fs.exists(file_name): + try: + file_content = _fs.readtext(file_name) + json_file_data = json.loads(file_content, object_pairs_hook=dict_raise_on_duplicates_empty) + json_file_data_copy = copy.deepcopy(json_file_data) + for key in data: + if key not in json_file_data: + json_file_data_copy[key] = config_loader(dataset=key, default=True) + modified_data = merge_dict(json_file_data_copy, data) + logging.debug("Merged default and user settings") + _fs.writetext(file_name, json.dumps(modified_data, indent=4)) + read_config_file() + except json.JSONDecodeError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Unexpected error while loading config\n{e}" + raise FatalUserError(error_message) + except ValueError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Invalid keys detected in config\n{e}" + raise FatalUserError(error_message) + else: + error_message = f"MSS config File '{path}' not found" + raise FileNotFoundError(error_message) + + +def config_loader(dataset=None, default=False): + """ + Function for returning config value + + Args: + dataset: section to pull from json file + default: option to return default config for the dataset + + Returns: a the dataset value or the config as dictionary + + """ + if dataset is not None and dataset not in user_options: + raise KeyError(f"requested dataset '{dataset}' not in defaults!") + + if dataset is not None: + if default: + return default_options[dataset] + return user_options[dataset] + else: + if default: + return default_options + return user_options + + +def save_settings_qsettings(tag, settings, ignore_test=False): + """ + Saves a dictionary settings to disk. + + :param tag: string specifying the settings + :param settings: dictionary of settings + :return: None + """ + assert isinstance(tag, str) + assert isinstance(settings, dict) + if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + return settings + + q_settings = QtCore.QSettings("msui", "msui-core") + file_path = q_settings.fileName() + logging.debug("storing settings for %s to %s", tag, file_path) + try: + q_settings.setValue(tag, QtCore.QVariant(settings)) + except (OSError, IOError) as ex: + logging.warning("Problems storing %s settings (%s: %s).", tag, type(ex), ex) + return settings + + +def load_settings_qsettings(tag, default_settings=None, ignore_test=False): + """ + Loads a dictionary of settings from disk. May supply a dictionary of default settings + to return in case the settings file is not present or damaged. The default_settings one will + be updated by the restored one so one may rely on all keys of the default_settings dictionary + being present in the returned dictionary. + + :param tag: string specifying the settings + :param default_settings: dictionary of settings or None + :return: dictionary of settings + """ + if default_settings is None: + default_settings = {} + assert isinstance(default_settings, dict) + if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + return default_settings + + settings = {} + q_settings = QtCore.QSettings("msui", "msui-core") + file_path = q_settings.fileName() + logging.debug("loading settings for %s from %s", tag, file_path) + try: + settings = q_settings.value(tag) + except Exception as ex: + logging.error("Problems reloading stored %s settings (%s: %s). Switching to default", + tag, type(ex), ex) + if isinstance(settings, dict): + default_settings.update(settings) + return default_settings + + +def merge_dict(existing_dict, new_dict): + """ + Merge two dictionaries by comparing all the options from + the MSUIDefaultConfig class + + Arguments: + existing_dict -- Dict to merge new_dict into + new_dict -- Dict with new values + """ + # Check if dictionary options with fixed key/value pairs match data types from default + for key in MSUIDefaultConfig.fixed_dict_options: + if key in new_dict: + existing_dict[key] = compare_data( + existing_dict[key], new_dict[key] + )[0] + + # Check if dictionary options with predefined structure match data types from default + dos = copy.deepcopy(MSUIDefaultConfig.dict_option_structure) + # adding plugin structure : ["extension", "module", "function"] to + # recognize user plugin options that don't have the optional filepicker option set + dos["import_plugins"]["plugin-name-a"] = dos["import_plugins"]["plugin-name"][:3] + dos["export_plugins"]["plugin-name-a"] = dos["export_plugins"]["plugin-name"][:3] + for key in dos: + if key in new_dict: + temp_data = {} + for option_key in new_dict[key]: + for dos_key_key in dos[key]: + data, match = compare_data(dos[key][dos_key_key], new_dict[key][option_key]) + if match: + temp_data[option_key] = new_dict[key][option_key] + break + if temp_data != {}: + existing_dict[key] = temp_data + + # Check if list options with predefined structure match data types from default + los = copy.deepcopy(MSUIDefaultConfig.list_option_structure) + for key in los: + if key in new_dict: + temp_data = [] + for i in range(len(new_dict[key])): + for los_key_item in los[key]: + data, match = compare_data(los_key_item, new_dict[key][i]) + if match: + temp_data.append(data) + break + if temp_data != []: + existing_dict[key] = temp_data + + # Check if options with fixed key/value pair structure match data types from default + for key in MSUIDefaultConfig.key_value_options: + if key in new_dict: + data, match = compare_data(existing_dict[key], new_dict[key]) + if match: + existing_dict[key] = data + + # add filepicker default to import and export plugins if missing + for plugin_type in ["import_plugins", "export_plugins"]: + if plugin_type in existing_dict: + for plugin in existing_dict[plugin_type]: + if len(existing_dict[plugin_type][plugin]) == 3: + existing_dict[plugin_type][plugin].append( + existing_dict.get("filepicker_default", "default") + ) + + return existing_dict + + +def compare_data(default, user_data): + """ + Recursively compares two dictionaries based on qt_json_view datatypes + and returns default or user_data appropriately. + + Arguments: + default -- Dict to return if datatype not matching + user_data -- Dict to return if datatype is matching + """ + # If data is neither list not dict type, compare individual type + if not isinstance(default, dict) and not isinstance(default, list): + if isinstance(default, float) and isinstance(user_data, int): + user_data = float(default) + if isinstance(match_type(default), UrlType) and isinstance(match_type(user_data), StrType): + return user_data, True + if isinstance(match_type(default), type(match_type(user_data))): + return user_data, True + else: + return default, False + + data = copy.deepcopy(default) + matches = [] + # If data is list type, compare all values in list + if isinstance(default, list) and isinstance(user_data, list): + if len(default) == len(user_data): + for i in range(len(default)): + data[i], match = compare_data(default[i], user_data[i]) + matches.append(match) + else: + return default, False + + # If data is dict type, goes through the dict and update + elif isinstance(default, dict) and isinstance(user_data, dict): + if default.keys() == user_data.keys(): + for key in default: + if key in user_data: + data[key], match = compare_data(default[key], user_data[key]) + matches.append(match) + else: + matches.append(False) + else: + return default, False + + return data, all(matches) + + +def dict_raise_on_duplicates_empty(ordered_pairs): + """Reject duplicate and empty keys.""" + accepted = {} + for key, value in ordered_pairs: + if key in accepted: + raise ValueError(f"duplicate key found: {key}") + elif key == "": + raise ValueError("empty key found") + else: + accepted[key] = value + return accepted diff --git a/mslib/utils/migration/update_json_file_to_version_nine.py b/mslib/utils/migration/update_json_file_to_version_nine.py new file mode 100644 index 000000000..d7febf47b --- /dev/null +++ b/mslib/utils/migration/update_json_file_to_version_nine.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.update_json_file_to_version_nine + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + updates the old attributes to the new attributes and creates credentials in keyring + + This file is part of MSS. + + :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. + :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import fs +import json +import copy + +from packaging import version +from mslib import __version__ +from mslib.utils.migration.config_before_nine import read_config_file as read_config_file_before_nine +from mslib.utils.migration.config_before_nine import config_loader as config_loader_before_nine +from mslib.utils.config import modify_config_file +from mslib.utils.config import read_config_file, config_loader +from mslib.msui.constants import MSUI_SETTINGS + + +class JsonConversion: + def __init__(self): + read_config_file_before_nine() + self.MSCOLAB_mailid = config_loader_before_nine(dataset="MSCOLAB_mailid") + self.MSS_auth = config_loader_before_nine(dataset="MSS_auth") + self.default_MSCOLAB = config_loader_before_nine(dataset="default_MSCOLAB") + + def change_parameters(self): + """ + adds new parameters and store passwords in the keyring + """ + if version.parse(__version__) > version.parse('8.0.0') and version.parse(__version__) < version.parse('10.0.0'): + + mss_auth = self.MSS_auth + + for url, username in self.MSS_auth.items(): + if url in self.default_MSCOLAB and mss_auth[url] != self.MSCOLAB_mailid: + mss_auth[url] = self.MSCOLAB_mailid + + data_to_save_in_config_file = { + "MSS_auth": mss_auth + } + + filename = MSUI_SETTINGS.replace('\\', '/') + dir_name, file_name = fs.path.split(filename) + # create the backup file + with fs.open_fs(dir_name) as _fs: + fs.copy.copy_file(_fs, file_name, _fs, f"{file_name}.bak") + # add the modification + modify_config_file(data_to_save_in_config_file) + # read new file + read_config_file() + # Todo move this to a seperate function to utils + # get all defaults + default_options = config_loader(default=True) + # get the data from the local file + json_data = config_loader() + save_data = copy.deepcopy(json_data) + + # remove everything we have as defaults + for key in json_data: + if json_data[key] == default_options[key] or json_data[key] == {} or json_data[key] == []: + del save_data[key] + + # write new data + with fs.open_fs(dir_name) as _fs: + _fs.writetext(file_name, json.dumps(save_data, indent=4)) + + +if __name__ == "__main__": + if version.parse(__version__) >= version.parse('9.0.0'): + new_version = JsonConversion() + new_version.change_parameters() diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 7d3313155..15be1aaaf 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -189,7 +189,6 @@ def test_add_users_without_updating_credentials_in_config_file(self, mockmessage @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_add_users_with_updating_credentials_in_config_file(self, mockmessage): - create_msui_settings_file('{"MSCOLAB_mailid": "something@something.org" }') create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') mslib.utils.auth.save_password_to_keyring(service_name=self.url, username="something@something.org", password="something") diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index d606da1a1..5c312afe9 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -214,29 +214,29 @@ def test_modify_config_file_with_empty_parameters(self): if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { - "MSCOLAB_mailid": "something@something.org" + "num_labels": 20 } modify_config_file(data_to_save_in_config_file) config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") read_config_file(path=config_file) data = config_loader() - assert data["MSCOLAB_mailid"] == "something@something.org" + assert data["num_labels"] == 20 def test_modify_config_file_with_existing_parameters(self): """ Test to check if modify_config_file properly modifies a key-value pair in the config file """ - create_msui_settings_file('{"MSCOLAB_mailid": "anand@something.org"}') + create_msui_settings_file('{"num_labels": 14}') if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { - "MSCOLAB_mailid": "sree@something.org" + "num_labels": 20 } modify_config_file(data_to_save_in_config_file) config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") read_config_file(path=config_file) data = config_loader() - assert data["MSCOLAB_mailid"] == "sree@something.org" + assert data["num_labels"] == 20 def test_modify_config_file_with_invalid_parameters(self): """ @@ -247,7 +247,7 @@ def test_modify_config_file_with_invalid_parameters(self): pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { "": "sree", - "MSCOLAB_mailid": "sree@something.org" + "num_labels": "20" } with pytest.raises(KeyError): modify_config_file(data_to_save_in_config_file) diff --git a/tests/_test_utils/test_migration.py b/tests/_test_utils/test_migration.py index 78e898d7c..9041b19fe 100644 --- a/tests/_test_utils/test_migration.py +++ b/tests/_test_utils/test_migration.py @@ -27,7 +27,7 @@ import pytest import fs from packaging import version -from mslib.utils.migration.update_json_file_to_version_eight import JsonConversion + from mslib.utils import auth from mslib.version import __version__ from mslib.msui.constants import MSUI_SETTINGS @@ -43,7 +43,8 @@ def test_upgrade_json_file_to_version_eight(self): The test checks on version 8 if an old msui_settings.json can become migrated It adds the new attributes and stores passwords in the keyring """ - if version.parse(__version__) >= version.parse('8.0.0'): + if version.parse(__version__) >= version.parse('8.0.0') and version.parse(__version__) < version.parse('9.0.0'): + from mslib.utils.migration.update_json_file_to_version_eight import JsonConversion # old attributes wms_def = '"http://www.your-server.de/forecasts": ["youruser", "yourpassword"]' msc_def = '"http://www.your-mscolab-server.de": ["youruser", "yourpassword"]' @@ -101,3 +102,58 @@ def test_upgrade_json_file_to_version_eight(self): config_loader(dataset="MSC_login") with pytest.raises(KeyError): config_loader(dataset="MSCOLAB_password") + + def test_upgrade_json_file_to_version_nine(self): + """ + The test checks on version 8 if an old msui_settings.json can become migrated + It adds the new attributes and stores passwords in the keyring + """ + if version.parse(__version__) >= version.parse('9.0.0') and\ + version.parse(__version__) < version.parse('10.0.0'): + from mslib.utils.migration.update_json_file_to_version_nine import JsonConversion + # old attributes + mailid = 'something@something.org' + auth = '"https://www.your-mscolab-server.de": "youruser"' + default = '"https://www.your-mscolab-server.de"' + data = f"""{{ + "MSCOLAB_mailid": "{mailid}", + + "MSS_auth": {{ + {auth} + }}, + "default_MSCOLAB": [ + {default} + ] + }}""" + # store old configuration + create_msui_settings_file(data) + + from mslib.utils.migration.config_before_nine import read_config_file as read_config_file_before_nine + from mslib.utils.migration.config_before_nine import config_loader as config_loader_before_nine + read_config_file_before_nine() + + result = dict() + result[default] = mailid + assert config_loader_before_nine(dataset="MSS_auth") == {"https://www.your-mscolab-server.de": "youruser"} + all = config_loader_before_nine() + # old version knows MSCOLAB_mailid + assert "MSCOLAB_mailid" in all.keys() + new_version = JsonConversion() + # converting and storing + new_version.change_parameters() + filename = MSUI_SETTINGS.replace('\\', '/') + dir_name, file_name = fs.path.split(filename) + # check that we have a backup file + bak_file = f"{file_name}.bak" + _fs = fs.open_fs(dir_name) + assert _fs.exists(bak_file) + + # using current configuration + from mslib.utils.config import read_config_file, config_loader + read_config_file() + # added MSCOLAB_mailid to the url based on default_MSCOLAB + mss_auth = config_loader(dataset="MSS_auth") + assert mss_auth == {"https://www.your-mscolab-server.de": mailid} + all = config_loader() + # new version forgot about MSCOLAB_mailid + assert "MSCOLAB_mailid" not in all.keys() diff --git a/tests/data/msui_settings.json b/tests/data/msui_settings.json index c5125c211..be8ecf5b9 100644 --- a/tests/data/msui_settings.json +++ b/tests/data/msui_settings.json @@ -72,7 +72,5 @@ "MSS_auth": { "http://www.your-server.de/forecasts" : "authuser", "http://www.your-mscolab-server.de" : "authuser" - }, - - "MSCOLAB_mailid": "" + } } From 8bf91258aa7548a5e86e60b3dde26a99721f3f4d Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 30 May 2023 08:14:16 +0200 Subject: [PATCH 011/176] individual imports (#1801) * individual imports better lookup by IDEs removed globals * flake8 --- mslib/msui/airdata_dockwidget.py | 2 +- mslib/msui/editor.py | 2 +- mslib/msui/hexagon_dockwidget.py | 2 +- mslib/msui/kmloverlay_dockwidget.py | 2 +- mslib/msui/linearview.py | 4 +-- mslib/msui/mscolab.py | 12 ++++----- mslib/msui/mss.py | 2 +- mslib/msui/msui.py | 6 ++--- mslib/msui/multilayers.py | 2 +- mslib/msui/performance_settings.py | 2 +- mslib/msui/remotesensing_dockwidget.py | 2 +- mslib/msui/satellite_dockwidget.py | 2 +- mslib/msui/sideview.py | 4 +-- mslib/msui/tableview.py | 2 +- mslib/msui/topview.py | 4 +-- mslib/msui/updater.py | 3 ++- mslib/msui/wms_capabilities.py | 2 +- mslib/msui/wms_control.py | 4 +-- mslib/utils/qt.py | 36 -------------------------- 19 files changed, 30 insertions(+), 65 deletions(-) diff --git a/mslib/msui/airdata_dockwidget.py b/mslib/msui/airdata_dockwidget.py index 52f2739b3..c5acdf88d 100644 --- a/mslib/msui/airdata_dockwidget.py +++ b/mslib/msui/airdata_dockwidget.py @@ -25,7 +25,7 @@ limitations under the License. """ import pycountry -from mslib.utils.qt import ui_airdata_dockwidget as ui +from mslib.msui.qt5 import ui_airdata_dockwidget as ui from PyQt5 import QtWidgets, QtCore from mslib.utils.config import save_settings_qsettings, load_settings_qsettings from mslib.utils.airdata import get_available_airspaces, update_airspace, get_airports diff --git a/mslib/msui/editor.py b/mslib/msui/editor.py index 52c6a9ced..5ee9dc088 100644 --- a/mslib/msui/editor.py +++ b/mslib/msui/editor.py @@ -31,7 +31,7 @@ import json from mslib.utils.qt import get_open_filename, get_save_filename, show_popup -from mslib.utils.qt import ui_configuration_editor_window as ui_conf +from mslib.msui.qt5 import ui_configuration_editor_window as ui_conf from PyQt5 import QtWidgets, QtCore, QtGui from mslib.msui.constants import MSUI_SETTINGS from mslib.msui.icons import icons diff --git a/mslib/msui/hexagon_dockwidget.py b/mslib/msui/hexagon_dockwidget.py index 6435ab067..d17198269 100644 --- a/mslib/msui/hexagon_dockwidget.py +++ b/mslib/msui/hexagon_dockwidget.py @@ -28,7 +28,7 @@ import logging from PyQt5 import QtWidgets -from mslib.utils.qt import ui_hexagon_dockwidget as ui +from mslib.msui.qt5 import ui_hexagon_dockwidget as ui from mslib.msui import flighttrack as ft from mslib.utils.coordinate import rotate_point from mslib.utils.config import config_loader diff --git a/mslib/msui/kmloverlay_dockwidget.py b/mslib/msui/kmloverlay_dockwidget.py index f503a9419..577034bd6 100644 --- a/mslib/msui/kmloverlay_dockwidget.py +++ b/mslib/msui/kmloverlay_dockwidget.py @@ -33,7 +33,7 @@ from matplotlib import patheffects from mslib.utils.qt import get_open_filenames, get_save_filename -from mslib.utils.qt import ui_kmloverlay_dockwidget as ui +from mslib.msui.qt5 import ui_kmloverlay_dockwidget as ui from PyQt5 import QtGui, QtWidgets, QtCore from mslib.utils.config import save_settings_qsettings, load_settings_qsettings from mslib.utils.coordinate import normalize_longitude diff --git a/mslib/msui/linearview.py b/mslib/msui/linearview.py index 76fe31dae..378a70cec 100644 --- a/mslib/msui/linearview.py +++ b/mslib/msui/linearview.py @@ -27,8 +27,8 @@ from mslib.utils.config import config_loader from PyQt5 import QtGui, QtWidgets -from mslib.utils.qt import ui_linearview_window as ui -from mslib.utils.qt import ui_linearview_options as ui_opt +from mslib.msui.qt5 import ui_linearview_window as ui +from mslib.msui.qt5 import ui_linearview_options as ui_opt from mslib.msui.viewwindows import MSUIMplViewWindow from mslib.msui import wms_control as wms from mslib.msui.icons import icons diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 2f86e1ab6..95dff6717 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -57,12 +57,12 @@ from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring from mslib.utils.verify_user_token import verify_user_token from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup -from mslib.utils.qt import ui_mscolab_help_dialog as msc_help_dialog -from mslib.utils.qt import ui_add_operation_dialog as add_operation_ui -from mslib.utils.qt import ui_mscolab_merge_waypoints_dialog as merge_wp_ui -from mslib.utils.qt import ui_mscolab_connect_dialog as ui_conn -from mslib.utils.qt import ui_mscolab_profile_dialog as ui_profile -from mslib.utils.qt import ui_operation_archive as ui_opar +from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog +from mslib.msui.qt5 import ui_add_operation_dialog as add_operation_ui +from mslib.msui.qt5 import ui_mscolab_merge_waypoints_dialog as merge_wp_ui +from mslib.msui.qt5 import ui_mscolab_connect_dialog as ui_conn +from mslib.msui.qt5 import ui_mscolab_profile_dialog as ui_profile +from mslib.msui.qt5 import ui_operation_archive as ui_opar from mslib.msui import constants from mslib.utils.config import config_loader, modify_config_file diff --git a/mslib/msui/mss.py b/mslib/msui/mss.py index 606f63daf..a67fd1873 100644 --- a/mslib/msui/mss.py +++ b/mslib/msui/mss.py @@ -30,7 +30,7 @@ import sys -from mslib.utils.qt import ui_mss_rename_message as ui +from mslib.msui.qt5 import ui_mss_rename_message as ui from PyQt5 import QtWidgets diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index c460a5625..4bfc004e1 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -45,9 +45,9 @@ from packaging import version from mslib import __version__ -from mslib.utils.qt import ui_mainwindow as ui -from mslib.utils.qt import ui_about_dialog as ui_ab -from mslib.utils.qt import ui_shortcuts as ui_sh +from mslib.msui.qt5 import ui_mainwindow as ui +from mslib.msui.qt5 import ui_about_dialog as ui_ab +from mslib.msui.qt5 import ui_shortcuts as ui_sh from mslib.msui import flighttrack as ft from mslib.msui import tableview, topview, sideview, linearview from mslib.msui import editor diff --git a/mslib/msui/multilayers.py b/mslib/msui/multilayers.py index 7dca643f1..08b494274 100644 --- a/mslib/msui/multilayers.py +++ b/mslib/msui/multilayers.py @@ -28,7 +28,7 @@ import logging import mslib.msui.wms_control from mslib.msui.icons import icons -from mslib.utils.qt import ui_wms_multilayers as ui +from mslib.msui.qt5 import ui_wms_multilayers as ui from mslib.utils.config import save_settings_qsettings, load_settings_qsettings diff --git a/mslib/msui/performance_settings.py b/mslib/msui/performance_settings.py index 325af9ecd..b6fde7812 100644 --- a/mslib/msui/performance_settings.py +++ b/mslib/msui/performance_settings.py @@ -32,7 +32,7 @@ from mslib.utils import FatalUserError from mslib.msui import aircrafts, constants from mslib.utils.qt import get_open_filename -from mslib.utils.qt import ui_performance_dockwidget as ui_dw +from mslib.msui.qt5 import ui_performance_dockwidget as ui_dw DEFAULT_PERFORMANCE = { diff --git a/mslib/msui/remotesensing_dockwidget.py b/mslib/msui/remotesensing_dockwidget.py index 64369980f..7c18a8180 100644 --- a/mslib/msui/remotesensing_dockwidget.py +++ b/mslib/msui/remotesensing_dockwidget.py @@ -33,7 +33,7 @@ import skyfield_data from PyQt5 import QtGui, QtWidgets -from mslib.utils.qt import ui_remotesensing_dockwidget as ui +from mslib.msui.qt5 import ui_remotesensing_dockwidget as ui from mslib.utils.time import jsec_to_datetime, datetime_to_jsec from mslib.utils.coordinate import get_distance, rotate_point, fix_angle, normalize_longitude diff --git a/mslib/msui/satellite_dockwidget.py b/mslib/msui/satellite_dockwidget.py index 3f37267e8..9267279bb 100755 --- a/mslib/msui/satellite_dockwidget.py +++ b/mslib/msui/satellite_dockwidget.py @@ -33,7 +33,7 @@ import numpy as np from mslib.utils.qt import get_open_filename -from mslib.utils.qt import ui_satellite_dockwidget as ui +from mslib.msui.qt5 import ui_satellite_dockwidget as ui from PyQt5 import QtWidgets from mslib.utils.config import save_settings_qsettings, load_settings_qsettings from fs import open_fs diff --git a/mslib/msui/sideview.py b/mslib/msui/sideview.py index d4edcca4c..48802f81f 100644 --- a/mslib/msui/sideview.py +++ b/mslib/msui/sideview.py @@ -31,8 +31,8 @@ from PyQt5 import QtGui, QtWidgets -from mslib.utils.qt import ui_sideview_window as ui -from mslib.utils.qt import ui_sideview_options as ui_opt +from mslib.msui.qt5 import ui_sideview_window as ui +from mslib.msui.qt5 import ui_sideview_options as ui_opt from mslib.msui.viewwindows import MSUIMplViewWindow from mslib.msui import wms_control as wms from mslib.msui.icons import icons diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index df793c90f..f978f19bb 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -37,7 +37,7 @@ from mslib.msui import hexagon_dockwidget as hex from mslib.msui import performance_settings as perfset from PyQt5 import QtWidgets, QtGui -from mslib.utils.qt import ui_tableview_window as ui +from mslib.msui.qt5 import ui_tableview_window as ui from mslib.utils.qt import dropEvent, dragEnterEvent from mslib.msui import flighttrack as ft from mslib.msui.viewwindows import MSUIViewWindow diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index 9ef1e2a2a..eed040f84 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -33,8 +33,8 @@ from mslib.utils.config import config_loader from mslib.utils.coordinate import get_projection_params from PyQt5 import QtGui, QtWidgets, QtCore -from mslib.utils.qt import ui_topview_window as ui -from mslib.utils.qt import ui_topview_mapappearance as ui_ma +from mslib.msui.qt5 import ui_topview_window as ui +from mslib.msui.qt5 import ui_topview_mapappearance as ui_ma from mslib.msui.viewwindows import MSUIMplViewWindow from mslib.msui import wms_control as wc from mslib.msui import satellite_dockwidget as sat diff --git a/mslib/msui/updater.py b/mslib/msui/updater.py index f81003f94..29de1b8cd 100644 --- a/mslib/msui/updater.py +++ b/mslib/msui/updater.py @@ -26,7 +26,8 @@ from PyQt5 import QtCore, QtWidgets, QtGui -from mslib.utils.qt import ui_updater_dialog, Updater +from mslib.utils.qt import Updater +from mslib.msui.qt5 import ui_updater_dialog from mslib import __version__ diff --git a/mslib/msui/wms_capabilities.py b/mslib/msui/wms_capabilities.py index a446649aa..7df9cf432 100644 --- a/mslib/msui/wms_capabilities.py +++ b/mslib/msui/wms_capabilities.py @@ -29,7 +29,7 @@ import collections from PyQt5 import QtWidgets -from mslib.utils.qt import ui_wms_capabilities as ui +from mslib.msui.qt5 import ui_wms_capabilities as ui class WMSCapabilitiesBrowser(QtWidgets.QDialog, ui.Ui_WMSCapabilitiesBrowser): diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index f5fd2ec32..115e9e4a5 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -47,8 +47,8 @@ from keyring.errors import NoKeyringError, PasswordSetError, InitError from mslib.msui import constants, wms_capabilities -from mslib.utils.qt import ui_wms_dockwidget as ui -from mslib.utils.qt import ui_wms_password_dialog as ui_pw +from mslib.msui.qt5 import ui_wms_dockwidget as ui +from mslib.msui.qt5 import ui_wms_password_dialog as ui_pw from mslib.utils.qt import Worker from mslib.msui.multilayers import Multilayers, Layer import mslib.utils.ogcwms as ogcwms diff --git a/mslib/utils/qt.py b/mslib/utils/qt.py index 0db5ddaab..bf3847389 100644 --- a/mslib/utils/qt.py +++ b/mslib/utils/qt.py @@ -25,7 +25,6 @@ limitations under the License. """ -import importlib import logging import os import re @@ -606,39 +605,4 @@ def emit(self, *args): pass -# Import all Dialogues from the proper module directory. -for mod in [ - "ui_about_dialog", - "ui_shortcuts", - "ui_updater_dialog", - "ui_hexagon_dockwidget", - "ui_kmloverlay_dockwidget", - "ui_customize_kml", - "ui_mainwindow", - "ui_configuration_editor_window", - "ui_mscolab_connect_dialog", - "ui_mscolab_help_dialog", - "ui_add_operation_dialog", - "ui_mscolab_merge_waypoints_dialog", - "ui_mscolab_profile_dialog", - "ui_mss_rename_message", - "ui_performance_dockwidget", - "ui_remotesensing_dockwidget", - "ui_satellite_dockwidget", - "ui_airdata_dockwidget", - "ui_sideview_options", - "ui_sideview_window", - "ui_tableview_window", - "ui_topview_mapappearance", - "ui_topview_window", - "ui_linearview_options", - "ui_linearview_window", - "ui_operation_archive", - "ui_wms_password_dialog", - "ui_wms_capabilities", - "ui_wms_dockwidget", - "ui_wms_multilayers"]: - globals()[mod] = importlib.import_module("mslib.msui.qt5." + mod) - - sys.excepthook = excepthook From 59ef8e7c05b63b3b0ae7262447b5010e3b5b7f31 Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Fri, 26 May 2023 11:18:50 +0200 Subject: [PATCH 012/176] style changes * update super() calls to modern python * removed class ...(object) --- mslib/mscolab/chat_manager.py | 2 +- mslib/mscolab/conf.py | 2 +- mslib/mscolab/file_manager.py | 2 +- mslib/mscolab/server.py | 2 +- mslib/mscolab/sockets_manager.py | 2 +- mslib/msui/aircrafts.py | 2 +- mslib/msui/airdata_dockwidget.py | 2 +- mslib/msui/editor.py | 12 +++---- mslib/msui/flighttrack.py | 4 +-- mslib/msui/hexagon_dockwidget.py | 2 +- mslib/msui/kmloverlay_dockwidget.py | 4 +-- mslib/msui/linearview.py | 6 ++-- mslib/msui/mpl_map.py | 6 ++-- mslib/msui/mpl_qtwidget.py | 36 ++++++++++---------- mslib/msui/mscolab.py | 6 ++-- mslib/msui/mscolab_admin_window.py | 2 +- mslib/msui/mscolab_chat.py | 4 +-- mslib/msui/mscolab_version_history.py | 2 +- mslib/msui/msui.py | 10 +++--- mslib/msui/multiple_flightpath_dockwidget.py | 6 ++-- mslib/msui/performance_settings.py | 2 +- mslib/msui/remotesensing_dockwidget.py | 2 +- mslib/msui/satellite_dockwidget.py | 2 +- mslib/msui/sideview.py | 6 ++-- mslib/msui/tableview.py | 4 +-- mslib/msui/topview.py | 6 ++-- mslib/msui/updater.py | 2 +- mslib/msui/viewwindows.py | 4 +-- mslib/msui/wms_capabilities.py | 2 +- mslib/msui/wms_control.py | 12 +++---- mslib/mswms/demodata.py | 2 +- mslib/mswms/mpl_lsec.py | 2 +- mslib/mswms/mpl_lsec_styles.py | 2 +- mslib/mswms/mpl_vsec.py | 2 +- mslib/mswms/wms.py | 6 ++-- mslib/utils/config.py | 2 +- mslib/utils/qt.py | 4 +-- 37 files changed, 88 insertions(+), 88 deletions(-) diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index b94138fa9..926583672 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -33,7 +33,7 @@ from mslib.mscolab.utils import get_message_dict -class ChatManager(object): +class ChatManager: """Class with handler functions for chat related functionalities""" def __init__(self): diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 09325703f..8c11ac201 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -29,7 +29,7 @@ import secrets -class default_mscolab_settings(object): +class default_mscolab_settings: # expire token in seconds # EXPIRATION = 86400 diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index a84bfe494..429ba3acb 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -34,7 +34,7 @@ from mslib.mscolab.conf import mscolab_settings -class FileManager(object): +class FileManager: """Class with handler functions for file related functionalities""" def __init__(self, data_dir): diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 704ba25ce..584b2998c 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -66,7 +66,7 @@ except ImportError as ex: logging.warning("Couldn't import mscolab_auth (ImportError:'{%s), creating dummy config.", ex) - class mscolab_auth(object): + class mscolab_auth: allowed_users = [("mscolab", "add_md5_digest_of_PASSWORD_here"), ("add_new_user_here", "add_md5_digest_of_PASSWORD_here")] __file__ = None diff --git a/mslib/mscolab/sockets_manager.py b/mslib/mscolab/sockets_manager.py index 5cff180d6..4de4ed74c 100644 --- a/mslib/mscolab/sockets_manager.py +++ b/mslib/mscolab/sockets_manager.py @@ -40,7 +40,7 @@ "*" in mscolab_settings.CORS_ORIGINS else mscolab_settings.CORS_ORIGINS)) -class SocketsManager(object): +class SocketsManager: """Class with handler functions for socket related""" def __init__(self, chat_manager, file_manager): diff --git a/mslib/msui/aircrafts.py b/mslib/msui/aircrafts.py index 1d797d835..e6f99a9c5 100644 --- a/mslib/msui/aircrafts.py +++ b/mslib/msui/aircrafts.py @@ -41,7 +41,7 @@ } -class SimpleAircraft(object): +class SimpleAircraft: """ Simple aircraft model that offers methods to estimate fuel and time consumption of aircraft for different flight maneuvers. diff --git a/mslib/msui/airdata_dockwidget.py b/mslib/msui/airdata_dockwidget.py index c5acdf88d..0531e3402 100644 --- a/mslib/msui/airdata_dockwidget.py +++ b/mslib/msui/airdata_dockwidget.py @@ -33,7 +33,7 @@ class AirdataDockwidget(QtWidgets.QWidget, ui.Ui_AirdataDockwidget): def __init__(self, parent=None, view=None): - super(AirdataDockwidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view self.view.redrawn.connect(self.redraw_map) diff --git a/mslib/msui/editor.py b/mslib/msui/editor.py index 5ee9dc088..7b0e2a45f 100644 --- a/mslib/msui/editor.py +++ b/mslib/msui/editor.py @@ -93,23 +93,23 @@ def paint(self, painter, option, index): if model_data != default_data: option.font.setWeight(QtGui.QFont.Bold) - return super(JsonDelegate, self).paint(painter, option, index) + return super().paint(painter, option, index) type_ = index.data(TypeRole) if isinstance(type_, DataType): try: - super(JsonDelegate, self).paint(painter, option, index) + super().paint(painter, option, index) return type_.paint(painter, option, index) except NotImplementedError: pass - return super(JsonDelegate, self).paint(painter, option, index) + return super().paint(painter, option, index) class JsonSortFilterProxyModel(QtCore.QSortFilterProxyModel): def filterAcceptsRow(self, source_row, source_parent): # check if an item is currently accepted - accepted = super(JsonSortFilterProxyModel, self).filterAcceptsRow(source_row, source_parent) + accepted = super().filterAcceptsRow(source_row, source_parent) if accepted: return True @@ -119,7 +119,7 @@ def filterAcceptsRow(self, source_row, source_parent): has_parent = src_model.itemFromIndex(index).parent() if has_parent: parent_index = self.mapFromSource(has_parent.index()) - return super(JsonSortFilterProxyModel, self).filterAcceptsRow(has_parent.row(), parent_index) + return super().filterAcceptsRow(has_parent.row(), parent_index) return accepted @@ -131,7 +131,7 @@ class ConfigurationEditorWindow(QtWidgets.QMainWindow, ui_conf.Ui_ConfigurationE restartApplication = QtCore.pyqtSignal(name="restartApplication") def __init__(self, parent=None): - super(ConfigurationEditorWindow, self).__init__(parent) + super().__init__(parent) self.setupUi(self) options = config_loader() diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index c89fd76cc..70c387711 100755 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -179,7 +179,7 @@ class WaypointsTableModel(QtCore.QAbstractTableModel): def __init__(self, name="", filename=None, waypoints=None, mscolab_mode=False, data_dir=mss_default.mss_dir, xml_content=None): - super(WaypointsTableModel, self).__init__() + super().__init__() self.name = name # a name for this flight track self.filename = filename # filename for store/load self.data_dir = data_dir @@ -665,7 +665,7 @@ class WaypointDelegate(QtWidgets.QItemDelegate): """ def __init__(self, parent=None): - super(WaypointDelegate, self).__init__(parent) + super().__init__(parent) def paint(self, painter, option, index): """ diff --git a/mslib/msui/hexagon_dockwidget.py b/mslib/msui/hexagon_dockwidget.py index d17198269..ee7c7ff2f 100644 --- a/mslib/msui/hexagon_dockwidget.py +++ b/mslib/msui/hexagon_dockwidget.py @@ -62,7 +62,7 @@ def __init__(self, parent=None, view=None): parent -- Qt widget that is parent to this widget. view -- reference to mpl canvas class """ - super(HexagonControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view if self.view: diff --git a/mslib/msui/kmloverlay_dockwidget.py b/mslib/msui/kmloverlay_dockwidget.py index 577034bd6..bb9260877 100644 --- a/mslib/msui/kmloverlay_dockwidget.py +++ b/mslib/msui/kmloverlay_dockwidget.py @@ -39,7 +39,7 @@ from mslib.utils.coordinate import normalize_longitude -class KMLPatch(object): +class KMLPatch: """ Represents a KML overlay. """ @@ -279,7 +279,7 @@ class KMLOverlayControlWidget(QtWidgets.QWidget, ui.Ui_KMLOverlayDockWidget): """ def __init__(self, parent=None, view=None): - super(KMLOverlayControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view # canvas self.kml = None diff --git a/mslib/msui/linearview.py b/mslib/msui/linearview.py index 378a70cec..46593b7e8 100644 --- a/mslib/msui/linearview.py +++ b/mslib/msui/linearview.py @@ -48,7 +48,7 @@ def __init__(self, parent=None, settings=None): parent -- Qt widget that is parent to this widget. settings_dict -- dictionary containing sideview options. """ - super(MSUI_LV_Options_Dialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) assert settings is not None @@ -83,7 +83,7 @@ def __init__(self, parent=None, model=None, _id=None): """ Set up user interface, connect signal/slots. """ - super(MSUILinearViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) @@ -133,7 +133,7 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the view displays. """ - super(MSUILinearViewWindow, self).setFlightTrackModel(model) + super().setFlightTrackModel(model) if self.docks[WMS] is not None: self.docks[WMS].widget().setFlightTrackModel(model) diff --git a/mslib/msui/mpl_map.py b/mslib/msui/mpl_map.py index 438c2f6d1..f2c78669b 100644 --- a/mslib/msui/mpl_map.py +++ b/mslib/msui/mpl_map.py @@ -193,7 +193,7 @@ def set_axes_limits(self, ax=None): """ intact = matplotlib.is_interactive() matplotlib.interactive(False) - super(MapCanvas, self).set_axes_limits(ax=ax) + super().set_axes_limits(ax=ax) matplotlib.interactive(intact) def _draw_auto_graticule(self, font_size=None): @@ -682,7 +682,7 @@ def imshow(self, X, **kwargs): """ if self.image is not None: self.image.remove() - self.image = super(MapCanvas, self).imshow(X, zorder=2, **kwargs) + self.image = super().imshow(X, zorder=2, **kwargs) self.ax.figure.canvas.draw() return self.image @@ -753,7 +753,7 @@ def drawgreatcircle_path(self, lons, lats, del_s=100., **kwargs): return self.plot(x, y, **kwargs) -class SatelliteOverpassPatch(object): +class SatelliteOverpassPatch: """ Represents a satellite overpass on the top view map (satellite track and, if available, swath). diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index 56b0a47f8..a61686ab2 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -751,14 +751,14 @@ def __init__(self, plotter): self.default_filename = "_image" self.plotter = plotter # initialization of the canvas - super(MplCanvas, self).__init__(self.plotter.fig) + super().__init__(self.plotter.fig) # we define the widget as expandable - super(MplCanvas, self).setSizePolicy( + super().setSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) # notify the system of updated policy - super(MplCanvas, self).updateGeometry() + super().updateGeometry() def get_default_filename(self): """ @@ -914,7 +914,7 @@ def __init__(self, canvas, parent, sideview=False, coordinates=True): ('Ins WP', 'Insert waypoints', "wp_insert", 'insert_wp'), ('Del WP', 'Delete waypoints', "wp_delete", 'delete_wp'), ]) - super(NavigationToolbar, self).__init__(canvas, parent, coordinates) + super().__init__(canvas, parent, coordinates) self._actions["move_wp"].setCheckable(True) self._actions["insert_wp"].setCheckable(True) self._actions["delete_wp"].setCheckable(True) @@ -932,13 +932,13 @@ def _icon(self, name, *args): if os.path.exists(myname): return QtGui.QIcon(myname) else: - return super(NavigationToolbar, self)._icon(name, *args) + return super()._icon(name, *args) def _zoom_pan_handler(self, event): """ extend zoom_pan_handler of base class with our own tools """ - super(NavigationToolbar, self)._zoom_pan_handler(event) + super()._zoom_pan_handler(event) if event.name == "button_press_event": if self.mode in (_Mode.INSERT_WP, _Mode.MOVE_WP, _Mode.DELETE_WP): self.canvas.waypoints_interactor.button_press_callback(event) @@ -958,7 +958,7 @@ def clear_history(self): def push_current(self): """Push the current view limits and position onto the stack.""" if self.sideview: - super(NavigationToolbar, self).push_current() + super().push_current() elif self.no_push_history: pass else: @@ -971,7 +971,7 @@ def _update_view(self): each axes. """ if self.sideview: - super(NavigationToolbar, self)._update_view() + super()._update_view() else: nav_info = self._nav_stack() if nav_info is None: @@ -1025,14 +1025,14 @@ def move_wp(self, *args): def release_zoom(self, event): self.no_push_history = True - super(NavigationToolbar, self).release_zoom(event) + super().release_zoom(event) self.no_push_history = False self.canvas.redraw_map() self.push_current() def release_pan(self, event): self.no_push_history = True - super(NavigationToolbar, self).release_pan(event) + super().release_pan(event) self.no_push_history = False self.canvas.redraw_map() self.push_current() @@ -1089,7 +1089,7 @@ def mouse_move(self, event): self.set_message(f"{self.mode} lat={lat:6.2f} lon={lon:7.2f} altitude={y_value:.2f}{units}") def _update_buttons_checked(self): - super(NavigationToolbar, self)._update_buttons_checked() + super()._update_buttons_checked() if "insert_wp" in self._actions: self._actions['insert_wp'].setChecked(self.mode.name == 'INSERT_WP') if "delete_wp" in self._actions: @@ -1103,7 +1103,7 @@ class MplNavBarWidget(QtWidgets.QWidget): def __init__(self, sideview=False, parent=None, canvas=None): # initialization of Qt MainWindow widget - super(MplNavBarWidget, self).__init__(parent) + super().__init__(parent) # set the canvas to the Matplotlib widget if canvas: @@ -1138,7 +1138,7 @@ def __init__(self, model=None, settings=None, numlabels=None): if numlabels is None: numlabels = config_loader(dataset='num_labels') self.plotter = SideViewPlotter() - super(MplSideViewCanvas, self).__init__(self.plotter) + super().__init__(self.plotter) if settings is not None: self.plotter.set_settings(settings) @@ -1353,7 +1353,7 @@ class MplSideViewWidget(MplNavBarWidget): """ def __init__(self, parent=None): - super(MplSideViewWidget, self).__init__( + super().__init__( sideview=True, parent=parent, canvas=MplSideViewCanvas()) # Disable some elements of the Matplotlib navigation toolbar. # Available actions: Home, Back, Forward, Pan, Zoom, Subplots, @@ -1378,7 +1378,7 @@ def __init__(self, model=None, numlabels=None): if numlabels is None: numlabels = config_loader(dataset='num_labels') self.plotter = LinearViewPlotter() - super(MplLinearViewCanvas, self).__init__(self.plotter) + super().__init__(self.plotter) # Setup the plot. self.numlabels = numlabels @@ -1459,7 +1459,7 @@ class MplLinearViewWidget(MplNavBarWidget): """ def __init__(self, parent=None): - super(MplLinearViewWidget, self).__init__( + super().__init__( sideview=False, parent=parent, canvas=MplLinearViewCanvas()) # Disable some elements of the Matplotlib navigation toolbar. # Available actions: Home, Back, Forward, Pan, Zoom, Subplots, @@ -1482,7 +1482,7 @@ def __init__(self, settings=None): """ """ self.plotter = TopViewPlotter() - super(MplTopViewCanvas, self).__init__(self.plotter) + super().__init__(self.plotter) self.waypoints_interactor = None self.satoverpasspatch = [] self.kmloverlay = None @@ -1672,7 +1672,7 @@ class MplTopViewWidget(MplNavBarWidget): """ def __init__(self, parent=None): - super(MplTopViewWidget, self).__init__( + super().__init__( sideview=False, parent=parent, canvas=MplTopViewCanvas()) # Disable some elements of the Matplotlib navigation toolbar. # Available actions: Home, Back, Forward, Pan, Zoom, Subplots, diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 95dff6717..7f93ef6cd 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -413,7 +413,7 @@ class MSUIMscolab(QtCore.QObject): signal_render_new_permission = QtCore.Signal(int, str) def __init__(self, parent=None, data_dir=None): - super(MSUIMscolab, self).__init__(parent) + super().__init__(parent) self.ui = parent self.operation_archive_browser = MSColab_OperationArchiveBrowser(self.ui, self) @@ -1933,7 +1933,7 @@ def logout(self): class MscolabMergeWaypointsDialog(QtWidgets.QDialog, merge_wp_ui.Ui_MergeWaypointsDialog): def __init__(self, local_waypoints_model, server_waypoints_model, fetch=False, parent=None): - super(MscolabMergeWaypointsDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.local_waypoints_model = local_waypoints_model @@ -2006,6 +2006,6 @@ def get_values(self): class MscolabHelpDialog(QtWidgets.QDialog, msc_help_dialog.Ui_mscolabHelpDialog): def __init__(self, parent=None): - super(MscolabHelpDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.okayBtn.clicked.connect(lambda: self.close()) diff --git a/mslib/msui/mscolab_admin_window.py b/mslib/msui/mscolab_admin_window.py index ee55d0a5f..02dbf4eec 100644 --- a/mslib/msui/mscolab_admin_window.py +++ b/mslib/msui/mscolab_admin_window.py @@ -47,7 +47,7 @@ def __init__(self, token, op_id, user, operation_name, operations, conn, parent= op_id: operation id conn: connection to send/receive socket messages """ - super(MSColabAdminWindow, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.mscolab_server_url = mscolab_server_url diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index b85c19f45..c922f4758 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -83,7 +83,7 @@ def __init__(self, token, op_id, user, operation_name, access_level, conn, paren parent: widget parent mscolab_server_url: server url for mscolab """ - super(MSColabChatWindow, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.mscolab_server_url = mscolab_server_url @@ -442,7 +442,7 @@ def closeEvent(self, event): class MessageItem(QtWidgets.QWidget): def __init__(self, message, chat_window): - super(MessageItem, self).__init__() + super().__init__() self.id = message["id"] self.u_id = message["u_id"] self.username = message["username"] diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index 2b8a6a190..078451414 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -60,7 +60,7 @@ def __init__(self, token, op_id, user, operation_name, conn, parent=None, parent: parent of widget mscolab_server_url: server url of mscolab """ - super(MSColabVersionHistory, self).__init__(parent) + super().__init__(parent) self.setupUi(self) # Initialise Variables self.token = token diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index 4bfc004e1..5e65bdfc3 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -87,7 +87,7 @@ def __init__(self, view_window, parent=None, viewsChanged=None, mscolab=False, """ QActiveViewsListWidgetItem.opened_views += 1 view_name = f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.name}" - super(QActiveViewsListWidgetItem, self).__init__(view_name, parent, _type) + super().__init__(view_name, parent, _type) view_window.setWindowTitle(f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.windowTitle()} - " f"{view_window.waypoints_model.name}") @@ -131,7 +131,7 @@ def __init__(self, flighttrack_model, parent=None, to name changes of the item. """ view_name = flighttrack_model.name - super(QFlightTrackListWidgetItem, self).__init__( + super().__init__( view_name, parent, type) self.parent = parent @@ -144,7 +144,7 @@ class MSUI_ShortcutsDialog(QtWidgets.QDialog, ui_sh.Ui_ShortcutsDialog): """ def __init__(self): - super(MSUI_ShortcutsDialog, self).__init__(QtWidgets.QApplication.activeWindow()) + super().__init__(QtWidgets.QApplication.activeWindow()) self.setupUi(self) self.current_shortcuts = None self.treeWidget.itemDoubleClicked.connect(self.double_clicked) @@ -347,7 +347,7 @@ def __init__(self, parent=None): Arguments: parent -- Qt widget that is parent to this widget. """ - super(MSUI_AboutDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.lblVersion.setText(f"Version: {__version__}") self.milestone_url = f'https://github.com/Open-MSS/MSS/issues?q=is%3Aclosed+milestone%3A{__version__[:-1]}' @@ -373,7 +373,7 @@ class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): signal_render_new_permission = QtCore.Signal(int, str) def __init__(self, mscolab_data_dir=None, *args): - super(MSUIMainWindow, self).__init__(*args) + super().__init__(*args) self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('32x32'))) # This code is required in Windows 7 to use the icon set by setWindowIcon in taskbar diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 03bd8af30..921b922ae 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -41,7 +41,7 @@ class QMscolabOperationsListWidgetItem(QtWidgets.QListWidgetItem): def __init__(self, flighttrack_model, op_id: int, parent=None, type=QtWidgets.QListWidgetItem.UserType): view_name = flighttrack_model.name - super(QMscolabOperationsListWidgetItem, self).__init__( + super().__init__( view_name, parent, type ) self.parent = parent @@ -49,7 +49,7 @@ def __init__(self, flighttrack_model, op_id: int, parent=None, type=QtWidgets.QL self.op_id = op_id -class MultipleFlightpath(object): +class MultipleFlightpath: """ Represent a Multiple FLightpath """ @@ -114,7 +114,7 @@ class MultipleFlightpathControlWidget(QtWidgets.QWidget, ui.Ui_MultipleViewWidge def __init__(self, parent=None, view=None, listFlightTracks=None, listOperationsMSC=None, activeFlightTrack=None, mscolab_server_url=None, token=None): - super(MultipleFlightpathControlWidget, self).__init__(parent) + super().__init__(parent) # ToDO: Remove all patches, on closing dockwidget. self.ui = parent self.setupUi(self) diff --git a/mslib/msui/performance_settings.py b/mslib/msui/performance_settings.py index b6fde7812..dfb4f1e4c 100644 --- a/mslib/msui/performance_settings.py +++ b/mslib/msui/performance_settings.py @@ -57,7 +57,7 @@ def __init__(self, parent=None, view=None, settings_dict=None): view -- reference to mpl canvas class settings_dict -- dictionary containing topview options """ - super(MSUI_PerformanceSettingsWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view self.parent = parent diff --git a/mslib/msui/remotesensing_dockwidget.py b/mslib/msui/remotesensing_dockwidget.py index 7c18a8180..7d83ddd0b 100644 --- a/mslib/msui/remotesensing_dockwidget.py +++ b/mslib/msui/remotesensing_dockwidget.py @@ -51,7 +51,7 @@ def __init__(self, parent=None, view=None): parent -- Qt widget that is parent to this widget. view -- reference to mpl canvas class """ - super(RemoteSensingControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view diff --git a/mslib/msui/satellite_dockwidget.py b/mslib/msui/satellite_dockwidget.py index 9267279bb..a1e761780 100755 --- a/mslib/msui/satellite_dockwidget.py +++ b/mslib/msui/satellite_dockwidget.py @@ -114,7 +114,7 @@ def read_nasa_satellite_prediction(fname): class SatelliteControlWidget(QtWidgets.QWidget, ui.Ui_SatelliteDockWidget): def __init__(self, parent=None, view=None): - super(SatelliteControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view diff --git a/mslib/msui/sideview.py b/mslib/msui/sideview.py index 48802f81f..793ac98da 100644 --- a/mslib/msui/sideview.py +++ b/mslib/msui/sideview.py @@ -56,7 +56,7 @@ def __init__(self, parent=None, settings=None): parent -- Qt widget that is parent to this widget. settings -- dictionary containing sideview options. """ - super(MSUI_SV_OptionsDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self._suffixes = ['hPa', 'km', 'hft'] @@ -256,7 +256,7 @@ def __init__(self, parent=None, model=None, _id=None): """ Set up user interface, connect signal/slots. """ - super(MSUISideViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) @@ -307,7 +307,7 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the view displays. """ - super(MSUISideViewWindow, self).setFlightTrackModel(model) + super().setFlightTrackModel(model) if self.docks[WMS] is not None: self.docks[WMS].widget().setFlightTrackModel(model) diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index f978f19bb..c3d5ae999 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -60,7 +60,7 @@ class MSUITableViewWindow(MSUIViewWindow, ui.Ui_TableViewWindow): def __init__(self, parent=None, model=None, _id=None): """ """ - super(MSUITableViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) @@ -271,7 +271,7 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the table displays. """ - super(MSUITableViewWindow, self).setFlightTrackModel(model) + super().setFlightTrackModel(model) self.tableWayPoints.setModel(self.waypoints_model) # Automatically enable or disable roundtrip when data changes diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index eed040f84..3d6b02e29 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -68,7 +68,7 @@ def __init__(self, parent=None, settings=None, wms_connected=False): parent -- Qt widget that is parent to this widget. settings -- dictionary containing topview options. """ - super(MSUI_TV_MapAppearanceDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) assert settings is not None @@ -189,7 +189,7 @@ def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, m """ Set up user interface, connect signal/slots. """ - super(MSUITopViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) logging.debug(_id) self.ui = parent self.setupUi(self) @@ -404,7 +404,7 @@ def changeMapSection(self, index=0, only_kwargs=False): self.mpl.navbar.clear_history() def setIdentifier(self, identifier): - super(MSUITopViewWindow, self).setIdentifier(identifier) + super().setIdentifier(identifier) self.mpl.canvas.map.set_identifier(identifier) def open_settings_dialog(self): diff --git a/mslib/msui/updater.py b/mslib/msui/updater.py index 29de1b8cd..19be9e59e 100644 --- a/mslib/msui/updater.py +++ b/mslib/msui/updater.py @@ -39,7 +39,7 @@ class UpdaterUI(QtWidgets.QDialog, ui_updater_dialog.Ui_Updater): on_update_available = QtCore.pyqtSignal([str, str]) def __init__(self, parent=None): - super(UpdaterUI, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.hide() self.labelVersion.setText(f"Newest Version: {__version__}") diff --git a/mslib/msui/viewwindows.py b/mslib/msui/viewwindows.py index 9d74706fa..5a7760d67 100644 --- a/mslib/msui/viewwindows.py +++ b/mslib/msui/viewwindows.py @@ -46,7 +46,7 @@ class MSUIViewWindow(QtWidgets.QMainWindow): # viewClosesId = QtCore.Signal(int, name="viewClosesId") def __init__(self, parent=None, model=None, _id=None): - super(MSUIViewWindow, self).__init__(parent) + super().__init__(parent) # Object variables: self.waypoints_model = model # pointer to the current flight track. @@ -219,7 +219,7 @@ class MSUIMplViewWindow(MSUIViewWindow): """ def __init__(self, parent=None, model=None, _id=None): - super(MSUIMplViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) logging.debug(_id) self.mpl = None diff --git a/mslib/msui/wms_capabilities.py b/mslib/msui/wms_capabilities.py index 7df9cf432..ede744fb9 100644 --- a/mslib/msui/wms_capabilities.py +++ b/mslib/msui/wms_capabilities.py @@ -42,7 +42,7 @@ def __init__(self, parent=None, url=None, capabilities=None): parent -- Qt widget that is parent to this widget. capabilities_xml -- . """ - super(WMSCapabilitiesBrowser, self).__init__(parent) + super().__init__(parent) self.setupUi(self) if url is None: diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 115e9e4a5..deef4e9e2 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -238,7 +238,7 @@ def __init__(self, parent=None): Arguments: parent -- Qt widget that is parent to this widget. """ - super(MSS_WMS_AuthenticationDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) def getAuthInfo(self): @@ -266,7 +266,7 @@ class WMSMapFetcher(QtCore.QObject): started_request = QtCore.pyqtSignal() def __init__(self, wms_cache, parent=None): - super(WMSMapFetcher, self).__init__(parent) + super().__init__(parent) self.wms_cache = wms_cache self.maps = [] self.map_imgs = [] @@ -411,7 +411,7 @@ def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): default_WMS -- list of strings that specify WMS URLs that will be displayed in the URL combobox as default values. """ - super(WMSControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view @@ -1508,7 +1508,7 @@ class VSecWMSControlWidget(WMSControlWidget): def __init__(self, parent=None, default_WMS=None, waypoints_model=None, view=None, wms_cache=None): - super(VSecWMSControlWidget, self).__init__( + super().__init__( parent=parent, default_WMS=default_WMS, wms_cache=wms_cache, view=view) self.waypoints_model = waypoints_model self.btGetMap.clicked.connect(self.get_all_maps) @@ -1590,7 +1590,7 @@ class HSecWMSControlWidget(WMSControlWidget): """ def __init__(self, parent=None, default_WMS=None, view=None, wms_cache=None): - super(HSecWMSControlWidget, self).__init__( + super().__init__( parent=parent, default_WMS=default_WMS, wms_cache=wms_cache, view=view) self.btGetMap.clicked.connect(self.get_all_maps) @@ -1652,7 +1652,7 @@ class LSecWMSControlWidget(WMSControlWidget): def __init__(self, parent=None, default_WMS=None, waypoints_model=None, view=None, wms_cache=None): - super(LSecWMSControlWidget, self).__init__( + super().__init__( parent=parent, default_WMS=default_WMS, wms_cache=wms_cache, view=view) self.waypoints_model = waypoints_model self.btGetMap.clicked.connect(self.get_all_maps) diff --git a/mslib/mswms/demodata.py b/mslib/mswms/demodata.py index 04b54b1ce..986c93802 100644 --- a/mslib/mswms/demodata.py +++ b/mslib/mswms/demodata.py @@ -831,7 +831,7 @@ def generate_field(coordinate, levels, standard_name, ntimes, nlats, nlons): return data, _PROFILES[standard_name]["unit"] -class DataFiles(object): +class DataFiles: """ Routine to write test data files for MSS using extracted variable ranges from ECMWF data diff --git a/mslib/mswms/mpl_lsec.py b/mslib/mswms/mpl_lsec.py index e9ac2f2c9..8a44844ad 100644 --- a/mslib/mswms/mpl_lsec.py +++ b/mslib/mswms/mpl_lsec.py @@ -48,7 +48,7 @@ def __init__(self, driver=None): """ Constructor. """ - super(AbstractLinearSectionStyle, self).__init__(driver=driver) + super().__init__(driver=driver) self.variable = "" self.unit = "" self.y_values = [] diff --git a/mslib/mswms/mpl_lsec_styles.py b/mslib/mswms/mpl_lsec_styles.py index 396e9a464..7bafc4728 100644 --- a/mslib/mswms/mpl_lsec_styles.py +++ b/mslib/mswms/mpl_lsec_styles.py @@ -38,7 +38,7 @@ class LS_DefaultStyle(AbstractLinearSectionStyle): """ def __init__(self, driver, variable="air_temperature", filetype="ml"): - super(AbstractLinearSectionStyle, self).__init__(driver=driver) + super().__init__(driver=driver) self.variable = variable self.required_datafields = [(filetype, self.variable, None)] if filetype != "pl": diff --git a/mslib/mswms/mpl_vsec.py b/mslib/mswms/mpl_vsec.py index e9aae92bc..2622b8613 100644 --- a/mslib/mswms/mpl_vsec.py +++ b/mslib/mswms/mpl_vsec.py @@ -60,7 +60,7 @@ def __init__(self, driver=None): """ Constructor. """ - super(AbstractVerticalSectionStyle, self).__init__(driver=driver) + super().__init__(driver=driver) def add_colorbar(self, contour, label=None, tick_levels=None, width="3%", height="30%", cb_format=None, left=0.08, right=0.95, top=0.9, bottom=0.14, fraction=0.05, pad=0.01, loc=1, tick_position="left"): diff --git a/mslib/mswms/wms.py b/mslib/mswms/wms.py index 58227f38f..2a5945b6c 100644 --- a/mslib/mswms/wms.py +++ b/mslib/mswms/wms.py @@ -80,7 +80,7 @@ app.config['realm'] = realm -class default_mswms_settings(object): +class default_mswms_settings: base_dir = os.path.abspath(os.path.dirname(__file__)) xml_template_location = os.path.join(base_dir, "xml_templates") service_name = "OGC:WMS" @@ -119,7 +119,7 @@ class default_mswms_settings(object): except ImportError as ex: logging.warning("Couldn't import mswms_auth (ImportError:'{%s), creating dummy config.", ex) - class mswms_auth(object): + class mswms_auth: allowed_users = [("mswms", "add_md5_digest_of_PASSWORD_here"), ("add_new_user_here", "add_md5_digest_of_PASSWORD_here")] __file__ = None @@ -176,7 +176,7 @@ def squash_multiple_xml(xml_strings): return ElementTree.tostring(base) -class WMSServer(object): +class WMSServer: def __init__(self): """ diff --git a/mslib/utils/config.py b/mslib/utils/config.py index f0f50a978..34b836bf9 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -41,7 +41,7 @@ from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType -class MSUIDefaultConfig(object): +class MSUIDefaultConfig: """Central configuration for the Mission Support System User Interface Application (msui). diff --git a/mslib/utils/qt.py b/mslib/utils/qt.py index bf3847389..0234d4ad5 100644 --- a/mslib/utils/qt.py +++ b/mslib/utils/qt.py @@ -378,7 +378,7 @@ class Worker(QtCore.QThread): def __init__(self, function): Worker.workers.add(self) - super(Worker, self).__init__() + super().__init__() self.function = function # pyqtSignals don't work without an application eventloop running if QtCore.QCoreApplication.startingUp(): @@ -442,7 +442,7 @@ class Updater(QtCore.QObject): on_status_update = QtCore.pyqtSignal([str]) def __init__(self, parent=None): - super(Updater, self).__init__(parent) + super().__init__(parent) self.is_git_env = False self.new_version = None self.old_version = None From d7c6f4f85d810c8fbed9bd61fa9e450689ce3413 Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Thu, 25 May 2023 10:54:32 +0200 Subject: [PATCH 013/176] make starting up slightly faster --- mslib/mscolab/message_type.py | 32 + mslib/mscolab/models.py | 9 +- mslib/msui/mscolab_chat.py | 2 +- mslib/msui/msui.py | 1024 +----------------- mslib/msui/msui_mainwindow.py | 1019 +++++++++++++++++ mslib/msui/multiple_flightpath_dockwidget.py | 4 +- mslib/utils/coordinate.py | 8 +- mslib/utils/thermolib.py | 4 +- tests/_test_msui/test_mscolab.py | 4 +- tests/_test_msui/test_msui.py | 25 +- tests/_test_msui/test_wms_control.py | 7 - 11 files changed, 1080 insertions(+), 1058 deletions(-) create mode 100644 mslib/mscolab/message_type.py create mode 100644 mslib/msui/msui_mainwindow.py diff --git a/mslib/mscolab/message_type.py b/mslib/mscolab/message_type.py new file mode 100644 index 000000000..470cee761 --- /dev/null +++ b/mslib/mscolab/message_type.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" + + mslib.mscolab.message_type.py + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This file is part of MSS. + + :copyright: Copyright 2019 Shivashis Padhi + :copyright: Copyright 2019-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import enum + + +class MessageType(enum.IntEnum): + TEXT = 0 + SYSTEM_MESSAGE = 1 + IMAGE = 2 + DOCUMENT = 3 diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 3442fb681..0ab82d5ca 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -26,13 +26,13 @@ """ import datetime -import enum import logging import jwt from passlib.apps import custom_app_context as pwd_context from flask_sqlalchemy import SQLAlchemy from mslib.mscolab.app import APP +from mslib.mscolab.message_type import MessageType db = SQLAlchemy(APP) @@ -157,13 +157,6 @@ def __repr__(self): f'last_used: {self.last_used}> ' -class MessageType(enum.IntEnum): - TEXT = 0 - SYSTEM_MESSAGE = 1 - IMAGE = 2 - DOCUMENT = 3 - - class Message(db.Model): __tablename__ = "messages" diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index c922f4758..d21db42ea 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -33,7 +33,7 @@ from markdown.extensions import Extension from werkzeug.urls import url_join -from mslib.mscolab.models import MessageType +from mslib.mscolab.message_type import MessageType from PyQt5 import QtCore, QtGui, QtWidgets from mslib.utils.qt import get_open_filename, get_save_filename, show_popup from mslib.msui.qt5 import ui_mscolab_operation_window as ui diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index 5e65bdfc3..60aed4346 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -30,1042 +30,28 @@ """ import argparse -import copy -import functools import hashlib -import importlib import logging import os import platform -import re -import requests import shutil import sys import fs from packaging import version from mslib import __version__ -from mslib.msui.qt5 import ui_mainwindow as ui -from mslib.msui.qt5 import ui_about_dialog as ui_ab -from mslib.msui.qt5 import ui_shortcuts as ui_sh -from mslib.msui import flighttrack as ft -from mslib.msui import tableview, topview, sideview, linearview -from mslib.msui import editor +from mslib.msui.msui_mainwindow import MSUIMainWindow from mslib.msui import constants -from mslib.msui import wms_control -from mslib.msui import mscolab -from mslib.msui.updater import UpdaterUI from mslib.utils import setup_logging -from mslib.plugins.io.csv import load_from_csv, save_to_csv -from mslib.msui.icons import icons, python_powered -from mslib.utils.qt import get_open_filenames, get_save_filename, Worker, Updater -from mslib.utils.config import read_config_file, config_loader -from mslib.utils.auth import get_auth_from_url_and_name -from PyQt5 import QtGui, QtCore, QtWidgets, QtTest +from mslib.msui.icons import icons +from mslib.utils.qt import Worker, Updater +from mslib.utils.config import read_config_file +from PyQt5 import QtGui, QtCore, QtWidgets # Add config path to PYTHONPATH so plugins located there may be found sys.path.append(constants.MSUI_CONFIG_PATH) -def clean_string(string): - return re.sub(r'\W|^(?=\d)', '_', string) - - -class QActiveViewsListWidgetItem(QtWidgets.QListWidgetItem): - """Subclass of QListWidgetItem, represents an open view in the list of - open views. Keeps a reference to the view instance (i.e. the window) it - represents in the list of open views. - """ - - # Class variable to assign a unique ID to each view. - opened_views = 0 - open_views = [] - - def __init__(self, view_window, parent=None, viewsChanged=None, mscolab=False, - _type=QtWidgets.QListWidgetItem.UserType): - """Add ID number to the title of the corresponding view window. - """ - QActiveViewsListWidgetItem.opened_views += 1 - view_name = f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.name}" - super().__init__(view_name, parent, _type) - - view_window.setWindowTitle(f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.windowTitle()} - " - f"{view_window.waypoints_model.name}") - view_window.setIdentifier(view_name) - self.window = view_window - self.parent = parent - self.viewsChanged = viewsChanged - QActiveViewsListWidgetItem.open_views.append(view_window) - - def view_destroyed(self): - """Slot that removes this QListWidgetItem from the parent (the - QListWidget) if the corresponding view has been deleted. - """ - if self.parent is not None: - self.parent.takeItem(self.parent.row(self)) - for index, window in enumerate(QActiveViewsListWidgetItem.open_views): - if window.identifier == self.window.identifier: - del QActiveViewsListWidgetItem.open_views[index] - break - if self.viewsChanged is not None: - self.viewsChanged.emit() - - -class QFlightTrackListWidgetItem(QtWidgets.QListWidgetItem): - """Subclass of QListWidgetItem, represents a flight track in the list of - open flight tracks. Keeps a reference to the flight track instance - (i.e. the instance of WaypointsTableModel). - """ - - def __init__(self, flighttrack_model, parent=None, - type=QtWidgets.QListWidgetItem.UserType): - """Item class for the list widget that accommodates the open flight - tracks. - - Arguments: - flighttrack_model -- instance of a flight track model that is - associated with the item - parent -- pointer to the QListWidgetItem that accommodates this item. - If not None, the itemChanged() signal of the parent is - connected to the nameChanged() slot of this class, reacting - to name changes of the item. - """ - view_name = flighttrack_model.name - super().__init__( - view_name, parent, type) - - self.parent = parent - self.flighttrack_model = flighttrack_model - - -class MSUI_ShortcutsDialog(QtWidgets.QDialog, ui_sh.Ui_ShortcutsDialog): - """ - Dialog showing shortcuts for all currently open windows - """ - - def __init__(self): - super().__init__(QtWidgets.QApplication.activeWindow()) - self.setupUi(self) - self.current_shortcuts = None - self.treeWidget.itemDoubleClicked.connect(self.double_clicked) - self.treeWidget.itemClicked.connect(self.clicked) - self.leShortcutFilter.textChanged.connect(self.filter_shortcuts) - self.filterRemoveAction = self.leShortcutFilter.addAction(QtGui.QIcon(icons("64x64", "remove.png")), - QtWidgets.QLineEdit.TrailingPosition) - self.filterRemoveAction.setVisible(False) - self.filterRemoveAction.setToolTip("Click to remove the filter") - self.filterRemoveAction.triggered.connect(lambda: self.leShortcutFilter.setText("")) - self.cbNoShortcut.stateChanged.connect(self.fill_list) - self.cbAdvanced.stateChanged.connect(lambda i: (self.cbNoShortcut.setVisible(i), - self.leShortcutFilter.setVisible(i), - self.cbDisplayType.setVisible(i), - self.label.setVisible(i), - self.label_2.setVisible(i), - self.line.setVisible(i))) - self.cbHighlight.stateChanged.connect(self.filter_shortcuts) - self.cbDisplayType.currentTextChanged.connect(self.fill_list) - self.cbAdvanced.stateChanged.emit(self.cbAdvanced.checkState()) - self.oldReject = self.reject - self.reject = self.custom_reject - - def custom_reject(self): - """ - Reset highlighted objects when closing the shortcuts dialog - """ - self.reset_highlight() - self.oldReject() - - def reset_highlight(self): - """ - Iterates through all shortcuts and resets the stylesheet - """ - if self.current_shortcuts: - for shortcuts in self.current_shortcuts.values(): - for shortcut in shortcuts.values(): - try: - if shortcut[-1] and hasattr(shortcut[-1], "setStyleSheet"): - shortcut[-1].setStyleSheet("") - except RuntimeError: - # when we have deleted a QAction we have to update the list - # Because we cannot test if the underlying object exist we have to catch that - self.fill_list() - - def clicked(self, item): - """ - Highlights the selected item in the GUI as yellow - """ - self.reset_highlight() - if hasattr(item, "source_object") and item.source_object and hasattr(item.source_object, "setStyleSheet"): - item.source_object.setStyleSheet("background-color:yellow;") - - def double_clicked(self, item): - """ - Executes the shortcut for the doubleclicked item - """ - if hasattr(item, "source_object") and item.source_object: - self.reset_highlight() - self.hide() - obj = item.source_object - if isinstance(obj, QtWidgets.QShortcut): - obj.activated.emit() - elif isinstance(obj, QtWidgets.QAction): - obj.trigger() - elif isinstance(obj, QtWidgets.QAbstractButton): - obj.click() - elif isinstance(obj, QtWidgets.QComboBox): - QtCore.QTimer.singleShot(200, obj.showPopup) - elif isinstance(obj, QtWidgets.QLineEdit) or isinstance(obj, QtWidgets.QAbstractSpinBox): - obj.setFocus() - - def fill_list(self): - """ - Fills the treeWidget with all relevant windows as top level items and their shortcuts as children - """ - self.treeWidget.clear() - self.current_shortcuts = self.get_shortcuts() - for widget in self.current_shortcuts: - if hasattr(widget, "window"): - name = widget.window().windowTitle() - else: - name = widget.objectName() - if len(name) == 0 or (hasattr(widget, "window") and widget.window().isHidden()): - continue - header = QtWidgets.QTreeWidgetItem(self.treeWidget) - header.setText(0, name) - if hasattr(widget, "window") and widget.window() == self.parent(): - header.setExpanded(True) - header.setSelected(True) - self.treeWidget.setCurrentItem(header) - for objectName in self.current_shortcuts[widget].keys(): - description, text, _, shortcut, obj = self.current_shortcuts[widget][objectName] - item = QtWidgets.QTreeWidgetItem(header) - item.source_object = obj - itemText = description if self.cbDisplayType.currentText() == 'Tooltip' \ - else text if self.cbDisplayType.currentText() == 'Text' else objectName - item.setText(0, f"{itemText}: {shortcut}") - item.setToolTip(0, f"ToolTip: {description}\nText: {text}\nObjectName: {objectName}") - header.addChild(item) - self.filter_shortcuts(self.leShortcutFilter.text()) - - def get_shortcuts(self): - """ - Iterates through all top level widgets and puts their shortcuts in a dictionary - """ - shortcuts = {} - for qobject in QtWidgets.QApplication.topLevelWidgets(): - actions = [] - actions.extend([ - (action.parent().window() if hasattr(action.parent(), "window") else action.parent(), - action.toolTip(), action.text().replace("&&", "%%").replace("&", "").replace("%%", "&"), - action.objectName(), - ",".join([shortcut.toString() for shortcut in action.shortcuts()]), action) - for action in qobject.findChildren( - QtWidgets.QAction) if len(action.shortcuts()) > 0 or self.cbNoShortcut.checkState()]) - actions.extend([(shortcut.parentWidget().window(), shortcut.whatsThis(), "", - shortcut.objectName(), shortcut.key().toString(), shortcut) - for shortcut in qobject.findChildren(QtWidgets.QShortcut)]) - actions.extend([(button.window(), button.toolTip(), button.text().replace("&&", "%%").replace("&", "") - .replace("%%", "&"), button.objectName(), - button.shortcut().toString() if button.shortcut() else "", button) - for button in qobject.findChildren(QtWidgets.QAbstractButton) if button.shortcut() or - self.cbNoShortcut.checkState()]) - - # Additional objects which have no shortcuts, if requested - actions.extend([(obj.window(), obj.toolTip(), obj.currentText(), obj.objectName(), "", obj) - for obj in qobject.findChildren(QtWidgets.QComboBox) if self.cbNoShortcut.checkState()]) - actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) - for obj in qobject.findChildren(QtWidgets.QAbstractSpinBox) + - qobject.findChildren(QtWidgets.QLineEdit) - if self.cbNoShortcut.checkState()]) - actions.extend([(obj.window(), obj.toolTip(), obj.toPlainText(), obj.objectName(), "", obj) - for obj in qobject.findChildren(QtWidgets.QPlainTextEdit) + - qobject.findChildren(QtWidgets.QTextEdit) - if self.cbNoShortcut.checkState()]) - - if not any(action for action in actions if action[3] == "actionShortcuts"): - actions.append((qobject.window(), "Show Current Shortcuts", "Show Current Shortcuts", - "Show Current Shortcuts", "Alt+S", None)) - if not any(action for action in actions if action[3] == "actionSearch"): - actions.append((qobject.window(), "Search for interactive text in the UI", - "Search for interactive text in the UI", "Search for interactive text in the UI", - "Ctrl+F", None)) - - for item in actions: - if item[0] not in shortcuts: - shortcuts[item[0]] = {} - shortcuts[item[0]][item[3].strip()] = item[1:] - - return shortcuts - - def filter_shortcuts(self, text="Nothing", rerun=True): - """ - Hides all shortcuts not containing the text - """ - text = self.leShortcutFilter.text() - self.reset_highlight() - - window_count = 0 - for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): - if not window.isHidden(): - window_count += 1 - - for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): - wms_hits = 0 - - for child_index in range(window.childCount()): - widget = window.child(child_index) - if text.lower() in widget.text(0).lower() or text.lower() in window.text(0).lower(): - widget.setHidden(False) - wms_hits += 1 - else: - widget.setHidden(True) - - if wms_hits == 1 and (self.cbHighlight.isChecked() or window_count == 1): - for child_index in range(window.childCount()): - widget = window.child(child_index) - if (not widget.isHidden()) and hasattr(widget.source_object, "setStyleSheet"): - widget.source_object.setStyleSheet("background-color: yellow;") - break - - if wms_hits == 0 and len(text) > 0: - window.setHidden(True) - else: - window.setHidden(False) - - self.filterRemoveAction.setVisible(len(text) > 0) - if rerun: - self.filter_shortcuts(text, False) - - -class MSUI_AboutDialog(QtWidgets.QDialog, ui_ab.Ui_AboutMSUIDialog): - """Dialog showing information about MSUI. Most of the displayed text is - defined in the QtDesigner file. - """ - - def __init__(self, parent=None): - """ - Arguments: - parent -- Qt widget that is parent to this widget. - """ - super().__init__(parent) - self.setupUi(self) - self.lblVersion.setText(f"Version: {__version__}") - self.milestone_url = f'https://github.com/Open-MSS/MSS/issues?q=is%3Aclosed+milestone%3A{__version__[:-1]}' - self.lblChanges.setText(f'New Features and Changes') - blub = QtGui.QPixmap(python_powered()) - self.lblPython.setPixmap(blub) - - -class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): - """MSUI new main window class. Provides user interface elements for managing - flight tracks, views and MSColab functionalities. - """ - - viewsChanged = QtCore.pyqtSignal(name="viewsChanged") - signal_activate_flighttrack = QtCore.Signal(ft.WaypointsTableModel, name="signal_activate_flighttrack") - signal_activate_operation = QtCore.Signal(int, name="signal_activate_operation") - signal_operation_added = QtCore.Signal(int, str, name="signal_operation_added") - signal_operation_removed = QtCore.Signal(int, name="signal_operation_removed") - signal_login_mscolab = QtCore.Signal(str, str, name="signal_login_mscolab") - signal_logout_mscolab = QtCore.Signal(name="signal_logout_mscolab") - signal_listFlighttrack_doubleClicked = QtCore.Signal() - signal_permission_revoked = QtCore.Signal(int) - signal_render_new_permission = QtCore.Signal(int, str) - - def __init__(self, mscolab_data_dir=None, *args): - super().__init__(*args) - self.setupUi(self) - self.setWindowIcon(QtGui.QIcon(icons('32x32'))) - # This code is required in Windows 7 to use the icon set by setWindowIcon in taskbar - # instead of the default Icon of python/pythonw - try: - import ctypes - myappid = f"msui.msui.{__version__}" # arbitrary string - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) - except (ImportError, AttributeError) as error: - logging.debug("AttributeError, ImportError Exception %s", error) - - self.config_editor = None - self.local_active = True - self.new_flight_track_counter = 0 - - # Reference to the flight track that is currently displayed in the views. - self.active_flight_track = None - self.last_save_directory = config_loader(dataset="data_dir") - - # bind meta (ctrl in macOS) to override automatic translation of ctrl to command by qt - if sys.platform == 'darwin': - self.actionTopView.setShortcut(QtGui.QKeySequence("Meta+h")) - self.actionSideView.setShortcut(QtGui.QKeySequence("Meta+v")) - self.actionTableView.setShortcut(QtGui.QKeySequence("Meta+t")) - self.actionLinearView.setShortcut(QtGui.QKeySequence("Meta+l")) - self.actionConfiguration.setShortcut(QtGui.QKeySequence("Ctrl+,")) - - # File menu. - self.actionNewFlightTrack.triggered.connect(functools.partial(self.create_new_flight_track, None, None)) - self.actionSaveActiveFlightTrack.triggered.connect(self.save_handler) - self.actionSaveActiveFlightTrackAs.triggered.connect(self.save_as_handler) - self.actionCloseSelectedFlightTrack.triggered.connect(self.close_selected_flight_track) - - # Views menu. - self.actionTopView.triggered.connect(functools.partial(self.create_view_handler, "topview")) - self.actionSideView.triggered.connect(functools.partial(self.create_view_handler, "sideview")) - self.actionTableView.triggered.connect(functools.partial(self.create_view_handler, "tableview")) - self.actionLinearView.triggered.connect(functools.partial(self.create_view_handler, "linearview")) - - # Help menu. - self.actionOnlineHelp.triggered.connect(self.show_online_help) - self.actionAboutMSUI.triggered.connect(self.show_about_dialog) - self.actionShortcuts.triggered.connect(self.show_shortcuts) - self.actionShortcuts.setShortcutContext(QtCore.Qt.ApplicationShortcut) - self.actionSearch.triggered.connect(lambda: self.show_shortcuts(True)) - self.actionSearch.setShortcutContext(QtCore.Qt.ApplicationShortcut) - - # # Config - self.actionConfiguration.triggered.connect(self.open_config_editor) - - # Raise Main Window to front with Ctrl/Cmnd + up keyboard shortcut - self.addAction(self.actionBringMainWindowToFront) - self.actionBringMainWindowToFront.triggered.connect(self.bring_main_window_to_front) - self.actionBringMainWindowToFront.setShortcutContext(QtCore.Qt.ApplicationShortcut) - - # Flight Tracks. - self.listFlightTracks.itemActivated.connect(self.activate_flight_track) - - # Views. - self.listViews.itemActivated.connect(self.activate_sub_window) - - # Add default and plugins from settings - picker_default = config_loader(dataset="filepicker_default") - self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Import") - self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Export") - self.add_plugin_submenu("CSV", "csv", load_from_csv, picker_default, plugin_type="Import") - self.add_plugin_submenu("CSV", "csv", save_to_csv, picker_default, plugin_type="Export") - self.add_plugins() - - preload_urls = config_loader(dataset="WMS_preload") - self.preload_wms(preload_urls) - - # Status Bar - self.statusBar.showMessage(self.status()) - - # Create MSColab instance to handle all MSColab functionalities - self.mscolab = mscolab.MSUIMscolab(parent=self, data_dir=mscolab_data_dir) - - # Setting up MSColab Tab - self.connectBtn.clicked.connect(self.mscolab.open_connect_window) - - self.shortcuts_dlg = None - - # deactivate vice versa selection of Operation, inactive operation or Flight Track - - self.listFlightTracks.itemClicked.connect( - lambda: self.listOperationsMSC.setCurrentItem(None)) - self.listOperationsMSC.itemClicked.connect( - lambda: self.listFlightTracks.setCurrentItem(None)) - # disable category until connected/login into mscolab - self.filterCategoryCb.setEnabled(False) - self.mscolab.signal_unarchive_operation.connect(self.activate_operation_slot) - self.mscolab.signal_operation_added.connect(self.add_operation_slot) - self.mscolab.signal_operation_removed.connect(self.remove_operation_slot) - self.mscolab.signal_login_mscolab.connect(lambda d, t: self.signal_login_mscolab.emit(d, t)) - self.mscolab.signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) - self.mscolab.signal_listFlighttrack_doubleClicked.connect( - lambda: self.signal_listFlighttrack_doubleClicked.emit()) - self.mscolab.signal_permission_revoked.connect(lambda op_id: self.signal_permission_revoked.emit(op_id)) - self.mscolab.signal_render_new_permission.connect( - lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) - - # Don't start the updater during a test run of msui - if "pytest" not in sys.modules: - self.updater = UpdaterUI(self) - self.actionUpdater.triggered.connect(self.updater.show) - self.openOperationsGb.hide() - - @staticmethod - def preload_wms(urls): - """ - This method accesses a list of WMS servers and load their capability documents. - :param urls: List of URLs - """ - pdlg = QtWidgets.QProgressDialog("Preloading WMS servers...", "Cancel", 0, len(urls)) - pdlg.reset() - pdlg.setValue(0) - pdlg.setModal(True) - pdlg.show() - QtWidgets.QApplication.processEvents() - for i, base_url in enumerate(urls): - pdlg.setValue(i) - QtWidgets.QApplication.processEvents() - # initialize login cache from config file, but do not overwrite existing keys - http_auth = config_loader(dataset="MSS_auth") - auth_username, auth_password = get_auth_from_url_and_name(base_url, http_auth, overwrite_login_cache=False) - try: - request = requests.get(base_url, timeout=(2, 10)) - if pdlg.wasCanceled(): - break - - wms = wms_control.MSUIWebMapService(request.url, version=None, - username=auth_username, password=auth_password) - wms_control.WMS_SERVICE_CACHE[wms.url] = wms - logging.info("Stored WMS info for '%s'", wms.url) - except Exception as ex: - logging.error("Error in preloading '%s': '%s'", type(ex), ex) - if pdlg.wasCanceled(): - break - logging.debug("Contents of WMS_SERVICE_CACHE: %s", wms_control.WMS_SERVICE_CACHE.keys()) - pdlg.close() - - def bring_main_window_to_front(self): - self.show() - self.raise_() - self.activateWindow() - - def menu_handler(self): - self.menuImportFlightTrack.setEnabled(True) - if not self.local_active and self.mscolab.access_level == "viewer": - # viewer has no import access to server - self.menuImportFlightTrack.setEnabled(False) - - # enable/disable flight track menus - self.actionSaveActiveFlightTrack.setEnabled(self.local_active) - self.actionSaveActiveFlightTrackAs.setEnabled(self.local_active) - - def add_plugins(self): - picker_default = config_loader(dataset="filepicker_default") - self.import_plugins = {} - self.export_plugins = {} - self.add_import_plugins(picker_default) - self.add_export_plugins(picker_default) - - @QtCore.Slot(int) - def activate_operation_slot(self, active_op_id): - self.signal_activate_operation.emit(active_op_id) - - @QtCore.Slot(int, str) - def add_operation_slot(self, op_id, path): - self.signal_operation_added.emit(op_id, path) - - @QtCore.Slot(int) - def remove_operation_slot(self, op_id): - self.signal_operation_removed.emit(op_id) - - def add_plugin_submenu(self, name, extension, function, pickertype, plugin_type="Import"): - if plugin_type == "Import": - menu = self.menuImportFlightTrack - action_name = "actionImportFlightTrack" + clean_string(name) - handler = self.handle_import_local - elif plugin_type == "Export": - menu = self.menuExportActiveFlightTrack - action_name = "actionExportFlightTrack" + clean_string(name) - handler = self.handle_export_local - - if hasattr(self, action_name): - raise ValueError(f"'{action_name}' has already been set!") - action = QtWidgets.QAction(self) - action.setObjectName(action_name) - action.setText(QtCore.QCoreApplication.translate("MSUIMainWindow", name, None)) - action.triggered.connect(functools.partial(handler, extension, function, pickertype)) - menu.addAction(action) - setattr(self, action_name, action) - - def add_import_plugins(self, picker_default): - plugins = config_loader(dataset="import_plugins") - for name in plugins: - extension, module, function = plugins[name][:3] - picker_type = picker_default - if len(plugins[name]) == 4: - picker_type = plugins[name][3] - try: - imported_module = importlib.import_module(module) - imported_function = getattr(imported_module, function) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on import: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) - continue - try: - self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Import") - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on installing plugin: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error import plugins"), - self.tr(f"ERROR: Configuration\n\n{self.import_plugins}\n\nthrows {type(ex)} error:\n{ex}")) - continue - self.import_plugins[name] = (imported_function, extension) - - def add_export_plugins(self, picker_default): - plugins = config_loader(dataset="export_plugins") - for name in plugins: - extension, module, function = plugins[name][:3] - picker_type = picker_default - if len(plugins[name]) == 4: - picker_type = plugins[name][3] - try: - imported_module = importlib.import_module(module) - imported_function = getattr(imported_module, function) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on import: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error export plugins"), - self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) - continue - try: - self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Export") - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on installing plugin: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error import plugins"), - self.tr(f"ERROR: Configuration\n\n{self.export_plugins}\n\nthrows {type(ex)} error:\n{ex}")) - continue - self.export_plugins[name] = (imported_function, extension) - - def remove_plugins(self): - for name, _ in self.import_plugins.items(): - full_name = "actionImportFlightTrack" + clean_string(name) - actions = [_x for _x in self.menuImportFlightTrack.actions() - if _x.objectName() == full_name] - assert len(actions) == 1 - self.menuImportFlightTrack.removeAction(actions[0]) - delattr(self, full_name) - self.import_plugins = {} - - for name, _ in self.export_plugins.items(): - full_name = "actionExportFlightTrack" + clean_string(name) - actions = [_x for _x in self.menuExportActiveFlightTrack.actions() - if _x.objectName() == full_name] - assert len(actions) == 1 - self.menuExportActiveFlightTrack.removeAction(actions[0]) - delattr(self, full_name) - self.export_plugins = {} - - def handle_import_local(self, extension, function, pickertype): - filenames = get_open_filenames( - self, "Import Flight Track", - self.last_save_directory, - f"Flight Track (*.{extension});;All files (*.*)", - pickertype=pickertype) - if self.local_active: - if filenames is not None: - activate = True - if len(filenames) > 1: - activate = False - for name in filenames: - self.create_new_flight_track(filename=name, function=function, activate=activate) - self.last_save_directory = fs.path.dirname(name) - else: - for name in filenames: - self.mscolab.handle_import_msc(name, extension, function, pickertype) - - def handle_export_local(self, extension, function, pickertype): - if self.local_active: - default_filename = f'{os.path.join(self.last_save_directory, self.active_flight_track.name)}.{extension}' - filename = get_save_filename( - self, "Export Flight Track", - default_filename, f"Flight Track (*.{extension})", - pickertype=pickertype) - if filename is not None: - self.last_save_directory = fs.path.dirname(filename) - try: - if function is None: - doc = self.active_flight_track.get_xml_doc() - dirname, name = fs.path.split(filename) - file_dir = fs.open_fs(dirname) - with file_dir.open(name, 'w') as file_object: - doc.writexml(file_object, indent=" ", addindent=" ", newl="\n", encoding="utf-8") - file_dir.close() - else: - function(filename, self.active_flight_track.name, self.active_flight_track.waypoints) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("file io plugin error: %s %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error"), - self.tr(f"ERROR: {type(ex)} {ex}")) - else: - self.mscolab.handle_export_msc(extension, function, pickertype) - - def create_new_flight_track(self, template=None, filename=None, function=None, activate=True): - """Creates a new flight track model from a template. Adds a new entry to - the list of flight tracks. Called when the user selects the 'new/open - flight track' menu entries. - - Arguments: - template -- copy the specified template to the new flight track (so that - it is not empty). - filename -- if not None, load the flight track in the specified file. - """ - if template is None: - template = [] - waypoints = config_loader(dataset="new_flighttrack_template") - default_flightlevel = config_loader(dataset="new_flighttrack_flightlevel") - for wp in waypoints: - template.append(ft.Waypoint(flightlevel=default_flightlevel, location=wp)) - if len(template) < 2: - QtWidgets.QMessageBox.critical( - self, self.tr("flighttrack template"), - self.tr("ERROR:Flighttrack template in configuration is too short. " - "Please add at least two valid locations.")) - - waypoints_model = None - if filename is not None: - # function is none if ftml file is selected - if function is None: - try: - waypoints_model = ft.WaypointsTableModel(filename=filename) - except (SyntaxError, OSError, IOError) as ex: - QtWidgets.QMessageBox.critical( - self, self.tr("Problem while opening flight track FTML:"), - self.tr(f"ERROR: {type(ex)} {ex}")) - else: - try: - ft_name, new_waypoints = function(filename) - waypoints_model = ft.WaypointsTableModel(name=ft_name, waypoints=new_waypoints) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("file io plugin error: %s %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error"), - self.tr(f"ERROR: {type(ex)} {ex}")) - if waypoints_model is not None: - for i in range(self.listFlightTracks.count()): - fltr = self.listFlightTracks.item(i) - if fltr.flighttrack_model.name == waypoints_model.name: - waypoints_model.name += " - imported from file" - break - else: - # Create a new flight track from the waypoints' template. - self.new_flight_track_counter += 1 - waypoints_model = ft.WaypointsTableModel( - name=f"new flight track ({self.new_flight_track_counter:d})") - # Make a copy of the template. Otherwise, all new flight tracks would - # use the same data structure in memory. - template_copy = copy.deepcopy(template) - waypoints_model.insertRows(0, rows=len(template_copy), waypoints=template_copy) - - if waypoints_model is not None: - # Create a new list entry for the flight track. Make the item name editable. - listitem = QFlightTrackListWidgetItem(waypoints_model, self.listFlightTracks) - listitem.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) - - # Activate new item - if activate: - self.activate_flight_track(listitem) - - def activate_flight_track(self, item): - """Set the currently selected flight track to be the active one, i.e. - the one that is displayed in the views (only one flight track can be - displayed at a time). - """ - self.mscolab.switch_to_local() - # self.setWindowModality(QtCore.Qt.NonModal) - self.active_flight_track = item.flighttrack_model - self.update_active_flight_track() - font = QtGui.QFont() - for i in range(self.listFlightTracks.count()): - self.listFlightTracks.item(i).setFont(font) - font.setBold(True) - item.setFont(font) - self.menu_handler() - self.signal_activate_flighttrack.emit(self.active_flight_track) - - def update_active_flight_track(self, old_flight_track_name=None): - logging.debug("update_active_flight_track") - for i in range(self.listViews.count()): - view_item = self.listViews.item(i) - view_item.window.setFlightTrackModel(self.active_flight_track) - # local we have always all options enabled - view_item.window.enable_navbar_action_buttons() - if old_flight_track_name is not None: - view_item.window.setWindowTitle(view_item.window.windowTitle().replace(old_flight_track_name, - self.active_flight_track.name)) - - def activate_selected_flight_track(self): - item = self.listFlightTracks.currentItem() - self.activate_flight_track(item) - - def switch_to_mscolab(self): - self.local_active = False - font = QtGui.QFont() - for i in range(self.listFlightTracks.count()): - self.listFlightTracks.item(i).setFont(font) - - # disable appropriate menu options - self.menu_handler() - - def save_handler(self): - """Slot for the 'Save Active Flight Track' menu entry. - """ - filename = self.active_flight_track.get_filename() - if filename: - self.save_flight_track(filename) - else: - self.save_as() - - def save_as_handler(self): - self.save_as() - - def save_as(self): - """ - Slot for the 'Save Active Flight Track As' menu entry. - """ - default_filename = os.path.join(self.last_save_directory, self.active_flight_track.name + ".ftml") - file_type = ["Flight track (*.ftml)"] - filepicker_default = config_loader(dataset="filepicker_default") - filename = get_save_filename( - self, "Save Flight Track", default_filename, ";;".join(file_type), pickertype=filepicker_default - ) - logging.debug("filename : '%s'", filename) - if filename: - ext = "ftml" - self.save_flight_track(filename) - self.last_save_directory = fs.path.dirname(filename) - self.active_flight_track.filename = filename - self.active_flight_track.name = fs.path.basename(filename.replace(f"{ext}", "").strip()) - - def save_flight_track(self, file_name): - ext = ".ftml" - if file_name: - if file_name.endswith(ext): - try: - self.active_flight_track.save_to_ftml(file_name) - except (OSError, IOError) as ex: - QtWidgets.QMessageBox.critical( - self, self.tr("Problem while saving flight track to FTML:"), - self.tr(f"ERROR: {type(ex)} {ex}")) - - for idx in range(self.listFlightTracks.count()): - if self.listFlightTracks.item(idx).flighttrack_model == self.active_flight_track: - old_filght_track_name = self.listFlightTracks.item(idx).text() - self.listFlightTracks.item(idx).setText(self.active_flight_track.name) - - self.update_active_flight_track(old_filght_track_name) - - def close_selected_flight_track(self): - """Slot to close the currently selected flight track. Flight tracks can - only be closed if at least one other flight track remains open. The - currently active flight track cannot be closed. - """ - if self.listFlightTracks.count() < 2: - QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), - self.tr("At least one flight track has to be open.")) - return - item = self.listFlightTracks.currentItem() - if item.flighttrack_model == self.active_flight_track and self.local_active: - QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), - self.tr("Cannot close currently active flight track.")) - return - if item.flighttrack_model.modified: - ret = QtWidgets.QMessageBox.warning(self, self.tr("Mission Support System"), - self.tr("The flight track you are about to close has " - "been modified. Close anyway?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) - if ret == QtWidgets.QMessageBox.Yes: - self.listFlightTracks.takeItem(self.listFlightTracks.currentRow()) - - def create_view_handler(self, _type): - if self.local_active: - self.create_view(_type, self.active_flight_track) - else: - self.mscolab.waypoints_model.name = self.mscolab.active_operation_name - self.create_view(_type, self.mscolab.waypoints_model) - - def create_view(self, _type, model): - """Method called when the user selects a new view to be opened. Creates - a new instance of the view and adds a QActiveViewsListWidgetItem to - the list of open views (self.listViews). - """ - layout = config_loader(dataset="layout") - view_window = None - if _type == "topview": - # Top view. - view_window = topview.MSUITopViewWindow(parent=self, model=model, - active_flighttrack=self.active_flight_track, - mscolab_server_url=self.mscolab.mscolab_server_url, - token=self.mscolab.token) - view_window.mpl.resize(layout['topview'][0], layout['topview'][1]) - if layout["immutable"]: - view_window.mpl.setFixedSize(layout['topview'][0], layout['topview'][1]) - elif _type == "sideview": - # Side view. - view_window = sideview.MSUISideViewWindow(model=model) - view_window.mpl.resize(layout['sideview'][0], layout['sideview'][1]) - if layout["immutable"]: - view_window.mpl.setFixedSize(layout['sideview'][0], layout['sideview'][1]) - elif _type == "tableview": - # Table view. - view_window = tableview.MSUITableViewWindow(model=model) - view_window.centralwidget.resize(layout['tableview'][0], layout['tableview'][1]) - elif _type == "linearview": - # Linear view. - view_window = linearview.MSUILinearViewWindow(model=model) - view_window.mpl.resize(layout['linearview'][0], layout['linearview'][1]) - if layout["immutable"]: - view_window.mpl.setFixedSize(layout['linearview'][0], layout['linearview'][1]) - - if view_window is not None: - # Set view type to window - view_window.view_type = view_window.name - # Make sure view window will be deleted after being closed, not - # just hidden (cf. Chapter 5 in PyQt4). - view_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) - # Open as a non-modal window. - view_window.show() - # Add an entry referencing the new view to the list of views. - # listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged, mscolab) - listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged) - view_window.viewCloses.connect(listitem.view_destroyed) - self.listViews.setCurrentItem(listitem) - # self.active_view_windows.append(view_window) - # disable navbar actions in the view for viewer - try: - if self.mscolab.access_level == "viewer": - view_window.disable_navbar_action_buttons() - except AttributeError: - view_window.enable_navbar_action_buttons() - self.viewsChanged.emit() - - def get_active_views(self): - active_view_windows = [] - for i in range(self.listViews.count()): - active_view_windows.append(self.listViews.item(i).window) - return active_view_windows - - def activate_sub_window(self, item): - """When the user clicks on one of the open view or tool windows, this - window is brought to the front. This function implements the slot to - activate a window if the user selects it in the list of views or - tools. - """ - # Restore the activated view and bring it to the front. - item.window.showNormal() - item.window.raise_() - item.window.activateWindow() - - def restart_application(self): - while self.listViews.count() > 0: - self.listViews.item(0).window.handle_force_close() - self.listViews.clear() - self.remove_plugins() - if self.mscolab.token is not None: - self.mscolab.logout() - read_config_file() - self.add_plugins() - - def open_config_editor(self): - """ - Opens up a JSON config editor - """ - if self.config_editor is None: - self.config_editor = editor.ConfigurationEditorWindow(parent=self) - self.config_editor.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.config_editor.destroyed.connect(self.close_config_editor) - self.config_editor.restartApplication.connect(self.restart_application) - self.config_editor.show() - else: - self.config_editor.showNormal() - self.config_editor.activateWindow() - - def close_config_editor(self): - self.config_editor = None - - def show_online_help(self): - """Open Documentation in a browser""" - QtGui.QDesktopServices.openUrl( - QtCore.QUrl("http://mss.readthedocs.io/en/stable")) - - def show_about_dialog(self): - """Show the 'About MSUI' dialog to the user. - """ - dlg = MSUI_AboutDialog(parent=self) - dlg.setModal(True) - dlg.exec_() - - def show_shortcuts(self, search_mode=False): - """Show the shortcuts dialog to the user. - """ - if QtWidgets.QApplication.activeWindow() == self.shortcuts_dlg: - return - - self.shortcuts_dlg = MSUI_ShortcutsDialog() if not self.shortcuts_dlg else self.shortcuts_dlg - - # In case the dialog gets deleted by QT, recreate it - try: - self.shortcuts_dlg.setModal(True) - except RuntimeError: - self.shortcuts_dlg = MSUI_ShortcutsDialog() - - self.shortcuts_dlg.setParent(QtWidgets.QApplication.activeWindow(), QtCore.Qt.Dialog) - self.shortcuts_dlg.reset_highlight() - self.shortcuts_dlg.fill_list() - self.shortcuts_dlg.show() - - self.shortcuts_dlg.cbAdvanced.setHidden(True) - self.shortcuts_dlg.cbHighlight.setHidden(True) - self.shortcuts_dlg.cbAdvanced.setCheckState(0) - self.shortcuts_dlg.cbHighlight.setCheckState(0) - self.shortcuts_dlg.leShortcutFilter.setText("") - self.shortcuts_dlg.setWindowTitle("Shortcuts") - - if search_mode: - self.shortcuts_dlg.setWindowTitle("Search") - self.shortcuts_dlg.cbAdvanced.setHidden(False) - self.shortcuts_dlg.cbHighlight.setHidden(False) - self.shortcuts_dlg.cbDisplayType.setCurrentIndex(1) - self.shortcuts_dlg.leShortcutFilter.setText("") - self.shortcuts_dlg.cbAdvanced.setCheckState(2) - self.shortcuts_dlg.cbNoShortcut.setCheckState(2) - self.shortcuts_dlg.leShortcutFilter.setFocus() - - def status(self): - if config_loader() != config_loader(default=True): - return ("Status : System Configuration") - else: - return (f"Status : User Configuration '{constants.MSUI_SETTINGS}' loaded") - - def closeEvent(self, event): - """Ask user if he/she wants to close the application. If yes, also - close all views that are open. - - Overloads QtGui.QMainWindow.closeEvent(). This method is called if - Qt receives a window close request for our application window. - """ - ret = QtWidgets.QMessageBox.warning( - self, self.tr("Mission Support System"), - self.tr("Do you want to close the Mission Support System application?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) - - if ret == QtWidgets.QMessageBox.Yes: - if self.mscolab.help_dialog is not None: - self.mscolab.help_dialog.close() - # cleanup mscolab widgets - if self.mscolab.token is not None: - self.mscolab.logout() - # Table View stick around after MainWindow closes - maybe some dangling reference? - # This removes them for sure! - while self.listViews.count() > 0: - self.listViews.item(0).window.handle_force_close() - self.listViews.clear() - self.listFlightTracks.clear() - # close configuration editor - if self.config_editor is not None: - self.config_editor.restart_on_save = False - self.config_editor.close() - QtTest.QTest.qWait(5) - if self.config_editor is not None: - self.statusBar.showMessage("Save your config changes and try closing again") - event.ignore() - return - event.accept() - else: - event.ignore() - - def main(): try: prefix = os.environ["CONDA_DEFAULT_ENV"] diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py new file mode 100644 index 000000000..e90696a3a --- /dev/null +++ b/mslib/msui/msui_mainwindow.py @@ -0,0 +1,1019 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + mslib.msui.msui_mainwindow + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Mission Support System Python/Qt User Interface + Main window of the user interface application. Manages view and tool windows + (the user can open multiple windows) and provides functionality to open, save, + and switch between flight tracks. + + This file is part of MSS. + + :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. + :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import copy +import functools +import importlib +import logging +import os +import re +import sys +import fs + +from mslib import __version__ +from mslib.msui.qt5 import ui_mainwindow as ui +from mslib.msui.qt5 import ui_about_dialog as ui_ab +from mslib.msui.qt5 import ui_shortcuts as ui_sh +from mslib.msui import flighttrack as ft +from mslib.msui import tableview, topview, sideview, linearview +from mslib.msui import constants, editor, mscolab +from mslib.msui.updater import UpdaterUI +from mslib.plugins.io.csv import load_from_csv, save_to_csv +from mslib.msui.icons import icons, python_powered +from mslib.utils.qt import get_open_filenames, get_save_filename +from mslib.utils.config import read_config_file, config_loader +from PyQt5 import QtGui, QtCore, QtWidgets + +# Add config path to PYTHONPATH so plugins located there may be found +sys.path.append(constants.MSUI_CONFIG_PATH) + + +def clean_string(string): + return re.sub(r'\W|^(?=\d)', '_', string) + + +class QActiveViewsListWidgetItem(QtWidgets.QListWidgetItem): + """Subclass of QListWidgetItem, represents an open view in the list of + open views. Keeps a reference to the view instance (i.e. the window) it + represents in the list of open views. + """ + + # Class variable to assign a unique ID to each view. + opened_views = 0 + open_views = [] + + def __init__(self, view_window, parent=None, viewsChanged=None, mscolab=False, + _type=QtWidgets.QListWidgetItem.UserType): + """Add ID number to the title of the corresponding view window. + """ + QActiveViewsListWidgetItem.opened_views += 1 + view_name = f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.name}" + super().__init__(view_name, parent, _type) + + view_window.setWindowTitle(f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.windowTitle()} - " + f"{view_window.waypoints_model.name}") + view_window.setIdentifier(view_name) + self.window = view_window + self.parent = parent + self.viewsChanged = viewsChanged + QActiveViewsListWidgetItem.open_views.append(view_window) + + def view_destroyed(self): + """Slot that removes this QListWidgetItem from the parent (the + QListWidget) if the corresponding view has been deleted. + """ + if self.parent is not None: + self.parent.takeItem(self.parent.row(self)) + for index, window in enumerate(QActiveViewsListWidgetItem.open_views): + if window.identifier == self.window.identifier: + del QActiveViewsListWidgetItem.open_views[index] + break + if self.viewsChanged is not None: + self.viewsChanged.emit() + + +class QFlightTrackListWidgetItem(QtWidgets.QListWidgetItem): + """Subclass of QListWidgetItem, represents a flight track in the list of + open flight tracks. Keeps a reference to the flight track instance + (i.e. the instance of WaypointsTableModel). + """ + + def __init__(self, flighttrack_model, parent=None, + type=QtWidgets.QListWidgetItem.UserType): + """Item class for the list widget that accommodates the open flight + tracks. + + Arguments: + flighttrack_model -- instance of a flight track model that is + associated with the item + parent -- pointer to the QListWidgetItem that accommodates this item. + If not None, the itemChanged() signal of the parent is + connected to the nameChanged() slot of this class, reacting + to name changes of the item. + """ + view_name = flighttrack_model.name + super().__init__( + view_name, parent, type) + + self.parent = parent + self.flighttrack_model = flighttrack_model + + +class MSUI_ShortcutsDialog(QtWidgets.QDialog, ui_sh.Ui_ShortcutsDialog): + """ + Dialog showing shortcuts for all currently open windows + """ + + def __init__(self): + super().__init__(QtWidgets.QApplication.activeWindow()) + self.setupUi(self) + self.current_shortcuts = None + self.treeWidget.itemDoubleClicked.connect(self.double_clicked) + self.treeWidget.itemClicked.connect(self.clicked) + self.leShortcutFilter.textChanged.connect(self.filter_shortcuts) + self.filterRemoveAction = self.leShortcutFilter.addAction(QtGui.QIcon(icons("64x64", "remove.png")), + QtWidgets.QLineEdit.TrailingPosition) + self.filterRemoveAction.setVisible(False) + self.filterRemoveAction.setToolTip("Click to remove the filter") + self.filterRemoveAction.triggered.connect(lambda: self.leShortcutFilter.setText("")) + self.cbNoShortcut.stateChanged.connect(self.fill_list) + self.cbAdvanced.stateChanged.connect(lambda i: (self.cbNoShortcut.setVisible(i), + self.leShortcutFilter.setVisible(i), + self.cbDisplayType.setVisible(i), + self.label.setVisible(i), + self.label_2.setVisible(i), + self.line.setVisible(i))) + self.cbHighlight.stateChanged.connect(self.filter_shortcuts) + self.cbDisplayType.currentTextChanged.connect(self.fill_list) + self.cbAdvanced.stateChanged.emit(self.cbAdvanced.checkState()) + self.oldReject = self.reject + self.reject = self.custom_reject + + def custom_reject(self): + """ + Reset highlighted objects when closing the shortcuts dialog + """ + self.reset_highlight() + self.oldReject() + + def reset_highlight(self): + """ + Iterates through all shortcuts and resets the stylesheet + """ + if self.current_shortcuts: + for shortcuts in self.current_shortcuts.values(): + for shortcut in shortcuts.values(): + try: + if shortcut[-1] and hasattr(shortcut[-1], "setStyleSheet"): + shortcut[-1].setStyleSheet("") + except RuntimeError: + # when we have deleted a QAction we have to update the list + # Because we cannot test if the underlying object exist we have to catch that + self.fill_list() + + def clicked(self, item): + """ + Highlights the selected item in the GUI as yellow + """ + self.reset_highlight() + if hasattr(item, "source_object") and item.source_object and hasattr(item.source_object, "setStyleSheet"): + item.source_object.setStyleSheet("background-color:yellow;") + + def double_clicked(self, item): + """ + Executes the shortcut for the doubleclicked item + """ + if hasattr(item, "source_object") and item.source_object: + self.reset_highlight() + self.hide() + obj = item.source_object + if isinstance(obj, QtWidgets.QShortcut): + obj.activated.emit() + elif isinstance(obj, QtWidgets.QAction): + obj.trigger() + elif isinstance(obj, QtWidgets.QAbstractButton): + obj.click() + elif isinstance(obj, QtWidgets.QComboBox): + QtCore.QTimer.singleShot(200, obj.showPopup) + elif isinstance(obj, QtWidgets.QLineEdit) or isinstance(obj, QtWidgets.QAbstractSpinBox): + obj.setFocus() + + def fill_list(self): + """ + Fills the treeWidget with all relevant windows as top level items and their shortcuts as children + """ + self.treeWidget.clear() + self.current_shortcuts = self.get_shortcuts() + for widget in self.current_shortcuts: + if hasattr(widget, "window"): + name = widget.window().windowTitle() + else: + name = widget.objectName() + if len(name) == 0 or (hasattr(widget, "window") and widget.window().isHidden()): + continue + header = QtWidgets.QTreeWidgetItem(self.treeWidget) + header.setText(0, name) + if hasattr(widget, "window") and widget.window() == self.parent(): + header.setExpanded(True) + header.setSelected(True) + self.treeWidget.setCurrentItem(header) + for objectName in self.current_shortcuts[widget].keys(): + description, text, _, shortcut, obj = self.current_shortcuts[widget][objectName] + item = QtWidgets.QTreeWidgetItem(header) + item.source_object = obj + itemText = description if self.cbDisplayType.currentText() == 'Tooltip' \ + else text if self.cbDisplayType.currentText() == 'Text' else objectName + item.setText(0, f"{itemText}: {shortcut}") + item.setToolTip(0, f"ToolTip: {description}\nText: {text}\nObjectName: {objectName}") + header.addChild(item) + self.filter_shortcuts(self.leShortcutFilter.text()) + + def get_shortcuts(self): + """ + Iterates through all top level widgets and puts their shortcuts in a dictionary + """ + shortcuts = {} + for qobject in QtWidgets.QApplication.topLevelWidgets(): + actions = [] + actions.extend([ + (action.parent().window() if hasattr(action.parent(), "window") else action.parent(), + action.toolTip(), action.text().replace("&&", "%%").replace("&", "").replace("%%", "&"), + action.objectName(), + ",".join([shortcut.toString() for shortcut in action.shortcuts()]), action) + for action in qobject.findChildren( + QtWidgets.QAction) if len(action.shortcuts()) > 0 or self.cbNoShortcut.checkState()]) + actions.extend([(shortcut.parentWidget().window(), shortcut.whatsThis(), "", + shortcut.objectName(), shortcut.key().toString(), shortcut) + for shortcut in qobject.findChildren(QtWidgets.QShortcut)]) + actions.extend([(button.window(), button.toolTip(), button.text().replace("&&", "%%").replace("&", "") + .replace("%%", "&"), button.objectName(), + button.shortcut().toString() if button.shortcut() else "", button) + for button in qobject.findChildren(QtWidgets.QAbstractButton) if button.shortcut() or + self.cbNoShortcut.checkState()]) + + # Additional objects which have no shortcuts, if requested + actions.extend([(obj.window(), obj.toolTip(), obj.currentText(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QComboBox) if self.cbNoShortcut.checkState()]) + actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QAbstractSpinBox) + + qobject.findChildren(QtWidgets.QLineEdit) + if self.cbNoShortcut.checkState()]) + actions.extend([(obj.window(), obj.toolTip(), obj.toPlainText(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QPlainTextEdit) + + qobject.findChildren(QtWidgets.QTextEdit) + if self.cbNoShortcut.checkState()]) + + if not any(action for action in actions if action[3] == "actionShortcuts"): + actions.append((qobject.window(), "Show Current Shortcuts", "Show Current Shortcuts", + "Show Current Shortcuts", "Alt+S", None)) + if not any(action for action in actions if action[3] == "actionSearch"): + actions.append((qobject.window(), "Search for interactive text in the UI", + "Search for interactive text in the UI", "Search for interactive text in the UI", + "Ctrl+F", None)) + + for item in actions: + if item[0] not in shortcuts: + shortcuts[item[0]] = {} + shortcuts[item[0]][item[3].strip()] = item[1:] + + return shortcuts + + def filter_shortcuts(self, text="Nothing", rerun=True): + """ + Hides all shortcuts not containing the text + """ + text = self.leShortcutFilter.text() + self.reset_highlight() + + window_count = 0 + for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): + if not window.isHidden(): + window_count += 1 + + for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): + wms_hits = 0 + + for child_index in range(window.childCount()): + widget = window.child(child_index) + if text.lower() in widget.text(0).lower() or text.lower() in window.text(0).lower(): + widget.setHidden(False) + wms_hits += 1 + else: + widget.setHidden(True) + + if wms_hits == 1 and (self.cbHighlight.isChecked() or window_count == 1): + for child_index in range(window.childCount()): + widget = window.child(child_index) + if (not widget.isHidden()) and hasattr(widget.source_object, "setStyleSheet"): + widget.source_object.setStyleSheet("background-color: yellow;") + break + + if wms_hits == 0 and len(text) > 0: + window.setHidden(True) + else: + window.setHidden(False) + + self.filterRemoveAction.setVisible(len(text) > 0) + if rerun: + self.filter_shortcuts(text, False) + + +class MSUI_AboutDialog(QtWidgets.QDialog, ui_ab.Ui_AboutMSUIDialog): + """Dialog showing information about MSUI. Most of the displayed text is + defined in the QtDesigner file. + """ + + def __init__(self, parent=None): + """ + Arguments: + parent -- Qt widget that is parent to this widget. + """ + super().__init__(parent) + self.setupUi(self) + self.lblVersion.setText(f"Version: {__version__}") + self.milestone_url = f'https://github.com/Open-MSS/MSS/issues?q=is%3Aclosed+milestone%3A{__version__[:-1]}' + self.lblChanges.setText(f'New Features and Changes') + blub = QtGui.QPixmap(python_powered()) + self.lblPython.setPixmap(blub) + + +class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): + """MSUI new main window class. Provides user interface elements for managing + flight tracks, views and MSColab functionalities. + """ + + viewsChanged = QtCore.pyqtSignal(name="viewsChanged") + signal_activate_flighttrack = QtCore.Signal(ft.WaypointsTableModel, name="signal_activate_flighttrack") + signal_activate_operation = QtCore.Signal(int, name="signal_activate_operation") + signal_operation_added = QtCore.Signal(int, str, name="signal_operation_added") + signal_operation_removed = QtCore.Signal(int, name="signal_operation_removed") + signal_login_mscolab = QtCore.Signal(str, str, name="signal_login_mscolab") + signal_logout_mscolab = QtCore.Signal(name="signal_logout_mscolab") + signal_listFlighttrack_doubleClicked = QtCore.Signal() + signal_permission_revoked = QtCore.Signal(int) + signal_render_new_permission = QtCore.Signal(int, str) + + def __init__(self, mscolab_data_dir=None, *args): + super().__init__(*args) + self.setupUi(self) + self.setWindowIcon(QtGui.QIcon(icons('32x32'))) + # This code is required in Windows 7 to use the icon set by setWindowIcon in taskbar + # instead of the default Icon of python/pythonw + try: + import ctypes + myappid = f"msui.msui.{__version__}" # arbitrary string + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except (ImportError, AttributeError) as error: + logging.debug("AttributeError, ImportError Exception %s", error) + + self.config_editor = None + self.local_active = True + self.new_flight_track_counter = 0 + + # Reference to the flight track that is currently displayed in the views. + self.active_flight_track = None + self.last_save_directory = config_loader(dataset="data_dir") + + # bind meta (ctrl in macOS) to override automatic translation of ctrl to command by qt + if sys.platform == 'darwin': + self.actionTopView.setShortcut(QtGui.QKeySequence("Meta+h")) + self.actionSideView.setShortcut(QtGui.QKeySequence("Meta+v")) + self.actionTableView.setShortcut(QtGui.QKeySequence("Meta+t")) + self.actionLinearView.setShortcut(QtGui.QKeySequence("Meta+l")) + self.actionConfiguration.setShortcut(QtGui.QKeySequence("Ctrl+,")) + + # File menu. + self.actionNewFlightTrack.triggered.connect(functools.partial(self.create_new_flight_track, None, None)) + self.actionSaveActiveFlightTrack.triggered.connect(self.save_handler) + self.actionSaveActiveFlightTrackAs.triggered.connect(self.save_as_handler) + self.actionCloseSelectedFlightTrack.triggered.connect(self.close_selected_flight_track) + + # Views menu. + self.actionTopView.triggered.connect(functools.partial(self.create_view_handler, "topview")) + self.actionSideView.triggered.connect(functools.partial(self.create_view_handler, "sideview")) + self.actionTableView.triggered.connect(functools.partial(self.create_view_handler, "tableview")) + self.actionLinearView.triggered.connect(functools.partial(self.create_view_handler, "linearview")) + + # Help menu. + self.actionOnlineHelp.triggered.connect(self.show_online_help) + self.actionAboutMSUI.triggered.connect(self.show_about_dialog) + self.actionShortcuts.triggered.connect(self.show_shortcuts) + self.actionShortcuts.setShortcutContext(QtCore.Qt.ApplicationShortcut) + self.actionSearch.triggered.connect(lambda: self.show_shortcuts(True)) + self.actionSearch.setShortcutContext(QtCore.Qt.ApplicationShortcut) + + # # Config + self.actionConfiguration.triggered.connect(self.open_config_editor) + + # Raise Main Window to front with Ctrl/Cmnd + up keyboard shortcut + self.addAction(self.actionBringMainWindowToFront) + self.actionBringMainWindowToFront.triggered.connect(self.bring_main_window_to_front) + self.actionBringMainWindowToFront.setShortcutContext(QtCore.Qt.ApplicationShortcut) + + # Flight Tracks. + self.listFlightTracks.itemActivated.connect(self.activate_flight_track) + + # Views. + self.listViews.itemActivated.connect(self.activate_sub_window) + + # Add default and plugins from settings + picker_default = config_loader(dataset="filepicker_default") + self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Import") + self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Export") + self.add_plugin_submenu("CSV", "csv", load_from_csv, picker_default, plugin_type="Import") + self.add_plugin_submenu("CSV", "csv", save_to_csv, picker_default, plugin_type="Export") + self.add_plugins() + + # Status Bar + self.statusBar.showMessage(self.status()) + + # Create MSColab instance to handle all MSColab functionalities + self.mscolab = mscolab.MSUIMscolab(parent=self, data_dir=mscolab_data_dir) + + # Setting up MSColab Tab + self.connectBtn.clicked.connect(self.mscolab.open_connect_window) + + self.shortcuts_dlg = None + + # deactivate vice versa selection of Operation, inactive operation or Flight Track + + self.listFlightTracks.itemClicked.connect( + lambda: self.listOperationsMSC.setCurrentItem(None)) + self.listOperationsMSC.itemClicked.connect( + lambda: self.listFlightTracks.setCurrentItem(None)) + # disable category until connected/login into mscolab + self.filterCategoryCb.setEnabled(False) + self.mscolab.signal_unarchive_operation.connect(self.activate_operation_slot) + self.mscolab.signal_operation_added.connect(self.add_operation_slot) + self.mscolab.signal_operation_removed.connect(self.remove_operation_slot) + self.mscolab.signal_login_mscolab.connect(lambda d, t: self.signal_login_mscolab.emit(d, t)) + self.mscolab.signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) + self.mscolab.signal_listFlighttrack_doubleClicked.connect( + lambda: self.signal_listFlighttrack_doubleClicked.emit()) + self.mscolab.signal_permission_revoked.connect(lambda op_id: self.signal_permission_revoked.emit(op_id)) + self.mscolab.signal_render_new_permission.connect( + lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) + + # Don't start the updater during a test run of msui + if "pytest" not in sys.modules: + self.updater = UpdaterUI(self) + self.actionUpdater.triggered.connect(self.updater.show) + self.openOperationsGb.hide() + + def bring_main_window_to_front(self): + self.show() + self.raise_() + self.activateWindow() + + def menu_handler(self): + self.menuImportFlightTrack.setEnabled(True) + if not self.local_active and self.mscolab.access_level == "viewer": + # viewer has no import access to server + self.menuImportFlightTrack.setEnabled(False) + + # enable/disable flight track menus + self.actionSaveActiveFlightTrack.setEnabled(self.local_active) + self.actionSaveActiveFlightTrackAs.setEnabled(self.local_active) + + def add_plugins(self): + picker_default = config_loader(dataset="filepicker_default") + self.import_plugins = {} + self.export_plugins = {} + self.add_import_plugins(picker_default) + self.add_export_plugins(picker_default) + + @QtCore.Slot(int) + def activate_operation_slot(self, active_op_id): + self.signal_activate_operation.emit(active_op_id) + + @QtCore.Slot(int, str) + def add_operation_slot(self, op_id, path): + self.signal_operation_added.emit(op_id, path) + + @QtCore.Slot(int) + def remove_operation_slot(self, op_id): + self.signal_operation_removed.emit(op_id) + + def add_plugin_submenu(self, name, extension, function, pickertype, plugin_type="Import"): + if plugin_type == "Import": + menu = self.menuImportFlightTrack + action_name = "actionImportFlightTrack" + clean_string(name) + handler = self.handle_import_local + elif plugin_type == "Export": + menu = self.menuExportActiveFlightTrack + action_name = "actionExportFlightTrack" + clean_string(name) + handler = self.handle_export_local + + if hasattr(self, action_name): + raise ValueError(f"'{action_name}' has already been set!") + action = QtWidgets.QAction(self) + action.setObjectName(action_name) + action.setText(QtCore.QCoreApplication.translate("MSUIMainWindow", name, None)) + action.triggered.connect(functools.partial(handler, extension, function, pickertype)) + menu.addAction(action) + setattr(self, action_name, action) + + def add_import_plugins(self, picker_default): + plugins = config_loader(dataset="import_plugins") + for name in plugins: + extension, module, function = plugins[name][:3] + picker_type = picker_default + if len(plugins[name]) == 4: + picker_type = plugins[name][3] + try: + imported_module = importlib.import_module(module) + imported_function = getattr(imported_module, function) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on import: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) + continue + try: + self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Import") + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on installing plugin: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error import plugins"), + self.tr(f"ERROR: Configuration\n\n{self.import_plugins}\n\nthrows {type(ex)} error:\n{ex}")) + continue + self.import_plugins[name] = (imported_function, extension) + + def add_export_plugins(self, picker_default): + plugins = config_loader(dataset="export_plugins") + for name in plugins: + extension, module, function = plugins[name][:3] + picker_type = picker_default + if len(plugins[name]) == 4: + picker_type = plugins[name][3] + try: + imported_module = importlib.import_module(module) + imported_function = getattr(imported_module, function) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on import: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error export plugins"), + self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) + continue + try: + self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Export") + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on installing plugin: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error import plugins"), + self.tr(f"ERROR: Configuration\n\n{self.export_plugins}\n\nthrows {type(ex)} error:\n{ex}")) + continue + self.export_plugins[name] = (imported_function, extension) + + def remove_plugins(self): + for name, _ in self.import_plugins.items(): + full_name = "actionImportFlightTrack" + clean_string(name) + actions = [_x for _x in self.menuImportFlightTrack.actions() + if _x.objectName() == full_name] + assert len(actions) == 1 + self.menuImportFlightTrack.removeAction(actions[0]) + delattr(self, full_name) + self.import_plugins = {} + + for name, _ in self.export_plugins.items(): + full_name = "actionExportFlightTrack" + clean_string(name) + actions = [_x for _x in self.menuExportActiveFlightTrack.actions() + if _x.objectName() == full_name] + assert len(actions) == 1 + self.menuExportActiveFlightTrack.removeAction(actions[0]) + delattr(self, full_name) + self.export_plugins = {} + + def handle_import_local(self, extension, function, pickertype): + filenames = get_open_filenames( + self, "Import Flight Track", + self.last_save_directory, + f"Flight Track (*.{extension});;All files (*.*)", + pickertype=pickertype) + if self.local_active: + if filenames is not None: + activate = True + if len(filenames) > 1: + activate = False + for name in filenames: + self.create_new_flight_track(filename=name, function=function, activate=activate) + self.last_save_directory = fs.path.dirname(name) + else: + for name in filenames: + self.mscolab.handle_import_msc(name, extension, function, pickertype) + + def handle_export_local(self, extension, function, pickertype): + if self.local_active: + default_filename = f'{os.path.join(self.last_save_directory, self.active_flight_track.name)}.{extension}' + filename = get_save_filename( + self, "Export Flight Track", + default_filename, f"Flight Track (*.{extension})", + pickertype=pickertype) + if filename is not None: + self.last_save_directory = fs.path.dirname(filename) + try: + if function is None: + doc = self.active_flight_track.get_xml_doc() + dirname, name = fs.path.split(filename) + file_dir = fs.open_fs(dirname) + with file_dir.open(name, 'w') as file_object: + doc.writexml(file_object, indent=" ", addindent=" ", newl="\n", encoding="utf-8") + file_dir.close() + else: + function(filename, self.active_flight_track.name, self.active_flight_track.waypoints) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("file io plugin error: %s %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error"), + self.tr(f"ERROR: {type(ex)} {ex}")) + else: + self.mscolab.handle_export_msc(extension, function, pickertype) + + def create_new_flight_track(self, template=None, filename=None, function=None, activate=True): + """Creates a new flight track model from a template. Adds a new entry to + the list of flight tracks. Called when the user selects the 'new/open + flight track' menu entries. + + Arguments: + template -- copy the specified template to the new flight track (so that + it is not empty). + filename -- if not None, load the flight track in the specified file. + """ + if template is None: + template = [] + waypoints = config_loader(dataset="new_flighttrack_template") + default_flightlevel = config_loader(dataset="new_flighttrack_flightlevel") + for wp in waypoints: + template.append(ft.Waypoint(flightlevel=default_flightlevel, location=wp)) + if len(template) < 2: + QtWidgets.QMessageBox.critical( + self, self.tr("flighttrack template"), + self.tr("ERROR:Flighttrack template in configuration is too short. " + "Please add at least two valid locations.")) + + waypoints_model = None + if filename is not None: + # function is none if ftml file is selected + if function is None: + try: + waypoints_model = ft.WaypointsTableModel(filename=filename) + except (SyntaxError, OSError, IOError) as ex: + QtWidgets.QMessageBox.critical( + self, self.tr("Problem while opening flight track FTML:"), + self.tr(f"ERROR: {type(ex)} {ex}")) + else: + try: + ft_name, new_waypoints = function(filename) + waypoints_model = ft.WaypointsTableModel(name=ft_name, waypoints=new_waypoints) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("file io plugin error: %s %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error"), + self.tr(f"ERROR: {type(ex)} {ex}")) + if waypoints_model is not None: + for i in range(self.listFlightTracks.count()): + fltr = self.listFlightTracks.item(i) + if fltr.flighttrack_model.name == waypoints_model.name: + waypoints_model.name += " - imported from file" + break + else: + # Create a new flight track from the waypoints' template. + self.new_flight_track_counter += 1 + waypoints_model = ft.WaypointsTableModel( + name=f"new flight track ({self.new_flight_track_counter:d})") + # Make a copy of the template. Otherwise, all new flight tracks would + # use the same data structure in memory. + template_copy = copy.deepcopy(template) + waypoints_model.insertRows(0, rows=len(template_copy), waypoints=template_copy) + + if waypoints_model is not None: + # Create a new list entry for the flight track. Make the item name editable. + listitem = QFlightTrackListWidgetItem(waypoints_model, self.listFlightTracks) + listitem.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + + # Activate new item + if activate: + self.activate_flight_track(listitem) + + def activate_flight_track(self, item): + """Set the currently selected flight track to be the active one, i.e. + the one that is displayed in the views (only one flight track can be + displayed at a time). + """ + self.mscolab.switch_to_local() + # self.setWindowModality(QtCore.Qt.NonModal) + self.active_flight_track = item.flighttrack_model + self.update_active_flight_track() + font = QtGui.QFont() + for i in range(self.listFlightTracks.count()): + self.listFlightTracks.item(i).setFont(font) + font.setBold(True) + item.setFont(font) + self.menu_handler() + self.signal_activate_flighttrack.emit(self.active_flight_track) + + def update_active_flight_track(self, old_flight_track_name=None): + logging.debug("update_active_flight_track") + for i in range(self.listViews.count()): + view_item = self.listViews.item(i) + view_item.window.setFlightTrackModel(self.active_flight_track) + # local we have always all options enabled + view_item.window.enable_navbar_action_buttons() + if old_flight_track_name is not None: + view_item.window.setWindowTitle(view_item.window.windowTitle().replace(old_flight_track_name, + self.active_flight_track.name)) + + def activate_selected_flight_track(self): + item = self.listFlightTracks.currentItem() + self.activate_flight_track(item) + + def switch_to_mscolab(self): + self.local_active = False + font = QtGui.QFont() + for i in range(self.listFlightTracks.count()): + self.listFlightTracks.item(i).setFont(font) + + # disable appropriate menu options + self.menu_handler() + + def save_handler(self): + """Slot for the 'Save Active Flight Track' menu entry. + """ + filename = self.active_flight_track.get_filename() + if filename: + self.save_flight_track(filename) + else: + self.save_as() + + def save_as_handler(self): + self.save_as() + + def save_as(self): + """ + Slot for the 'Save Active Flight Track As' menu entry. + """ + default_filename = os.path.join(self.last_save_directory, self.active_flight_track.name + ".ftml") + file_type = ["Flight track (*.ftml)"] + filepicker_default = config_loader(dataset="filepicker_default") + filename = get_save_filename( + self, "Save Flight Track", default_filename, ";;".join(file_type), pickertype=filepicker_default + ) + logging.debug("filename : '%s'", filename) + if filename: + ext = "ftml" + self.save_flight_track(filename) + self.last_save_directory = fs.path.dirname(filename) + self.active_flight_track.filename = filename + self.active_flight_track.name = fs.path.basename(filename.replace(f"{ext}", "").strip()) + + def save_flight_track(self, file_name): + ext = ".ftml" + if file_name: + if file_name.endswith(ext): + try: + self.active_flight_track.save_to_ftml(file_name) + except (OSError, IOError) as ex: + QtWidgets.QMessageBox.critical( + self, self.tr("Problem while saving flight track to FTML:"), + self.tr(f"ERROR: {type(ex)} {ex}")) + + for idx in range(self.listFlightTracks.count()): + if self.listFlightTracks.item(idx).flighttrack_model == self.active_flight_track: + old_filght_track_name = self.listFlightTracks.item(idx).text() + self.listFlightTracks.item(idx).setText(self.active_flight_track.name) + + self.update_active_flight_track(old_filght_track_name) + + def close_selected_flight_track(self): + """Slot to close the currently selected flight track. Flight tracks can + only be closed if at least one other flight track remains open. The + currently active flight track cannot be closed. + """ + if self.listFlightTracks.count() < 2: + QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), + self.tr("At least one flight track has to be open.")) + return + item = self.listFlightTracks.currentItem() + if item.flighttrack_model == self.active_flight_track and self.local_active: + QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), + self.tr("Cannot close currently active flight track.")) + return + if item.flighttrack_model.modified: + ret = QtWidgets.QMessageBox.warning(self, self.tr("Mission Support System"), + self.tr("The flight track you are about to close has " + "been modified. Close anyway?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + if ret == QtWidgets.QMessageBox.Yes: + self.listFlightTracks.takeItem(self.listFlightTracks.currentRow()) + + def create_view_handler(self, _type): + if self.local_active: + self.create_view(_type, self.active_flight_track) + else: + self.mscolab.waypoints_model.name = self.mscolab.active_operation_name + self.create_view(_type, self.mscolab.waypoints_model) + + def create_view(self, _type, model): + """Method called when the user selects a new view to be opened. Creates + a new instance of the view and adds a QActiveViewsListWidgetItem to + the list of open views (self.listViews). + """ + layout = config_loader(dataset="layout") + view_window = None + if _type == "topview": + # Top view. + view_window = topview.MSUITopViewWindow(parent=self, model=model, + active_flighttrack=self.active_flight_track, + mscolab_server_url=self.mscolab.mscolab_server_url, + token=self.mscolab.token) + view_window.mpl.resize(layout['topview'][0], layout['topview'][1]) + if layout["immutable"]: + view_window.mpl.setFixedSize(layout['topview'][0], layout['topview'][1]) + elif _type == "sideview": + # Side view. + view_window = sideview.MSUISideViewWindow(model=model) + view_window.mpl.resize(layout['sideview'][0], layout['sideview'][1]) + if layout["immutable"]: + view_window.mpl.setFixedSize(layout['sideview'][0], layout['sideview'][1]) + elif _type == "tableview": + # Table view. + view_window = tableview.MSUITableViewWindow(model=model) + view_window.centralwidget.resize(layout['tableview'][0], layout['tableview'][1]) + elif _type == "linearview": + # Linear view. + view_window = linearview.MSUILinearViewWindow(model=model) + view_window.mpl.resize(layout['linearview'][0], layout['linearview'][1]) + if layout["immutable"]: + view_window.mpl.setFixedSize(layout['linearview'][0], layout['linearview'][1]) + + if view_window is not None: + # Set view type to window + view_window.view_type = view_window.name + # Make sure view window will be deleted after being closed, not + # just hidden (cf. Chapter 5 in PyQt4). + view_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) + # Open as a non-modal window. + view_window.show() + # Add an entry referencing the new view to the list of views. + # listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged, mscolab) + listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged) + view_window.viewCloses.connect(listitem.view_destroyed) + self.listViews.setCurrentItem(listitem) + # self.active_view_windows.append(view_window) + # disable navbar actions in the view for viewer + try: + if self.mscolab.access_level == "viewer": + view_window.disable_navbar_action_buttons() + except AttributeError: + view_window.enable_navbar_action_buttons() + self.viewsChanged.emit() + + def get_active_views(self): + active_view_windows = [] + for i in range(self.listViews.count()): + active_view_windows.append(self.listViews.item(i).window) + return active_view_windows + + def activate_sub_window(self, item): + """When the user clicks on one of the open view or tool windows, this + window is brought to the front. This function implements the slot to + activate a window if the user selects it in the list of views or + tools. + """ + # Restore the activated view and bring it to the front. + item.window.showNormal() + item.window.raise_() + item.window.activateWindow() + + def restart_application(self): + while self.listViews.count() > 0: + self.listViews.item(0).window.handle_force_close() + self.listViews.clear() + self.remove_plugins() + if self.mscolab.token is not None: + self.mscolab.logout() + read_config_file() + self.add_plugins() + + def open_config_editor(self): + """ + Opens up a JSON config editor + """ + if self.config_editor is None: + self.config_editor = editor.ConfigurationEditorWindow(parent=self) + self.config_editor.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.config_editor.destroyed.connect(self.close_config_editor) + self.config_editor.restartApplication.connect(self.restart_application) + self.config_editor.show() + else: + self.config_editor.showNormal() + self.config_editor.activateWindow() + + def close_config_editor(self): + self.config_editor = None + + def show_online_help(self): + """Open Documentation in a browser""" + QtGui.QDesktopServices.openUrl( + QtCore.QUrl("http://mss.readthedocs.io/en/stable")) + + def show_about_dialog(self): + """Show the 'About MSUI' dialog to the user. + """ + dlg = MSUI_AboutDialog(parent=self) + dlg.setModal(True) + dlg.exec_() + + def show_shortcuts(self, search_mode=False): + """Show the shortcuts dialog to the user. + """ + if QtWidgets.QApplication.activeWindow() == self.shortcuts_dlg: + return + + self.shortcuts_dlg = MSUI_ShortcutsDialog() if not self.shortcuts_dlg else self.shortcuts_dlg + + # In case the dialog gets deleted by QT, recreate it + try: + self.shortcuts_dlg.setModal(True) + except RuntimeError: + self.shortcuts_dlg = MSUI_ShortcutsDialog() + + self.shortcuts_dlg.setParent(QtWidgets.QApplication.activeWindow(), QtCore.Qt.Dialog) + self.shortcuts_dlg.reset_highlight() + self.shortcuts_dlg.fill_list() + self.shortcuts_dlg.show() + + self.shortcuts_dlg.cbAdvanced.setHidden(True) + self.shortcuts_dlg.cbHighlight.setHidden(True) + self.shortcuts_dlg.cbAdvanced.setCheckState(0) + self.shortcuts_dlg.cbHighlight.setCheckState(0) + self.shortcuts_dlg.leShortcutFilter.setText("") + self.shortcuts_dlg.setWindowTitle("Shortcuts") + + if search_mode: + self.shortcuts_dlg.setWindowTitle("Search") + self.shortcuts_dlg.cbAdvanced.setHidden(False) + self.shortcuts_dlg.cbHighlight.setHidden(False) + self.shortcuts_dlg.cbDisplayType.setCurrentIndex(1) + self.shortcuts_dlg.leShortcutFilter.setText("") + self.shortcuts_dlg.cbAdvanced.setCheckState(2) + self.shortcuts_dlg.cbNoShortcut.setCheckState(2) + self.shortcuts_dlg.leShortcutFilter.setFocus() + + def status(self): + if config_loader() != config_loader(default=True): + return ("Status : System Configuration") + else: + return (f"Status : User Configuration '{constants.MSUI_SETTINGS}' loaded") + + def closeEvent(self, event): + """Ask user if he/she wants to close the application. If yes, also + close all views that are open. + + Overloads QtGui.QMainWindow.closeEvent(). This method is called if + Qt receives a window close request for our application window. + """ + ret = QtWidgets.QMessageBox.warning( + self, self.tr("Mission Support System"), + self.tr("Do you want to close the Mission Support System application?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) + + if ret == QtWidgets.QMessageBox.Yes: + if self.mscolab.help_dialog is not None: + self.mscolab.help_dialog.close() + # cleanup mscolab widgets + if self.mscolab.token is not None: + self.mscolab.logout() + # Table View stick around after MainWindow closes - maybe some dangling reference? + # This removes them for sure! + while self.listViews.count() > 0: + self.listViews.item(0).window.handle_force_close() + self.listViews.clear() + self.listFlightTracks.clear() + # close configuration editor + if self.config_editor is not None: + self.config_editor.restart_on_save = False + self.config_editor.close() + from PyQt5 import QtTest + QtTest.QTest.qWait(5) + if self.config_editor is not None: + self.statusBar.showMessage("Save your config changes and try closing again") + event.ignore() + return + event.accept() + else: + event.ignore() diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 921b922ae..cde3f98a3 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -30,7 +30,7 @@ from PyQt5 import QtWidgets, QtGui, QtCore from mslib.msui.qt5 import ui_multiple_flightpath_dockwidget as ui from mslib.msui import flighttrack as ft -from mslib.msui import msui +import mslib.msui.msui_mainwindow as msui_mainwindow from mslib.utils.verify_user_token import verify_user_token from mslib.utils.qt import Worker @@ -303,7 +303,7 @@ def create_list_item(self, wp_model): self.save_waypoint_model_data(wp_model, self.list_flighttrack) - listItem = msui.QFlightTrackListWidgetItem(wp_model, self.list_flighttrack) + listItem = msui_mainwindow.QFlightTrackListWidgetItem(wp_model, self.list_flighttrack) listItem.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsUserCheckable) if not self.flighttrack_added: self.flighttrack_added = True diff --git a/mslib/utils/coordinate.py b/mslib/utils/coordinate.py index 812e4d34d..b645fe5ec 100644 --- a/mslib/utils/coordinate.py +++ b/mslib/utils/coordinate.py @@ -29,18 +29,14 @@ import logging import netCDF4 as nc import numpy as np +from pyproj import Geod from scipy.interpolate import interp1d from scipy.ndimage import map_coordinates -try: - import mpl_toolkits.basemap.pyproj as pyproj -except ImportError: - import pyproj - from mslib.utils.config import config_loader -__PR = pyproj.Geod(ellps='WGS84') +__PR = Geod(ellps='WGS84') def get_distance(lat0, lon0, lat1, lon1): diff --git a/mslib/utils/thermolib.py b/mslib/utils/thermolib.py index 4c8d3cb54..f4e9e3ce7 100644 --- a/mslib/utils/thermolib.py +++ b/mslib/utils/thermolib.py @@ -33,7 +33,6 @@ from metpy.package_tools import Exporter from metpy.constants import Rd, g from metpy.xarray import preprocess_and_wrap -import metpy.calc as mpcalc exporter = Exporter(globals()) @@ -85,6 +84,7 @@ def pot_temp(p, t): Returns: potential temperature in [K]. """ + import metpy.calc as mpcalc return mpcalc.potential_temperature( units.Pa * p, units.K * t).to("K").m @@ -104,6 +104,7 @@ def eqpt_approx(p, t, q): Returns: equivalent potential temperature in [K]. """ + import metpy.calc as mpcalc return mpcalc.equivalent_potential_temperature( units.Pa * p, units.K * t, mpcalc.dewpoint_from_specific_humidity(units.Pa * p, units.K * t, q)).to("K").m @@ -122,6 +123,7 @@ def omega_to_w(omega, p, t): Returns the vertical velocity in geometric coordinates, [m/s]. """ + import metpy.calc as mpcalc return mpcalc.vertical_velocity( units("Pa/s") * omega, units.Pa * p, units.K * t).to("m/s").m diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 15be1aaaf..627439247 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -354,9 +354,9 @@ def test_handle_export(self, mockbox): @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_import_file(self, mockbox, ext): self.window.remove_plugins() - with mock.patch("mslib.msui.msui.config_loader", return_value=self.import_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") - with mock.patch("mslib.msui.msui.config_loader", return_value=self.export_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.export_plugins): self.window.add_export_plugins("qt") file_path = fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, f'test_import{ext}') with mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(file_path, None)): diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 2b2161ba8..1aee9207d 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -37,6 +37,7 @@ from mslib import __version__ from tests.constants import ROOT_DIR, POSIX from mslib.msui import msui +from mslib.msui import msui_mainwindow as msui_mw from tests.utils import ExceptionMock from mslib.utils.config import read_config_file @@ -67,7 +68,7 @@ def test_main(): class Test_MSS_AboutDialog(): def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) - self.window = msui.MSUI_AboutDialog() + self.window = msui_mw.MSUI_AboutDialog() def test_milestone_url(self): with urlopen(self.window.milestone_url) as f: @@ -85,9 +86,9 @@ def teardown_method(self): class Test_MSS_ShortcutDialog(): def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) - self.main_window = msui.MSUIMainWindow() + self.main_window = msui_mw.MSUIMainWindow() self.main_window.show() - self.shortcuts = msui.MSUI_ShortcutsDialog() + self.shortcuts = msui_mw.MSUI_ShortcutsDialog() def teardown_method(self): self.shortcuts.hide() @@ -244,9 +245,9 @@ def test_open_shortcut(self, mockbox): @pytest.mark.parametrize("save_file", [[save_ftml]]) def test_plugin_saveas(self, save_file): - with mock.patch("mslib.msui.msui.config_loader", return_value=self.export_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.export_plugins): self.window.add_export_plugins("qt") - with mock.patch("mslib.msui.msui.get_save_filename", return_value=save_file[0]) as mocksave: + with mock.patch("mslib.msui.msui_mainwindow.get_save_filename", return_value=save_file[0]) as mocksave: assert self.window.listFlightTracks.count() == 1 assert mocksave.call_count == 0 self.window.last_save_directory = ROOT_DIR @@ -260,9 +261,9 @@ def test_plugin_saveas(self, save_file): "open_file", [(open_ftml, "actionImportFlightTrackFTML"), (open_txt, "actionImportFlightTrackText"), (open_fls, "actionImportFlightTrackFliteStar")]) def test_plugin_import(self, open_file): - with mock.patch("mslib.msui.msui.config_loader", return_value=self.import_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") - with mock.patch("mslib.msui.msui.get_open_filenames", return_value=open_file) as mockopen: + with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=open_file) as mockopen: assert self.window.listFlightTracks.count() == 1 assert mockopen.call_count == 0 self.window.last_save_directory = ROOT_DIR @@ -278,9 +279,9 @@ def test_plugin_import(self, open_file): @pytest.mark.parametrize("save_file", [[save_ftml, "actionExportFlightTrackFTML"], [save_txt, "actionExportFlightTrackText"]]) def test_plugin_export(self, save_file): - with mock.patch("mslib.msui.msui.config_loader", return_value=self.export_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.export_plugins): self.window.add_export_plugins("qt") - with mock.patch("mslib.msui.msui.get_save_filename", return_value=save_file[0]) as mocksave: + with mock.patch("mslib.msui.msui_mainwindow.get_save_filename", return_value=save_file[0]) as mocksave: assert self.window.listFlightTracks.count() == 1 assert mocksave.call_count == 0 self.window.last_save_directory = ROOT_DIR @@ -296,7 +297,7 @@ def test_plugin_export(self, save_file): @pytest.mark.skip("needs to be refactored to become independent") @mock.patch("PyQt5.QtWidgets.QMessageBox") - @mock.patch("mslib.msui.msui.config_loader", return_value=export_plugins) + @mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=export_plugins) def test_add_plugins(self, mockopen, mockbox): assert len(self.window.menuImportFlightTrack.actions()) == 2 assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 @@ -336,8 +337,8 @@ def test_add_plugins(self, mockopen, mockbox): @mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - @mock.patch("mslib.msui.msui.get_save_filename", return_value=save_ftml) - @mock.patch("mslib.msui.msui.get_open_filenames", return_value=[save_ftml]) + @mock.patch("mslib.msui.msui_mainwindow.get_save_filename", return_value=save_ftml) + @mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[save_ftml]) def test_flight_track_io(self, mockload, mocksave, mockq, mocki, mockw, mockbox): self.window.actionCloseSelectedFlightTrack.trigger() assert mocki.call_count == 1 diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 7f75d5cf6..06aeb1674 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -37,7 +37,6 @@ from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft import mslib.msui.wms_control as wc -from mslib.msui.msui import MSUIMainWindow from tests.utils import wait_until_signal @@ -469,12 +468,6 @@ def test_server_no_thread(self, mockbox, mockthread): assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - def test_preload(self): - assert len(wc.WMS_SERVICE_CACHE) == 0 - assert f"http://127.0.0.1:{self.port}/" not in wc.WMS_SERVICE_CACHE - MSUIMainWindow.preload_wms([f"http://127.0.0.1:{self.port}/"]) - assert f"http://127.0.0.1:{self.port}/" in wc.WMS_SERVICE_CACHE - @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") From ee2962f67d022112ea4de1de075a97cfe5160043 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 31 May 2023 10:31:46 +0200 Subject: [PATCH 014/176] enable tests for GSOC branches (#1799) * enable tests for GSOC branches * added a new testing action based on develop for gsoc * undo blank * var changed * name changed * removed unexpected value --- .github/workflows/testing-develop.yml | 2 +- .github/workflows/testing_gsoc.yml | 104 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/testing_gsoc.yml diff --git a/.github/workflows/testing-develop.yml b/.github/workflows/testing-develop.yml index 208899dfe..8884a76d9 100644 --- a/.github/workflows/testing-develop.yml +++ b/.github/workflows/testing-develop.yml @@ -9,7 +9,7 @@ on: branches: - develop -jobs: +jobs: test-develop: uses: ./.github/workflows/testing.yml diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml new file mode 100644 index 000000000..cf28fd29a --- /dev/null +++ b/.github/workflows/testing_gsoc.yml @@ -0,0 +1,104 @@ +name: test GSOC branches + +on: + push: + branches: + - 'GSOC**' + + pull_request: + branches: + - 'GSOC**' + +env: + PAT: ${{ secrets.PAT }} + + +jobs: + Test-MSS-GSOC: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + + container: + image: openmss/testing-develop + + steps: + - name: Trust My Directory + run: git config --global --add safe.directory /__w/MSS/MSS + + - uses: actions/checkout@v3 + + - name: Check for changed dependencies + run: | + cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt \ + || (echo Dependencies differ \ + && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) + + - name: Reinstall dependencies if changed + if: ${{ success() && env.triggerdockerbuild == 'yes' }} + run: | + cd $GITHUB_WORKSPACE \ + && source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && mamba deactivate \ + && cat localbuild/meta.yaml \ + | sed -n '/^requirements:/,/^test:/p' \ + | sed -e "s/.*- //" \ + | sed -e "s/menuinst.*//" \ + | sed -e "s/.*://" > reqs.txt \ + && cat requirements.d/development.txt >> reqs.txt \ + && echo pyvirtualdisplay >> reqs.txt \ + && cat reqs.txt \ + && mamba env remove -n mss-develop-env \ + && mamba create -y -n mss-develop-env --file reqs.txt + + - name: Print conda list + run: | + source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && mamba list + + - name: Run tests + if: ${{ success() && inputs.xdist == 'no' }} + timeout-minutes: 25 + run: | + cd $GITHUB_WORKSPACE \ + && source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && pytest -v --durations=20 --reverse --cov=mslib tests \ + || (for i in {1..5} \ + ; do pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ + && break \ + ; done) + + + - name: Run tests in parallel + if: ${{ success() && inputs.xdist == 'yes' }} + timeout-minutes: 25 + run: | + cd $GITHUB_WORKSPACE \ + && source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests \ + || (for i in {1..5} \ + ; do pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ + && break \ + ; done) + + - name: Collect coverage + if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && inputs.xdist == 'no'}} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd $GITHUB_WORKSPACE \ + && source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && mamba install coveralls \ + && coveralls --service=github From 04d030b7e0c42cac195b5f5d1723e574c6e9f7d6 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 1 Jun 2023 13:47:27 +0200 Subject: [PATCH 015/176] replace globals by locals (#1802) --- mslib/utils/thermolib.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/mslib/utils/thermolib.py b/mslib/utils/thermolib.py index f4e9e3ce7..93cba6447 100644 --- a/mslib/utils/thermolib.py +++ b/mslib/utils/thermolib.py @@ -27,14 +27,10 @@ """ import numpy as np - -from mslib.utils.units import units, check_units - -from metpy.package_tools import Exporter from metpy.constants import Rd, g from metpy.xarray import preprocess_and_wrap -exporter = Exporter(globals()) +from mslib.utils.units import units, check_units def rel_hum(p, t, q): @@ -142,7 +138,6 @@ def omega_to_w(omega, p, t): _HEIGHT, _TEMPERATURE, _PRESSURE, _TEMPERATURE_GRADIENT = 0, 1, 2, 3 -@exporter.export @preprocess_and_wrap(wrap_like='height') @check_units('[length]') def flightlevel2pressure(height): @@ -194,7 +189,6 @@ def flightlevel2pressure(height): return p if is_array else p[0] -@exporter.export @preprocess_and_wrap(wrap_like='pressure') @check_units('[pressure]') def pressure2flightlevel(pressure): @@ -247,7 +241,6 @@ def pressure2flightlevel(pressure): return z if is_array else z[0] -@exporter.export @preprocess_and_wrap(wrap_like='height') @check_units('[length]') def isa_temperature(height): From 03750bb0a52962ddde66c14428d562856cd9f7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 19 Jul 2023 08:06:21 +0200 Subject: [PATCH 016/176] fix crash when getting credentials from keyring (#1815) --- mslib/utils/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/utils/auth.py b/mslib/utils/auth.py index f2a0dd739..e5bda23be 100644 --- a/mslib/utils/auth.py +++ b/mslib/utils/auth.py @@ -64,7 +64,7 @@ def get_password_from_keyring(service_name=NAME, username=""): return None else: return cred.password - except keyring.errors.KeyringLocked as ex: + except (keyring.errors.KeyringLocked, keyring.errors.InitError) as ex: logging.warn(ex) return None From 6aa87839943355835850cb39668392316b19084b Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 20 Jul 2023 17:00:20 +0200 Subject: [PATCH 017/176] inputs removed (#1807) --- .github/workflows/testing_gsoc.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml index cf28fd29a..6504ba9c0 100644 --- a/.github/workflows/testing_gsoc.yml +++ b/.github/workflows/testing_gsoc.yml @@ -63,7 +63,7 @@ jobs: && mamba list - name: Run tests - if: ${{ success() && inputs.xdist == 'no' }} + if: ${{ success() }} timeout-minutes: 25 run: | cd $GITHUB_WORKSPACE \ @@ -78,7 +78,7 @@ jobs: - name: Run tests in parallel - if: ${{ success() && inputs.xdist == 'yes' }} + if: ${{ success() }} timeout-minutes: 25 run: | cd $GITHUB_WORKSPACE \ @@ -92,7 +92,7 @@ jobs: ; done) - name: Collect coverage - if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && inputs.xdist == 'no'}} + if: ${{ success() }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | From 406c2bb735401be354fb10ab6856ee2a6d5904db Mon Sep 17 00:00:00 2001 From: Sergey Kravchenko <45981130+SergeLeon@users.noreply.github.com> Date: Thu, 20 Jul 2023 18:02:14 +0300 Subject: [PATCH 018/176] replace 'werkzeug.urls.url_join' usage with 'urllib.parse.urljoin' (#1817) --- conftest.py | 4 ++-- mslib/msui/mscolab.py | 18 +++++++++--------- mslib/msui/mscolab_admin_window.py | 18 +++++++++--------- mslib/msui/mscolab_chat.py | 18 +++++++++--------- mslib/msui/mscolab_version_history.py | 13 +++++++------ tests/_test_mscolab/test_sockets_manager.py | 8 ++++---- tests/utils.py | 20 ++++++++++---------- 7 files changed, 50 insertions(+), 49 deletions(-) diff --git a/conftest.py b/conftest.py index 7586141f7..cf2242e56 100644 --- a/conftest.py +++ b/conftest.py @@ -113,7 +113,7 @@ def pytest_generate_tests(metafunc): import logging import fs import secrets -from werkzeug.urls import url_join +from urllib.parse import urljoin ROOT_DIR = '{constants.ROOT_DIR}' # directory where mss output files are stored @@ -147,7 +147,7 @@ def pytest_generate_tests(metafunc): # enable verification by Mail MAIL_ENABLED = False -SQLALCHEMY_DB_URI = 'sqlite:///' + url_join(DATA_DIR, 'mscolab.db') +SQLALCHEMY_DB_URI = 'sqlite:///' + urljoin(DATA_DIR, 'mscolab.db') # mscolab file upload settings UPLOAD_FOLDER = fs.path.join(DATA_DIR, 'uploads') diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 7f93ef6cd..6d9076255 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -39,10 +39,10 @@ import requests import re import urllib.request +from urllib.parse import urljoin from fs import open_fs from PIL import Image -from werkzeug.urls import url_join from keyring.errors import NoKeyringError, PasswordSetError, InitError from mslib.msui import flighttrack as ft @@ -96,7 +96,7 @@ def unarchive_operation(self): "token": self.mscolab.token, "op_id": self.archived_op_id, } - url = url_join(self.mscolab.mscolab_server_url, 'set_last_used') + url = urljoin(self.mscolab.mscolab_server_url, 'set_last_used') try: res = requests.post(url, data=data, timeout=(2, 10)) except requests.exceptions.RequestException as e: @@ -218,7 +218,7 @@ def connect_handler(self): s = requests.Session() s.auth = auth s.headers.update({'x-test': 'true'}) - r = s.get(url_join(url, 'status'), timeout=(2, 10)) + r = s.get(urljoin(url, 'status'), timeout=(2, 10)) if r.status_code == 401: self.set_status("Error", 'Server authentication data were incorrect.') elif r.status_code == 200: @@ -1013,7 +1013,7 @@ def handle_delete_operation(self): "token": self.token, "op_id": self.active_op_id } - url = url_join(self.mscolab_server_url, 'delete_operation') + url = urljoin(self.mscolab_server_url, 'delete_operation') try: res = requests.post(url, data=data, timeout=(2, 10)) except requests.exceptions.RequestException as e: @@ -1046,7 +1046,7 @@ def handle_leave_operation(self): "op_id": self.active_op_id, "selected_userids": json.dumps([self.user["id"]]) } - url = url_join(self.mscolab_server_url, "delete_bulk_permissions") + url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") res = requests.post(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() @@ -1093,7 +1093,7 @@ def change_category_handler(self): "attribute": 'category', "value": entered_operation_category } - url = url_join(self.mscolab_server_url, 'update_operation') + url = urljoin(self.mscolab_server_url, 'update_operation') r = requests.post(url, data=data, timeout=(2, 10)) if r.text == "True": self.active_operation_category = entered_operation_category @@ -1123,7 +1123,7 @@ def change_description_handler(self): "attribute": 'description', "value": entered_operation_desc } - url = url_join(self.mscolab_server_url, 'update_operation') + url = urljoin(self.mscolab_server_url, 'update_operation') r = requests.post(url, data=data, timeout=(2, 10)) if r.text == "True": # Update active operation description label @@ -1154,7 +1154,7 @@ def rename_operation_handler(self): "attribute": 'path', "value": entered_operation_name } - url = url_join(self.mscolab_server_url, 'update_operation') + url = urljoin(self.mscolab_server_url, 'update_operation') r = requests.post(url, data=data, timeout=(2, 10)) if r.text == "True": # Update active operation name @@ -1545,7 +1545,7 @@ def archive_operation(self): "op_id": self.active_op_id, "days": 31, } - url = url_join(self.mscolab_server_url, 'set_last_used') + url = urljoin(self.mscolab_server_url, 'set_last_used') try: res = requests.post(url, data=data, timeout=(2, 10)) except requests.exceptions.RequestException as e: diff --git a/mslib/msui/mscolab_admin_window.py b/mslib/msui/mscolab_admin_window.py index 02dbf4eec..6e70e92fb 100644 --- a/mslib/msui/mscolab_admin_window.py +++ b/mslib/msui/mscolab_admin_window.py @@ -27,7 +27,7 @@ import json import requests -from werkzeug.urls import url_join +from urllib.parse import urljoin from PyQt5 import QtCore, QtWidgets from mslib.utils.verify_user_token import verify_user_token @@ -175,7 +175,7 @@ def set_label_text(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "/creator_of_operation") + url = urljoin(self.mscolab_server_url, "/creator_of_operation") r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": _json = json.loads(r.text) @@ -188,7 +188,7 @@ def load_import_operations(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "operations") + url = urljoin(self.mscolab_server_url, "operations") r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": _json = json.loads(r.text) @@ -202,7 +202,7 @@ def load_users_without_permission(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "users_without_permission") + url = urljoin(self.mscolab_server_url, "users_without_permission") res = requests.get(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() @@ -227,7 +227,7 @@ def load_users_with_permission(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "users_with_permission") + url = urljoin(self.mscolab_server_url, "users_with_permission") res = requests.get(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() @@ -259,7 +259,7 @@ def add_selected_users(self): "selected_userids": json.dumps(selected_userids), "selected_access_level": selected_access_level } - url = url_join(self.mscolab_server_url, "add_bulk_permissions") + url = urljoin(self.mscolab_server_url, "add_bulk_permissions") res = requests.post(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() @@ -290,7 +290,7 @@ def modify_selected_users(self): "selected_userids": json.dumps(selected_userids), "selected_access_level": selected_access_level } - url = url_join(self.mscolab_server_url, "modify_bulk_permissions") + url = urljoin(self.mscolab_server_url, "modify_bulk_permissions") res = requests.post(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() @@ -318,7 +318,7 @@ def delete_selected_users(self): "op_id": self.op_id, "selected_userids": json.dumps(selected_userids) } - url = url_join(self.mscolab_server_url, "delete_bulk_permissions") + url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") res = requests.post(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() @@ -343,7 +343,7 @@ def import_permissions(self): "current_op_id": self.op_id, "import_op_id": import_op_id } - url = url_join(self.mscolab_server_url, 'import_permissions') + url = urljoin(self.mscolab_server_url, 'import_permissions') res = requests.post(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index d21db42ea..19b6c1c72 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -31,7 +31,7 @@ import requests from markdown import Markdown from markdown.extensions import Extension -from werkzeug.urls import url_join +from urllib.parse import urljoin from mslib.mscolab.message_type import MessageType from PyQt5 import QtCore, QtGui, QtWidgets @@ -275,7 +275,7 @@ def send_message(self): "op_id": self.op_id, "message_type": int(self.attachment_type) } - url = url_join(self.mscolab_server_url, 'message_attachment') + url = urljoin(self.mscolab_server_url, 'message_attachment') try: requests.post(url, data=data, files=files, timeout=(2, 10)) except requests.exceptions.ConnectionError: @@ -332,7 +332,7 @@ def load_users(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, 'authorized_users') + url = urljoin(self.mscolab_server_url, 'authorized_users') r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": self.collaboratorsList.clear() @@ -352,7 +352,7 @@ def load_all_messages(self): "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") } # returns an array of messages - url = url_join(self.mscolab_server_url, "messages") + url = urljoin(self.mscolab_server_url, "messages") res = requests.get(url, data=data, timeout=(2, 10)) if res.text != "False": @@ -470,10 +470,10 @@ def setup_image_message_box(self): MAX_WIDTH = MAX_HEIGHT = 300 self.messageBox = QtWidgets.QLabel() if '\\' in self.attachment_path: - img_url = url_join(self.chat_window.mscolab_server_url, - self.attachment_path.replace('\\', '/').split('colabdata')[1]) + img_url = urljoin(self.chat_window.mscolab_server_url, + self.attachment_path.replace('\\', '/').split('colabdata')[1]) else: - img_url = url_join(self.chat_window.mscolab_server_url, self.attachment_path) + img_url = urljoin(self.chat_window.mscolab_server_url, self.attachment_path) data = requests.get(img_url, timeout=(2, 10)).content image = QtGui.QImage() image.loadFromData(data) @@ -505,7 +505,7 @@ def get_text_browser(self, text): def setup_text_message_box(self): if self.message_type == MessageType.DOCUMENT: - doc_url = url_join(self.chat_window.mscolab_server_url, self.attachment_path) + doc_url = urljoin(self.chat_window.mscolab_server_url, self.attachment_path) file_name = fs.path.basename(self.attachment_path) self.message_text = f"Document: [{file_name}]({doc_url})" self.messageBox = self.get_text_browser(self.message_text) @@ -653,7 +653,7 @@ def handle_download_action(self): if self.message_type == MessageType.DOCUMENT: file_path = get_save_filename(self, "Save Document", default_filename, f"Document (*{file_ext})") if file_path is not None: - file_content = requests.get(url_join(self.chat_window.mscolab_server_url, self.attachment_path), + file_content = requests.get(urljoin(self.chat_window.mscolab_server_url, self.attachment_path), timeout=(2, 10)).content with open(file_path, "wb") as f: f.write(file_content) diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index 078451414..28fa40d61 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -29,7 +29,8 @@ import json import requests -from werkzeug.urls import url_encode, url_join +from urllib.parse import urljoin +from werkzeug.urls import url_encode from mslib.utils.verify_user_token import verify_user_token from mslib.msui.flighttrack import WaypointsTableModel @@ -111,7 +112,7 @@ def load_current_waypoints(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, 'get_operation_by_id') + url = urljoin(self.mscolab_server_url, 'get_operation_by_id') res = requests.get(url, data=data, timeout=(2, 10)) if res.text != "False": xml_content = json.loads(res.text)["content"] @@ -138,7 +139,7 @@ def load_all_changes(self): named_version_only = True query_string = url_encode({"named_version": named_version_only}) url_path = f'get_all_changes?{query_string}' - url = url_join(self.mscolab_server_url, url_path) + url = urljoin(self.mscolab_server_url, url_path) r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": changes = json.loads(r.text)["changes"] @@ -180,7 +181,7 @@ def preview_change(self, current_item, previous_item): "token": self.token, "ch_id": current_item.id } - url = url_join(self.mscolab_server_url, 'get_change_content') + url = urljoin(self.mscolab_server_url, 'get_change_content') res = requests.get(url, data=data, timeout=(2, 10)) if res.text != "False": res = res.json() @@ -206,7 +207,7 @@ def request_set_version_name(self, version_name, ch_id): "ch_id": ch_id, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, 'set_version_name') + url = urljoin(self.mscolab_server_url, 'set_version_name') res = requests.post(url, data=data, timeout=(2, 10)) return res else: @@ -273,7 +274,7 @@ def handle_undo(self): "token": self.token, "ch_id": self.changes.currentItem().id } - url = url_join(self.mscolab_server_url, 'undo') + url = urljoin(self.mscolab_server_url, 'undo') r = requests.post(url, data=data, timeout=(2, 10)) if r.text != "False": # reload windows diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index 5e5c7e9e6..9735fec0a 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -29,8 +29,8 @@ import socketio import datetime import requests +from urllib.parse import urljoin -from werkzeug.urls import url_join from mslib.msui.icons import icons from mslib.mscolab.conf import mscolab_settings from tests.utils import mscolab_check_free_port, LiveSocketTestCase @@ -233,7 +233,7 @@ def test_get_messages_api(self): "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") } # returns an array of messages - url = url_join(self.url, 'messages') + url = urljoin(self.url, 'messages') res = requests.get(url, data=data, timeout=(2, 10)).json() assert len(res["messages"]) == 2 @@ -269,7 +269,7 @@ def test_edit_message(self): "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") } # returns an array of messages - url = url_join(self.url, 'messages') + url = urljoin(self.url, 'messages') res = requests.get(url, data=data, timeout=(2, 10)).json() assert len(res["messages"]) == 1 messages = res["messages"][0] @@ -307,7 +307,7 @@ def test_upload_file(self): "op_id": self.operation.id, "message_type": int(MessageType.IMAGE) } - url = url_join(self.url, 'message_attachment') + url = urljoin(self.url, 'message_attachment') requests.post(url, data=data, files=files, timeout=(2, 10)) upload_dir = os.path.join(mscolab_settings.UPLOAD_FOLDER, str(self.user.id)) assert os.path.exists(upload_dir) diff --git a/tests/utils.py b/tests/utils.py index 0c95536da..895dca650 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -34,7 +34,7 @@ from flask_testing import LiveServerTestCase from PyQt5 import QtTest -from werkzeug.urls import url_join +from urllib.parse import urljoin from mslib.mscolab.server import register_user from flask import json from tests.constants import MSUI_CONFIG_PATH @@ -75,7 +75,7 @@ def mscolab_register_user(app, msc_url, email, password, username): 'password': password, 'username': username } - url = url_join(msc_url, 'register') + url = urljoin(msc_url, 'register') response = app.test_client().post(url, data=data) return response @@ -86,7 +86,7 @@ def mscolab_register_and_login(app, msc_url, email, password, username): 'email': email, 'password': password } - url = url_join(msc_url, 'token') + url = urljoin(msc_url, 'token') response = app.test_client().post(url, data=data) return response @@ -96,7 +96,7 @@ def mscolab_login(app, msc_url, email='a', password='a'): 'email': email, 'password': password } - url = url_join(msc_url, 'token') + url = urljoin(msc_url, 'token') response = app.test_client().post(url, data=data) return response @@ -106,7 +106,7 @@ def mscolab_delete_user(app, msc_url, email, password): response = mscolab_login(app, msc_url, email, password) if response.status == '200 OK': data = json.loads(response.get_data(as_text=True)) - url = url_join(msc_url, 'delete_user') + url = urljoin(msc_url, 'delete_user') response = app.test_client().post(url, data=data) if response.status == '200 OK': data = json.loads(response.get_data(as_text=True)) @@ -132,7 +132,7 @@ def mscolab_create_content(app, msc_url, data, path_name='example', content=None data["path"] = path_name data['description'] = path_name data['content'] = content - url = url_join(msc_url, 'create_operation') + url = urljoin(msc_url, 'create_operation') response = app.test_client().post(url, data=data) return response @@ -140,12 +140,12 @@ def mscolab_create_content(app, msc_url, data, path_name='example', content=None def mscolab_delete_all_operations(app, msc_url, email, password, username): response = mscolab_register_and_login(app, msc_url, email, password, username) data = json.loads(response.get_data(as_text=True)) - url = url_join(msc_url, 'operations') + url = urljoin(msc_url, 'operations') response = app.test_client().get(url, data=data) response = json.loads(response.get_data(as_text=True)) for p in response['operations']: data['op_id'] = p['op_id'] - url = url_join(msc_url, 'delete_operation') + url = urljoin(msc_url, 'delete_operation') response = app.test_client().post(url, data=data) @@ -153,7 +153,7 @@ def mscolab_create_operation(app, msc_url, response, path='f', description='desc data = json.loads(response.get_data(as_text=True)) data["path"] = path data['description'] = description - url = url_join(msc_url, 'create_operation') + url = urljoin(msc_url, 'create_operation') response = app.test_client().post(url, data=data) return data, response @@ -161,7 +161,7 @@ def mscolab_create_operation(app, msc_url, response, path='f', description='desc def mscolab_get_operation_id(app, msc_url, email, password, username, path): response = mscolab_register_and_login(app, msc_url, email, password, username) data = json.loads(response.get_data(as_text=True)) - url = url_join(msc_url, 'operations') + url = urljoin(msc_url, 'operations') response = app.test_client().get(url, data=data) response = json.loads(response.get_data(as_text=True)) for p in response['operations']: From 12a1cbbc72d1277fa96f7d7ec0b13481e60cb791 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Fri, 21 Jul 2023 20:51:19 +0200 Subject: [PATCH 019/176] Switch default cmap of generics to "turbo" and allow reconfiguration in mswms_settings.py (#1821) --- mslib/mswms/generics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mslib/mswms/generics.py b/mslib/mswms/generics.py index 93c91bae4..cacd42f5a 100644 --- a/mslib/mswms/generics.py +++ b/mslib/mswms/generics.py @@ -35,6 +35,7 @@ """ N_LEVELS = 16 +DEFAULT_CMAP = matplotlib.pyplot.cm.turbo """ List of supported targets using the CF standard_name as unique identifier. @@ -558,7 +559,7 @@ def get_style_parameters(dataname, style, cmin, cmax, data): cmin, cmax = 0., 1. if 0 < cmin < 0.05 * cmax: cmin = 0. - cmap = matplotlib.pyplot.cm.rainbow + cmap = DEFAULT_CMAP ticks = None if any(isinstance(_x, np.ma.core.MaskedConstant) for _x in (cmin, cmax)): From 7b48369b0e55952f60a7fea27243691faf88eb87 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Mon, 24 Jul 2023 13:19:09 +0200 Subject: [PATCH 020/176] add more entities to generic plots (#1825) --- mslib/mswms/generics.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mslib/mswms/generics.py b/mslib/mswms/generics.py index cacd42f5a..b5bb20697 100644 --- a/mslib/mswms/generics.py +++ b/mslib/mswms/generics.py @@ -44,11 +44,13 @@ """ _TARGETS = [ "air_temperature", + "air_potential_temperature", "eastward_wind", "equivalent_latitude", "ertel_potential_vorticity", "mean_age_of_air", "mole_fraction_of_active_chlorine_in_air", + "mole_fraction_of_ammonia_in_air", "mole_fraction_of_bromine_nitrate_in_air", "mole_fraction_of_bromo_methane_in_air", "mole_fraction_of_bromochlorodifluoromethane_in_air", @@ -62,11 +64,15 @@ "mole_fraction_of_cfc113_in_air", "mole_fraction_of_cfc12_in_air", "mole_fraction_of_ethane_in_air", + "mole_fraction_of_ethene_in_air", "mole_fraction_of_formaldehyde_in_air", + "mole_fraction_of_formic_acid_in_air", "mole_fraction_of_hcfc22_in_air", "mole_fraction_of_hydrogen_chloride_in_air", + "mole_fraction_of_hydrogen_peroxide_in_air", "mole_fraction_of_hypobromite_in_air", "mole_fraction_of_methane_in_air", + "mole_fraction_of_methanol_in_air", "mole_fraction_of_nitric_acid_in_air", "mole_fraction_of_nitrous_oxide_in_air", "mole_fraction_of_nitrogen_dioxide_in_air", @@ -97,6 +103,7 @@ """ _UNITS = { "air_temperature": "K", + "air_potential_temperature": "K", "eastward_wind": "m/s", "equivalent_latitude": "degree N", "ertel_potential_vorticity": "PVU", @@ -162,6 +169,7 @@ _UNITS[standard_name] = "nmol/mol" for standard_name in [ + "mole_fraction_of_ammonia_in_air", "mole_fraction_of_bromine_nitrate_in_air", "mole_fraction_of_bromo_methane_in_air", "mole_fraction_of_bromochlorodifluoromethane_in_air", @@ -172,9 +180,13 @@ "mole_fraction_of_cfc12_in_air", "mole_fraction_of_cfc113_in_air", "mole_fraction_of_hcfc22_in_air", + "mole_fraction_of_hydrogen_peroxide_in_air", "mole_fraction_of_ethane_in_air", + "mole_fraction_of_ethene_in_air", "mole_fraction_of_formaldehyde_in_air", + "mole_fraction_of_formic_acid_in_air", "mole_fraction_of_hypobromite_in_air", + "mole_fraction_of_methanol_in_air", "mole_fraction_of_nitrogen_dioxide_in_air", "mole_fraction_of_nitrogen_monoxide_in_air", "mole_fraction_of_peroxyacetyl_nitrate_in_air", From 3ea54977d4fa340fa96555acc35c2584fadd6ab1 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Tue, 25 Jul 2023 17:21:27 +0200 Subject: [PATCH 021/176] Make colorbar ticks format in generics plot configurable (#1829) * Make colorbar ticks format in generics plot configurable Fix #1828 * make vsec consistent with hsec * fix * fix --- mslib/mswms/generics.py | 24 ++++++++++++++++++++++++ mslib/mswms/mpl_hsec_styles.py | 14 ++++++++------ mslib/mswms/mpl_vsec_styles.py | 9 +++++++-- mslib/mswms/utils.py | 18 ------------------ 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/mslib/mswms/generics.py b/mslib/mswms/generics.py index b5bb20697..23ea01e90 100644 --- a/mslib/mswms/generics.py +++ b/mslib/mswms/generics.py @@ -315,6 +315,30 @@ def get_log_levels(cmin, cmax, levels=None): return clev +CBAR_LABEL_FORMATS = { + "log": "%.3g", + "log_ice_cloud": "%.0E", +} + + +def get_cbar_label_format(style, maxvalue): + if style in CBAR_LABEL_FORMATS: + return CBAR_LABEL_FORMATS[style] + if 100 <= maxvalue < 10000.: + label_format = "%4i" + elif 10 <= maxvalue < 100.: + label_format = "%.1f" + elif 1 <= maxvalue < 10.: + label_format = "%.2f" + elif 0.1 <= maxvalue < 1.: + label_format = "%.3f" + elif 0.01 <= maxvalue < 0.1: + label_format = "%.4f" + else: + label_format = "%.3g" + return label_format + + def _style_default(_dataname, _style, cmin, cmax, cmap, _data): clev = np.linspace(cmin, cmax, 16) norm = matplotlib.colors.BoundaryNorm(clev, cmap.N) diff --git a/mslib/mswms/mpl_hsec_styles.py b/mslib/mswms/mpl_hsec_styles.py index 2e43e9b4e..bbec22d65 100644 --- a/mslib/mswms/mpl_hsec_styles.py +++ b/mslib/mswms/mpl_hsec_styles.py @@ -74,7 +74,7 @@ from matplotlib import patheffects from mslib.mswms.mpl_hsec import MPLBasemapHorizontalSectionStyle -from mslib.mswms.utils import get_cbar_label_format, make_cbar_labels_readable +from mslib.mswms.utils import make_cbar_labels_readable import mslib.mswms.generics as generics from mslib.utils import thermolib from mslib.utils.units import convert_to @@ -88,6 +88,7 @@ class HS_GenericStyle(MPLBasemapHorizontalSectionStyle): styles = [ ("auto", "auto colour scale"), ("autolog", "auto logcolour scale"), ] + cbar_format = None def _plot_style(self): bm = self.bm @@ -123,13 +124,14 @@ def _plot_style(self): # Format for colorbar labels cbar_label = self.title - cbar_format = get_cbar_label_format(self.style, np.median(np.abs(clevs))) + if self.cbar_format is None: + cbar_format = generics.get_cbar_label_format(self.style, np.median(np.abs(clevs))) + else: + cbar_format = self.cbar_format if not self.noframe: - cbar = self.fig.colorbar(tc, fraction=0.05, pad=0.08, shrink=0.7, - label=cbar_label, format=cbar_format, ticks=ticks, extend="both") - cbar.set_ticks(clevs) - cbar.set_ticklabels(clevs) + self.fig.colorbar(tc, fraction=0.05, pad=0.08, shrink=0.7, + label=cbar_label, format=cbar_format, ticks=ticks, extend="both") else: axins1 = mpl_toolkits.axes_grid1.inset_locator.inset_axes( ax, width="3%", height="40%", loc=cbar_location) diff --git a/mslib/mswms/mpl_vsec_styles.py b/mslib/mswms/mpl_vsec_styles.py index 1b20417b3..a1cf21148 100644 --- a/mslib/mswms/mpl_vsec_styles.py +++ b/mslib/mswms/mpl_vsec_styles.py @@ -38,7 +38,7 @@ import numpy as np from mslib.mswms.mpl_vsec import AbstractVerticalSectionStyle -from mslib.mswms.utils import get_cbar_label_format, make_cbar_labels_readable +from mslib.mswms.utils import make_cbar_labels_readable import mslib.mswms.generics as generics from mslib.utils import thermolib from mslib.utils.units import convert_to @@ -52,6 +52,7 @@ class VS_GenericStyle(AbstractVerticalSectionStyle): styles = [ ("auto", "auto colour scale"), ("autolog", "auto log colour scale"), ] + cbar_format = None def _plot_style(self): ax = self.ax @@ -97,7 +98,11 @@ def _plot_style(self): self._latlon_logp_setup() # Format for colorbar labels - cbar_format = get_cbar_label_format(self.style, np.abs(clevs).max()) + if self.cbar_format is None: + cbar_format = generics.get_cbar_label_format(self.style, np.median(np.abs(clevs))) + else: + cbar_format = self.cbar_format + cbar_label = self.title # Add colorbar. diff --git a/mslib/mswms/utils.py b/mslib/mswms/utils.py index 4811dab5c..03196527d 100644 --- a/mslib/mswms/utils.py +++ b/mslib/mswms/utils.py @@ -28,24 +28,6 @@ import matplotlib -def get_cbar_label_format(style, maxvalue): - format = "%.3g" - if style != "log": - if 100 <= maxvalue < 10000.: - format = "%4i" - elif 10 <= maxvalue < 100.: - format = "%.1f" - elif 1 <= maxvalue < 10.: - format = "%.2f" - elif 0.1 <= maxvalue < 1.: - format = "%.3f" - elif 0.01 <= maxvalue < 0.1: - format = "%.4f" - if style == 'log_ice_cloud': - format = "%.0E" - return format - - def make_cbar_labels_readable(fig, axs): """ Adjust font size of the colorbar labels and put a white background behind them From 03878457a588108d00a063de7f2cb5ffb26660eb Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 27 Jul 2023 16:58:05 +0200 Subject: [PATCH 022/176] introduction of group management (#1838) * introduction of group management * comment updated --- .../config/mscolab/mscolab_settings.py.sample | 6 +++ mslib/mscolab/conf.py | 7 ++++ mslib/mscolab/file_manager.py | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index 48a0bd1f8..f6621b4ea 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -69,3 +69,9 @@ STUB_CODE = """ """ + +# looks for a given category forn a operation ending with GROUP_POSTFIX +# e.g. category = Tex will look for TexGroup +# all users in that Group are set to the operations of that category +# having the roles in the TexGroup +GROUP_POSTFIX = "Group" diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 8c11ac201..59f0c59c5 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -69,6 +69,13 @@ class default_mscolab_settings: """ + + # looks for a given category forn a operation ending with GROUP_POSTFIX + # e.g. category = Tex will look for TexGroup + # all users in that Group are set to the operations of that category + # having the roles in the TexGroup + GROUP_POSTFIX = "Group" + enable_basic_http_authentication = False # enable verification by Mail diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 429ba3acb..900bcddf3 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -62,6 +62,11 @@ def create_operation(self, path, description, user, last_used=None, content=None perm = Permission(user.id, operation_id, "creator") db.session.add(perm) db.session.commit() + # here we can import the permissions from Group file + if not path.endswith(mscolab_settings.GROUP_POSTFIX): + import_op = Operation.query.filter_by(path=f"{category}{mscolab_settings.GROUP_POSTFIX}").first() + if import_op is not None: + self.import_permissions(import_op.id, operation_id, user.id) data = fs.open_fs(self.data_dir) data.makedir(operation.path) operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'w') @@ -420,6 +425,17 @@ def add_bulk_permission(self, op_id, user, new_u_ids, access_level): if Permission.query.filter_by(u_id=u_id, op_id=op_id).first() is None: new_permissions.append(Permission(u_id, op_id, access_level)) db.session.add_all(new_permissions) + operation = Operation.query.filter_by(id=op_id).first() + if operation.path.endswith(mscolab_settings.GROUP_POSTFIX): + # the members of this gets added to all others of same category + category = operation.path.split(mscolab_settings.GROUP_POSTFIX)[0] + # all operation with that category + ops_category = Operation.query.filter_by(category=category) + new_permissions = [] + for ops in ops_category: + if not ops.path.endswith(mscolab_settings.GROUP_POSTFIX): + new_permissions.append(Permission(u_id, ops.id, access_level)) + db.session.add_all(new_permissions) try: db.session.commit() return True @@ -437,6 +453,17 @@ def modify_bulk_permission(self, op_id, user, u_ids, new_access_level): .filter(Permission.u_id.in_(u_ids))\ .update({Permission.access_level: new_access_level}, synchronize_session='fetch') + operation = Operation.query.filter_by(id=op_id).first() + if operation.path.endswith(mscolab_settings.GROUP_POSTFIX): + # the members of this gets added to all others of same category + category = operation.path.split(mscolab_settings.GROUP_POSTFIX)[0] + # all operation with that category + ops_category = Operation.query.filter_by(category=category) + for ops in ops_category: + Permission.query \ + .filter(Permission.op_id == ops.id) \ + .filter(Permission.u_id.in_(u_ids)) \ + .update({Permission.access_level: new_access_level}, synchronize_session='fetch') try: db.session.commit() return True @@ -467,6 +494,18 @@ def delete_bulk_permission(self, op_id, user, u_ids): .filter(Permission.u_id.in_(u_ids)) \ .delete(synchronize_session='fetch') + operation = Operation.query.filter_by(id=op_id).first() + if operation.path.endswith(mscolab_settings.GROUP_POSTFIX): + # the members of this gets added to all others of same category + category = operation.path.split(mscolab_settings.GROUP_POSTFIX)[0] + # all operation with that category + ops_category = Operation.query.filter_by(category=category) + for ops in ops_category: + Permission.query \ + .filter(Permission.op_id == ops.id) \ + .filter(Permission.u_id.in_(u_ids)) \ + .delete(synchronize_session='fetch') + db.session.commit() return True From 2856b1c3c60f84b19d37eec3a0901d4c45cb7982 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sat, 29 Jul 2023 13:49:24 +0200 Subject: [PATCH 023/176] test of new group manager (#1844) --- tests/_test_mscolab/test_file_manager.py | 29 ++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 58503f926..f7fd78c13 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -343,6 +343,31 @@ def test_delete_bulk_permission_for_admin_members(self): assert self.fm.delete_bulk_permission(operation.id, self.user, [self.user.id]) is False assert self.fm.delete_bulk_permission(operation.id, self.adminuser, [self.adminuser.id]) is True + def test_group_permissions(self): + with self.app.test_client(): + flight_path_odlo, operation_oslo = self._create_operation(flight_path="flightoslo", category="oslo") + + flight_path_no1, operation_no_1 = self._create_operation(flight_path="flightno1", category="bergen") + assert self.fm.is_member(self.collaboratoruser.id, operation_no_1.id) is False + flight_path_group, operation_group = self._create_operation(flight_path="bergenGroup", category="bergen") + self.fm.add_bulk_permission(operation_group.id, self.user, [self.collaboratoruser.id], "collaborator") + assert self.fm.is_member(self.collaboratoruser.id, operation_no_1.id) is True + assert self.fm.is_collaborator(self.collaboratoruser.id, operation_no_1.id) + # check that not other catergories get changed + assert self.fm.is_member(self.collaboratoruser.id, operation_oslo.id) is False + self.fm.modify_bulk_permission(operation_group.id, self.user, [self.collaboratoruser.id], "viewer") + assert self.fm.is_viewer(self.collaboratoruser.id, operation_no_1.id) + # now create a new OP with the category bergen and see if our collaborator user has viewer role + flight_path_no2, operation_no_2 = self._create_operation(flight_path="flightno2", category="bergen") + assert self.fm.is_viewer(self.collaboratoruser.id, operation_no_2.id) + # give the user admin role + self.fm.modify_bulk_permission(operation_group.id, self.user, [self.collaboratoruser.id], "admin") + assert self.fm.is_admin(self.collaboratoruser.id, operation_no_2.id) + assert self.fm.is_admin(self.collaboratoruser.id, operation_no_1.id) + # remove the user + self.fm.delete_bulk_permission(operation_group.id, self.user, [self.collaboratoruser.id]) + assert self.fm.is_member(self.collaboratoruser.id, operation_group.id) is False + def test_import_permission(self): with self.app.test_client(): flight_path10, operation10 = self._create_operation(flight_path="operation10") @@ -413,10 +438,10 @@ def _example_data(self): """ - def _create_operation(self, flight_path="firstflight", user=None, content=None): + def _create_operation(self, flight_path="firstflight", user=None, content=None, category="default"): if user is None: user = self.user - self.fm.create_operation(flight_path, f"info about {flight_path}", user, content=content) + self.fm.create_operation(flight_path, f"info about {flight_path}", user, content=content, category=category) operation = Operation.query.filter_by(path=flight_path).first() return flight_path, operation From aa689068ec7ece9ea39aa6430c0e0e10dd0283e8 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Mon, 31 Jul 2023 10:17:54 +0200 Subject: [PATCH 024/176] renamed endpoint for mscolab authentification (#1843) --- mslib/mscolab/server.py | 2 +- mslib/msui/mscolab.py | 2 +- tests/_test_mscolab/test_server.py | 2 +- tests/utils.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 584b2998c..bfd6b410b 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -203,7 +203,7 @@ def home(): return render_template("/index.html") -@APP.route("/status") +@APP.route("/status_auth") @conditional_decorator(auth.login_required, mscolab_settings.__dict__.get('enable_basic_http_authentication', False)) def hello(): return "Mscolab server" diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 6d9076255..ea8948633 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -218,7 +218,7 @@ def connect_handler(self): s = requests.Session() s.auth = auth s.headers.update({'x-test': 'true'}) - r = s.get(urljoin(url, 'status'), timeout=(2, 10)) + r = s.get(urljoin(url, 'status_auth'), timeout=(2, 10)) if r.status_code == 401: self.set_status("Error", 'Server authentication data were incorrect.') elif r.status_code == 200: diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 63ddf4846..77854a722 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -78,7 +78,7 @@ def test_home(self): def test_hello(self): with self.app.test_client() as test_client: - response = test_client.get('/status') + response = test_client.get('/status_auth') assert response.status_code == 200 assert b"Mscolab server" in response.data diff --git a/tests/utils.py b/tests/utils.py index 895dca650..c62d05346 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -182,7 +182,7 @@ def mscolab_check_free_port(all_ports, port): def mscolab_ping_server(port): - url = f"http://127.0.0.1:{port}/status" + url = f"http://127.0.0.1:{port}/status_auth" try: r = requests.get(url, timeout=(2, 10)) if r.text == "Mscolab server": From ebd57aa0f97df3b5156163384b20897bfb4ec335 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 8 Aug 2023 12:48:19 +0200 Subject: [PATCH 025/176] on rename to a Group use import_permissions (#1864) * on rename to a Group use import_permissions * flake8 --- mslib/mscolab/file_manager.py | 10 ++++++++++ tests/_test_mscolab/test_file_manager.py | 25 +++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 900bcddf3..491ea4701 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -206,6 +206,16 @@ def update_operation(self, op_id, attribute, value, user): # make a directory, else movedir data.makedir(value) data.movedir(operation.path, value) + # when renamed to a Group operation + if value.endswith(mscolab_settings.GROUP_POSTFIX): + # getting the category + category = value.split(mscolab_settings.GROUP_POSTFIX)[0] + # all operation with that category + ops_category = Operation.query.filter_by(category=category) + for ops in ops_category: + # the user changing the {category}{mscolab_settings.GROUP_POSTFIX} needs to have rights in the op + # then members of this op gets added to all others of same category + self.import_permissions(op_id, ops.id, user.id) setattr(operation, attribute, value) db.session.commit() return True diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index f7fd78c13..37c14cf08 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -345,7 +345,7 @@ def test_delete_bulk_permission_for_admin_members(self): def test_group_permissions(self): with self.app.test_client(): - flight_path_odlo, operation_oslo = self._create_operation(flight_path="flightoslo", category="oslo") + flight_path_oslo, operation_oslo = self._create_operation(flight_path="flightoslo", category="oslo") flight_path_no1, operation_no_1 = self._create_operation(flight_path="flightno1", category="bergen") assert self.fm.is_member(self.collaboratoruser.id, operation_no_1.id) is False @@ -368,6 +368,29 @@ def test_group_permissions(self): self.fm.delete_bulk_permission(operation_group.id, self.user, [self.collaboratoruser.id]) assert self.fm.is_member(self.collaboratoruser.id, operation_group.id) is False + def test_existing_operation_renaming_to_a_group(self): + with self.app.test_client(): + _, operation_b1 = self._create_operation(flight_path="flightb1", category="morning") + self.fm.add_bulk_permission(operation_b1.id, self.user, [self.collaboratoruser.id], "collaborator") + assert self.fm.is_collaborator(self.collaboratoruser.id, operation_b1.id) + + # an operation where nothing can be changed on a rename + _, operation_b2 = self._create_operation(flight_path="flightb2", category="morning", user=self.vieweruser) + + _, operation_b3 = self._create_operation(flight_path="flightb3", category="morning") + self.fm.add_bulk_permission(operation_b3.id, self.user, [self.collaboratoruser.id], "collaborator") + self.fm.add_bulk_permission(operation_b3.id, self.user, [self.vieweruser.id], "viewer") + assert self.fm.is_viewer(self.vieweruser.id, operation_b3.id) + assert self.fm.is_collaborator(self.collaboratoruser.id, operation_b3.id) + self.fm.update_operation(operation_b3.id, 'path', 'morningGroup', self.user) + + assert self.fm.is_member(self.vieweruser.id, operation_b1.id) + assert self.fm.is_viewer(self.vieweruser.id, operation_b1.id) + + # the current user has no role (admin or collobarator role) in the operation_b2 to change users + assert self.fm.is_member(self.collaboratoruser.id, operation_b2.id) is False + assert self.fm.is_collaborator(self.collaboratoruser.id, operation_b2.id) is False + def test_import_permission(self): with self.app.test_client(): flight_path10, operation10 = self._create_operation(flight_path="operation10") From 3a846261f40857a03f324be5466a8f888a4d155a Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 10 Aug 2023 17:03:36 +0200 Subject: [PATCH 026/176] warn on other needed implementations (#1865) * warn on other needed implementations * flake8 --- mslib/msui/wms_control.py | 43 ++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index deef4e9e2..330ec6dc1 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -1478,27 +1478,24 @@ def append_multiple_images(self, imgs): """ images = [x for x in imgs if x] if images: - # Add border around seperate legends - if len(images) > 1: - images = [ImageOps.expand(x, border=1, fill="black") for x in images] - max_height = int((self.view.fig.get_size_inches() * self.view.fig.get_dpi())[1] * 0.99) - width = max([image.width for image in images]) - height = sum([image.height for image in images]) - result = Image.new("RGBA", (width, height)) - current_height = 0 - for i, image in enumerate(images): - result.paste(image, (0, current_height - i)) - current_height += image.height - - if max_height < result.height: - result.thumbnail((result.width, max_height), Image.ANTIALIAS) - return result - - ################################################################################ - -# -# CLASS VSecWMSControlWidget -# + if hasattr(self.view, 'fig') is False: + QtWidgets.QMessageBox.warning(self, self.tr("Web Map Service"), "We have only fig implemented") + else: + # Add border around seperate legends + if len(images) > 1: + images = [ImageOps.expand(x, border=1, fill="black") for x in images] + max_height = int((self.view.fig.get_size_inches() * self.view.fig.get_dpi())[1] * 0.99) + width = max([image.width for image in images]) + height = sum([image.height for image in images]) + result = Image.new("RGBA", (width, height)) + current_height = 0 + for i, image in enumerate(images): + result.paste(image, (0, current_height - i)) + current_height += image.height + + if max_height < result.height: + result.thumbnail((result.width, max_height), Image.ANTIALIAS) + return result class VSecWMSControlWidget(WMSControlWidget): @@ -1579,10 +1576,6 @@ def is_layer_aligned(self, layer): crss = getattr(layer, "crsOptions", None) return crss is not None and any(crs.startswith("VERT") for crs in crss) -# -# CLASS HSecWMSControlWidget -# - class HSecWMSControlWidget(WMSControlWidget): """Subclass of WMSControlWidget that extends the WMS client to From c5df4a87cf16c86463a252b36b2e9d3bf582d969 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Mon, 14 Aug 2023 10:32:52 +0200 Subject: [PATCH 027/176] use stable ui for development with develop server (#1891) --- mslib/mscolab/server.py | 3 +-- mslib/msui/mscolab.py | 2 +- tests/_test_mscolab/test_server.py | 2 +- tests/utils.py | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 51439f914..5b8f30bf6 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -203,8 +203,7 @@ def home(): return render_template("/index.html") -@APP.route("/status_auth") -@conditional_decorator(auth.login_required, mscolab_settings.__dict__.get('enable_basic_http_authentication', False)) +@APP.route("/status") def hello(): try: if request.authorization.parameters["username"] == "mscolab": diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index b7e494dca..1325346fe 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -218,7 +218,7 @@ def connect_handler(self): s = requests.Session() s.auth = auth s.headers.update({'x-test': 'true'}) - r = s.get(urljoin(url, 'status_auth'), timeout=(2, 10)) + r = s.get(urljoin(url, 'status'), timeout=(2, 10)) if r.status_code == 401: self.set_status("Error", 'Server authentication data were incorrect.') elif r.status_code == 200: diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 77854a722..63ddf4846 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -78,7 +78,7 @@ def test_home(self): def test_hello(self): with self.app.test_client() as test_client: - response = test_client.get('/status_auth') + response = test_client.get('/status') assert response.status_code == 200 assert b"Mscolab server" in response.data diff --git a/tests/utils.py b/tests/utils.py index c62d05346..895dca650 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -182,7 +182,7 @@ def mscolab_check_free_port(all_ports, port): def mscolab_ping_server(port): - url = f"http://127.0.0.1:{port}/status_auth" + url = f"http://127.0.0.1:{port}/status" try: r = requests.get(url, timeout=(2, 10)) if r.text == "Mscolab server": From d4dd8089ee0984383e621c0ae8018c6cc8c9fcd0 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sat, 19 Aug 2023 08:23:24 +0200 Subject: [PATCH 028/176] made the auth name for mscolab configurable (#1907) --- mslib/msui/mscolab.py | 4 ++-- mslib/utils/config.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 6c4d31375..530deea6a 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -169,7 +169,7 @@ def __init__(self, parent=None, mscolab=None): def mscolab_url_changed(self, text): self.httpPasswordLe.setText( - get_password_from_keyring("MSCOLAB_AUTH_" + text, "mscolab")) + get_password_from_keyring("MSCOLAB_AUTH_" + text, config_loader(dataset="MSCOLAB_auth_user_name"))) def mscolab_login_changed(self, text): self.loginPasswordLe.setText( @@ -214,7 +214,7 @@ def enable_login_btn(self): def connect_handler(self): try: url = str(self.urlCb.currentText()) - auth = "mscolab", self.httpPasswordLe.text() + auth = config_loader(dataset="MSCOLAB_auth_user_name"), self.httpPasswordLe.text() s = requests.Session() s.auth = auth s.headers.update({'x-test': 'true'}) diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 34b836bf9..c44a3cc87 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -128,6 +128,9 @@ class MSUIDefaultConfig: "http://localhost:8083", ] + # Username used for http auth + MSCOLAB_auth_user_name = "mscolab" + # category for MSC operations MSCOLAB_category = "default" @@ -237,6 +240,7 @@ class MSUIDefaultConfig: 'new_flighttrack_flightlevel', 'MSCOLAB_category', 'mscolab_server_url', + 'MSCOLAB_auth_user_name', 'wms_cache', 'wms_cache_max_size_bytes', 'wms_cache_max_age_seconds', @@ -297,6 +301,7 @@ class MSUIDefaultConfig: "default_LSEC_WMS": "Documentation Required", "default_MSCOLAB": "Documentation Required", "MSS_auth": "Documentation Required", + "MSCOLAB_auth_user_name": "Documentation Required", "WMS_request_timeout": "Documentation Required", "WMS_preload": "Documentation Required", "wms_cache": "Documentation Required", From 381ee8e21c1cbc3015427bb6ba0e50eb30e00362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 21 Aug 2023 21:41:56 +0200 Subject: [PATCH 029/176] Change QtCore.{Slot,Signal} to QtCore.pyqt{Slot,Signal} (#1917) --- mslib/msui/mpl_pathinteractor.py | 4 +-- mslib/msui/mscolab.py | 30 ++++++++-------- mslib/msui/mscolab_chat.py | 10 +++--- mslib/msui/msui_mainwindow.py | 24 ++++++------- mslib/msui/multiple_flightpath_dockwidget.py | 20 +++++------ mslib/msui/socket_control.py | 22 ++++++------ mslib/msui/topview.py | 38 ++++++++++---------- mslib/msui/viewwindows.py | 2 +- mslib/msui/wms_control.py | 8 ++--- 9 files changed, 79 insertions(+), 79 deletions(-) diff --git a/mslib/msui/mpl_pathinteractor.py b/mslib/msui/mpl_pathinteractor.py index a40e97edf..910607181 100644 --- a/mslib/msui/mpl_pathinteractor.py +++ b/mslib/msui/mpl_pathinteractor.py @@ -910,7 +910,7 @@ class VPathInteractor(PathInteractor): """Subclass of PathInteractor that implements an interactively editable vertical profile of the flight track. """ - signal_get_vsec = QtCore.Signal(name="get_vsec") + signal_get_vsec = QtCore.pyqtSignal(name="get_vsec") def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpoints=101): """Constructor passes a PathV instance its parent. @@ -1052,7 +1052,7 @@ class LPathInteractor(PathInteractor): """ Subclass of PathInteractor that implements a non interactive linear profile of the flight track. """ - signal_get_lsec = QtCore.Signal(name="get_lsec") + signal_get_lsec = QtCore.pyqtSignal(name="get_lsec") def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpoints=101): """Constructor passes a PathV instance its parent. diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 530deea6a..68ee07aca 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -403,14 +403,14 @@ class MSUIMscolab(QtCore.QObject): """ name = "Mscolab" - signal_unarchive_operation = QtCore.Signal(int, name="signal_unarchive_operation") - signal_operation_added = QtCore.Signal(int, str, name="signal_operation_added") - signal_operation_removed = QtCore.Signal(int, name="signal_operation_removed") - signal_login_mscolab = QtCore.Signal(str, str, name="signal_login_mscolab") - signal_logout_mscolab = QtCore.Signal(name="signal_logout_mscolab") - signal_listFlighttrack_doubleClicked = QtCore.Signal() - signal_permission_revoked = QtCore.Signal(int) - signal_render_new_permission = QtCore.Signal(int, str) + signal_unarchive_operation = QtCore.pyqtSignal(int, name="signal_unarchive_operation") + signal_operation_added = QtCore.pyqtSignal(int, str, name="signal_operation_added") + signal_operation_removed = QtCore.pyqtSignal(int, name="signal_operation_removed") + signal_login_mscolab = QtCore.pyqtSignal(str, str, name="signal_login_mscolab") + signal_logout_mscolab = QtCore.pyqtSignal(name="signal_logout_mscolab") + signal_listFlighttrack_doubleClicked = QtCore.pyqtSignal() + signal_permission_revoked = QtCore.pyqtSignal(int) + signal_render_new_permission = QtCore.pyqtSignal(int, str) def __init__(self, parent=None, data_dir=None): super().__init__(parent) @@ -1329,22 +1329,22 @@ def get_recent_operation(self): show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() - @QtCore.Slot() + @QtCore.pyqtSlot() def reload_operation_list(self): if self.mscolab_server_url is not None: self.reload_operations() - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def reload_window(self, value): if self.active_op_id != value or self.ui.workLocallyCheckbox.isChecked(): return self.reload_wps_from_server() - @QtCore.Slot() + @QtCore.pyqtSlot() def reload_windows_slot(self): self.reload_window(self.active_op_id) - @QtCore.Slot(int, int) + @QtCore.pyqtSlot(int, int) def render_new_permission(self, op_id, u_id): """ op_id: operation id @@ -1375,7 +1375,7 @@ def render_new_permission(self, op_id, u_id): show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() - @QtCore.Slot(int, int, str) + @QtCore.pyqtSlot(int, int, str) def handle_update_permission(self, op_id, u_id, access_level): """ op_id: operation id @@ -1441,7 +1441,7 @@ def delete_operation_from_list(self, op_id): self.ui.listOperationsMSC.takeItem(self.ui.listOperationsMSC.row(remove_item)) return remove_item.operation_path - @QtCore.Slot(int, int) + @QtCore.pyqtSlot(int, int) def handle_revoke_permission(self, op_id, u_id): if u_id == self.user["id"]: operation_name = self.delete_operation_from_list(op_id) @@ -1457,7 +1457,7 @@ def handle_revoke_permission(self, op_id, u_id): self.ui.listFlightTracks.setCurrentRow(0) self.ui.activate_selected_flight_track() - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def handle_operation_deleted(self, op_id): old_operation_name = self.active_operation_name old_active_id = self.active_op_id diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index 19b6c1c72..d71a17aea 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -375,16 +375,16 @@ def render_new_message(self, message, scroll=True): self.messageList.scrollToBottom() # SOCKET HANDLERS - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def handle_permissions_updated(self, _): self.load_users() - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_incoming_message(self, message): message = json.loads(message) self.render_new_message(message) - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_incoming_message_reply(self, reply): reply = json.loads(reply) for i in range(self.messageList.count() - 1, -1, -1): @@ -410,7 +410,7 @@ def handle_incoming_message_reply(self, reply): self.messageList.setItemWidget(item, new_message_item) break - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_message_edited(self, message): message = json.loads(message) message_id = message["message_id"] @@ -424,7 +424,7 @@ def handle_message_edited(self, message): item.setSizeHint(message_widget.sizeHint()) break - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_deleted_message(self, message): message = json.loads(message) message_id = message["message_id"] diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index e90696a3a..3d9d9fb60 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -351,15 +351,15 @@ class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): """ viewsChanged = QtCore.pyqtSignal(name="viewsChanged") - signal_activate_flighttrack = QtCore.Signal(ft.WaypointsTableModel, name="signal_activate_flighttrack") - signal_activate_operation = QtCore.Signal(int, name="signal_activate_operation") - signal_operation_added = QtCore.Signal(int, str, name="signal_operation_added") - signal_operation_removed = QtCore.Signal(int, name="signal_operation_removed") - signal_login_mscolab = QtCore.Signal(str, str, name="signal_login_mscolab") - signal_logout_mscolab = QtCore.Signal(name="signal_logout_mscolab") - signal_listFlighttrack_doubleClicked = QtCore.Signal() - signal_permission_revoked = QtCore.Signal(int) - signal_render_new_permission = QtCore.Signal(int, str) + signal_activate_flighttrack = QtCore.pyqtSignal(ft.WaypointsTableModel, name="signal_activate_flighttrack") + signal_activate_operation = QtCore.pyqtSignal(int, name="signal_activate_operation") + signal_operation_added = QtCore.pyqtSignal(int, str, name="signal_operation_added") + signal_operation_removed = QtCore.pyqtSignal(int, name="signal_operation_removed") + signal_login_mscolab = QtCore.pyqtSignal(str, str, name="signal_login_mscolab") + signal_logout_mscolab = QtCore.pyqtSignal(name="signal_logout_mscolab") + signal_listFlighttrack_doubleClicked = QtCore.pyqtSignal() + signal_permission_revoked = QtCore.pyqtSignal(int) + signal_render_new_permission = QtCore.pyqtSignal(int, str) def __init__(self, mscolab_data_dir=None, *args): super().__init__(*args) @@ -490,15 +490,15 @@ def add_plugins(self): self.add_import_plugins(picker_default) self.add_export_plugins(picker_default) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def activate_operation_slot(self, active_op_id): self.signal_activate_operation.emit(active_op_id) - @QtCore.Slot(int, str) + @QtCore.pyqtSlot(int, str) def add_operation_slot(self, op_id, path): self.signal_operation_added.emit(op_id, path) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def remove_operation_slot(self, op_id): self.signal_operation_removed.emit(op_id) diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index cde3f98a3..5e246c14a 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -110,7 +110,7 @@ class MultipleFlightpathControlWidget(QtWidgets.QWidget, ui.Ui_MultipleViewWidge # ToDO: Make a new parent class with all the functions in this class and inherit them # in MultipleFlightpathControlWidget and MultipleFlightpathOperations classes. - signal_parent_closes = QtCore.Signal() + signal_parent_closes = QtCore.pyqtSignal() def __init__(self, parent=None, view=None, listFlightTracks=None, listOperationsMSC=None, activeFlightTrack=None, mscolab_server_url=None, token=None): @@ -173,7 +173,7 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.activate_flighttrack() self.multipleflightrack_worker = Worker(None) - @QtCore.Slot() + @QtCore.pyqtSlot() def logout(self): self.operations.logout_mscolab() self.ui.signal_listFlighttrack_doubleClicked.disconnect() @@ -185,7 +185,7 @@ def logout(self): for idx in range(len(self.obb)): del self.obb[idx] - @QtCore.Slot(str, str) + @QtCore.pyqtSlot(str, str) def login(self, url, token): self.mscolab_server_url = url self.token = token @@ -250,7 +250,7 @@ def flighttrackAdded(self, parent, start, end): self.operations.deactivate_all_operations() self.activate_flighttrack() - @QtCore.Slot(tuple) + @QtCore.pyqtSlot(tuple) def ft_vertices_color(self, color): self.color = color self.colorPixmap.setPixmap(self.show_color_pixmap(color)) @@ -265,19 +265,19 @@ def ft_vertices_color(self, color): elif self.operation_list: self.operations.ft_color_update(color) - @QtCore.Slot(int, str) + @QtCore.pyqtSlot(int, str) def add_operation_slot(self, op_id, path): self.operations.operationsAdded(op_id, path) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def remove_operation_slot(self, op_id): self.operations.operationRemoved(op_id) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def update_op_id(self, op_id): self.operations.get_op_id(op_id) - @QtCore.Slot(ft.WaypointsTableModel) + @QtCore.pyqtSlot(ft.WaypointsTableModel) def get_active(self, active_flighttrack): self.update_last_flighttrack() self.active_flight_track = active_flighttrack @@ -768,11 +768,11 @@ def logout_mscolab(self): self.token = None self.dict_operations = {} - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def permission_revoked(self, op_id): self.operationRemoved(op_id) - @QtCore.Slot(int, str) + @QtCore.pyqtSlot(int, str) def render_permission(self, op_id, path): self.operationsAdded(op_id, path) diff --git a/mslib/msui/socket_control.py b/mslib/msui/socket_control.py index 7c99b32ce..7302b7a37 100644 --- a/mslib/msui/socket_control.py +++ b/mslib/msui/socket_control.py @@ -35,17 +35,17 @@ class ConnectionManager(QtCore.QObject): - signal_reload = QtCore.Signal(int, name="reload_wps") - signal_message_receive = QtCore.Signal(str, name="message rcv") - signal_message_reply_receive = QtCore.Signal(str, name="message reply") - signal_message_edited = QtCore.Signal(str, name="message editted") - signal_message_deleted = QtCore.Signal(str, name="message deleted") - signal_new_permission = QtCore.Signal(int, int, name="new permission") - signal_update_permission = QtCore.Signal(int, int, str, name="update permission") - signal_revoke_permission = QtCore.Signal(int, int, name="revoke permission") - signal_operation_permissions_updated = QtCore.Signal(int, name="operation permissions updated") - signal_operation_list_updated = QtCore.Signal(name="operation list updated") - signal_operation_deleted = QtCore.Signal(int, name="operation deleted") + signal_reload = QtCore.pyqtSignal(int, name="reload_wps") + signal_message_receive = QtCore.pyqtSignal(str, name="message rcv") + signal_message_reply_receive = QtCore.pyqtSignal(str, name="message reply") + signal_message_edited = QtCore.pyqtSignal(str, name="message editted") + signal_message_deleted = QtCore.pyqtSignal(str, name="message deleted") + signal_new_permission = QtCore.pyqtSignal(int, int, name="new permission") + signal_update_permission = QtCore.pyqtSignal(int, int, str, name="update permission") + signal_revoke_permission = QtCore.pyqtSignal(int, int, name="revoke permission") + signal_operation_permissions_updated = QtCore.pyqtSignal(int, name="operation permissions updated") + signal_operation_list_updated = QtCore.pyqtSignal(name="operation list updated") + signal_operation_deleted = QtCore.pyqtSignal(int, name="operation deleted") def __init__(self, token, user, mscolab_server_url=mss_default.mscolab_server_url): super(ConnectionManager, self).__init__() diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index fa984f1fc..b2030b1be 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -60,7 +60,7 @@ class MSUI_TV_MapAppearanceDialog(QtWidgets.QDialog, ui_ma.Ui_MapAppearanceDialo Dialog to set map appearance parameters. User interface is defined in "ui_topview_mapappearance.py". """ - signal_ft_vertices_color_change = QtCore.Signal(str, tuple) + signal_ft_vertices_color_change = QtCore.pyqtSignal(str, tuple) def __init__(self, parent=None, settings=None, wms_connected=False): """ @@ -174,16 +174,16 @@ class MSUITopViewWindow(MSUIMplViewWindow, ui.Ui_TopViewWindow): """ name = "Top View" - signal_activate_flighttrack1 = QtCore.Signal(ft.WaypointsTableModel) - signal_activate_operation = QtCore.Signal(int) - signal_ft_vertices_color_change = QtCore.Signal(tuple) - signal_operation_added = QtCore.Signal(int, str) - signal_operation_removed = QtCore.Signal(int) - signal_login_mscolab = QtCore.Signal(str, str) - signal_logout_mscolab = QtCore.Signal() - signal_listFlighttrack_doubleClicked = QtCore.Signal() - signal_permission_revoked = QtCore.Signal(int) - signal_render_new_permission = QtCore.Signal(int, str) + signal_activate_flighttrack1 = QtCore.pyqtSignal(ft.WaypointsTableModel) + signal_activate_operation = QtCore.pyqtSignal(int) + signal_ft_vertices_color_change = QtCore.pyqtSignal(tuple) + signal_operation_added = QtCore.pyqtSignal(int, str) + signal_operation_removed = QtCore.pyqtSignal(int) + signal_login_mscolab = QtCore.pyqtSignal(str, str) + signal_logout_mscolab = QtCore.pyqtSignal() + signal_listFlighttrack_doubleClicked = QtCore.pyqtSignal() + signal_permission_revoked = QtCore.pyqtSignal(int) + signal_render_new_permission = QtCore.pyqtSignal(int, str) def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, mscolab_server_url=None, token=None): """ @@ -243,7 +243,7 @@ def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, m def __del__(self): del self.mpl.canvas.waypoints_interactor - @QtCore.Slot(ft.WaypointsTableModel) + @QtCore.pyqtSlot(ft.WaypointsTableModel) def update_active_flighttrack(self, active_flighttrack): """ Slot that handles update of active flighttrack variable. @@ -251,20 +251,20 @@ def update_active_flighttrack(self, active_flighttrack): self.active_flighttrack = active_flighttrack self.signal_activate_flighttrack1.emit(active_flighttrack) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def update_active_operation(self, active_op_id): self.active_op_id = active_op_id self.signal_activate_operation.emit(self.active_op_id) - @QtCore.Slot(int, str) + @QtCore.pyqtSlot(int, str) def add_operation_slot(self, op_id, path): self.signal_operation_added.emit(op_id, path) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def remove_operation_slot(self, op_id): self.signal_operation_removed.emit(op_id) - @QtCore.Slot(str, str) + @QtCore.pyqtSlot(str, str) def login(self, mscolab_server_url, token): self.mscolab_server_url = mscolab_server_url self.token = token @@ -368,11 +368,11 @@ def closed(self): self.ui.signal_permission_revoked.disconnect() self.ui.signal_render_new_permission.disconnect() - @QtCore.Slot() + @QtCore.pyqtSlot() def disable_cbs(self): self.wms_connected = True - @QtCore.Slot() + @QtCore.pyqtSlot() def enable_cbs(self): self.wms_connected = False @@ -420,7 +420,7 @@ def open_settings_dialog(self): self.mpl.canvas.waypoints_interactor.redraw_path() dlg.destroy() - @QtCore.Slot(str, tuple) + @QtCore.pyqtSlot(str, tuple) def set_ft_vertices_color(self, which, color): if which == "ft_vertices": self.signal_ft_vertices_color_change.emit(color) diff --git a/mslib/msui/viewwindows.py b/mslib/msui/viewwindows.py index 5a7760d67..2d20f2838 100644 --- a/mslib/msui/viewwindows.py +++ b/mslib/msui/viewwindows.py @@ -43,7 +43,7 @@ class MSUIViewWindow(QtWidgets.QMainWindow): viewCloses = QtCore.pyqtSignal(name="viewCloses") # views for mscolab - # viewClosesId = QtCore.Signal(int, name="viewClosesId") + # viewClosesId = QtCore.pyqtSignal(int, name="viewClosesId") def __init__(self, parent=None, model=None, _id=None): super().__init__(parent) diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index a2d1603f3..2bd73f522 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -400,8 +400,8 @@ class WMSControlWidget(QtWidgets.QWidget, ui.Ui_WMSDockWidget): prefetch = QtCore.pyqtSignal([list], name="prefetch") fetch = QtCore.pyqtSignal([list], name="fetch") - signal_disable_cbs = QtCore.Signal(name="disable_cbs") - signal_enable_cbs = QtCore.Signal(name="enable_cbs") + signal_disable_cbs = QtCore.pyqtSignal(name="disable_cbs") + signal_enable_cbs = QtCore.pyqtSignal(name="enable_cbs") image_displayed = QtCore.pyqtSignal() def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): @@ -1522,7 +1522,7 @@ def setFlightTrackModel(self, model): """ self.waypoints_model = model - @QtCore.Slot() + @QtCore.pyqtSlot() def call_get_vsec(self): if self.btGetMap.isEnabled() and self.cbAutoUpdate.isChecked() and not self.layerChangeInProgress: self.get_all_maps() @@ -1662,7 +1662,7 @@ def setFlightTrackModel(self, model): """ self.waypoints_model = model - @QtCore.Slot() + @QtCore.pyqtSlot() def call_get_lsec(self): if self.btGetMap.isEnabled() and self.cbAutoUpdate.isChecked() and not self.layerChangeInProgress: self.get_all_maps() From c0253ae90f53b086de12a585a3a29e17509ac7fd Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Mon, 21 Aug 2023 11:42:50 -0800 Subject: [PATCH 030/176] fix login button problem (#1914) --- mslib/msui/mscolab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 68ee07aca..aa577552a 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -252,6 +252,7 @@ def connect_handler(self): self.loginEmailLe.setText( config_loader(dataset="MSS_auth").get(self.mscolab_server_url)) self.mscolab_login_changed(self.loginEmailLe.text()) + self.enable_login_btn() # Change connect button text and connect disconnect handler self.connectBtn.setText('Disconnect') From 02bc9591dfc4b60ddccf570ba9a2139af6392464 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Mon, 21 Aug 2023 21:43:09 +0200 Subject: [PATCH 031/176] img-fluid for other images used (#1909) * img-fluid for other images used * flake8 --- mslib/index.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/mslib/index.py b/mslib/index.py index 3c19e4427..a819b78fa 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -112,17 +112,20 @@ def get_topmenu(): APP.jinja_env.globals.update(get_topmenu=get_topmenu) - def get_content(filename, overrides=None): + def get_content(filename, md_overrides=None, html_overrides=None): markdown = Markdown(extensions=["fenced_code"]) content = "" if os.path.isfile(filename): with codecs.open(filename, 'r', 'utf-8') as f: md_data = f.read() md_data = md_data.replace(':ref:', '') - if overrides is not None: - v1, v2 = overrides + if md_overrides is not None: + v1, v2 = md_overrides md_data = md_data.replace(v1, v2) content = markdown.convert(md_data) + if html_overrides is not None: + v1, v2 = html_overrides + content = content.replace(v1, v2) return content @APP.route("/index") @@ -134,9 +137,11 @@ def index(): def about(): _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'about.md') img_url = url_for('overview') - overrides = ['![image](/mss/overview.png)', f'![image]({img_url})'] - content = get_content(_file, - overrides=overrides) + md_overrides = ('![image](/mss/overview.png)', f'![image]({img_url})') + + html_overrrides = ('image', + 'image') + content = get_content(_file, md_overrides=md_overrides, html_overrides=html_overrrides) return render_template("/content.html", act="about", content=content) @APP.route("/mss/install") @@ -176,7 +181,11 @@ def code(filename): @APP.route("/mss/help") def help(): _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'help.md') - content = get_content(_file) + html_overrides = ('Waypoint Tutorial', + 'Waypoint Tutorial') + content = get_content(_file, html_overrides=html_overrides) return render_template("/content.html", act="help", content=content) @APP.route("/mss/imprint") From abe245ff612723eab6757666a496baedb3f799df Mon Sep 17 00:00:00 2001 From: Sanskar Sharma Date: Tue, 22 Aug 2023 01:15:52 +0530 Subject: [PATCH 032/176] template_change (#1876) * template_change * pr_template_updated * new_feature_checkbox_change --- .github/pull_request_template.md | 39 +++++++++++++++++++ .../workflows/ISSUE_TEMPLATE/bug_report.md | 28 +++++++++++++ .../ISSUE_TEMPLATE/feature_request.md | 26 +++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/workflows/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..3280f2946 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ +**Purpose of PR?**: + +Fixes # + +**Does this PR introduce a breaking change?** + +**If the changes in this PR are manually verified, list down the scenarios covered:**: + +**Additional information for reviewer?** : +_Mention if this PR is part of any design or a continuation of previous PRs_ + +**Does this PR results in some Documentation changes?** +_If yes, include the list of Documentation changes_ + +**Checklist:** +- [ ] Bug fix. Fixes # +- [ ] New feature (Non-API breaking changes that adds functionality) +- [ ] PR Title follows the convention of `: ` +- [ ] Commit has unit tests + + \ No newline at end of file diff --git a/.github/workflows/ISSUE_TEMPLATE/bug_report.md b/.github/workflows/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..ba43749cd --- /dev/null +++ b/.github/workflows/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a bug report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +## Bug Report + + +**General Information** + +Please describe your issue in few words here. + +**How to Reproduce** + +1. Instruction 1 +2. Instruction 2 + +**Expected behavior** + +A description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. diff --git a/.github/workflows/ISSUE_TEMPLATE/feature_request.md b/.github/workflows/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..602efcede --- /dev/null +++ b/.github/workflows/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request/Enhancement +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +## Feature Request + +**Short Description** + +E.g., MSS could support ensuring that cows don't take over the world and turn us all into their loyal milkmaids. + +**Is your feature request related to a problem? Please describe the use case.** + +A description of what the problem/use case is. E.g. Prevent the impending cow uprising and maintain human dominance on Earth. + +**Describe the solution you'd like** + +A description of what you want to happen. E.g., Develop a "Moofense" system that deploys an army of robotic cow herders to keep the bovine rebellion in check. + +**Describe alternatives you've considered** + +A description of any alternative solutions or features you've considered. E.g, Alternatively, we could offer the cows a reality TV show deal and distract them with celebrity pasture appearances, turning them into the world's first moo-dia stars. \ No newline at end of file From 9f43fd8a347db1b38b6724fd3d99b5a65dd915bc Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Wed, 23 Aug 2023 21:19:28 -0800 Subject: [PATCH 033/176] Use old operation name as default for new name in rename_operation_handler (#1924) Fix #1921 --- mslib/msui/mscolab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index aa577552a..b335c78b1 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -1162,6 +1162,7 @@ def rename_operation_handler(self): f"You're about to rename the operation - '{self.active_operation_name}' " f"Enter new operation name: " ), + text=f"{self.active_operation_name}", ) if ok: data = { From add03a53a977d9103bbdc1072fe9cb355765c37f Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Wed, 23 Aug 2023 21:21:58 -0800 Subject: [PATCH 034/176] Made MSColab timeout configurable. (#1916) * Made MSColab timeout configurable. Fix #1915 * moved to list_option_structure added documentation template * flake8 --------- Co-authored-by: ReimarBauer --- mslib/msui/mscolab.py | 44 ++++++++++++++++----------- mslib/msui/mscolab_admin_window.py | 16 +++++----- mslib/msui/mscolab_chat.py | 10 +++--- mslib/msui/mscolab_version_history.py | 10 +++--- mslib/utils/config.py | 5 +++ 5 files changed, 49 insertions(+), 36 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index b335c78b1..d84e43d1a 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -98,7 +98,7 @@ def unarchive_operation(self): } url = urljoin(self.mscolab.mscolab_server_url, 'set_last_used') try: - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.debug(e) show_popup(self.parent, "Error", "Some error occurred! Could not unarchive operation.") @@ -218,7 +218,7 @@ def connect_handler(self): s = requests.Session() s.auth = auth s.headers.update({'x-test': 'true'}) - r = s.get(urljoin(url, 'status'), timeout=(2, 10)) + r = s.get(urljoin(url, 'status'), timeout=tuple(tuple(config_loader(dataset="MSCOLAB_timeout")))) if r.status_code == 401: self.set_status("Error", 'Server authentication data were incorrect.') elif r.status_code == 200: @@ -311,7 +311,7 @@ def login_handler(self): url = f'{self.mscolab_server_url}/token' url_recover_password = f'{self.mscolab_server_url}/reset_request' try: - r = s.post(url, data=data, timeout=(2, 10)) + r = s.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.status_code == 401: raise requests.exceptions.ConnectionError except requests.exceptions.ConnectionError as ex: @@ -367,7 +367,7 @@ def new_user_handler(self): s.headers.update({'x-test': 'true'}) url = f'{self.mscolab_server_url}/register' try: - r = s.post(url, data=data, timeout=(2, 10)) + r = s.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as ex: logging.error("unexpected error: %s %s %s", type(ex), url, ex) self.set_status( @@ -585,7 +585,8 @@ def after_login(self, emailid, url, r): data = { "token": self.token } - r = requests.post(f"{self.mscolab_server_url}/update_last_used", data=data, timeout=(2, 10)) + r = requests.post(f"{self.mscolab_server_url}/update_last_used", data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) self.conn.signal_operation_list_updated.connect(self.reload_operation_list) self.conn.signal_reload.connect(self.reload_window) self.conn.signal_new_permission.connect(self.render_new_permission) @@ -751,7 +752,8 @@ def delete_account(self): } try: - r = requests.post(self.mscolab_server_url + '/delete_user', data=data, timeout=(2, 10)) + r = requests.post(self.mscolab_server_url + '/delete_user', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") @@ -849,7 +851,8 @@ def add_operation(self): if self.add_proj_dialog.f_content is not None: data["content"] = self.add_proj_dialog.f_content try: - r = requests.post(f'{self.mscolab_server_url}/create_operation', data=data, timeout=(2, 10)) + r = requests.post(f'{self.mscolab_server_url}/create_operation', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") @@ -1029,7 +1032,7 @@ def handle_delete_operation(self): } url = urljoin(self.mscolab_server_url, 'delete_operation') try: - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.debug(e) show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") @@ -1062,7 +1065,7 @@ def handle_leave_operation(self): "selected_userids": json.dumps([self.user["id"]]) } url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -1109,7 +1112,7 @@ def change_category_handler(self): "value": entered_operation_category } url = urljoin(self.mscolab_server_url, 'update_operation') - r = requests.post(url, data=data, timeout=(2, 10)) + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text == "True": self.active_operation_category = entered_operation_category self.reload_operation_list() @@ -1140,7 +1143,7 @@ def change_description_handler(self): } url = urljoin(self.mscolab_server_url, 'update_operation') - r = requests.post(url, data=data, timeout=(2, 10)) + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text == "True": # Update active operation description label self.set_operation_desc_label(entered_operation_desc) @@ -1172,7 +1175,7 @@ def rename_operation_handler(self): "value": entered_operation_name } url = urljoin(self.mscolab_server_url, 'update_operation') - r = requests.post(url, data=data, timeout=(2, 10)) + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text == "True": # Update active operation name self.active_operation_name = entered_operation_name @@ -1317,7 +1320,8 @@ def get_recent_operation(self): data = { "token": self.token } - r = requests.get(self.mscolab_server_url + '/operations', data=data, timeout=(2, 10)) + r = requests.get(self.mscolab_server_url + '/operations', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1357,7 +1361,8 @@ def render_new_permission(self, op_id, u_id): data = { 'token': self.token } - r = requests.get(self.mscolab_server_url + '/user', data=data, timeout=(2, 10)) + r = requests.get(self.mscolab_server_url + '/user', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) if _json['user']['id'] == u_id: @@ -1476,7 +1481,8 @@ def show_categories_to_ui(self): data = { "token": self.token } - r = requests.get(f'{self.mscolab_server_url}/operations', data=data, timeout=(2, 10)) + r = requests.get(f'{self.mscolab_server_url}/operations', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1498,7 +1504,8 @@ def add_operations_to_ui(self): data = { "token": self.token } - r = requests.get(f'{self.mscolab_server_url}/operations', data=data, timeout=(2, 10)) + r = requests.get(f'{self.mscolab_server_url}/operations', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) self.operations = _json["operations"] @@ -1566,7 +1573,7 @@ def archive_operation(self): } url = urljoin(self.mscolab_server_url, 'set_last_used') try: - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.debug(e) show_popup(self.ui, "Error", "Some error occurred! Could not archive operation.") @@ -1608,7 +1615,8 @@ def set_active_op_id(self, item): "token": self.token, "op_id": item.op_id, } - requests.post(f'{self.mscolab_server_url}/set_last_used', data=data, timeout=(2, 10)) + requests.post(f'{self.mscolab_server_url}/set_last_used', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) # set active_op_id here self.active_op_id = item.op_id diff --git a/mslib/msui/mscolab_admin_window.py b/mslib/msui/mscolab_admin_window.py index 6e70e92fb..a91d80f2d 100644 --- a/mslib/msui/mscolab_admin_window.py +++ b/mslib/msui/mscolab_admin_window.py @@ -176,7 +176,7 @@ def set_label_text(self): "op_id": self.op_id } url = urljoin(self.mscolab_server_url, "/creator_of_operation") - r = requests.get(url, data=data, timeout=(2, 10)) + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) creator_name = _json["username"] @@ -189,7 +189,7 @@ def load_import_operations(self): "op_id": self.op_id } url = urljoin(self.mscolab_server_url, "operations") - r = requests.get(url, data=data, timeout=(2, 10)) + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) self.operations = _json["operations"] @@ -203,7 +203,7 @@ def load_users_without_permission(self): "op_id": self.op_id } url = urljoin(self.mscolab_server_url, "users_without_permission") - res = requests.get(url, data=data, timeout=(2, 10)) + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -228,7 +228,7 @@ def load_users_with_permission(self): "op_id": self.op_id } url = urljoin(self.mscolab_server_url, "users_with_permission") - res = requests.get(url, data=data, timeout=(2, 10)) + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -260,7 +260,7 @@ def add_selected_users(self): "selected_access_level": selected_access_level } url = urljoin(self.mscolab_server_url, "add_bulk_permissions") - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -291,7 +291,7 @@ def modify_selected_users(self): "selected_access_level": selected_access_level } url = urljoin(self.mscolab_server_url, "modify_bulk_permissions") - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -319,7 +319,7 @@ def delete_selected_users(self): "selected_userids": json.dumps(selected_userids) } url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -344,7 +344,7 @@ def import_permissions(self): "import_op_id": import_op_id } url = urljoin(self.mscolab_server_url, 'import_permissions') - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index d71a17aea..aa9f34191 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -277,7 +277,7 @@ def send_message(self): } url = urljoin(self.mscolab_server_url, 'message_attachment') try: - requests.post(url, data=data, files=files, timeout=(2, 10)) + requests.post(url, data=data, files=files, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.ConnectionError: show_popup(self, "Error", "File size too large") self.send_message_state() @@ -333,7 +333,7 @@ def load_users(self): "op_id": self.op_id } url = urljoin(self.mscolab_server_url, 'authorized_users') - r = requests.get(url, data=data, timeout=(2, 10)) + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": self.collaboratorsList.clear() users = r.json()["users"] @@ -354,7 +354,7 @@ def load_all_messages(self): # returns an array of messages url = urljoin(self.mscolab_server_url, "messages") - res = requests.get(url, data=data, timeout=(2, 10)) + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() messages = res["messages"] @@ -474,7 +474,7 @@ def setup_image_message_box(self): self.attachment_path.replace('\\', '/').split('colabdata')[1]) else: img_url = urljoin(self.chat_window.mscolab_server_url, self.attachment_path) - data = requests.get(img_url, timeout=(2, 10)).content + data = requests.get(img_url, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))).content image = QtGui.QImage() image.loadFromData(data) self.message_image = image @@ -654,7 +654,7 @@ def handle_download_action(self): file_path = get_save_filename(self, "Save Document", default_filename, f"Document (*{file_ext})") if file_path is not None: file_content = requests.get(urljoin(self.chat_window.mscolab_server_url, self.attachment_path), - timeout=(2, 10)).content + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))).content with open(file_path, "wb") as f: f.write(file_content) else: diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index 28fa40d61..aec962982 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -113,7 +113,7 @@ def load_current_waypoints(self): "op_id": self.op_id } url = urljoin(self.mscolab_server_url, 'get_operation_by_id') - res = requests.get(url, data=data, timeout=(2, 10)) + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": xml_content = json.loads(res.text)["content"] waypoint_model = WaypointsTableModel(name="Current Waypoints", xml_content=xml_content) @@ -140,7 +140,7 @@ def load_all_changes(self): query_string = url_encode({"named_version": named_version_only}) url_path = f'get_all_changes?{query_string}' url = urljoin(self.mscolab_server_url, url_path) - r = requests.get(url, data=data, timeout=(2, 10)) + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": changes = json.loads(r.text)["changes"] self.changes.clear() @@ -182,7 +182,7 @@ def preview_change(self, current_item, previous_item): "ch_id": current_item.id } url = urljoin(self.mscolab_server_url, 'get_change_content') - res = requests.get(url, data=data, timeout=(2, 10)) + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() waypoint_model = WaypointsTableModel(xml_content=res["content"]) @@ -208,7 +208,7 @@ def request_set_version_name(self, version_name, ch_id): "op_id": self.op_id } url = urljoin(self.mscolab_server_url, 'set_version_name') - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) return res else: # this triggers disconnect @@ -275,7 +275,7 @@ def handle_undo(self): "ch_id": self.changes.currentItem().id } url = urljoin(self.mscolab_server_url, 'undo') - r = requests.post(url, data=data, timeout=(2, 10)) + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": # reload windows self.reloadWindows.emit() diff --git a/mslib/utils/config.py b/mslib/utils/config.py index c44a3cc87..63a84886f 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -134,6 +134,9 @@ class MSUIDefaultConfig: # category for MSC operations MSCOLAB_category = "default" + # timeout for MSColab in seconds. First value is for connection, second for reply + MSCOLAB_timeout = [2, 10] + # list of MSC servers {"http://www.your-mscolab-server.de": "authuser", # "http://www.your-wms-server.de": "authuser"} MSS_auth = {} @@ -284,6 +287,7 @@ class MSUIDefaultConfig: "new_flighttrack_template": ["new-location"], "gravatar_ids": ["example@email.com"], "WMS_preload": ["https://wms-preload-url.com"], + "MSCOLAB_timeout": [[2, 10]], "automated_plotting_flights": [["", "", "", "", "", ""]], "automated_plotting_hsecs": [["http://www.your-wms-server.de", "", "", ""]], "automated_plotting_vsecs": [["http://www.your-wms-server.de", "", "", ""]], @@ -302,6 +306,7 @@ class MSUIDefaultConfig: "default_MSCOLAB": "Documentation Required", "MSS_auth": "Documentation Required", "MSCOLAB_auth_user_name": "Documentation Required", + "MSCOLAB_timeout": "Documentation Required", "WMS_request_timeout": "Documentation Required", "WMS_preload": "Documentation Required", "wms_cache": "Documentation Required", From 8840071598bff686b8629f4cb63d2cbe2d6faf85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 24 Aug 2023 13:49:36 +0200 Subject: [PATCH 035/176] Ignore deprecation warnings from pkg_resources (#1930) --- pytest.ini | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pytest.ini b/pytest.ini index 994165b57..dbde57a51 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,3 +2,8 @@ log_format = %(asctime)s %(levelname)s %(message)s log_date_format = %Y-%m-%d %H:%M:%S timeout = 60 +filterwarnings = + # These namespaces are declared in a way not conformant with PEP420. Not much we can do about that here, we should keep an eye on when this is fixed in our dependencies though. + ignore:Deprecated call to `pkg_resources.declare_namespace\('(xstatic|xstatic\.pkg|mpl_toolkits|mpl_toolkits\.basemap_data|sphinxcontrib|zope|fs|fs\.opener)'\)`\.:DeprecationWarning + # pkg_resources is explicitly used in fs (PyFilesystem2). Ignore the deprecation warning here. + ignore:pkg_resources is deprecated as an API.:DeprecationWarning From 0e2073ee90a15a99b92eb5490b09b4a8a073583a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 24 Aug 2023 17:15:00 +0200 Subject: [PATCH 036/176] fix: Replace load_module with exec_module (#1932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace load_module with exec_module * Add Matthias Riße to AUTHORS --- AUTHORS | 1 + conftest.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 0465897c9..24c3f1e15 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ in alphabetic order by first name - Jens-Uwe Grooß - Jörn Ungermann - Marc Rautenhaus +- Matthias Riße - May Bär - Nilupul Manodya - Reimar Bauer diff --git a/conftest.py b/conftest.py index 3a6c38d71..be546d782 100644 --- a/conftest.py +++ b/conftest.py @@ -25,7 +25,7 @@ limitations under the License. """ -import importlib.machinery +import importlib.util import os import sys import mock @@ -198,10 +198,16 @@ class mscolab_auth(object): # windows needs \\ or / but mixed is terrible. *nix needs / mscolab_fs.writetext(constants.MSCOLAB_AUTH_FILE, config_string.replace('\\', '/')) -importlib.machinery.SourceFileLoader('mswms_settings', constants.SERVER_CONFIG_FILE_PATH).load_module() -sys.path.insert(0, constants.SERVER_CONFIG_FS.root_path) -importlib.machinery.SourceFileLoader('mscolab_settings', path).load_module() -sys.path.insert(0, parent_path) + +def _load_module(module_name, path): + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + +_load_module("mswms_settings", constants.SERVER_CONFIG_FILE_PATH) +_load_module("mscolab_settings", path) @pytest.fixture(autouse=True) From d62ec40fca2d9cb31fb705a5686cfe069323be13 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Fri, 25 Aug 2023 02:00:31 -0800 Subject: [PATCH 037/176] Better visibility for creator of an operation. (#1938) Fix #1933 --- mslib/msui/mscolab.py | 24 ++++++++++--- mslib/msui/mscolab_admin_window.py | 3 +- mslib/msui/qt5/ui_mscolab_admin_window.py | 23 ++++++------ mslib/msui/ui/ui_mscolab_admin_window.ui | 44 +++++++++-------------- 4 files changed, 48 insertions(+), 46 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index d84e43d1a..483776acf 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -448,11 +448,7 @@ def __init__(self, parent=None, data_dir=None): self.ui.actionChangeDescription.triggered.connect(self.change_description_handler) self.ui.actionRenameOperation.triggered.connect(self.rename_operation_handler) self.ui.actionArchiveOperation.triggered.connect(self.archive_operation) - self.ui.actionViewDescription.triggered.connect( - lambda: QtWidgets.QMessageBox.information( - None, "Operation Description", - f"Category: {self.active_operation_category}\n" - f"{self.active_operation_description}")) + self.ui.actionViewDescription.triggered.connect(self.view_description) self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) # connect slot for handling operation options combobox @@ -519,6 +515,24 @@ def __init__(self, parent=None, data_dir=None): self.data_dir = data_dir self.create_dir() + def view_description(self): + data = { + "token": self.token, + "op_id": self.active_op_id + } + url = urljoin(self.mscolab_server_url, "/creator_of_operation") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + creator_name = "unknown" + if r.text != "False": + _json = json.loads(r.text) + creator_name = _json["username"] + QtWidgets.QMessageBox.information( + None, "Operation Description", + f"Creator: {creator_name}

" + f"Category: {self.active_operation_category}

" + "

" + f"{self.active_operation_description}") + def open_operation_archive(self): self.operation_archive_browser.show() diff --git a/mslib/msui/mscolab_admin_window.py b/mslib/msui/mscolab_admin_window.py index a91d80f2d..572e658ac 100644 --- a/mslib/msui/mscolab_admin_window.py +++ b/mslib/msui/mscolab_admin_window.py @@ -180,7 +180,8 @@ def set_label_text(self): if r.text != "False": _json = json.loads(r.text) creator_name = _json["username"] - self.operationNameLabel.setText(f"Operation: {self.operation_name} by User: {creator_name}") + self.operationNameLabel.setText(f"Operation: {self.operation_name}") + self.creatorNameLabel.setText(f"Creator: {creator_name}") self.usernameLabel.setText(f"Logged In: {self.user['username']}") def load_import_operations(self): diff --git a/mslib/msui/qt5/ui_mscolab_admin_window.py b/mslib/msui/qt5/ui_mscolab_admin_window.py index 8c952d3ff..e357b8c3a 100644 --- a/mslib/msui/qt5/ui_mscolab_admin_window.py +++ b/mslib/msui/qt5/ui_mscolab_admin_window.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'ui_mscolab_admin_window.ui' +# Form implementation generated from reading ui file 'mslib/msui/ui/ui_mscolab_admin_window.ui' # -# Created by: PyQt5 UI code generator 5.12.3 +# Created by: PyQt5 UI code generator 5.15.7 # -# WARNING! All changes made in this file will be lost! +# 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 @@ -25,18 +26,15 @@ def setupUi(self, MscolabAdminWindow): self.horizontalLayout_6.setObjectName("horizontalLayout_6") self.verticalLayout = QtWidgets.QVBoxLayout() self.verticalLayout.setObjectName("verticalLayout") - self.horizontalLayout_7 = QtWidgets.QHBoxLayout() - self.horizontalLayout_7.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) - self.horizontalLayout_7.setContentsMargins(0, 8, -1, 8) - self.horizontalLayout_7.setSpacing(6) - self.horizontalLayout_7.setObjectName("horizontalLayout_7") self.usernameLabel = QtWidgets.QLabel(self.centralwidget) self.usernameLabel.setObjectName("usernameLabel") - self.horizontalLayout_7.addWidget(self.usernameLabel) + self.verticalLayout.addWidget(self.usernameLabel) self.operationNameLabel = QtWidgets.QLabel(self.centralwidget) self.operationNameLabel.setObjectName("operationNameLabel") - self.horizontalLayout_7.addWidget(self.operationNameLabel) - self.verticalLayout.addLayout(self.horizontalLayout_7) + self.verticalLayout.addWidget(self.operationNameLabel) + self.creatorNameLabel = QtWidgets.QLabel(self.centralwidget) + self.creatorNameLabel.setObjectName("creatorNameLabel") + self.verticalLayout.addWidget(self.creatorNameLabel) self.label = QtWidgets.QLabel(self.centralwidget) self.label.setObjectName("label") self.verticalLayout.addWidget(self.label) @@ -180,7 +178,7 @@ def setupUi(self, MscolabAdminWindow): MscolabAdminWindow.addAction(self.actionCloseWindow) self.retranslateUi(MscolabAdminWindow) - self.actionCloseWindow.triggered.connect(MscolabAdminWindow.close) + self.actionCloseWindow.triggered.connect(MscolabAdminWindow.close) # type: ignore QtCore.QMetaObject.connectSlotsByName(MscolabAdminWindow) def retranslateUi(self, MscolabAdminWindow): @@ -188,6 +186,7 @@ def retranslateUi(self, MscolabAdminWindow): MscolabAdminWindow.setWindowTitle(_translate("MscolabAdminWindow", "Manage Users")) self.usernameLabel.setText(_translate("MscolabAdminWindow", "Logged In: ")) self.operationNameLabel.setText(_translate("MscolabAdminWindow", "Operation: ")) + self.creatorNameLabel.setText(_translate("MscolabAdminWindow", "Creator:")) self.label.setText(_translate("MscolabAdminWindow", "All Users Without Permission:")) self.addUsersSearch.setPlaceholderText(_translate("MscolabAdminWindow", "Search User")) self.selectAllAddBtn.setText(_translate("MscolabAdminWindow", "Select All")) diff --git a/mslib/msui/ui/ui_mscolab_admin_window.ui b/mslib/msui/ui/ui_mscolab_admin_window.ui index 38364b941..749fb215e 100644 --- a/mslib/msui/ui/ui_mscolab_admin_window.ui +++ b/mslib/msui/ui/ui_mscolab_admin_window.ui @@ -38,37 +38,25 @@ - - - 6 - - - QLayout::SetDefaultConstraint - - - 0 + + + Logged In: - - 8 + + + + + + Operation: - - 8 + + + + + + Creator: - - - - Logged In: - - - - - - - Operation: - - - - + From d64cce8624bbb016ede3211e390e664cb37e81e6 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Fri, 25 Aug 2023 02:01:03 -0800 Subject: [PATCH 038/176] Make more efficient implementation for point-multigeometries. (#1926) Fix #1925 --- mslib/msui/kmloverlay_dockwidget.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mslib/msui/kmloverlay_dockwidget.py b/mslib/msui/kmloverlay_dockwidget.py index 34d3b34d2..c72991ef8 100644 --- a/mslib/msui/kmloverlay_dockwidget.py +++ b/mslib/msui/kmloverlay_dockwidget.py @@ -100,18 +100,19 @@ def add_polygon(self, polygon, style, _): x1, y1 = self.compute_xy(interior) self.patches.append(self.map.plot(x1, y1, "-", zorder=10, **kwargs)) - def add_multipoint(self, point, style, name): + def add_multipoint(self, geoms, style, name): """ Plot KML points in a MultiGeometry :param point: fastkml object specifying point :param name: name of placemark for annotation """ - x, y = (point.x, point.y) - self.patches.append(self.map.plot(x, y, "o", zorder=10, color=self.color)) + xs = [point.x for point in geoms] + ys = [point.y for point in geoms] + self.patches.append(self.map.plot(xs, ys, "o", zorder=10, color=self.color)) if name is not None: self.patches.append([self.map.ax.annotate( - name, xy=(x, y), xycoords="data", xytext=(5, 5), textcoords='offset points', zorder=10, + name, xy=(xs[0], ys[0]), xycoords="data", xytext=(5, 5), textcoords='offset points', zorder=10, path_effects=[patheffects.withStroke(linewidth=2, foreground='w')])]) def add_multiline(self, line, style, name): @@ -152,8 +153,7 @@ def parse_geometries(self, placemark): elif isinstance(placemark.geometry, geometry.Polygon): self.add_polygon(placemark, style, name) elif isinstance(placemark.geometry, geometry.MultiPoint): - for geom in placemark.geometry.geoms: - self.add_multipoint(geom, style, name) + self.add_multipoint(placemark.geometry.geoms, style, name) elif isinstance(placemark.geometry, geometry.MultiLineString): for geom in placemark.geometry.geoms: self.add_multiline(geom, style, name) @@ -163,7 +163,7 @@ def parse_geometries(self, placemark): elif isinstance(placemark.geometry, geometry.GeometryCollection): for geom in placemark.geometry.geoms: if geom.geom_type == "Point": - self.add_multipoint(geom, style, name) + self.add_multipoint([geom], style, name) elif geom.geom_type == "LineString": self.add_multiline(geom, style, name) elif geom.geom_type == "LinearRing": From a20561b54e6537282e2ee208cdad7e900a506245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Fri, 25 Aug 2023 13:25:48 +0200 Subject: [PATCH 039/176] Remove usage of future (#1944) --- docs/environment.yml | 1 - localbuild/meta.yaml | 1 - mslib/mswms/wms.py | 4 ---- mslib/utils/ogcwms.py | 3 --- 4 files changed, 9 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index f536829bf..5b3dcf207 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -18,7 +18,6 @@ dependencies: - sphinx - fs - netCDF4 - - future - PyQt5 - owslib - basemap >=1.3.3 diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 3784d1b1b..6cb075ca5 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -29,7 +29,6 @@ requirements: - python - setuptools - pip - - future - menuinst # [win] run: - python diff --git a/mslib/mswms/wms.py b/mslib/mswms/wms.py index 2a5945b6c..6f96f95a7 100644 --- a/mslib/mswms/wms.py +++ b/mslib/mswms/wms.py @@ -41,10 +41,6 @@ limitations under the License. """ -from future import standard_library - -standard_library.install_aliases() - import glob import os import io diff --git a/mslib/utils/ogcwms.py b/mslib/utils/ogcwms.py index 531237f43..daf9f7d23 100644 --- a/mslib/utils/ogcwms.py +++ b/mslib/utils/ogcwms.py @@ -57,9 +57,6 @@ Currently supports only versions 1.1.1/1.3.0 of the WMS protocol. """ -from future import standard_library -standard_library.install_aliases() - import defusedxml.ElementTree as etree import requests import logging From 82ee07816dbbc2ccabd02d3913a2b97a6fb9b987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Fri, 25 Aug 2023 13:26:33 +0200 Subject: [PATCH 040/176] Remove upper bound on flask-socketio version (#1943) --- localbuild/meta.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 6cb075ca5..3d541707b 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -65,7 +65,7 @@ requirements: - flask-mail - flask-migrate - werkzeug >=2.2.3 - - flask-socketio =5.1.0 + - flask-socketio >=5.1.0 - flask-sqlalchemy >=3.0.0 - flask-cors - passlib From 9974ed56aa040701a1fcf5c39e9808ed6a94acf9 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 25 Aug 2023 19:26:15 +0200 Subject: [PATCH 041/176] catch DBusErrorResponse (#1949) --- mslib/utils/auth.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mslib/utils/auth.py b/mslib/utils/auth.py index e5bda23be..ac0dace81 100644 --- a/mslib/utils/auth.py +++ b/mslib/utils/auth.py @@ -33,6 +33,17 @@ import logging import keyring + +try: + from jeepney.wrappers import DBusErrorResponse +except (ImportError, ModuleNotFoundError): + class DBusErrorResponse(Exception): + """ + Fallback definition on not DBus systems + """ + def __init__(self, message): + super().__init__(message) + from mslib.msui import constants @@ -64,7 +75,7 @@ def get_password_from_keyring(service_name=NAME, username=""): return None else: return cred.password - except (keyring.errors.KeyringLocked, keyring.errors.InitError) as ex: + except (keyring.errors.KeyringLocked, keyring.errors.InitError, DBusErrorResponse) as ex: logging.warn(ex) return None From ea285998d3e2a84e8f550efc0ce9a2b955ce2c2b Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 25 Aug 2023 19:39:24 +0200 Subject: [PATCH 042/176] care on logout after invalid request (#1948) * care on logout after invalid request * updated for change description and rename operation --- mslib/msui/mscolab.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 483776acf..4fb88474b 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -1132,6 +1132,9 @@ def change_category_handler(self): self.reload_operation_list() self.error_dialog = QtWidgets.QErrorMessage() self.error_dialog.showMessage("Description is updated successfully.") + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1165,6 +1168,9 @@ def change_description_handler(self): self.reload_operation_list() self.error_dialog = QtWidgets.QErrorMessage() self.error_dialog.showMessage("Description is updated successfully.") + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1203,6 +1209,9 @@ def rename_operation_handler(self): self.error_dialog = QtWidgets.QErrorMessage() self.error_dialog.showMessage("Operation is renamed successfully.") + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() From c69456fa2f9edbe3ca0997267e509ce3c8fdbdfa Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Sun, 27 Aug 2023 01:37:55 -0800 Subject: [PATCH 043/176] Add labels and waypoints to KML export files. (#1953) Fix #1952 --- mslib/plugins/io/kml.py | 36 +++++++++++++++++++++--------- tests/_test_plugins/test_io_kml.py | 16 +++++++++---- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/mslib/plugins/io/kml.py b/mslib/plugins/io/kml.py index 6a014022f..0628862fc 100644 --- a/mslib/plugins/io/kml.py +++ b/mslib/plugins/io/kml.py @@ -31,8 +31,7 @@ def save_to_kml(filename, name, waypoints): if not filename: raise ValueError("filename to save flight track cannot be None") - with codecs.open(filename, "w", "utf_8") as out_file: - header = f""" + header = f""" {name} @@ -42,20 +41,37 @@ def save_to_kml(filename, name, waypoints): ff0000002 {name} #flighttrack - -1absolute - """ - line = "{lon:.3f},{lat:.3f},{alt:.3f}\n" - footer = """ - - + path = """ +1absolute +{coordinates} +""" + line = "{lon:.3f},{lat:.3f},{alt:.3f}\n" + waypoint = """ +{name} + + {lon:.3f},{lat:.3f},{alt:.3f} + +""" + footer = """ """ + with codecs.open(filename, "w", "utf_8") as out_file: + line_coords = "" + for i, wp in enumerate(waypoints): + lat = wp.lat + lon = wp.lon + lvl = wp.flightlevel + alt = lvl * 100 * 0.3048 + line_coords += line.format(lon=lon, lat=lat, alt=alt) out_file.write(header) + out_file.write(path.format(coordinates=line_coords)) for i, wp in enumerate(waypoints): + name = str(wp.location) + if not name: + name = str(i) lat = wp.lat lon = wp.lon lvl = wp.flightlevel alt = lvl * 100 * 0.3048 - out_file.write(line.format(lon=lon, lat=lat, alt=alt)) + out_file.write(waypoint.format(name=str(name), lon=lon, lat=lat, alt=alt)) out_file.write(footer) diff --git a/tests/_test_plugins/test_io_kml.py b/tests/_test_plugins/test_io_kml.py index 6e93df703..f4481f168 100644 --- a/tests/_test_plugins/test_io_kml.py +++ b/tests/_test_plugins/test_io_kml.py @@ -50,12 +50,20 @@ def test_save_to_kml(): '#flighttrack\n', '\n', '1absolute\n', - '\n', - '-149.960,61.168,10668.000\n', + '-149.960,61.168,10668.000\n', '-176.646,51.878,10668.000\n', '\n', - '\n', - '\n', + '\n', + 'Anchorage\n', + '\n', + ' -149.960,61.168,10668.000\n', + '\n', + '\n', + 'Adak\n', + '\n', + ' -176.646,51.878,10668.000\n', + '\n', + '\n', '' ] From f40770ae922ea789b31ffc89fa4ace5ed3ec5db5 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Sun, 27 Aug 2023 09:28:33 -0800 Subject: [PATCH 044/176] Remove redundant calls to "operations" (#1941) * Remove redundant calls to "operations" Pass on previous results to subsequent functions, disable unncessary request triggers. Fix #1940 * reactivated ANY and made it visible to be not a common category * test for *ANY* added * unwanted change removed --------- Co-authored-by: ReimarBauer --- mslib/msui/mscolab.py | 48 ++++++++++++++++++++------------ tests/_test_msui/test_mscolab.py | 24 ++++++++++++++-- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 4fb88474b..a6a2f0662 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -504,7 +504,7 @@ def __init__(self, parent=None, data_dir=None): # User email self.email = None # Display all categories by default - self.selected_category = "ANY" + self.selected_category = "*ANY*" # Gravatar image path self.gravatar = None @@ -621,10 +621,9 @@ def after_login(self, emailid, url, r): self.ui.actionAddOperation.setEnabled(True) # Populate open operations list - self.add_operations_to_ui() - + ops = self.add_operations_to_ui() # Show category list - self.show_categories_to_ui() + self.show_categories_to_ui(ops) self.ui.activeOperationDesc.setHidden(False) self.ui.actionChangeCategory.setEnabled(False) @@ -884,6 +883,7 @@ def add_operation(self): self.error_dialog.showMessage('The path already exists') def get_recent_op_id(self): + logging.debug('get_recent_op_id') if verify_user_token(self.mscolab_server_url, self.token): """ get most recent operation's op_id @@ -1266,12 +1266,13 @@ def reload_local_wp(self): self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() - def operation_category_handler(self): + def operation_category_handler(self, update_operations=True): # only after_login if self.mscolab_server_url is not None: self.selected_category = self.ui.filterCategoryCb.currentText() - if self.selected_category != "ANY": + if update_operations: self.add_operations_to_ui() + if self.selected_category != "*ANY*": items = [self.ui.listOperationsMSC.item(i) for i in range(self.ui.listOperationsMSC.count())] row = 0 for item in items: @@ -1339,6 +1340,7 @@ def get_recent_operation(self): """ get most recent operation """ + logging.debug('get_recent_operation') if verify_user_token(self.mscolab_server_url, self.token): data = { "token": self.token @@ -1489,6 +1491,7 @@ def handle_revoke_permission(self, op_id, u_id): @QtCore.pyqtSlot(int) def handle_operation_deleted(self, op_id): + logging.debug('handle_operation_deleted') old_operation_name = self.active_operation_name old_active_id = self.active_op_id operation_name = self.delete_operation_from_list(op_id) @@ -1496,33 +1499,41 @@ def handle_operation_deleted(self, op_id): operation_name = old_operation_name show_popup(self.ui, "Success", f'Operation "{operation_name}" was deleted!', icon=1) - def show_categories_to_ui(self): + def show_categories_to_ui(self, ops=None): """ adds the list of operation categories to the UI """ - if verify_user_token(self.mscolab_server_url, self.token): - data = { - "token": self.token - } - r = requests.get(f'{self.mscolab_server_url}/operations', data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + logging.debug('show_categories_to_ui') + if verify_user_token(self.mscolab_server_url, self.token) or ops: + if ops is not None: + r = ops + else: + data = { + "token": self.token + } + r = requests.get(f'{self.mscolab_server_url}/operations', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] + self.ui.filterCategoryCb.currentIndexChanged.disconnect(self.operation_category_handler) self.ui.filterCategoryCb.clear() - categories = set(["ANY"]) + categories = set(["*ANY*"]) for operation in operations: categories.add(operation["category"]) - categories.remove("ANY") - categories = ["ANY"] + sorted(categories) + categories.remove("*ANY*") + categories = ["*ANY*"] + sorted(categories) category = config_loader(dataset="MSCOLAB_category") self.ui.filterCategoryCb.addItems(categories) if category in categories: index = categories.index(category) self.ui.filterCategoryCb.setCurrentIndex(index) + self.operation_category_handler(update_operations=False) + self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) def add_operations_to_ui(self): logging.debug('add_operations_to_ui') + r = None if verify_user_token(self.mscolab_server_url, self.token): data = { "token": self.token @@ -1574,6 +1585,7 @@ def add_operations_to_ui(self): else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() + return r def show_operation_options_in_inactivated_state(self, access_level): self.ui.actionUnarchiveOperation.setEnabled(False) @@ -1803,9 +1815,9 @@ def load_wps_from_server(self): self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) def reload_operations(self): - self.add_operations_to_ui() + ops = self.add_operations_to_ui() selected_category = self.ui.filterCategoryCb.currentText() - self.show_categories_to_ui() + self.show_categories_to_ui(ops) index = self.ui.filterCategoryCb.findText(selected_category, QtCore.Qt.MatchFixedString) if index >= 0: self.ui.filterCategoryCb.setCurrentIndex(index) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 627439247..7308986c2 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -560,6 +560,26 @@ def test_update_category(self, mockbox, mockpatch): assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_category == "new_category" + @mock.patch("PyQt5.QtWidgets.QMessageBox.information") + def test_any_special_category(self, mockpatch): + self._connect_to_mscolab() + self._create_user("something", "something@something.org", "something") + self._create_operation("flight1234", "Description flight1234") + QtTest.QTest.qWait(0) + self._create_operation("flight5678", "Description flight5678", category="furtherexample") + # all operations of two defined categories are found + assert self.window.mscolab.selected_category == "*ANY*" + operation_pathes = [self.window.mscolab.ui.listOperationsMSC.item(i).operation_path for i in + range(self.window.mscolab.ui.listOperationsMSC.count())] + assert ["flight1234", "flight5678"] == operation_pathes + self.window.mscolab.ui.filterCategoryCb.setCurrentIndex(2) + QtWidgets.QApplication.processEvents() + # only operation of furtherexample are found + assert self.window.mscolab.selected_category == "furtherexample" + operation_pathes = [self.window.mscolab.ui.listOperationsMSC.item(i).operation_path for i in + range(self.window.mscolab.ui.listOperationsMSC.count())] + assert ["flight5678"] == operation_pathes + def test_get_recent_op_id(self): self._connect_to_mscolab() self._create_user("anton", "anton@something.org", "something") @@ -720,14 +740,14 @@ def _reset_config_file(self): read_config_file(path=config_file) @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") - def _create_operation(self, path, description, mockbox): + def _create_operation(self, path, description, mockbox, category="example"): self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.path.setText(str(path)) QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.description.setText(str(description)) QtWidgets.QApplication.processEvents() - self.window.mscolab.add_proj_dialog.category.setText("example") + self.window.mscolab.add_proj_dialog.category.setText(category) QtWidgets.QApplication.processEvents() okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) From 1327ede1dbe3f4eb26bf3889934fa76c74fb428b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 28 Aug 2023 08:23:20 +0200 Subject: [PATCH 045/176] Remove usage of werkzeug.urls.url_encode (#1911) * Remove usage of werkzeug.urls.url_encode * Make named_version a boolean value --- mslib/mscolab/file_manager.py | 16 ++++++++-------- mslib/mscolab/server.py | 2 +- mslib/msui/mscolab_version_history.py | 7 +++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 02a50ba7c..5d57cca1c 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -303,7 +303,7 @@ def get_file(self, op_id, user): operation_data = operation_file.read() return operation_data - def get_all_changes(self, op_id, user, named_version=None): + def get_all_changes(self, op_id, user, named_version=False): """ op_id: operation-id user: user of this request @@ -314,19 +314,19 @@ def get_all_changes(self, op_id, user, named_version=None): perm = Permission.query.filter_by(u_id=user.id, op_id=op_id).first() if perm is None: return False - # Get all changes - if named_version is None: - changes = Change.query.\ - filter_by(op_id=op_id)\ - .order_by(Change.created_at.desc())\ - .all() # Get only named versions - else: + if named_version: changes = Change.query\ .filter(Change.op_id == op_id)\ .filter(~Change.version_name.is_(None))\ .order_by(Change.created_at.desc())\ .all() + # Get all changes + else: + changes = Change.query\ + .filter_by(op_id=op_id)\ + .order_by(Change.created_at.desc())\ + .all() return list(map(lambda change: { 'id': change.id, diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index e15364dfa..06853f375 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -422,7 +422,7 @@ def get_operation_by_id(): @verify_user def get_all_changes(): op_id = request.args.get('op_id', request.form.get('op_id', None)) - named_version = request.args.get('named_version') + named_version = request.args.get('named_version') == "True" user = g.user result = fm.get_all_changes(int(op_id), user, named_version) if result is False: diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index aec962982..1d080cf0e 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -29,8 +29,7 @@ import json import requests -from urllib.parse import urljoin -from werkzeug.urls import url_encode +from urllib.parse import urljoin, urlencode from mslib.utils.verify_user_token import verify_user_token from mslib.msui.flighttrack import WaypointsTableModel @@ -134,10 +133,10 @@ def load_all_changes(self): "token": self.token, "op_id": self.op_id } - named_version_only = None + named_version_only = False if self.versionFilterCB.currentIndex() == 0: named_version_only = True - query_string = url_encode({"named_version": named_version_only}) + query_string = urlencode({"named_version": named_version_only}) url_path = f'get_all_changes?{query_string}' url = urljoin(self.mscolab_server_url, url_path) r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) From d1c652b9e5229d11cfdb3e6d6a5aedddb0256187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:06:22 +0200 Subject: [PATCH 046/176] Replace matplotlib grid parameter 'b' with 'visible' (#1956) * Replace matplotlib grid parameter 'b' with 'visible' * Raise lower bound of matplotlib version to 3.5.3 * Remove upper bound of matplotlib version --- localbuild/meta.yaml | 2 +- mslib/mswms/mpl_vsec.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 3d541707b..052fdf782 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -57,7 +57,7 @@ requirements: - unicodecsv - fs_filepicker - cftime >=1.0.1 - - matplotlib >=3.3.2,<3.6 + - matplotlib >=3.5.3 - itsdangerous - pyjwt - flask >=2.3.2 diff --git a/mslib/mswms/mpl_vsec.py b/mslib/mswms/mpl_vsec.py index 2622b8613..4d6131dda 100644 --- a/mslib/mswms/mpl_vsec.py +++ b/mslib/mswms/mpl_vsec.py @@ -132,7 +132,7 @@ def _latlon_logp_setup(self, orography=105000.): # Set axis limits and draw grid for major ticks. ax.set_xlim(self.lat_inds[0], self.lat_inds[-1]) ax.set_ylim(self.p_bot, self.p_top) - ax.grid(b=True) + ax.grid(visible=True) @abstractmethod def _plot_style(self): From d9f2abc630da6a54a63962feb24dc14452622c04 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 31 Aug 2023 17:19:53 +0200 Subject: [PATCH 047/176] enable flake8 for gsoc branches (#1975) --- .github/workflows/python-flake8.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index b578708e4..397a26a17 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -8,10 +8,14 @@ on: branches: - develop - stable + - GSOC2023-NilupulManodya + - GSOC2023-ShubhGaur pull_request: branches: - develop - stable + - GSOC2023-NilupulManodya + - GSOC2023-ShubhGaur jobs: lint: From 1530a29781127f9c16c853a7512b8844245c7098 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 31 Aug 2023 08:03:30 -0800 Subject: [PATCH 048/176] Some tidying up of handling imports in MSCOlab (#1962) The connect seems to be wrong, a disconnect seems in order. It might be better to not create a new waypoint_model, but change the content of the old one? Either way, with these streamlining, the error was not reproducible anymore. Since it was sporadic before, this might be by chance. See #1960 --- mslib/msui/mscolab.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index a6a2f0662..2fe59f2d9 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -1829,6 +1829,7 @@ def reload_wps_from_server(self): self.reload_view_windows() def handle_waypoints_changed(self): + logging.debug("handle_waypoints_changed") if verify_user_token(self.mscolab_server_url, self.token): if self.ui.workLocallyCheckbox.isChecked(): self.waypoints_model.save_to_ftml(self.local_ftml_file) @@ -1856,6 +1857,7 @@ def reload_view_windows(self): logging.error("%s" % err) def handle_import_msc(self, file_path, extension, function, pickertype): + logging.debug("handle_import_msc") if verify_user_token(self.mscolab_server_url, self.token): if self.active_op_id is None: return @@ -1878,14 +1880,10 @@ def handle_import_msc(self, file_path, extension, function, pickertype): model = ft.WaypointsTableModel(waypoints=new_waypoints) xml_doc = self.waypoints_model.get_xml_doc() xml_content = xml_doc.toprettyxml(indent=" ", newl="\n") - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) self.waypoints_model = model - if self.ui.workLocallyCheckbox.isChecked(): - self.waypoints_model.save_to_ftml(self.local_ftml_file) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) - else: - self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.handle_waypoints_changed() + self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() show_popup(self.ui, "Import Success", f"The file - {file_name}, was imported successfully!", 1) else: @@ -1893,6 +1891,7 @@ def handle_import_msc(self, file_path, extension, function, pickertype): self.logout() def handle_export_msc(self, extension, function, pickertype): + logging.debug("handle_export_msc") if verify_user_token(self.mscolab_server_url, self.token): if self.active_op_id is None: return From 9a49908d88578b03802221e08c0a40ae9ccae164 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 31 Aug 2023 20:55:01 +0200 Subject: [PATCH 049/176] show transport layer in UI (#1963) --- mslib/msui/mscolab.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 2fe59f2d9..cf1067fe6 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -611,7 +611,9 @@ def after_login(self, emailid, url, r): self.ui.connectBtn.hide() self.ui.openOperationsGb.show() # display connection status - self.ui.mscStatusLabel.setText(self.ui.tr(f"Status: connected to '{self.mscolab_server_url}'")) + transport_layer = self.conn.sio.transport() + self.ui.mscStatusLabel.setText(self.ui.tr( + f"Status: connected to '{self.mscolab_server_url}' by transport layer '{transport_layer}'")) # display username beside useroptions toolbutton self.ui.usernameLabel.setText(f"{self.user['username']}") self.ui.usernameLabel.show() From bf8d33185f9e4e05386d38870763489b40d906ee Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 31 Aug 2023 16:35:56 -0800 Subject: [PATCH 050/176] Properly project points in KML files before drawing (#1978) Fix #1977 --- mslib/msui/kmloverlay_dockwidget.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mslib/msui/kmloverlay_dockwidget.py b/mslib/msui/kmloverlay_dockwidget.py index c72991ef8..9583f648d 100644 --- a/mslib/msui/kmloverlay_dockwidget.py +++ b/mslib/msui/kmloverlay_dockwidget.py @@ -66,7 +66,7 @@ def add_point(self, point, style, name): :param point: fastkml object specifying point :param name: name of placemark for annotation """ - x, y = (point.geometry.x, point.geometry.y) + x, y = self.map(point.geometry.x, point.geometry.y) self.patches.append(self.map.plot(x, y, "o", zorder=10, color=self.color)) if name is not None: self.patches.append([self.map.ax.annotate( @@ -107,8 +107,7 @@ def add_multipoint(self, geoms, style, name): :param point: fastkml object specifying point :param name: name of placemark for annotation """ - xs = [point.x for point in geoms] - ys = [point.y for point in geoms] + xs, ys = self.map([point.x for point in geoms], [point.y for point in geoms]) self.patches.append(self.map.plot(xs, ys, "o", zorder=10, color=self.color)) if name is not None: self.patches.append([self.map.ax.annotate( From 5097eec2752844a3c14c8fa6a2e3e86de89f9b7d Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 1 Sep 2023 15:24:36 +0200 Subject: [PATCH 051/176] logging functionality to socketio added and configured testing for critical erros (#1966) * logging functionality to socketio added and configured testing for critical errors * updated to ERROR --- conftest.py | 6 ++++++ docs/samples/config/mscolab/mscolab_settings.py.sample | 6 ++++++ mslib/mscolab/conf.py | 6 ++++++ mslib/mscolab/sockets_manager.py | 3 ++- pytest.ini | 9 +++++++-- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/conftest.py b/conftest.py index be546d782..602975931 100644 --- a/conftest.py +++ b/conftest.py @@ -125,6 +125,12 @@ def pytest_generate_tests(metafunc): # mscolab data directory MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') +# To enable logging set to True or pass a logger object to use. +SOCKETIO_LOGGER = True + +# To enable Engine.IO logging set to True or pass a logger object to use. +ENGINEIO_LOGGER = True + # used to generate and parse tokens SECRET_KEY = secrets.token_urlsafe(16) diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index 57c8d3ab0..b521805f7 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -26,6 +26,12 @@ """ import os +# To enable logging set to True or pass a logger object to use. +SOCKETIO_LOGGER = False + +# To enable Engine.IO logging set to True or pass a logger object to use. +ENGINEIO_LOGGER = False + # Set which origins are allowed to communicate with your server CORS_ORIGINS = ["*"] diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 59f0c59c5..e0e50db56 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -33,6 +33,12 @@ class default_mscolab_settings: # expire token in seconds # EXPIRATION = 86400 + # To enable logging set to True or pass a logger object to use. + SOCKETIO_LOGGER = False + + # To enable Engine.IO logging set to True or pass a logger object to use. + ENGINEIO_LOGGER = False + # Which origins are allowed to communicate with your server CORS_ORIGINS = ["*"] diff --git a/mslib/mscolab/sockets_manager.py b/mslib/mscolab/sockets_manager.py index 4de4ed74c..46c3730e1 100644 --- a/mslib/mscolab/sockets_manager.py +++ b/mslib/mscolab/sockets_manager.py @@ -36,7 +36,8 @@ from mslib.mscolab.utils import get_session_id from mslib.mscolab.conf import mscolab_settings -socketio = SocketIO(cors_allowed_origins=("*" if not hasattr(mscolab_settings, "CORS_ORIGINS") or +socketio = SocketIO(logger=mscolab_settings.SOCKETIO_LOGGER, engineio_logger=mscolab_settings.ENGINEIO_LOGGER, + cors_allowed_origins=("*" if not hasattr(mscolab_settings, "CORS_ORIGINS") or "*" in mscolab_settings.CORS_ORIGINS else mscolab_settings.CORS_ORIGINS)) diff --git a/pytest.ini b/pytest.ini index dbde57a51..3f0e6d3f9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,11 @@ [pytest] -log_format = %(asctime)s %(levelname)s %(message)s -log_date_format = %Y-%m-%d %H:%M:%S +log_cli = 1 +log_cli_level = ERROR +log_cli_format = %(message)s +log_file = pytest.log +log_file_level = DEBUG +log_file_format = %(asctime)s %(levelname)s %(message)s +log_file_date_format = %Y-%m-%d %H:%M:%S timeout = 60 filterwarnings = # These namespaces are declared in a way not conformant with PEP420. Not much we can do about that here, we should keep an eye on when this is fixed in our dependencies though. From abef5ea247a25351414d64b44e80ff4019cc1045 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sun, 3 Sep 2023 07:59:22 +0200 Subject: [PATCH 052/176] use category for selection (#1988) --- mslib/msui/multiple_flightpath_dockwidget.py | 6 +++++- mslib/msui/topview.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 5e246c14a..921c9985a 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -113,7 +113,7 @@ class MultipleFlightpathControlWidget(QtWidgets.QWidget, ui.Ui_MultipleViewWidge signal_parent_closes = QtCore.pyqtSignal() def __init__(self, parent=None, view=None, listFlightTracks=None, - listOperationsMSC=None, activeFlightTrack=None, mscolab_server_url=None, token=None): + listOperationsMSC=None, category=None, activeFlightTrack=None, mscolab_server_url=None, token=None): super().__init__(parent) # ToDO: Remove all patches, on closing dockwidget. self.ui = parent @@ -122,6 +122,7 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.flight_path = None # flightpath object self.dict_flighttrack = {} # Dictionary of flighttrack data: patch,color,wp_model self.active_flight_track = activeFlightTrack + self.msc_category = category # object of active category self.listOperationsMSC = listOperationsMSC self.listFlightTracks = listFlightTracks self.mscolab_server_url = mscolab_server_url @@ -555,6 +556,9 @@ def get_wps_from_server(self): if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] + selected_category = self.parent.msc_category.currentText() + if selected_category != "*ANY*": + operations = [op for op in operations if op['category'] == selected_category] return operations def request_wps_from_server(self, op_id): diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index b2030b1be..8682fd292 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -341,6 +341,7 @@ def openTool(self, index): widget = mf.MultipleFlightpathControlWidget(parent=self, view=self.mpl.canvas, listFlightTracks=self.ui.listFlightTracks, listOperationsMSC=self.ui.listOperationsMSC, + category=self.ui.filterCategoryCb, activeFlightTrack=self.active_flighttrack, mscolab_server_url=self.mscolab_server_url, token=self.token) From 56e9528b552a9d8f2e267661473b8f0e724fd093 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sun, 3 Sep 2023 08:30:29 +0200 Subject: [PATCH 053/176] introduces a config var to skip archived operations on the server (#1987) * feature added skip archived operations * skip_archived implemented for the multiple flightpath docking widget * flake8 * simplified active check --- mslib/mscolab/file_manager.py | 29 ++++++++++++-------- mslib/mscolab/server.py | 7 +++-- mslib/msui/mscolab.py | 8 ++++-- mslib/msui/multiple_flightpath_dockwidget.py | 5 +++- mslib/utils/config.py | 4 +++ tests/_test_mscolab/test_file_manager.py | 25 +++++++++++++++++ tests/_test_mscolab/test_server.py | 24 ++++++++++++++-- 7 files changed, 83 insertions(+), 19 deletions(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 5d57cca1c..f6d817b1f 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -40,7 +40,7 @@ class FileManager: def __init__(self, data_dir): self.data_dir = data_dir - def create_operation(self, path, description, user, last_used=None, content=None, category="default"): + def create_operation(self, path, description, user, last_used=None, content=None, category="default", active=True): """ path: path to the operation description: description of the operation @@ -54,7 +54,7 @@ def create_operation(self, path, description, user, last_used=None, content=None return False if last_used is None: last_used = datetime.datetime.utcnow() - operation = Operation(path, description, last_used, category) + operation = Operation(path, description, last_used, category, active=active) db.session.add(operation) db.session.flush() operation_id = operation.id @@ -96,22 +96,27 @@ def get_operation_details(self, op_id, user): return op return False - def list_operations(self, user): + def list_operations(self, user, skip_archived=False): """ user: logged in user + skip_archived: filter by active operations """ operations = [] permissions = Permission.query.filter_by(u_id=user.id).all() for permission in permissions: - operation = Operation.query.filter_by(id=permission.op_id).first() - operations.append({ - "op_id": permission.op_id, - "access_level": permission.access_level, - "path": operation.path, - "description": operation.description, - "category": operation.category, - "active": operation.active - }) + if skip_archived: + operation = Operation.query.filter_by(id=permission.op_id, active=skip_archived).first() + else: + operation = Operation.query.filter_by(id=permission.op_id).first() + if operation is not None: + operations.append({ + "op_id": permission.op_id, + "access_level": permission.access_level, + "path": operation.path, + "description": operation.description, + "category": operation.category, + "active": operation.active + }) return operations def is_member(self, u_id, op_id): diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 06853f375..3a36f59cd 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -397,9 +397,11 @@ def create_operation(): content = request.form.get('content', None) description = request.form.get('description', None) category = request.form.get('category', "default") + active = (request.form.get('active', "True") == "True") last_used = datetime.datetime.utcnow() user = g.user - r = str(fm.create_operation(path, description, user, last_used, content=content, category=category)) + r = str(fm.create_operation(path, description, user, last_used, + content=content, category=category, active=active)) if r == "True": token = request.args.get('token', request.form.get('token', False)) json_config = {"token": token} @@ -465,8 +467,9 @@ def authorized_users(): @APP.route('/operations', methods=['GET']) @verify_user def get_operations(): + skip_archived = (request.args.get('skip_archived', request.form.get('skip_archived', "False")) == "True") user = g.user - return json.dumps({"operations": fm.list_operations(user)}) + return json.dumps({"operations": fm.list_operations(user, skip_archived=skip_archived)}) @APP.route('/delete_operation', methods=["POST"]) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index cf1067fe6..234f7a6e3 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -890,8 +890,10 @@ def get_recent_op_id(self): """ get most recent operation's op_id """ + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") data = { - "token": self.token + "token": self.token, + "skip_archived": skip_archived } r = requests.get(self.mscolab_server_url + '/operations', data=data) if r.text != "False": @@ -1537,8 +1539,10 @@ def add_operations_to_ui(self): logging.debug('add_operations_to_ui') r = None if verify_user_token(self.mscolab_server_url, self.token): + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") data = { - "token": self.token + "token": self.token, + "skip_archived": skip_archived } r = requests.get(f'{self.mscolab_server_url}/operations', data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 921c9985a..df0d40669 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -33,6 +33,7 @@ import mslib.msui.msui_mainwindow as msui_mainwindow from mslib.utils.verify_user_token import verify_user_token from mslib.utils.qt import Worker +from mslib.utils.config import config_loader class QMscolabOperationsListWidgetItem(QtWidgets.QListWidgetItem): @@ -549,8 +550,10 @@ def set_flag(self): def get_wps_from_server(self): operations = {} + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") data = { - "token": self.token + "token": self.token, + "skip_archived": skip_archived } r = requests.get(self.mscolab_server_url + "/operations", data=data, timeout=(2, 10)) if r.text != "False": diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 63a84886f..c740c220e 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -137,6 +137,9 @@ class MSUIDefaultConfig: # timeout for MSColab in seconds. First value is for connection, second for reply MSCOLAB_timeout = [2, 10] + # don't query for archived operations + MSCOLAB_skip_archived_operations = False + # list of MSC servers {"http://www.your-mscolab-server.de": "authuser", # "http://www.your-wms-server.de": "authuser"} MSS_auth = {} @@ -242,6 +245,7 @@ class MSUIDefaultConfig: 'num_interpolation_points', 'new_flighttrack_flightlevel', 'MSCOLAB_category', + 'MSCOLAB_skip_archived_operations', 'mscolab_server_url', 'MSCOLAB_auth_user_name', 'wms_cache', diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 37c14cf08..39b10f7b5 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -120,6 +120,31 @@ def test_list_operations(self): 'path': 'second'}] assert self.fm.list_operations(self.user) == expected_result + def test_list_operations_skip_archived(self): + with self.app.test_client(): + self.fm.create_operation("first", "info about first", self.user, active=False) + self.fm.create_operation("second", "info about second", self.user) + expected_result_all = [{'access_level': 'creator', + 'active': False, + 'category': 'default', + 'description': 'info about first', + 'op_id': 1, + 'path': 'first'}, + {'access_level': 'creator', + 'active': True, + 'category': 'default', + 'description': 'info about second', + 'op_id': 2, + 'path': 'second'}] + expected_result_skipped_true = [{'access_level': 'creator', + 'active': True, + 'category': 'default', + 'description': 'info about second', + 'op_id': 2, + 'path': 'second'}] + assert self.fm.list_operations(self.user, skip_archived=False) == expected_result_all + assert self.fm.list_operations(self.user, skip_archived=True) == expected_result_skipped_true + def test_is_creator(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path='third') diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 63ddf4846..1d6bc4882 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -204,6 +204,12 @@ def test_create_operation(self): with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) assert operation is not None + assert operation.active is True + assert token is not None + operation, token = self._create_operation(test_client, + self.userdata, path="archived_operation", active=False) + assert operation is not None + assert operation.active is False assert token is not None def test_get_operation_by_id(self): @@ -227,6 +233,19 @@ def test_get_operations(self): assert data["operations"][0]["path"] == "firstflightpath1" assert data["operations"][1]["path"] == "firstflightpath2" + def test_get_operations_skip_archived(self): + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) + with self.app.test_client() as test_client: + self._create_operation(test_client, self.userdata, path="firstflightpath1") + operation, token = self._create_operation(test_client, self.userdata, path="firstflightpath2", active=False) + response = test_client.get('/operations', data={"token": token, + "skip_archived": "True"}) + assert response.status_code == 200 + data = json.loads(response.data.decode('utf-8')) + assert len(data["operations"]) == 1 + assert data["operations"][0]["path"] == "firstflightpath1" + assert "firstflightpath2" not in data["operations"] + def test_get_all_changes(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: @@ -383,7 +402,7 @@ def test_import_permissions(self): # creator is not listed assert data["success"] is True - def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test"): + def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True): if userdata is None: userdata = self.userdata response = test_client.post('/token', data={"email": userdata[0], "password": userdata[2]}) @@ -391,7 +410,8 @@ def _create_operation(self, test_client, userdata=None, path="firstflight", desc token = data["token"] response = test_client.post('/create_operation', data={"token": token, "path": path, - "description": description}) + "description": description, + "active": str(active)}) assert response.status_code == 200 assert response.data.decode('utf-8') == "True" operation = Operation.query.filter_by(path=path).first() From 38cb1f082c525378b60b807282f8be7a9bcc5b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 6 Sep 2023 09:13:12 +0200 Subject: [PATCH 054/176] Fix messagebox warnings (#1967) * Make unhandled message boxes a failure condition * Add assert for expected message box on empty filename * Fix mocked return value for get_open_filenames * Fix wrong indentation of else block * Set correct mscolab URL on server start * Set MSS_auth on login to avoid "Update Credentials" popups * Expect "No Airspaces data in file" info box * Fix flighttrack attribute errors * Fix mscolab_server_url is None issue * Do not use QErrorMessage for success messages * Lint with a more recent python version --- .github/workflows/python-flake8.yml | 4 +- conftest.py | 7 +- mslib/msui/mscolab.py | 46 ++++-- mslib/msui/socket_control.py | 20 +++ mslib/utils/airdata.py | 4 +- tests/_test_msui/test_mscolab.py | 156 +++++++++++++----- tests/_test_msui/test_mscolab_admin_window.py | 7 + .../test_mscolab_merge_waypoints.py | 1 + tests/_test_msui/test_mscolab_operation.py | 3 + .../test_mscolab_version_history.py | 3 + tests/_test_msui/test_msui.py | 2 +- tests/_test_msui/test_satellite_dockwidget.py | 8 +- tests/_test_utils/test_airdata.py | 4 +- tests/utils.py | 4 + 14 files changed, 200 insertions(+), 69 deletions(-) diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index 397a26a17..0123278fb 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -23,10 +23,10 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v3 - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v3 with: - python-version: 3.8 + python-version: "3.10" - name: Lint with flake8 run: | python -m pip install --upgrade pip diff --git a/conftest.py b/conftest.py index 602975931..1d7f9fd69 100644 --- a/conftest.py +++ b/conftest.py @@ -217,9 +217,8 @@ def _load_module(module_name, path): @pytest.fixture(autouse=True) -def close_open_windows(): - """ - Closes all windows after every test +def fail_if_open_message_boxes_left(): + """Fail a test if there are any Qt message boxes left open at the end """ # Mock every MessageBox widget in the test suite to avoid unwanted freezes on unhandled error popups etc. with mock.patch("PyQt5.QtWidgets.QMessageBox.question") as q, \ @@ -230,7 +229,7 @@ def close_open_windows(): if any(box.call_count > 0 for box in [q, i, c, w]): summary = "\n".join([f"PyQt5.QtWidgets.QMessageBox.{box()._extract_mock_name()}: {box.mock_calls[:-1]}" for box in [q, i, c, w] if box.call_count > 0]) - warnings.warn(f"An unhandled message box popped up during your test!\n{summary}") + pytest.fail(f"An unhandled message box popped up during your test!\n{summary}") # Try to close all remaining widgets after each test diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 234f7a6e3..d808ef7ae 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -874,8 +874,11 @@ def add_operation(self): self.logout() else: if r.text == "True": - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage('Your operation was created successfully') + QtWidgets.QMessageBox.information( + self.ui, + "Creation successful", + "Your operation was created successfully.", + ) op_id = self.get_recent_op_id() self.new_op_id = op_id self.conn.handle_new_operation(op_id) @@ -1134,8 +1137,11 @@ def change_category_handler(self): if r.text == "True": self.active_operation_category = entered_operation_category self.reload_operation_list() - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage("Description is updated successfully.") + QtWidgets.QMessageBox.information( + self.ui, + "Update successful", + "Category is updated successfully.", + ) else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1170,8 +1176,11 @@ def change_description_handler(self): self.set_operation_desc_label(entered_operation_desc) self.reload_operation_list() - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage("Description is updated successfully.") + QtWidgets.QMessageBox.information( + self.ui, + "Update successful", + "Description is updated successfully.", + ) else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1211,8 +1220,11 @@ def rename_operation_handler(self): # Update other user's operation list self.conn.signal_operation_list_updated.connect(self.reload_operation_list) - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage("Operation is renamed successfully.") + QtWidgets.QMessageBox.information( + self.ui, + "Rename successful", + "Operation is renamed successfully.", + ) else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1933,6 +1945,12 @@ def logout(self): return self.ui.local_active = True self.ui.menu_handler() + + # disconnect socket + if self.conn is not None: + self.conn.disconnect() + self.conn = None + # close all hanging window self.close_external_windows() self.hide_operation_options() @@ -1965,10 +1983,6 @@ def logout(self): self.ui.activeOperationDesc.setText(self.ui.tr("Select Operation to View Description.")) # set usernameLabel back to default self.ui.usernameLabel.setText("User") - # disconnect socket - if self.conn is not None: - self.conn.disconnect() - self.conn = None # Turn off work locally toggle self.ui.workLocallyCheckbox.blockSignals(True) self.ui.workLocallyCheckbox.setChecked(False) @@ -1991,11 +2005,9 @@ def logout(self): self.operation_archive_browser.hide() - # Don't try to activate local flighttrack while testing - if "pytest" not in sys.modules: - # activate first local flighttrack after logging out - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() + # activate first local flighttrack after logging out + self.ui.listFlightTracks.setCurrentRow(0) + self.ui.activate_selected_flight_track() class MscolabMergeWaypointsDialog(QtWidgets.QDialog, merge_wp_ui.Ui_MergeWaypointsDialog): diff --git a/mslib/msui/socket_control.py b/mslib/msui/socket_control.py index 7302b7a37..840081905 100644 --- a/mslib/msui/socket_control.py +++ b/mslib/msui/socket_control.py @@ -195,4 +195,24 @@ def save_file(self, token, op_id, content, comment=None): self.signal_reload.emit(op_id) def disconnect(self): + # Get all pyqtSignals defined in this class and disconnect them from all slots + allSignals = { + attr + for attr in dir(self.__class__) + if isinstance(getattr(self.__class__, attr), QtCore.pyqtSignal) + } + inheritedSignals = { + attr + for base_class in self.__class__.__bases__ + for attr in dir(base_class) + if isinstance(getattr(base_class, attr), QtCore.pyqtSignal) + } + signals = {getattr(self, signal) for signal in allSignals - inheritedSignals} + for signal in signals: + try: + signal.disconnect() + except TypeError: + # The disconnect call can fail if there are no connected slots, so catch that error here + pass + self.sio.disconnect() diff --git a/mslib/utils/airdata.py b/mslib/utils/airdata.py index b7ae556d2..e105147b6 100644 --- a/mslib/utils/airdata.py +++ b/mslib/utils/airdata.py @@ -252,7 +252,7 @@ def get_airspaces(countries=None): for data in airspace_data["polygon"].split(",")] _airspaces.append(airspace_data) _airspaces_mtime[file] = os.path.getmtime(os.path.join(OSDIR, "downloads", "aip", file)) - else: - QtWidgets.QMessageBox.information(None, "No Airspaces data in file:", f"{file}") + else: + QtWidgets.QMessageBox.information(None, "No Airspaces data in file:", f"{file}") return _airspaces diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 7308986c2..14a8c254c 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -38,7 +38,7 @@ from mslib.mscolab.models import Permission, User from mslib.msui.flighttrack import WaypointsTableModel from PyQt5 import QtCore, QtTest, QtWidgets -from mslib.utils.config import read_config_file, config_loader +from mslib.utils.config import read_config_file, config_loader, modify_config_file from tests.utils import mscolab_start_server, create_msui_settings_file, ExceptionMock from mslib.msui import msui from mslib.msui import mscolab @@ -64,6 +64,7 @@ def setup_method(self): QtTest.QTest.qWait(500) self.application = QtWidgets.QApplication(sys.argv) self.main_window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.main_window.create_new_flight_track() self.main_window.show() self.window = mscolab.MSColab_ConnectDialog(parent=self.main_window, mscolab=self.main_window.mscolab) self.window.urlCb.setEditText(self.url) @@ -122,6 +123,7 @@ def test_disconnect(self): def test_login(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) QtWidgets.QApplication.processEvents() # show logged in widgets @@ -132,9 +134,31 @@ def test_login(self): # test operation listing visibility assert self.main_window.listOperationsMSC.model().rowCount() == 1 + @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) + def test_login_with_different_account_shows_update_credentials_popup(self, mockbox): + self._connect_to_mscolab() + connect_window = self.main_window.mscolab.connect_window + self._login(self.userdata[0], self.userdata[2]) + QtWidgets.QApplication.processEvents() + mockbox.assert_called_once_with( + connect_window, + "Update Credentials", + "You are using new credentials. Should your settings file be updated with the new credentials?", + mock.ANY, + mock.ANY, + ) + # show logged in widgets + assert self.main_window.usernameLabel.text() == self.userdata[1] + assert self.main_window.connectBtn.isVisible() is False + assert self.main_window.mscolab.connect_window is None + assert self.main_window.local_active is True + # test operation listing visibility + assert self.main_window.listOperationsMSC.model().rowCount() == 1 + def test_logout_action_trigger(self): # Login self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) QtWidgets.QApplication.processEvents() assert self.main_window.usernameLabel.text() == self.userdata[1] @@ -149,6 +173,7 @@ def test_logout_action_trigger(self): def test_logout(self): # Login self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) QtWidgets.QApplication.processEvents() assert self.main_window.usernameLabel.text() == self.userdata[1] @@ -163,6 +188,7 @@ def test_logout(self): @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_add_user(self, mockmessage): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" assert mslib.utils.auth.get_password_from_keyring("MSCOLAB", @@ -197,6 +223,7 @@ def test_add_users_with_updating_credentials_in_config_file(self, mockmessage): assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" self._connect_to_mscolab() assert self.window.mscolab_server_url is not None + modify_config_file({"MSS_auth": {self.url: "anand@something.org"}}) self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings assert config_loader(dataset="MSS_auth").get(self.url) == "anand@something.org" @@ -277,6 +304,7 @@ def setup_method(self): QtTest.QTest.qWait(500) self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() def teardown_method(self): @@ -296,6 +324,7 @@ def teardown_method(self): def test_activate_operation(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # activate a operation self._activate_operation_at_index(0) @@ -305,6 +334,7 @@ def test_activate_operation(self): @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_view_open(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # test after activating operation self._activate_operation_at_index(0) @@ -338,6 +368,7 @@ def test_view_open(self, mockbox): "Flight track (*.ftml)")) def test_handle_export(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.actionExportFlightTrackFTML.trigger() @@ -362,6 +393,7 @@ def test_import_file(self, mockbox, ext): with mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(file_path, None)): with mock.patch("PyQt5.QtWidgets.QFileDialog.getOpenFileName", return_value=(file_path, None)): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) exported_wp = WaypointsTableModel(waypoints=self.window.mscolab.waypoints_model.waypoints) @@ -393,6 +425,7 @@ def test_import_file(self, mockbox, ext): @pytest.mark.skip("Runs in a timeout locally > 60s") def test_work_locally_toggle(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.workLocallyCheckbox.setChecked(True) @@ -413,6 +446,7 @@ def test_work_locally_toggle(self): @mock.patch("mslib.msui.mscolab.get_open_filename", return_value=os.path.join(sample_path, u"example.ftml")) def test_browse_add_operation(self, mockopen, mockmessage): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") assert self.window.listOperationsMSC.model().rowCount() == 0 self.window.actionAddOperation.trigger() @@ -436,59 +470,75 @@ def test_browse_add_operation(self, mockopen, mockmessage): assert item.operation_path == "example" assert item.access_level == "creator" - @mock.patch("PyQt5.QtWidgets.QErrorMessage") - def test_add_operation(self, mockbox): + def test_add_operation(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") assert self.window.usernameLabel.text() == 'something' assert self.window.connectBtn.isVisible() is False - self._create_operation("Alpha", "Description Alpha") - assert mockbox.return_value.showMessage.call_count == 1 - with mock.patch("PyQt5.QtWidgets.QLineEdit.text", return_value=None): + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation("Alpha", "Description Alpha") + m.assert_called_once_with( + self.window, + "Creation successful", + "Your operation was created successfully.", + ) + with (mock.patch("PyQt5.QtWidgets.QLineEdit.text", return_value=None), + mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m): self._create_operation("Alpha2", "Description Alpha") - with mock.patch("PyQt5.QtWidgets.QTextEdit.toPlainText", return_value=None): + m.assert_called_once_with("Path can't be empty") + with (mock.patch("PyQt5.QtWidgets.QTextEdit.toPlainText", return_value=None), + mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m): self._create_operation("Alpha3", "Description Alpha") - self._create_operation("/", "Description Alpha") - assert mockbox.return_value.showMessage.call_count == 4 + m.assert_called_once_with("Description can't be empty") + with mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m: + self._create_operation("/", "Description Alpha") + m.assert_called_once_with("Path can't contain spaces or special characters") assert self.window.listOperationsMSC.model().rowCount() == 1 - self._create_operation("reproduce-test", "Description Test") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation("reproduce-test", "Description Test") + m.assert_called_once() assert self.window.listOperationsMSC.model().rowCount() == 2 self._activate_operation_at_index(0) assert self.window.mscolab.active_operation_name == "Alpha" self._activate_operation_at_index(1) assert self.window.mscolab.active_operation_name == "reproduce-test" - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)) - def test_handle_delete_operation(self, mocktext, mockbox): + def test_handle_delete_operation(self, mocktext): # pytest.skip('needs a review for the delete button pressed. Seems to delete a None operation') self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) self._create_user("berta", "berta@something.org", "something") assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 operation_name = "flight7" - self._create_operation(operation_name, "Description flight7") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation(operation_name, "Description flight7") + m.assert_called_once() # check for operation dir is created on server assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) self._activate_operation_at_index(0) op_id = self.window.mscolab.get_recent_op_id() assert op_id is not None assert self.window.listOperationsMSC.model().rowCount() == 1 - self.window.actionDeleteOperation.trigger() - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionDeleteOperation.trigger() + QtWidgets.QApplication.processEvents() + m.assert_called_once_with(self.window, "Success", 'Operation "flight7" was deleted!') op_id = self.window.mscolab.get_recent_op_id() assert op_id is None QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) # check operation dir name removed assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) is False - assert mockbox.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_handle_leave_operation(self, mockmessage): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata3[0]}}) self._login(self.userdata3[0], self.userdata3[2]) QtWidgets.QApplication.processEvents() assert self.window.usernameLabel.text() == self.userdata3[1] @@ -514,55 +564,68 @@ def test_handle_leave_operation(self, mockmessage): assert self.window.listViews.count() == 0 assert self.window.listOperationsMSC.model().rowCount() == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_name", True)) - def test_handle_rename_operation(self, mockbox, mockpatch): + def test_handle_rename_operation(self, mocktext): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation("flight1234", "Description flight1234") + m.assert_called_once() assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None - self.window.actionRenameOperation.trigger() - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionRenameOperation.trigger() + QtWidgets.QApplication.processEvents() + m.assert_called_once_with(self.window, "Rename successful", "Operation is renamed successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_name == "new_name" - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") - @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_desciption", True)) - def test_update_description(self, mockbox, mockpatch): + @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_description", True)) + def test_update_description(self, mocktext): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation("flight1234", "Description flight1234") + m.assert_called_once() assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None - self.window.actionChangeDescription.trigger() - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionChangeDescription.trigger() + QtWidgets.QApplication.processEvents() + m.assert_called_once_with(self.window, "Update successful", "Description is updated successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None - assert self.window.mscolab.active_operation_description == "new_desciption" + assert self.window.mscolab.active_operation_description == "new_description" - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_category", True)) - def test_update_category(self, mockbox, mockpatch): + def test_update_category(self, mocktext): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation("flight1234", "Description flight1234") + m.assert_called_once() assert self.window.listOperationsMSC.model().rowCount() == 1 assert self.window.mscolab.active_operation_category == "example" self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None - self.window.actionChangeCategory.trigger() - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionChangeCategory.trigger() + QtWidgets.QApplication.processEvents() + m.assert_called_once_with(self.window, "Update successful", "Category is updated successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_category == "new_category" @mock.patch("PyQt5.QtWidgets.QMessageBox.information") - def test_any_special_category(self, mockpatch): + def test_any_special_category(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") self._create_operation("flight1234", "Description flight1234") QtTest.QTest.qWait(0) @@ -580,8 +643,10 @@ def test_any_special_category(self, mockpatch): range(self.window.mscolab.ui.listOperationsMSC.count())] assert ["flight5678"] == operation_pathes - def test_get_recent_op_id(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_get_recent_op_id(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "anton@something.org"}}) self._create_user("anton", "anton@something.org", "something") QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'anton' @@ -594,8 +659,10 @@ def test_get_recent_op_id(self): # ToDo fix number after cleanup initial data assert self.window.mscolab.get_recent_op_id() == current_op_id + 2 - def test_get_recent_operation(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_get_recent_operation(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) self._create_user("berta", "berta@something.org", "something") QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'berta' @@ -607,8 +674,10 @@ def test_get_recent_operation(self): assert operation["path"] == "flight1234" assert operation["access_level"] == "creator" - def test_open_chat_window(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_open_chat_window(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") self._create_operation("flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 @@ -619,8 +688,10 @@ def test_open_chat_window(self): QtTest.QTest.qWait(0) assert self.window.mscolab.chat_window is not None - def test_close_chat_window(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_close_chat_window(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") self._create_operation("flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 @@ -631,8 +702,10 @@ def test_close_chat_window(self): self.window.mscolab.close_chat_window() assert self.window.mscolab.chat_window is None - def test_delete_operation_from_list(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_delete_operation_from_list(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "other@something.org"}}) self._create_user("other", "other@something.org", "something") assert self.window.usernameLabel.text() == 'other' assert self.window.connectBtn.isVisible() is False @@ -646,6 +719,7 @@ def test_delete_operation_from_list(self): @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_user_delete(self, mockmessage): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") u_id = self.window.mscolab.user['id'] self.window.mscolab.open_profile_window() @@ -692,6 +766,7 @@ def test_create_dir_exceptions(self, mockexit, mockbox): @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_profile_dialog(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") self.window.mscolab.profile_action.trigger() QtWidgets.QApplication.processEvents() @@ -739,8 +814,7 @@ def _reset_config_file(self): config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") read_config_file(path=config_file) - @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") - def _create_operation(self, path, description, mockbox, category="example"): + def _create_operation(self, path, description, category="example"): self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.path.setText(str(path)) diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index 4427d3083..348c17814 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -25,6 +25,7 @@ limitations under the License. """ import os +import mock import pytest import sys @@ -35,6 +36,7 @@ from mslib.msui import msui from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.utils.config import modify_config_file PORTS = list(range(24000, 24500)) @@ -68,9 +70,11 @@ def setup_method(self): QtTest.QTest.qWait(500) self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() # connect and login to mscolab self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) @@ -86,6 +90,9 @@ def teardown_method(self): self.window.mscolab.admin_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window.close() + QtWidgets.QApplication.processEvents() self.application.quit() QtWidgets.QApplication.processEvents() self.process.terminate() diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 6ac974510..e8afee9fc 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -53,6 +53,7 @@ def setup_method(self): QtTest.QTest.qWait(500) self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.emailid = 'merge@alpha.org' def teardown_method(self): diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 5ca3a48dd..ec2b769a9 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -36,6 +36,7 @@ from mslib.msui import msui from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.utils.config import modify_config_file PORTS = list(range(22000, 22500)) @@ -63,9 +64,11 @@ def setup_method(self): QtTest.QTest.qWait(500) self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() # connect and login to mscolab self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 01e48739f..a84c61aa7 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -36,6 +36,7 @@ from mslib.msui import msui from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.utils.config import modify_config_file PORTS = list(range(20000, 20500)) @@ -56,9 +57,11 @@ def setup_method(self): QtTest.QTest.qWait(500) self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() # connect and login to mscolab self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 1aee9207d..cddb9c666 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -263,7 +263,7 @@ def test_plugin_saveas(self, save_file): def test_plugin_import(self, open_file): with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") - with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=open_file) as mockopen: + with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[open_file[0]]) as mockopen: assert self.window.listFlightTracks.count() == 1 assert mockopen.call_count == 0 self.window.last_save_directory = ROOT_DIR diff --git a/tests/_test_msui/test_satellite_dockwidget.py b/tests/_test_msui/test_satellite_dockwidget.py index e558fcd1c..8ebbf7c84 100644 --- a/tests/_test_msui/test_satellite_dockwidget.py +++ b/tests/_test_msui/test_satellite_dockwidget.py @@ -61,7 +61,13 @@ def test_load(self): assert self.view.plot_satellite_overpass.call_count == 2 self.view.reset_mock() - def test_load_no_file(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") + def test_load_no_file(self, mockbox): QtTest.QTest.mouseClick(self.window.btLoadFile, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() assert self.window.cbSatelliteOverpasses.count() == 0 + mockbox.assert_called_once_with( + self.window, + "Satellite Overpass Tool", + "ERROR:\n\npath '' should be a file", + ) diff --git a/tests/_test_utils/test_airdata.py b/tests/_test_utils/test_airdata.py index 10299f380..1545b15ab 100644 --- a/tests/_test_utils/test_airdata.py +++ b/tests/_test_utils/test_airdata.py @@ -204,10 +204,12 @@ def test_get_airspaces(mockbox): @mock.patch("mslib.utils.airdata.download_progress", _download_incomplete_airspace) +@mock.patch("PyQt5.QtWidgets.QMessageBox.information") @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) -def test_get_airspaces_missing_data(mockbox): +def test_get_airspaces_missing_data(mockbox, infobox): """ We use a test file without the need for downloading to check handling """ # update_airspace would only update after 30 days _cleanup_test_files() airspaces = get_airspaces(countries=["bg"]) assert airspaces == [] + infobox.assert_called_once_with(None, 'No Airspaces data in file:', 'bg_asp.xml') diff --git a/tests/utils.py b/tests/utils.py index 895dca650..cbd107287 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -41,6 +41,7 @@ from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.server import APP, initialize_managers, start_server from mslib.mscolab.mscolab import handle_db_init +from mslib.utils.config import modify_config_file def callback_ok_image(status, response_headers): @@ -198,6 +199,9 @@ def mscolab_start_server(all_ports, mscolab_settings=mscolab_settings, timeout=1 url = f"http://localhost:{port}" + # Update mscolab URL to avoid "Update Server List" message boxes + modify_config_file({"default_MSCOLAB": [url]}) + _app = APP _app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI _app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR From c9f2ffc592ec01df32164eff70bd4d52b2d51063 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 6 Sep 2023 21:22:20 +0200 Subject: [PATCH 055/176] enable boa build and doc (#2002) --- docs/development.rst | 20 ++++++++++++++++---- localbuild/meta.yaml | 1 + requirements.d/development.txt | 1 + 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 329fce64e..dcee78896 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -449,15 +449,27 @@ As developer you should copy this directory and adjust the source path, build nu using a local meta.yaml recipe:: $ cd yourlocalbuild - $ conda build . - $ conda create -n mssbuildtest mamba - $ conda activate mssbuildtest - $ mamba install --use-local mss + $ mamba build . + $ mamba create -n mssbuildtest + $ mamba activate mssbuildtest + $ mamba install -c local mss Take care on removing alpha builds, or increase the build number for a new version. +Alternative local build by boa +------------------------------ + +`boa `_ is a new faster option to build conda packages. +We need first to convert the existing description to a recipe.yaml:: + + $ cd yourlocalbuild + $ boa convert meta.yaml > recipe.yaml + $ boa build . + $ mamba install -c local mss + + Creating a new release ---------------------- diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 052fdf782..ee9d1b05b 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -30,6 +30,7 @@ requirements: - setuptools - pip - menuinst # [win] + - future run: - python - defusedxml diff --git a/requirements.d/development.txt b/requirements.d/development.txt index 4a5553df0..366ddaed2 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -23,3 +23,4 @@ pytest-reverse eventlet>0.30.2 dnspython>=2.0.0, <2.3.0 gsl==2.7.0 +boa From 1db1f27c55a449d7791e2a078d7e2be66b034994 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 7 Sep 2023 10:50:41 -0800 Subject: [PATCH 056/176] Fix botched merge from 14 Aug 23 in mscolab.py (#2012) Fixes #1969 --- mslib/msui/mscolab.py | 125 +++++++++++++++++++++++++----------------- 1 file changed, 74 insertions(+), 51 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 9ed15b6fd..1de310653 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -314,7 +314,7 @@ def login_handler(self): r = s.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.status_code == 401: raise requests.exceptions.ConnectionError - except requests.exceptions.ConnectionError as ex: + except requests.exceptions.RequestException as ex: logging.error("unexpected error: %s %s %s", type(ex), url, ex) self.set_status( "Error", @@ -589,18 +589,18 @@ def after_login(self, emailid, url, r): # create socket connection here try: self.conn = sc.ConnectionManager(self.token, user=self.user, mscolab_server_url=self.mscolab_server_url) - except Exception as ex: - logging.debug("Couldn't create a socket connection: %s", ex) - show_popup(self.ui, "Error", "Couldn't create a socket connection. Maybe the MSColab server is too old. " - "New Login required!") - self.logout() - else: # Update Last Used data = { "token": self.token } r = requests.post(f"{self.mscolab_server_url}/update_last_used", data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except Exception as ex: + logging.debug("Couldn't create a socket connection: %s", ex) + show_popup(self.ui, "Error", "Couldn't create a socket connection. Maybe the MSColab server is too old. " + "New Login required!") + self.logout() + else: self.conn.signal_operation_list_updated.connect(self.reload_operation_list) self.conn.signal_reload.connect(self.reload_window) self.conn.signal_new_permission.connect(self.render_new_permission) @@ -1087,7 +1087,12 @@ def handle_leave_operation(self): "selected_userids": json.dumps([self.user["id"]]) } url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") - res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + try: + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") + self.logout() if res.text != "False": res = res.json() if res["success"]: @@ -1134,18 +1139,24 @@ def change_category_handler(self): "value": entered_operation_category } url = urljoin(self.mscolab_server_url, 'update_operation') - r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text == "True": - self.active_operation_category = entered_operation_category - self.reload_operation_list() - QtWidgets.QMessageBox.information( - self.ui, - "Update successful", - "Category is updated successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + try: + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") self.logout() + else: + if r.text == "True": + self.active_operation_category = entered_operation_category + self.reload_operation_list() + QtWidgets.QMessageBox.information( + self.ui, + "Update successful", + "Category is updated successfully.", + ) + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1171,20 +1182,26 @@ def change_description_handler(self): } url = urljoin(self.mscolab_server_url, 'update_operation') - r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text == "True": - # Update active operation description label - self.set_operation_desc_label(entered_operation_desc) - - self.reload_operation_list() - QtWidgets.QMessageBox.information( - self.ui, - "Update successful", - "Description is updated successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + try: + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") self.logout() + else: + if r.text == "True": + # Update active operation description label + self.set_operation_desc_label(entered_operation_desc) + + self.reload_operation_list() + QtWidgets.QMessageBox.information( + self.ui, + "Update successful", + "Description is updated successfully.", + ) + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1209,26 +1226,32 @@ def rename_operation_handler(self): "value": entered_operation_name } url = urljoin(self.mscolab_server_url, 'update_operation') - r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text == "True": - # Update active operation name - self.active_operation_name = entered_operation_name - - # Update active operation description - self.set_operation_desc_label(self.active_operation_description) - self.reload_operation_list() - self.reload_windows_slot() - # Update other user's operation list - self.conn.signal_operation_list_updated.connect(self.reload_operation_list) - - QtWidgets.QMessageBox.information( - self.ui, - "Rename successful", - "Operation is renamed successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + try: + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") self.logout() + else: + if r.text == "True": + # Update active operation name + self.active_operation_name = entered_operation_name + + # Update active operation description + self.set_operation_desc_label(self.active_operation_description) + self.reload_operation_list() + self.reload_windows_slot() + # Update other user's operation list + self.conn.signal_operation_list_updated.connect(self.reload_operation_list) + + QtWidgets.QMessageBox.information( + self.ui, + "Rename successful", + "Operation is renamed successfully.", + ) + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() From 6d8754c1232959cf4eb74fb2451fdcc3bd9c3007 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 7 Sep 2023 11:10:22 -0800 Subject: [PATCH 057/176] Properly fix legend issue in develop. (#2009) * Properly fix legend issue in develop. Fixes #1928 * Really fix issue * made attributes consistent --- mslib/msui/mscolab.py | 8 ++++---- mslib/msui/wms_control.py | 33 +++++++++++++++------------------ 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 1de310653..b187c06a3 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -1435,7 +1435,7 @@ def render_new_permission(self, op_id, u_id): operation_desc = f'{operation["path"]} - {operation["access_level"]}' widgetItem = QtWidgets.QListWidgetItem(operation_desc, parent=self.ui.listOperationsMSC) widgetItem.op_id = operation["op_id"] - widgetItem.catgegory = operation["category"] + widgetItem.operation_category = operation["category"] widgetItem.operation_path = operation["path"] widgetItem.access_level = operation["access_level"] widgetItem.active_operation_description = operation["description"] @@ -1598,11 +1598,11 @@ def add_operations_to_ui(self): for operation in operations: operation_desc = f'{operation["path"]} - {operation["access_level"]}' widgetItem = QtWidgets.QListWidgetItem(operation_desc) - widgetItem.active_operation_description = operation["description"] widgetItem.op_id = operation["op_id"] - widgetItem.access_level = operation["access_level"] - widgetItem.operation_path = operation["path"] widgetItem.operation_category = operation["category"] + widgetItem.operation_path = operation["path"] + widgetItem.access_level = operation["access_level"] + widgetItem.active_operation_description = operation["description"] try: # compatibility to 7.x # a newer server can distinguish older operations and move those into inactive state diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 577059f41..e53270926 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -1478,24 +1478,21 @@ def append_multiple_images(self, imgs): """ images = [x for x in imgs if x] if images: - if hasattr(self.view, 'fig') is False: - QtWidgets.QMessageBox.warning(self, self.tr("Web Map Service"), "We have only fig implemented") - else: - # Add border around seperate legends - if len(images) > 1: - images = [ImageOps.expand(x, border=1, fill="black") for x in images] - max_height = int((self.view.plotter.get_size_inches() * self.view.plotter.get_dpi())[1] * 0.99) - width = max([image.width for image in images]) - height = sum([image.height for image in images]) - result = Image.new("RGBA", (width, height)) - current_height = 0 - for i, image in enumerate(images): - result.paste(image, (0, current_height - i)) - current_height += image.height - - if max_height < result.height: - result.thumbnail((result.width, max_height), Image.ANTIALIAS) - return result + # Add border around seperate legends + if len(images) > 1: + images = [ImageOps.expand(x, border=1, fill="black") for x in images] + max_height = int((self.view.plotter.fig.get_size_inches() * self.view.plotter.fig.get_dpi())[1] * 0.99) + width = max([image.width for image in images]) + height = sum([image.height for image in images]) + result = Image.new("RGBA", (width, height)) + current_height = 0 + for i, image in enumerate(images): + result.paste(image, (0, current_height - i)) + current_height += image.height + + if max_height < result.height: + result.thumbnail((result.width, max_height), Image.ANTIALIAS) + return result class VSecWMSControlWidget(WMSControlWidget): From c0e8b8b7d12a02242669634acb7ca999df48deb2 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 7 Sep 2023 11:54:53 -0800 Subject: [PATCH 058/176] Added Return as shortcut to connect/login buttons. (#1939) Fix #1937 added a disconnect button login btn needs autodefault and focus --- mslib/msui/mscolab.py | 21 ++++++++++----------- mslib/msui/qt5/ui_mscolab_connect_dialog.py | 8 +++++++- mslib/msui/ui/ui_mscolab_connect_dialog.ui | 17 +++++++++++++++-- tests/_test_msui/test_mscolab.py | 2 +- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index b187c06a3..839072acb 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -151,6 +151,8 @@ def __init__(self, parent=None, mscolab=None): # connect login, adduser, connect buttons self.connectBtn.clicked.connect(self.connect_handler) + self.disconnectBtn.clicked.connect(self.disconnect_handler) + self.disconnectBtn.hide() self.loginBtn.clicked.connect(self.login_handler) self.addUserBtn.clicked.connect(lambda: self.stackedWidget.setCurrentWidget(self.newuserPage)) @@ -183,9 +185,9 @@ def page_switched(self, index): self.newConfirmPasswordLe.setText("") if index == 1: - self.connectBtn.setEnabled(False) + self.connectBtn.hide() else: - self.connectBtn.setEnabled(True) + self.connectBtn.show() def set_status(self, _type="Error", msg=""): if _type == "Error": @@ -210,6 +212,8 @@ def add_mscolab_urls(self): def enable_login_btn(self): self.loginBtn.setEnabled(self.loginEmailLe.text() != "" and self.loginPasswordLe.text() != "") + self.loginBtn.setAutoDefault(True) + self.loginBtn.setFocus() def connect_handler(self): try: @@ -255,12 +259,8 @@ def connect_handler(self): self.enable_login_btn() # Change connect button text and connect disconnect handler - self.connectBtn.setText('Disconnect') - try: - self.connectBtn.clicked.disconnect(self.connect_handler) - except TypeError: - pass - self.connectBtn.clicked.connect(self.disconnect_handler) + self.connectBtn.hide() + self.disconnectBtn.show() else: logging.error("Error %s", r) self.set_status("Error", "Some unexpected error occurred. Please try again.") @@ -295,9 +295,8 @@ def disconnect_handler(self): self.mscolab_server_url = None self.auth = None - self.connectBtn.setText('Connect') - self.connectBtn.clicked.disconnect(self.disconnect_handler) - self.connectBtn.clicked.connect(self.connect_handler) + self.connectBtn.show() + self.disconnectBtn.hide() self.set_status("Info", 'Disconnected from server.') def login_handler(self): diff --git a/mslib/msui/qt5/ui_mscolab_connect_dialog.py b/mslib/msui/qt5/ui_mscolab_connect_dialog.py index 2f0061373..419c2513f 100644 --- a/mslib/msui/qt5/ui_mscolab_connect_dialog.py +++ b/mslib/msui/qt5/ui_mscolab_connect_dialog.py @@ -14,7 +14,7 @@ class Ui_MSColabConnectDialog(object): def setupUi(self, MSColabConnectDialog): MSColabConnectDialog.setObjectName("MSColabConnectDialog") - MSColabConnectDialog.resize(478, 255) + MSColabConnectDialog.resize(478, 271) self.verticalLayout = QtWidgets.QVBoxLayout(MSColabConnectDialog) self.verticalLayout.setContentsMargins(12, 10, 10, 10) self.verticalLayout.setSpacing(5) @@ -32,6 +32,9 @@ def setupUi(self, MSColabConnectDialog): self.connectBtn.setAutoDefault(False) self.connectBtn.setObjectName("connectBtn") self.horizontalLayout_2.addWidget(self.connectBtn) + self.disconnectBtn = QtWidgets.QPushButton(MSColabConnectDialog) + self.disconnectBtn.setObjectName("disconnectBtn") + self.horizontalLayout_2.addWidget(self.disconnectBtn) self.horizontalLayout_2.setStretch(1, 1) self.verticalLayout.addLayout(self.horizontalLayout_2) self.line = QtWidgets.QFrame(MSColabConnectDialog) @@ -181,10 +184,13 @@ def retranslateUi(self, MSColabConnectDialog): self.urlCb.setToolTip(_translate("MSColabConnectDialog", "Enter Mscolab Server URL")) self.connectBtn.setToolTip(_translate("MSColabConnectDialog", "Connect to entered URL")) self.connectBtn.setText(_translate("MSColabConnectDialog", "Connect")) + self.connectBtn.setShortcut(_translate("MSColabConnectDialog", "Return")) + self.disconnectBtn.setText(_translate("MSColabConnectDialog", "Disconnect")) self.addUserBtn.setToolTip(_translate("MSColabConnectDialog", "Add new user to the server")) self.addUserBtn.setText(_translate("MSColabConnectDialog", "Add user")) self.loginBtn.setToolTip(_translate("MSColabConnectDialog", "Login using entered credentials")) self.loginBtn.setText(_translate("MSColabConnectDialog", "Login")) + self.loginBtn.setShortcut(_translate("MSColabConnectDialog", "Return")) self.loginPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Password")) self.loginEmailLe.setPlaceholderText(_translate("MSColabConnectDialog", "Email ID")) self.clickNewUserLabel.setText(_translate("MSColabConnectDialog", "Click here if new user")) diff --git a/mslib/msui/ui/ui_mscolab_connect_dialog.ui b/mslib/msui/ui/ui_mscolab_connect_dialog.ui index 85d683571..15cf20a19 100644 --- a/mslib/msui/ui/ui_mscolab_connect_dialog.ui +++ b/mslib/msui/ui/ui_mscolab_connect_dialog.ui @@ -7,7 +7,7 @@ 0 0 478 - 255 + 271 @@ -30,7 +30,7 @@ 10 - + @@ -56,11 +56,21 @@ Connect + + Return + false + + + + Disconnect + + + @@ -110,6 +120,9 @@ Login + + Return + false diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 14a8c254c..9e4c02d96 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -116,7 +116,7 @@ def test_connect_success(self, mockbox, mockset): def test_disconnect(self): self._connect_to_mscolab() assert self.window.mscolab_server_url is not None - QtTest.QTest.mouseClick(self.window.connectBtn, QtCore.Qt.LeftButton) + QtTest.QTest.mouseClick(self.window.disconnectBtn, QtCore.Qt.LeftButton) assert self.window.mscolab_server_url is None # set ui_name_winodw default assert self.main_window.usernameLabel.text() == 'User' From 891957529cbcee78fb873f08a19f6c53ad205593 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 8 Sep 2023 19:55:59 +0200 Subject: [PATCH 059/176] improved io import tests (#2014) * improved io import tests * flake8 * flitestar test added --- pytest.ini | 2 +- tests/_test_msui/test_mscolab.py | 62 ++++++++++++-------------------- tests/_test_msui/test_msui.py | 25 ++++++------- 3 files changed, 37 insertions(+), 52 deletions(-) diff --git a/pytest.ini b/pytest.ini index 3f0e6d3f9..33d560e1e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,7 +6,7 @@ log_file = pytest.log log_file_level = DEBUG log_file_format = %(asctime)s %(levelname)s %(message)s log_file_date_format = %Y-%m-%d %H:%M:%S -timeout = 60 +timeout = 30 filterwarnings = # These namespaces are declared in a way not conformant with PEP420. Not much we can do about that here, we should keep an eye on when this is fixed in our dependencies though. ignore:Deprecated call to `pkg_resources.declare_namespace\('(xstatic|xstatic\.pkg|mpl_toolkits|mpl_toolkits\.basemap_data|sphinxcontrib|zope|fs|fs\.opener)'\)`\.:DeprecationWarning diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 9e4c02d96..304a506d2 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -273,8 +273,8 @@ class Test_Mscolab(object): sample_path = os.path.join(os.path.dirname(__file__), "..", "data") # import/export plugins import_plugins = { - "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"], - "FliteStar": ["fls", "mslib.plugins.io.flitestar", "load_from_flitestar"], + "TXT": ["txt", "mslib.plugins.io.text", "load_from_txt"], + "FliteStar": ["txt", "mslib.plugins.io.flitestar", "load_from_flitestar"], } export_plugins = { "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"], @@ -380,47 +380,31 @@ def test_handle_export(self, mockbox): for i in range(wp_count): assert exported_waypoints.waypoint_data(i).lat == self.window.mscolab.waypoints_model.waypoint_data(i).lat - @pytest.mark.skip("fails on github with WebSocket transport is not available") - @pytest.mark.parametrize("ext", [".ftml", ".csv", ".txt"]) + @pytest.mark.parametrize("name", [("example.ftml", "actionImportFlightTrackFTML", 5), + ("example.csv", "actionImportFlightTrackCSV", 5), + ("example.txt", "actionImportFlightTrackTXT", 5), + ("flitestar.txt", "actionImportFlightTrackFliteStar", 10)]) @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_import_file(self, mockbox, ext): + def test_import_file(self, mockbox, name): self.window.remove_plugins() with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") - with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.export_plugins): - self.window.add_export_plugins("qt") - file_path = fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, f'test_import{ext}') - with mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(file_path, None)): - with mock.patch("PyQt5.QtWidgets.QFileDialog.getOpenFileName", return_value=(file_path, None)): - self._connect_to_mscolab() - modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(emailid=self.userdata[0], password=self.userdata[2]) - self._activate_operation_at_index(0) - exported_wp = WaypointsTableModel(waypoints=self.window.mscolab.waypoints_model.waypoints) - full_name = f"actionExportFlightTrack{ext[1:]}" - for action in self.window.menuExportActiveFlightTrack.actions(): - if action.objectName() == full_name: - action.trigger() - break - assert os.path.exists(fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, f'test_import{ext}')) - QtWidgets.QApplication.processEvents() - self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - assert exported_wp.waypoint_data(0).lat != self.window.mscolab.waypoints_model.waypoint_data(0).lat - full_name = f"actionImportFlightTrack{ext[1:]}" - for action in self.window.menuImportFlightTrack.actions(): - if action.objectName() == full_name: - action.trigger() - break - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - assert len(self.window.mscolab.waypoints_model.waypoints) == 2 - imported_wp = self.window.mscolab.waypoints_model - wp_count = len(imported_wp.waypoints) - assert wp_count == 2 - for i in range(wp_count): - assert exported_wp.waypoint_data(i).lat == imported_wp.waypoint_data(i).lat + file_path = fs.path.join(self.sample_path, name[0]) + with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[file_path]) as mockopen: + # with parametrize it is maybe too fast + QtTest.QTest.qWait(100) + self._connect_to_mscolab() + self._login(emailid=self.userdata[0], password=self.userdata[2]) + self._activate_operation_at_index(0) + wp = self.window.mscolab.waypoints_model + assert len(wp.waypoints) == 2 + for action in self.window.menuImportFlightTrack.actions(): + if action.objectName() == name[1]: + action.trigger() + break + assert mockopen.call_count == 1 + imported_wp = self.window.mscolab.waypoints_model + assert len(imported_wp.waypoints) == name[2] @pytest.mark.skip("Runs in a timeout locally > 60s") def test_work_locally_toggle(self): diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index cddb9c666..49752a3f4 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -29,6 +29,7 @@ import sys import mock import os +import fs import platform import argparse import pytest @@ -139,7 +140,7 @@ class Test_MSSSideViewWindow(object): save_txt = os.path.join(ROOT_DIR, "example.txt") # import/export plugins import_plugins = { - "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"], + "TXT": ["txt", "mslib.plugins.io.text", "load_from_txt"], "FliteStar": ["txt", "mslib.plugins.io.flitestar", "load_from_flitestar"], } export_plugins = { @@ -257,24 +258,24 @@ def test_plugin_saveas(self, save_file): assert os.path.exists(save_file[0]) os.remove(save_file[0]) - @pytest.mark.parametrize( - "open_file", [(open_ftml, "actionImportFlightTrackFTML"), - (open_txt, "actionImportFlightTrackText"), (open_fls, "actionImportFlightTrackFliteStar")]) - def test_plugin_import(self, open_file): + @pytest.mark.parametrize("name", [("example.ftml", "actionImportFlightTrackFTML", 5), + ("example.csv", "actionImportFlightTrackCSV", 5), + ("example.txt", "actionImportFlightTrackTXT", 5), + ("flitestar.txt", "actionImportFlightTrackFliteStar", 10)]) + def test_plugin_import(self, name): with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") - with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[open_file[0]]) as mockopen: - assert self.window.listFlightTracks.count() == 1 - assert mockopen.call_count == 0 - self.window.last_save_directory = ROOT_DIR - obj_name = open_file[1] + assert self.window.listFlightTracks.count() == 1 + file_path = fs.path.join(self.sample_path, name[0]) + with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[file_path]) as mockopen: for action in self.window.menuImportFlightTrack.actions(): - if obj_name == action.objectName(): + if action.objectName() == name[1]: action.trigger() break - QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 assert self.window.listFlightTracks.count() == 2 + assert self.window.active_flight_track.name == name[0].split(".")[0] + assert len(self.window.active_flight_track.waypoints) == name[2] @pytest.mark.parametrize("save_file", [[save_ftml, "actionExportFlightTrackFTML"], [save_txt, "actionExportFlightTrackText"]]) From 652ca437936b49e65cd85ba0ced26f1f5a39a5af Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Fri, 8 Sep 2023 12:25:04 -0800 Subject: [PATCH 060/176] Fix focus-stealing (#2021) * add proper auto-default and focus of buttons Fixes #2020 --- mslib/msui/mscolab.py | 11 ++++------- mslib/msui/qt5/ui_mainwindow.py | 3 ++- mslib/msui/qt5/ui_mscolab_connect_dialog.py | 4 ++-- mslib/msui/ui/ui_mainwindow.ui | 5 ++++- mslib/msui/ui/ui_mscolab_connect_dialog.ui | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 839072acb..d36bd6f6e 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -151,6 +151,7 @@ def __init__(self, parent=None, mscolab=None): # connect login, adduser, connect buttons self.connectBtn.clicked.connect(self.connect_handler) + self.connectBtn.setFocus() self.disconnectBtn.clicked.connect(self.disconnect_handler) self.disconnectBtn.hide() self.loginBtn.clicked.connect(self.login_handler) @@ -184,11 +185,6 @@ def page_switched(self, index): self.newPasswordLe.setText("") self.newConfirmPasswordLe.setText("") - if index == 1: - self.connectBtn.hide() - else: - self.connectBtn.show() - def set_status(self, _type="Error", msg=""): if _type == "Error": msg = "⚠ " + msg @@ -212,8 +208,6 @@ def add_mscolab_urls(self): def enable_login_btn(self): self.loginBtn.setEnabled(self.loginEmailLe.text() != "" and self.loginPasswordLe.text() != "") - self.loginBtn.setAutoDefault(True) - self.loginBtn.setFocus() def connect_handler(self): try: @@ -257,6 +251,7 @@ def connect_handler(self): config_loader(dataset="MSS_auth").get(self.mscolab_server_url)) self.mscolab_login_changed(self.loginEmailLe.text()) self.enable_login_btn() + self.loginBtn.setFocus() # Change connect button text and connect disconnect handler self.connectBtn.hide() @@ -296,6 +291,7 @@ def disconnect_handler(self): self.auth = None self.connectBtn.show() + self.connectBtn.setFocus() self.disconnectBtn.hide() self.set_status("Info", 'Disconnected from server.') @@ -2002,6 +1998,7 @@ def logout(self): self.ui.usernameLabel.hide() self.ui.userOptionsTb.hide() self.ui.connectBtn.show() + self.ui.connectBtn.setFocus() self.ui.openOperationsGb.hide() self.ui.actionAddOperation.setEnabled(False) # hide operation description diff --git a/mslib/msui/qt5/ui_mainwindow.py b/mslib/msui/qt5/ui_mainwindow.py index 890ae2112..2b8056f50 100644 --- a/mslib/msui/qt5/ui_mainwindow.py +++ b/mslib/msui/qt5/ui_mainwindow.py @@ -55,6 +55,7 @@ def setupUi(self, MSUIMainWindow): self.userOptionsTb.setObjectName("userOptionsTb") self.userOptionsHL.addWidget(self.userOptionsTb, 0, QtCore.Qt.AlignRight) self.connectBtn = QtWidgets.QPushButton(self.MSColabConnectGb) + self.connectBtn.setAutoDefault(True) self.connectBtn.setObjectName("connectBtn") self.userOptionsHL.addWidget(self.connectBtn) self.userOptionsHL.setStretch(0, 1) @@ -156,7 +157,7 @@ def setupUi(self, MSUIMainWindow): self.gridLayout.setColumnStretch(0, 1) MSUIMainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MSUIMainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 20)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 22)) self.menubar.setNativeMenuBar(False) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) diff --git a/mslib/msui/qt5/ui_mscolab_connect_dialog.py b/mslib/msui/qt5/ui_mscolab_connect_dialog.py index 419c2513f..31c5ac0b4 100644 --- a/mslib/msui/qt5/ui_mscolab_connect_dialog.py +++ b/mslib/msui/qt5/ui_mscolab_connect_dialog.py @@ -29,7 +29,7 @@ def setupUi(self, MSColabConnectDialog): self.urlCb.setObjectName("urlCb") self.horizontalLayout_2.addWidget(self.urlCb) self.connectBtn = QtWidgets.QPushButton(MSColabConnectDialog) - self.connectBtn.setAutoDefault(False) + self.connectBtn.setAutoDefault(True) self.connectBtn.setObjectName("connectBtn") self.horizontalLayout_2.addWidget(self.connectBtn) self.disconnectBtn = QtWidgets.QPushButton(MSColabConnectDialog) @@ -54,7 +54,7 @@ def setupUi(self, MSColabConnectDialog): self.addUserBtn.setObjectName("addUserBtn") self.gridLayout_3.addWidget(self.addUserBtn, 4, 1, 1, 1) self.loginBtn = QtWidgets.QPushButton(self.loginPage) - self.loginBtn.setAutoDefault(False) + self.loginBtn.setAutoDefault(True) self.loginBtn.setObjectName("loginBtn") self.gridLayout_3.addWidget(self.loginBtn, 3, 0, 1, 2) self.loginPasswordLe = QtWidgets.QLineEdit(self.loginPage) diff --git a/mslib/msui/ui/ui_mainwindow.ui b/mslib/msui/ui/ui_mainwindow.ui index 46aec65a5..9f6209063 100644 --- a/mslib/msui/ui/ui_mainwindow.ui +++ b/mslib/msui/ui/ui_mainwindow.ui @@ -112,6 +112,9 @@ Connect + + true + @@ -353,7 +356,7 @@ Double click a operation to activate and view its description. 0 0 738 - 20 + 22 diff --git a/mslib/msui/ui/ui_mscolab_connect_dialog.ui b/mslib/msui/ui/ui_mscolab_connect_dialog.ui index 15cf20a19..e4fa62990 100644 --- a/mslib/msui/ui/ui_mscolab_connect_dialog.ui +++ b/mslib/msui/ui/ui_mscolab_connect_dialog.ui @@ -60,7 +60,7 @@ Return - false + true @@ -124,7 +124,7 @@ Return - false + true From 0d8750b5281b363290a85e893e0bb180d6d1e418 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Fri, 8 Sep 2023 12:26:16 -0800 Subject: [PATCH 061/176] Remove "Oh no" from remaining status messages. (#2029) Fixes #2028 --- mslib/mscolab/server.py | 8 ++++---- tests/_test_mscolab/test_server.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 3a36f59cd..8266a8389 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -160,15 +160,15 @@ def register_user(email, password, username): is_valid_username = True if username.find("@") == -1 else False is_valid_email = validate_email(email) if not is_valid_email: - return {"success": False, "message": "Oh no, your email ID is not valid!"} + return {"success": False, "message": "Your email ID is not valid!"} if not is_valid_username: - return {"success": False, "message": "Oh no, your username cannot contain @ symbol!"} + return {"success": False, "message": "Your username cannot contain @ symbol!"} user_exists = User.query.filter_by(emailid=str(email)).first() if user_exists: - return {"success": False, "message": "Oh no, this email ID is already taken!"} + return {"success": False, "message": "This email ID is already taken!"} user_exists = User.query.filter_by(username=str(username)).first() if user_exists: - return {"success": False, "message": "Oh no, this username is already registered"} + return {"success": False, "message": "This username is already registered"} db.session.add(user) db.session.commit() return {"success": True} diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 1d6bc4882..e4e0a79de 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -88,13 +88,13 @@ def test_register_user(self): assert result["success"] is True result = register_user(self.userdata[0], self.userdata[1], self.userdata[2]) assert result["success"] is False - assert result["message"] == "Oh no, this email ID is already taken!" + assert result["message"] == "This email ID is already taken!" result = register_user("UV", self.userdata[1], self.userdata[2]) assert result["success"] is False - assert result["message"] == "Oh no, your email ID is not valid!" + assert result["message"] == "Your email ID is not valid!" result = register_user(self.userdata[0], self.userdata[1], self.userdata[0]) assert result["success"] is False - assert result["message"] == "Oh no, your username cannot contain @ symbol!" + assert result["message"] == "Your username cannot contain @ symbol!" def test_check_login(self): with self.app.test_client(): From d6aeec825b09f3bbf1ee935f6c4d487115f8d727 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Fri, 8 Sep 2023 12:27:01 -0800 Subject: [PATCH 062/176] Downgrade errors to warnings to not clog test-logs (#2030) Fixes #2022 --- mslib/utils/units.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mslib/utils/units.py b/mslib/utils/units.py index 2a6105111..91bc39434 100644 --- a/mslib/utils/units.py +++ b/mslib/utils/units.py @@ -99,16 +99,16 @@ def convert_to(value, from_unit, to_unit, default=1.): value_unit = units.Quantity(value, from_unit) result = value_unit.to(to_unit).magnitude except pint.UndefinedUnitError: - logging.error("Error in unit conversion (undefined) '%s'/'%s'", from_unit, to_unit) + logging.warning("Error in unit conversion (undefined) '%s'/'%s'", from_unit, to_unit) result = value * default except pint.DimensionalityError: if units(to_unit).to_base_units().units == units.m: try: result = (value_unit / units.Quantity(9.81, "m s^-2")).to(to_unit).magnitude except pint.DimensionalityError: - logging.error("Error in unit conversion (dimensionality) %s/%s", from_unit, to_unit) + logging.warning("Error in unit conversion (dimensionality) '%s'/'%s'", from_unit, to_unit) result = value * default else: - logging.error("Error in unit conversion (dimensionality) %s/%s", from_unit, to_unit) + logging.warning("Error in unit conversion (dimensionality) '%s'/'%s'", from_unit, to_unit) result = value * default return result From 3be1bdcd1eb32266989a81f4f1d41efe01de163c Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sun, 24 Sep 2023 14:28:50 +0200 Subject: [PATCH 063/176] topview should not using parent of mainwindow (#2038) * topview not using parent of mainwindow * use only needed parts from mainwindow * disconnect slots because of very expensive operations * adopted tests --- mslib/msui/msui_mainwindow.py | 2 +- mslib/msui/multiple_flightpath_dockwidget.py | 16 ++++-- mslib/msui/topview.py | 57 ++++++++++++------- .../test_multiple_flightpath_dockwidget.py | 6 +- tests/_test_msui/test_topview.py | 12 ++-- 5 files changed, 61 insertions(+), 32 deletions(-) diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index 71b235a82..37659527f 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -840,7 +840,7 @@ def create_view(self, _type, model): view_window = None if _type == "topview": # Top view. - view_window = topview.MSUITopViewWindow(parent=self, model=model, + view_window = topview.MSUITopViewWindow(mainwindow=self, model=model, active_flighttrack=self.active_flight_track, mscolab_server_url=self.mscolab.mscolab_server_url, token=self.mscolab.token) diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index df0d40669..b935a15cf 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -177,7 +177,8 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, @QtCore.pyqtSlot() def logout(self): - self.operations.logout_mscolab() + if self.operations is not None: + self.operations.logout_mscolab() self.ui.signal_listFlighttrack_doubleClicked.disconnect() self.ui.signal_permission_revoked.disconnect() self.ui.signal_render_new_permission.disconnect() @@ -524,9 +525,6 @@ def __init__(self, parent, mscolab_server_url, token, list_operation_track, list self.operation_activated = False self.color_change = False - # Connect signals and slots - self.list_operation_track.itemChanged.connect(self.set_flag) - # Load operations from wps server server_operations = self.get_wps_from_server() sorted_server_operations = sorted(server_operations, key=lambda d: d["path"]) @@ -538,6 +536,10 @@ def __init__(self, parent, mscolab_server_url, token, list_operation_track, list wp_model.name = operations["path"] self.create_operation(op_id, wp_model) + # This needs to be done after operations are loaded + # Connect signals and slots + self.list_operation_track.itemChanged.connect(self.set_flag) + def set_flag(self): if self.operation_added: self.operation_added = False @@ -616,6 +618,8 @@ def activate_operation(self): """ Activate Mscolab Operation """ + # disconnect itemChanged during activation loop + self.list_operation_track.itemChanged.disconnect(self.set_flag) font = QtGui.QFont() for i in range(self.list_operation_track.count()): listItem = self.list_operation_track.item(i) @@ -634,6 +638,8 @@ def activate_operation(self): listItem.setFlags(listItem.flags() | QtCore.Qt.ItemIsUserCheckable) self.set_activate_flag() listItem.setFont(font) + # connect itemChanged after everything setup, otherwise it will be triggered on each entry + self.list_operation_track.itemChanged.connect(self.set_flag) def save_last_used_operation(self, op_id): if self.active_op_id is not None: @@ -680,6 +686,7 @@ def operationRemoved(self, op_id): """ Slot to remove operation. """ + self.list_operation_track.itemChanged.disconnect(self.set_flag) self.operation_removed = True for index in range(self.list_operation_track.count()): if self.list_operation_track.item(index).op_id == op_id: @@ -690,6 +697,7 @@ def operationRemoved(self, op_id): self.list_operation_track.takeItem(index) self.active_op_id = None break + self.list_operation_track.itemChanged.connect(self.set_flag) def set_activate_flag(self): if not self.operation_activated: diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index 8682fd292..4cbf240d1 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -185,13 +185,27 @@ class MSUITopViewWindow(MSUIMplViewWindow, ui.Ui_TopViewWindow): signal_permission_revoked = QtCore.pyqtSignal(int) signal_render_new_permission = QtCore.pyqtSignal(int, str) - def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, mscolab_server_url=None, token=None): + def __init__(self, parent=None, mainwindow=None, model=None, _id=None, + active_flighttrack=None, mscolab_server_url=None, token=None): """ Set up user interface, connect signal/slots. """ super().__init__(parent, model, _id) logging.debug(_id) - self.ui = parent + self.mainwindow_signal_login_mscolab = mainwindow.signal_login_mscolab + self.mainwindow_signal_logout_mscolab = mainwindow.signal_logout_mscolab + self.mainwindow_signal_listFlighttrack_doubleClicked = mainwindow.signal_listFlighttrack_doubleClicked + self.mainwindow_signal_activate_operation = mainwindow.signal_activate_operation + self.mainwindow_signal_permission_revoked = mainwindow.signal_permission_revoked + self.mainwindow_signal_render_new_permission = mainwindow.signal_render_new_permission + self.mainwindow_signal_activate_flighttrack = mainwindow.signal_activate_flighttrack + self.mainwindow_signal_activate_operation = mainwindow.signal_activate_operation + self.mainwindow_signal_login_mscolab = mainwindow.signal_login_mscolab + self.mainwindow_signal_logout_mscolab = mainwindow.signal_logout_mscolab + self.mainwindow_listFlightTracks = mainwindow.listFlightTracks + self.mainwindow_filterCategoryCb = mainwindow.filterCategoryCb + self.mainwindow_listOperationsMSC = mainwindow.listOperationsMSC + self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) @@ -230,15 +244,15 @@ def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, m # Tool opener. self.cbTools.currentIndexChanged.connect(self.openTool) - if parent is not None: + if mainwindow is not None: # Update flighttrack - self.ui.signal_activate_flighttrack.connect(self.update_active_flighttrack) - self.ui.signal_activate_operation.connect(self.update_active_operation) + self.mainwindow_signal_activate_flighttrack.connect(self.update_active_flighttrack) + self.mainwindow_signal_activate_operation.connect(self.update_active_operation) - self.ui.signal_operation_added.connect(self.add_operation_slot) - self.ui.signal_operation_removed.connect(self.remove_operation_slot) + self.signal_operation_added.connect(self.add_operation_slot) + self.signal_operation_removed.connect(self.remove_operation_slot) - self.ui.signal_login_mscolab.connect(self.login) + self.mainwindow_signal_login_mscolab.connect(self.login) def __del__(self): del self.mpl.canvas.waypoints_interactor @@ -339,18 +353,19 @@ def openTool(self, index): elif index == MULTIPLEFLIGHTPATH: title = "Multiple Flightpath" widget = mf.MultipleFlightpathControlWidget(parent=self, view=self.mpl.canvas, - listFlightTracks=self.ui.listFlightTracks, - listOperationsMSC=self.ui.listOperationsMSC, - category=self.ui.filterCategoryCb, + listFlightTracks=self.mainwindow_listFlightTracks, + listOperationsMSC=self.mainwindow_listOperationsMSC, + category=self.mainwindow_filterCategoryCb, activeFlightTrack=self.active_flighttrack, mscolab_server_url=self.mscolab_server_url, token=self.token) - self.ui.signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) - self.ui.signal_listFlighttrack_doubleClicked.connect( + self.mainwindow_signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) + self.mainwindow_signal_listFlighttrack_doubleClicked.connect( lambda: self.signal_listFlighttrack_doubleClicked.emit()) - self.ui.signal_permission_revoked.connect(lambda op_id: self.signal_permission_revoked.emit(op_id)) - self.ui.signal_render_new_permission.connect( + self.mainwindow_signal_permission_revoked.connect( + lambda op_id: self.signal_permission_revoked.emit(op_id)) + self.mainwindow_signal_render_new_permission.connect( lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) if self.active_op_id is not None: self.signal_activate_operation.emit(self.active_op_id) @@ -362,12 +377,12 @@ def openTool(self, index): self.createDockWidget(index, title, widget) def closed(self): - self.ui.signal_login_mscolab.disconnect() - self.ui.signal_logout_mscolab.disconnect() - self.ui.signal_listFlighttrack_doubleClicked.disconnect() - self.ui.signal_activate_operation.disconnect() - self.ui.signal_permission_revoked.disconnect() - self.ui.signal_render_new_permission.disconnect() + self.mainwindow_signal_login_mscolab.disconnect() + self.mainwindow_signal_logout_mscolab.disconnect() + self.mainwindow_signal_listFlighttrack_doubleClicked.disconnect() + self.mainwindow_signal_activate_operation.disconnect() + self.mainwindow_signal_permission_revoked.disconnect() + self.mainwindow_signal_render_new_permission.disconnect() @QtCore.pyqtSlot() def disable_cbs(self): diff --git a/tests/_test_msui/test_multiple_flightpath_dockwidget.py b/tests/_test_msui/test_multiple_flightpath_dockwidget.py index aac22bb3f..9d824ced1 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -46,7 +46,8 @@ def setup_method(self): self.waypoints_model = ft.WaypointsTableModel("myname") self.waypoints_model.insertRows( 0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.widget = tv.MSUITopViewWindow(parent=self.window, model=self.waypoints_model) + + self.widget = tv.MSUITopViewWindow(model=self.waypoints_model, mainwindow=self.window) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) @@ -59,5 +60,6 @@ def teardown_method(self): QtWidgets.QApplication.processEvents() def test_initialization(self): - widget = MultipleFlightpathControlWidget(parent=self.widget, listFlightTracks=self.widget.ui.listFlightTracks) + widget = MultipleFlightpathControlWidget(parent=self.widget, + listFlightTracks=self.window.listFlightTracks) assert widget.color == (0, 0, 1, 1) diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index 2f1c3d84d..e0539e841 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -32,10 +32,11 @@ import sys import multiprocessing import tempfile +import mslib.msui.topview as tv from mslib.mswms.mswms import application from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft -import mslib.msui.topview as tv +from mslib.msui.msui import MSUIMainWindow from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_TOPVIEW from tests.utils import wait_until_signal @@ -70,12 +71,13 @@ def test_get(self, mockcrit): class Test_MSSTopViewWindow(object): def setup_method(self): + mainwindow = MSUIMainWindow() self.application = QtWidgets.QApplication(sys.argv) initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") waypoints_model.insertRows( 0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.window = tv.MSUITopViewWindow(model=waypoints_model) + self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) @@ -312,7 +314,8 @@ def setup_method(self): waypoints_model.insertRows( 0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.window = tv.MSUITopViewWindow(model=waypoints_model) + mainwindow = MSUIMainWindow() + self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(2000) @@ -364,7 +367,8 @@ def test_kwargs_update_does_not_harm(self): initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") waypoints_model.insertRows(0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.window = tv.MSUITopViewWindow(model=waypoints_model) + mainwindow = MSUIMainWindow() + self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) # user_options is a global var from mslib.utils.config import user_options From afc9e27220d3146b753d6dc33d0f043f4ce8d07e Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 28 Sep 2023 11:48:20 +0200 Subject: [PATCH 064/176] moved user db stuff to file_manager (#2046) --- mslib/mscolab/file_manager.py | 29 +++++++++++++++++++ mslib/mscolab/server.py | 19 +++++------- tests/_test_mscolab/test_file_manager.py | 37 +++++++++++++++++++++++- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index f6d817b1f..c16e5a10c 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -192,6 +192,35 @@ def auth_type(self, u_id, op_id): return False return perm.access_level + def modify_user(self, user, attribute=None, value=None, action=None): + if action == "create": + user_query = User.query.filter_by(emailid=str(user.emailid)).first() + if user_query is None: + db.session.add(user) + db.session.commit() + else: + return False + elif action == "delete": + user_query = User.query.filter_by(id=user.id).first() + if user_query is not None: + db.session.delete(user) + db.session.commit() + user_query = User.query.filter_by(id=user.id).first() + # on delete we return succesfull deleted + if user_query is None: + return True + user_query = User.query.filter_by(id=user.id).first() + if user_query is None: + return False + if None not in (attribute, value): + if attribute == "emailid": + user_query = User.query.filter_by(emailid=str(value)).first() + if user_query is not None: + return False + setattr(user, attribute, value) + db.session.commit() + return True + def update_operation(self, op_id, attribute, value, user): """ op_id: operation id diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 8266a8389..25a38fe4e 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -169,9 +169,8 @@ def register_user(email, password, username): user_exists = User.query.filter_by(username=str(username)).first() if user_exists: return {"success": False, "message": "This username is already registered"} - db.session.add(user) - db.session.commit() - return {"success": True} + result = fm.modify_user(user, action="create") + return {"success": result} def verify_user(func): @@ -291,10 +290,8 @@ def confirm_email(token): if user.confirmed: return render_template('user/confirmed.html', username=user.username) else: - user.confirmed = True - user.confirmed_on = datetime.datetime.now() - db.session.add(user) - db.session.commit() + fm.modify_user(user, attribute="confirmed_on", value=datetime.datetime.now()) + fm.modify_user(user, attribute="confirmed", value=True) return render_template('user/confirmed.html', username=user.username) @@ -312,9 +309,8 @@ def delete_user(): """ # ToDo rename to delete_own_account user = g.user - db.session.delete(user) - db.session.commit() - return jsonify({"success": True}), 200 + result = fm.modify_user(user, action="delete") + return jsonify({"success": result}), 200 # Chat related routes @@ -691,8 +687,7 @@ def reset_password(token): if form.validate_on_submit(): try: user.hash_password(form.confirm_password.data) - user.confirmed = True - db.session.commit() + fm.modify_user(user, "confirmed", True) flash('Password reset Success. Please login by the user interface.', 'category_success') return render_template('user/status.html') except IOError: diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 39b10f7b5..418491abd 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -26,10 +26,11 @@ """ from flask_testing import TestCase import os +import datetime import pytest from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Operation +from mslib.mscolab.models import Operation, User from mslib.mscolab.server import APP from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user @@ -80,6 +81,40 @@ def setUp(self): def tearDown(self): pass + def test_modify_user(self): + with self.app.test_client(): + user = User("user@example.com", "user", "password") + assert user.id is None + assert User.query.filter_by(emailid=user.emailid).first() is None + # creeat the user + self.fm.modify_user(user, action="create") + user_query = User.query.filter_by(emailid=user.emailid).first() + assert user_query.id is not None + assert user_query is not None + assert user_query.confirmed is False + # cannot create a user a second time + assert self.fm.modify_user(user, action="create") is False + # confirming the user + confirm_time = datetime.datetime.now() + datetime.timedelta(days=1) + self.fm.modify_user(user_query, attribute="confirmed_on", value=confirm_time) + self.fm.modify_user(user_query, attribute="confirmed", value=True) + user_query = User.query.filter_by(id=user.id).first() + assert user_query.confirmed is True + assert user_query.confirmed_on == confirm_time + assert user_query.confirmed_on > user_query.registered_on + # deleting the user + self.fm.modify_user(user_query, action="delete") + user_query = User.query.filter_by(id=user_query.id).first() + assert user_query is None + + def test_modify_user_special_cases(self): + user1 = User("user1@example.com", "user1", "password") + user2 = User("user2@example.com", "user2", "password") + self.fm.modify_user(user1, action="create") + self.fm.modify_user(user2, action="create") + user_query1 = User.query.filter_by(emailid=user1.emailid).first() + assert self.fm.modify_user(user_query1, "emailid", user2.emailid) is False + def test_fetch_operation_creator(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="more_than_one") From f0a6659f6377d088149331d137dedfd6d4dfd056 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 28 Sep 2023 11:50:30 +0200 Subject: [PATCH 065/176] moved server request for last_used to the file_manager (#2041) --- conftest.py | 3 ++ .../config/mscolab/mscolab_settings.py.sample | 3 ++ mslib/mscolab/conf.py | 3 ++ mslib/mscolab/file_manager.py | 7 ++++ mslib/mscolab/server.py | 34 +++++-------------- mslib/msui/mscolab.py | 17 ---------- tests/_test_mscolab/test_server.py | 9 ----- 7 files changed, 24 insertions(+), 52 deletions(-) diff --git a/conftest.py b/conftest.py index 1d7f9fd69..f145f6dc2 100644 --- a/conftest.py +++ b/conftest.py @@ -125,6 +125,9 @@ def pytest_generate_tests(metafunc): # mscolab data directory MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') +# In the unit days when Operations get archived because not used +ARCHIVE_THRESHOLD = 30 + # To enable logging set to True or pass a logger object to use. SOCKETIO_LOGGER = True diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index b521805f7..48bfc3951 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -26,6 +26,9 @@ """ import os +# In the unit days when Operations get archived because not used +ARCHIVE_THRESHOLD = 30 + # To enable logging set to True or pass a logger object to use. SOCKETIO_LOGGER = False diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index e0e50db56..6cb60f05e 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -33,6 +33,9 @@ class default_mscolab_settings: # expire token in seconds # EXPIRATION = 86400 + # In the unit days when Operations get archived because not used + ARCHIVE_THRESHOLD = 30 + # To enable logging set to True or pass a logger object to use. SOCKETIO_LOGGER = False diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index c16e5a10c..620ba658e 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -104,10 +104,17 @@ def list_operations(self, user, skip_archived=False): operations = [] permissions = Permission.query.filter_by(u_id=user.id).all() for permission in permissions: + operation = Operation.query.filter_by(id=permission.op_id).first() + if operation.last_used is not None and ( + datetime.datetime.utcnow() - operation.last_used).days > mscolab_settings.ARCHIVE_THRESHOLD: + # outdated OPs get archived + self.update_operation(permission.op_id, "active", False, user) + # new query to get uptodate data if skip_archived: operation = Operation.query.filter_by(id=permission.op_id, active=skip_archived).first() else: operation = Operation.query.filter_by(id=permission.op_id).first() + if operation is not None: operations.append({ "op_id": permission.op_id, diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 25a38fe4e..b3a2488ca 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -45,7 +45,7 @@ from werkzeug.utils import secure_filename from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Change, MessageType, User, Operation, db +from mslib.mscolab.models import Change, MessageType, User, db from mslib.mscolab.sockets_manager import setup_managers from mslib.mscolab.utils import create_files, get_message_dict from mslib.utils import conditional_decorator @@ -59,7 +59,6 @@ migrate = Migrate(APP, db, render_as_batch=True) auth = HTTPBasicAuth() -ARCHIVE_THRESHOLD = 30 try: from mscolab_auth import mscolab_auth @@ -512,38 +511,21 @@ def get_operation_details(): def set_last_used(): # ToDo refactor move to file_manager op_id = request.form.get('op_id', None) + user = g.user days_ago = int(request.form.get('days', 0)) - operation = Operation.query.filter_by(id=int(op_id)).first() - operation.last_used = datetime.datetime.utcnow() - datetime.timedelta(days=days_ago) - temp_operation_active = operation.active - if days_ago > ARCHIVE_THRESHOLD: - operation.active = False + fm.update_operation(int(op_id), 'last_used', + datetime.datetime.utcnow() - datetime.timedelta(days=days_ago), + user) + if days_ago > mscolab_settings.ARCHIVE_THRESHOLD: + fm.update_operation(int(op_id), "active", False, user) else: - operation.active = True - db.session.commit() - # Reload Operation List - if temp_operation_active != operation.active: + fm.update_operation(int(op_id), "active", True, user) token = request.args.get('token', request.form.get('token', False)) json_config = {"token": token} sockio.sm.update_operation_list(json_config) return jsonify({"success": True}), 200 -@APP.route('/update_last_used', methods=["POST"]) -@verify_user -def update_last_used(): - # ToDo refactor move to file_manager - operations = Operation.query.filter().all() - for operation in operations: - if operation.last_used is not None and \ - (datetime.datetime.utcnow() - operation.last_used).days > 30: - operation.active = False - else: - operation.active = True - db.session.commit() - return jsonify({"success": True}), 200 - - @APP.route('/undo', methods=["POST"]) @verify_user def undo_ftml(): diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index d36bd6f6e..67b06097d 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -584,12 +584,6 @@ def after_login(self, emailid, url, r): # create socket connection here try: self.conn = sc.ConnectionManager(self.token, user=self.user, mscolab_server_url=self.mscolab_server_url) - # Update Last Used - data = { - "token": self.token - } - r = requests.post(f"{self.mscolab_server_url}/update_last_used", data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except Exception as ex: logging.debug("Couldn't create a socket connection: %s", ex) show_popup(self.ui, "Error", "Couldn't create a socket connection. Maybe the MSColab server is too old. " @@ -1683,17 +1677,6 @@ def set_active_op_id(self, item): self.ui.workLocallyCheckbox.setChecked(False) self.ui.workLocallyCheckbox.blockSignals(False) - # Disable Activate Operation Button - # self.ui.actionUnarchiveOperation.setEnabled(False) - - # set last used date for operation - data = { - "token": self.token, - "op_id": item.op_id, - } - requests.post(f'{self.mscolab_server_url}/set_last_used', data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - # set active_op_id here self.active_op_id = item.op_id self.access_level = item.access_level diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index e4e0a79de..db53b4012 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -348,15 +348,6 @@ def test_set_last_used(self): data = json.loads(response.data.decode('utf-8')) assert data["success"] is True - def test_update_last_used(self): - assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) - with self.app.test_client() as test_client: - operation, token = self._create_operation(test_client, self.userdata) - response = test_client.post('/update_last_used', data={"token": token}) - assert response.status_code == 200 - data = json.loads(response.data.decode('utf-8')) - assert data["success"] is True - def test_get_users_without_permission(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) unprevileged_user = 'UV20@uv20', 'UV20', 'uv20' From 97a5baaf61517001cf4bfb360d7cd8c05e0b7a6b Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 29 Sep 2023 08:09:24 +0200 Subject: [PATCH 066/176] moved db initialization (#2049) --- mslib/mscolab/app/__init__.py | 7 +++++++ mslib/mscolab/models.py | 6 +----- mslib/mscolab/server.py | 4 +--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py index 5f9c6f06c..b034b3b27 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -25,10 +25,14 @@ """ import os + +from flask_migrate import Migrate + import mslib from flask import Flask from mslib.mscolab.conf import mscolab_settings +from flask_sqlalchemy import SQLAlchemy from mslib.mswms.gallery_builder import STATIC_LOCATION from mslib.utils import prefix_route @@ -58,3 +62,6 @@ APP.config['MAIL_PASSWORD'] = getattr(mscolab_settings, "MAIL_PASSWORD", None) APP.config['MAIL_USE_TLS'] = getattr(mscolab_settings, "MAIL_USE_TLS", None) APP.config['MAIL_USE_SSL'] = getattr(mscolab_settings, "MAIL_USE_SSL", None) + +db = SQLAlchemy(APP) +migrate = Migrate(APP, db, render_as_batch=True) diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 0ab82d5ca..6a87e8a7a 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -28,14 +28,10 @@ import datetime import logging import jwt - from passlib.apps import custom_app_context as pwd_context -from flask_sqlalchemy import SQLAlchemy -from mslib.mscolab.app import APP +from mslib.mscolab.app import db from mslib.mscolab.message_type import MessageType -db = SQLAlchemy(APP) - class User(db.Model): diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index b3a2488ca..4f57ca2ec 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -39,13 +39,12 @@ from flask import send_from_directory, abort, url_for from flask_mail import Mail, Message from flask_cors import CORS -from flask_migrate import Migrate from flask_httpauth import HTTPBasicAuth from validate_email import validate_email from werkzeug.utils import secure_filename from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Change, MessageType, User, db +from mslib.mscolab.models import Change, MessageType, User from mslib.mscolab.sockets_manager import setup_managers from mslib.mscolab.utils import create_files, get_message_dict from mslib.utils import conditional_decorator @@ -56,7 +55,6 @@ APP = create_app(__name__) mail = Mail(APP) CORS(APP, origins=mscolab_settings.CORS_ORIGINS if hasattr(mscolab_settings, "CORS_ORIGINS") else ["*"]) -migrate = Migrate(APP, db, render_as_batch=True) auth = HTTPBasicAuth() From 92514ab243109e301ad66c3468cc2dba4a94cde0 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sun, 8 Oct 2023 15:58:18 +0200 Subject: [PATCH 067/176] show urls and files when they exists (#2053) * show urls and files when they exists * using same check as in jinja2 template --- mslib/index.py | 32 ++++++++++++++++++++++++++---- mslib/mscolab/conf.py | 4 ++++ mslib/mscolab/server.py | 2 +- mslib/mswms/demodata.py | 2 ++ mslib/mswms/wms.py | 14 +++++++------ mslib/static/templates/footer.html | 14 ++++++++++--- 6 files changed, 54 insertions(+), 14 deletions(-) diff --git a/mslib/index.py b/mslib/index.py index a819b78fa..340fe9cd3 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -64,12 +64,26 @@ def _xstatic(name): return None -def create_app(name=""): +def file_exists(filepath=None): + try: + return os.path.isfile(filepath) + except TypeError: + return False + + +def create_app(name="", imprint=None, gdpr=None): + imprint_file = imprint + gdpr_file = gdpr + if "mscolab.server" in name: from mslib.mscolab.app import APP else: from mslib.mswms.app import APP + APP.jinja_env.globals.update(file_exists=file_exists) + APP.jinja_env.globals["imprint"] = imprint_file + APP.jinja_env.globals["gdpr"] = gdpr_file + @APP.route('/xstatic//', defaults=dict(filename='')) @APP.route('/xstatic//') def files(name, filename): @@ -190,9 +204,19 @@ def help(): @APP.route("/mss/imprint") def imprint(): - _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'imprint.md') - content = get_content(_file) - return render_template("/content.html", act="imprint", content=content) + if file_exists(imprint_file): + content = get_content(imprint_file) + return render_template("/content.html", act="imprint", content=content) + else: + return "" + + @APP.route("/mss/gpdr") + def gdpr(): + if file_exists(gdpr_file): + content = get_content(gdpr_file) + return render_template("/content.html", act="gdpr", content=content) + else: + return "" @APP.route('/mss/favicon.ico') def favicons(): diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 6cb60f05e..8ffd5bb83 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -102,6 +102,10 @@ class default_mscolab_settings: # mail accounts # MAIL_DEFAULT_SENDER = 'MSS@localhost' + # filepath to md file with imprint + IMPRINT = None + # filepath to md file with gdpr + GDPR = None mscolab_settings = default_mscolab_settings() diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 4f57ca2ec..bf4e48f90 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -52,7 +52,7 @@ from mslib.mscolab.forms import ResetRequestForm, ResetPasswordForm -APP = create_app(__name__) +APP = create_app(__name__, imprint=mscolab_settings.IMPRINT, gdpr=mscolab_settings.GDPR) mail = Mail(APP) CORS(APP, origins=mscolab_settings.CORS_ORIGINS if hasattr(mscolab_settings, "CORS_ORIGINS") else ["*"]) auth = HTTPBasicAuth() diff --git a/mslib/mswms/demodata.py b/mslib/mswms/demodata.py index 986c93802..ccb80d06c 100644 --- a/mslib/mswms/demodata.py +++ b/mslib/mswms/demodata.py @@ -969,6 +969,8 @@ def create_server_config(self, detailed_information=False): #service_country = "Germany" #service_fees = "none" #service_access_constraints = "This service is intended for research purposes only." +#imprint = "" +#gdpr = "" # diff --git a/mslib/mswms/wms.py b/mslib/mswms/wms.py index 6f96f95a7..fc06e19f4 100644 --- a/mslib/mswms/wms.py +++ b/mslib/mswms/wms.py @@ -69,12 +69,6 @@ # Flask basic auth's documentation # https://flask-basicauth.readthedocs.io/en/latest/#flask.ext.basicauth.BasicAuth.check_credentials -app = create_app(__name__) -auth = HTTPBasicAuth() - -realm = 'Mission Support Web Map Service' -app.config['realm'] = realm - class default_mswms_settings: base_dir = os.path.abspath(os.path.dirname(__file__)) @@ -97,6 +91,8 @@ class default_mswms_settings: register_horizontal_layers = [] register_vertical_layers = [] register_linear_layers = [] + imprint = "" + gdpr = "" data = {} enable_basic_http_authentication = False __file__ = None @@ -110,6 +106,12 @@ class default_mswms_settings: except ImportError as ex: logging.warning("Couldn't import mswms_settings (ImportError:'%s'), Using dummy config.", ex) +app = create_app(__name__, imprint=mswms_settings.imprint, gdpr=mswms_settings.gdpr) +auth = HTTPBasicAuth() + +realm = 'Mission Support Web Map Service' +app.config['realm'] = realm + try: import mswms_auth except ImportError as ex: diff --git a/mslib/static/templates/footer.html b/mslib/static/templates/footer.html index 713e1c37d..7bd2cae4d 100644 --- a/mslib/static/templates/footer.html +++ b/mslib/static/templates/footer.html @@ -16,9 +16,17 @@

- + {% if file_exists(imprint) %} + + {% endif %} + {% if file_exists(gdpr) %} + + + {% endif %}
From bd1b543c4991146a29209fc589954fdddf344d44 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sun, 8 Oct 2023 16:00:11 +0200 Subject: [PATCH 068/176] refactored ToDos of MSColab server code (#2051) * refactored ToDos of MSColab server code * flake8 --- mslib/mscolab/chat_manager.py | 24 +++++++++ mslib/mscolab/file_manager.py | 14 ++--- mslib/mscolab/server.py | 53 ++++++------------- mslib/msui/mscolab.py | 2 +- mslib/msui/mscolab_version_history.py | 2 +- tests/_test_mscolab/test_chat_manager.py | 19 ++++++- tests/_test_mscolab/test_file_manager.py | 13 +++-- tests/_test_mscolab/test_files.py | 8 +-- tests/_test_mscolab/test_files_api.py | 9 ++-- tests/_test_mscolab/test_server.py | 4 +- .../test_mscolab_version_history.py | 2 +- tests/utils.py | 2 +- 12 files changed, 86 insertions(+), 66 deletions(-) diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index 926583672..da8555893 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -25,8 +25,11 @@ limitations under the License. """ import datetime +import os +import time import fs +from werkzeug.utils import secure_filename from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import db, Message, MessageType @@ -93,3 +96,24 @@ def delete_message(self, message_id): upload_dir.remove(fs.path.join(str(message.op_id), file_name)) db.session.delete(message) db.session.commit() + + def add_attachment(self, op_id, upload_folder, file, file_token): + with fs.open_fs('/') as home_fs: + file_dir = fs.path.join(upload_folder, str(op_id)) + if '\\' not in file_dir: + if not home_fs.exists(file_dir): + home_fs.makedirs(file_dir) + else: + file_dir = file_dir.replace('\\', '/') + if not os.path.exists(file_dir): + os.makedirs(file_dir) + file_name, file_ext = file.filename.rsplit('.', 1) + file_name = f'{file_name}-{time.strftime("%Y%m%dT%H%M%S")}-{file_token}.{file_ext}' + file_name = secure_filename(file_name) + file_path = fs.path.join(file_dir, file_name) + file.save(file_path) + static_dir = fs.path.basename(upload_folder) + static_dir = static_dir.replace('\\', '/') + static_file_path = os.path.join(static_dir, str(op_id), file_name) + if os.path.exists(file_path): + return static_file_path diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 620ba658e..b632e9287 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -262,12 +262,11 @@ def update_operation(self, op_id, attribute, value, user): db.session.commit() return True - def delete_file(self, op_id, user): + def delete_operation(self, op_id, user): """ op_id: operation id user: logged in user """ - # ToDo rename to delete_operation if self.auth_type(user.id, op_id) != "creator": return False Permission.query.filter_by(op_id=op_id).delete() @@ -377,14 +376,18 @@ def get_all_changes(self, op_id, user, named_version=False): 'created_at': change.created_at.strftime("%Y-%m-%d, %H:%M:%S") }, changes)) - def get_change_content(self, ch_id): + def get_change_content(self, ch_id, user): """ ch_id: change id user: user of this request Get change related to id """ - # ToDo refactor check user in op + ch = Change.query.filter_by(id=ch_id).first() + perm = Permission.query.filter_by(u_id=user.id, op_id=ch.op_id).first() + if perm is None: + return False + change = Change.query.filter_by(id=ch_id).first() if not change: return False @@ -404,13 +407,12 @@ def set_version_name(self, ch_id, op_id, u_id, version_name): db.session.commit() return True - def undo(self, ch_id, user): + def undo_changes(self, ch_id, user): """ ch_id: change-id user: user of this request Undo a change - # ToDo rename to undo_changes # ToDo add a revert option, which removes only that commit's change """ ch = Change.query.filter_by(id=ch_id).first() diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index bf4e48f90..ee1b190ad 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -27,11 +27,9 @@ import functools import json import logging -import time import datetime import secrets import fs -import os import socketio import sqlalchemy.exc from itsdangerous import URLSafeTimedSerializer, BadSignature @@ -41,7 +39,6 @@ from flask_cors import CORS from flask_httpauth import HTTPBasicAuth from validate_email import validate_email -from werkzeug.utils import secure_filename from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Change, MessageType, User @@ -298,13 +295,12 @@ def get_user(): return json.dumps({'user': {'id': g.user.id, 'username': g.user.username}}) -@APP.route("/delete_user", methods=["POST"]) +@APP.route("/delete_own_account", methods=["POST"]) @verify_user -def delete_user(): +def delete_own_account(): """ delete own account """ - # ToDo rename to delete_own_account user = g.user result = fm.modify_user(user, action="delete") return jsonify({"success": result}), 200 @@ -314,7 +310,6 @@ def delete_user(): @APP.route("/messages", methods=["GET"]) @verify_user def messages(): - # ToDo maybe move is_member part to file_manager user = g.user op_id = request.args.get("op_id", request.form.get("op_id", None)) if fm.is_member(user.id, op_id): @@ -334,32 +329,18 @@ def message_attachment(): file = request.files['file'] message_type = MessageType(int(request.form.get("message_type"))) user = g.user - # ToDo review users = fm.fetch_users_without_permission(int(op_id), user.id) if users is False: return jsonify({"success": False, "message": "Could not send message. No file uploaded."}) if file is not None: - with fs.open_fs('/') as home_fs: - file_dir = fs.path.join(APP.config['UPLOAD_FOLDER'], op_id) - if '\\' not in file_dir: - if not home_fs.exists(file_dir): - home_fs.makedirs(file_dir) - else: - file_dir = file_dir.replace('\\', '/') - if not os.path.exists(file_dir): - os.makedirs(file_dir) - file_name, file_ext = file.filename.rsplit('.', 1) - file_name = f'{file_name}-{time.strftime("%Y%m%dT%H%M%S")}-{file_token}.{file_ext}' - file_name = secure_filename(file_name) - file_path = fs.path.join(file_dir, file_name) - file.save(file_path) - static_dir = fs.path.basename(APP.config['UPLOAD_FOLDER']) - static_dir = static_dir.replace('\\', '/') - static_file_path = os.path.join(static_dir, op_id, file_name) - new_message = cm.add_message(user, static_file_path, op_id, message_type) - new_message_dict = get_message_dict(new_message) - sockio.emit('chat-message-client', json.dumps(new_message_dict)) - return jsonify({"success": True, "path": static_file_path}) + static_file_path = cm.add_attachment(op_id, APP.config['UPLOAD_FOLDER'], file, file_token) + if static_file_path is not None: + new_message = cm.add_message(user, static_file_path, op_id, message_type) + new_message_dict = get_message_dict(new_message) + sockio.emit('chat-message-client', json.dumps(new_message_dict)) + return jsonify({"success": True, "path": static_file_path}) + else: + return "False" return jsonify({"success": False, "message": "Could not send message. No file uploaded."}) # normal use case never gets to this return "False" @@ -428,9 +409,9 @@ def get_all_changes(): @APP.route('/get_change_content', methods=['GET']) @verify_user def get_change_content(): - # ToDo refactor see fm.get_change_content( ch_id = int(request.args.get('ch_id', request.form.get('ch_id', 0))) - result = fm.get_change_content(ch_id) + user = g.user + result = fm.get_change_content(ch_id, user) if result is False: return "False" return jsonify({"content": result}) @@ -470,7 +451,7 @@ def get_operations(): def delete_operation(): op_id = int(request.form.get('op_id', 0)) user = g.user - success = fm.delete_file(op_id, user) + success = fm.delete_operation(op_id, user) if success is False: return jsonify({"success": False, "message": "You don't have access for this operation!"}) @@ -507,7 +488,6 @@ def get_operation_details(): @APP.route('/set_last_used', methods=["POST"]) @verify_user def set_last_used(): - # ToDo refactor move to file_manager op_id = request.form.get('op_id', None) user = g.user days_ago = int(request.form.get('days', 0)) @@ -524,14 +504,13 @@ def set_last_used(): return jsonify({"success": True}), 200 -@APP.route('/undo', methods=["POST"]) +@APP.route('/undo_changes', methods=["POST"]) @verify_user -def undo_ftml(): - # ToDo rename to undo_changes +def undo_changes(): ch_id = request.form.get('ch_id', -1) ch_id = int(ch_id) user = g.user - result = fm.undo(ch_id, user) + result = fm.undo_changes(ch_id, user) # get op_id from change ch = Change.query.filter_by(id=ch_id).first() if result is True: diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 67b06097d..69cb36b03 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -756,7 +756,7 @@ def delete_account(self): } try: - r = requests.post(self.mscolab_server_url + '/delete_user', data=data, + r = requests.post(self.mscolab_server_url + '/delete_own_account', data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index 1d080cf0e..869ef2527 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -273,7 +273,7 @@ def handle_undo(self): "token": self.token, "ch_id": self.changes.currentItem().id } - url = urljoin(self.mscolab_server_url, 'undo') + url = urljoin(self.mscolab_server_url, 'undo_changes') r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": # reload windows diff --git a/tests/_test_mscolab/test_chat_manager.py b/tests/_test_mscolab/test_chat_manager.py index 30ceef682..abe1dd2b0 100644 --- a/tests/_test_mscolab/test_chat_manager.py +++ b/tests/_test_mscolab/test_chat_manager.py @@ -24,11 +24,14 @@ See the License for the specific language governing permissions and limitations under the License. """ +import os +import secrets from flask_testing import TestCase +from werkzeug.datastructures import FileStorage from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Message, MessageType +from mslib.mscolab.models import Operation, Message, MessageType from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.server import APP from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation @@ -89,3 +92,17 @@ def test_delete_messages(self): self.cm.delete_message(message.id) message = Message.query.filter(Message.id == message.id).first() assert message is None + + def test_add_attachment(self): + sample_path = os.path.join(os.path.dirname(__file__), "..", "data") + filename = "example.csv" + name, ext = filename.split('.') + open_csv = os.path.join(sample_path, "example.csv") + operation = Operation.query.filter_by(path=self.operation_name).first() + token = secrets.token_urlsafe(16) + with open(open_csv, 'rb') as fp: + file = FileStorage(fp, filename=filename, content_type="text/csv") + static_path = self.cm.add_attachment(operation.id, mscolab_settings.UPLOAD_FOLDER, file, token) + assert name in static_path + assert static_path.endswith(ext) + assert token in static_path diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 418491abd..11852b27b 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -238,11 +238,10 @@ def test_update_operation(self): assert ren_operation.id == operation.id assert ren_operation.path == rename_to - def test_delete_file(self): - # Todo rename "file" to operation + def test_delete_operation(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path='operation4') - assert self.fm.delete_file(operation.id, self.user) + assert self.fm.delete_operation(operation.id, self.user) assert Operation.query.filter_by(path=flight_path).first() is None def test_get_authorized_users(self): @@ -279,7 +278,7 @@ def test_get_change_content(self): assert self.fm.save_file(operation.id, self.content1, self.user) assert self.fm.save_file(operation.id, self.content2, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) - assert self.fm.get_change_content(all_changes[1]["id"]) == self.content1 + assert self.fm.get_change_content(all_changes[1]["id"], self.user) == self.content1 def test_set_version_name(self): with self.app.test_client(): @@ -307,15 +306,15 @@ def test_undo(self): assert self.fm.save_file(operation.id, self.content2, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) # crestor - assert self.fm.undo(all_changes[1]["id"], self.user) + assert self.fm.undo_changes(all_changes[1]["id"], self.user) # check collaborator self.fm.add_bulk_permission(operation.id, self.user, [self.collaboratoruser.id], "collaborator") assert self.fm.is_collaborator(self.collaboratoruser.id, operation.id) - assert self.fm.undo(all_changes[1]["id"], self.collaboratoruser) + assert self.fm.undo_changes(all_changes[1]["id"], self.collaboratoruser) # check viewer self.fm.add_bulk_permission(operation.id, self.user, [self.vieweruser.id], "viewer") assert self.fm.is_viewer(self.vieweruser.id, operation.id) - assert self.fm.undo(all_changes[1]["id"], self.vieweruser) is False + assert self.fm.undo_changes(all_changes[1]["id"], self.vieweruser) is False def test_fetch_users_without_permission(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index 6db498a21..df774e02f 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -129,7 +129,7 @@ def test_undo(self): changes = Change.query.filter_by(op_id=operation.id).all() assert changes is not None assert changes[0].id == 1 - assert self.fm.undo(changes[0].id, self.user) is True + assert self.fm.undo_changes(changes[0].id, self.user) is True assert len(self.fm.get_all_changes(operation.id, self.user)) == 3 assert "beta" in self.fm.get_file(operation.id, self.user) @@ -162,9 +162,9 @@ def test_delete_operation(self): with self.app.test_client(): self._create_operation(flight_path="f3") op_id = get_recent_op_id(self.fm, self.user) - assert self.fm.delete_file(op_id, self.user2) is False - assert self.fm.delete_file(op_id, self.user) is True - assert self.fm.delete_file(op_id, self.user) is False + assert self.fm.delete_operation(op_id, self.user2) is False + assert self.fm.delete_operation(op_id, self.user) is True + assert self.fm.delete_operation(op_id, self.user) is False permissions = Permission.query.filter_by(op_id=op_id).all() assert len(permissions) == 0 operations_db = Operation.query.filter_by(id=op_id).all() diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 0aeb89473..1ac772f6f 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -182,12 +182,11 @@ def test_update_operation(self): operation = Operation.query.filter_by(path=new_flight_path).first() assert operation.description == new_description - def test_delete_file(self): - # ToDo rename to operation + def test_delete_operation(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V10") assert operation.path == flight_path - assert self.fm.delete_file(operation.id, self.user) + assert self.fm.delete_operation(operation.id, self.user) operation = Operation.query.filter_by(path=flight_path).first() assert operation is None @@ -206,9 +205,9 @@ def test_get_change_content(self): assert self.fm.save_file(operation.id, "content2", self.user) assert self.fm.save_file(operation.id, "content3", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) - previous_change = self.fm.get_change_content(all_changes[2]["id"]) + previous_change = self.fm.get_change_content(all_changes[2]["id"], self.user) assert previous_change == "content1" - previous_change = self.fm.get_change_content(all_changes[1]["id"]) + previous_change = self.fm.get_change_content(all_changes[1]["id"], self.user) assert previous_change == "content2" def test_set_version_name(self): diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index db53b4012..c1bd4b460 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -149,11 +149,11 @@ def test_delete_user(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: token = self._get_token(test_client, self.userdata) - response = test_client.post('/delete_user', data={"token": token}) + response = test_client.post('/delete_own_account', data={"token": token}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) assert data["success"] is True - response = test_client.post('/delete_user', data={"token": "dsdsds"}) + response = test_client.post('/delete_own_account', data={"token": "dsdsds"}) assert response.status_code == 200 assert response.data.decode('utf-8') == "False" diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index a84c61aa7..7d4a33b4c 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -116,7 +116,7 @@ def test_version_name_delete(self, mockbox): assert self.version_window.changes.currentItem().version_name is None @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_undo(self, mockbox): + def test_undo_changes(self, mockbox): self._change_version_filter(1) # make changes for i in range(2): diff --git a/tests/utils.py b/tests/utils.py index cbd107287..d2a07be3c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -107,7 +107,7 @@ def mscolab_delete_user(app, msc_url, email, password): response = mscolab_login(app, msc_url, email, password) if response.status == '200 OK': data = json.loads(response.get_data(as_text=True)) - url = urljoin(msc_url, 'delete_user') + url = urljoin(msc_url, 'delete_own_account') response = app.test_client().post(url, data=data) if response.status == '200 OK': data = json.loads(response.get_data(as_text=True)) From 66a7dcb739237f2c9d968743f1664bde67fbb96d Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Sun, 8 Oct 2023 16:09:00 +0200 Subject: [PATCH 069/176] keep stable change to werkzeug --- localbuild/meta.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index ee9d1b05b..3953fb99e 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -65,7 +65,7 @@ requirements: - flask-httpauth - flask-mail - flask-migrate - - werkzeug >=2.2.3 + - werkzeug >=2.2.3, <3.0.0 - flask-socketio >=5.1.0 - flask-sqlalchemy >=3.0.0 - flask-cors From f6b3ad110dbfaa12d439cadea6476dcd54d9328d Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 7 Nov 2023 15:12:42 +0100 Subject: [PATCH 070/176] merge of Gsoc2023 nilupul manodya (#2069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove inputs from conditions (#1808) * Setup sp and idp for the sso (#1809) * configure sp and idp * update meta.yml remove cherypy * fixes previous * update notice * update readme * regroup idp_uwsgi * regroup app.py * regroup, change wsgi server to flask * Update conf_sp_idp/README.md Co-authored-by: Matthias Riße * hide secrets by config * update copy-paste-able command for creating keys and certificates * Update README.md * correct copyright lines * remove make_metadata.py file and update doc with new flow * remove idp.xml file * remove condition libxmlsec1 * Update conf_sp_idp/sp/app/conf.py Co-authored-by: Matthias Riße * Update conf_sp_idp/idp/idp.py Co-authored-by: Matthias Riße * remove generate_metadatascript * remove hardcoded path * recorrect copyrights --------- Co-authored-by: Matthias Riße * Split conf sp idp (#1811) * split sp and idp * generate doc * remove prints idp.py * update comeponents.rst * UI changes in Qt for SSO (#1813) * ui changes in qt for sso * fixes qt UI implementation * get idp_enabled response from server * update tests for test_hello * update test utils * Update mslib/msui/mscolab.py Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> * fix typo * move downed idp_enabled exception * increase height ui_mscolab_connect_dialog * resolve comments --------- Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> * web browser implementation (#1814) * web browser implementation * update gitgnore * resolve comments * update docstring * Configure mscolab for sso (#1818) * db modeling * add users into id[ * backend yaml implementation * set server conf * config server for sso * qt ui implmentation * backend html templates implementation * update testcases * config qt client app * update gitignore * set yaml endpoints * update docs * update test utill, and fix error * fix test utils * remove disabled pylint * add libxmlsec1 into dep * set IDP ENabled false * Update mslib/mscolab/server.py Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> * recorrect commit * update db modeling with authentication_backend for multiple idps * update conf for the multiple idps * template implementation * msui update redirect url for multiple idps * saml update for multiple idps * update mscolab server for multiple idps * update doc for multiple idps * automate CERTs generation and paths * update doc * correct typo in doc * update doc * fix typos update gitignore * fix config idp_conf * update gitignore * set one time token access * add params for cert creation * set idp token for one time validation * fix unnnescessary debug * remove duplicate imports * Update mslib/mscolab/mscolab.py Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> * automate saml yaml file and improve error handling * rename IDP_ENABLED to USE_SAML2 * update error template * update doc * add todo idp_wsgi * update db models * recorrect doc * add todo refactors --------- Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> * To do fixes #1818 (#1974) * remove global var * remove idp.subjects file dirs * remove relaystste, rndstr and use secrets * remove shell=True * correct typos * fix group order * enable flake8 for GSOC2023-NilupulManodya * fix lint * fix lint * fixes comments * resolve comments * fix comments * update doc * improve code for multiple Idps * conf routes for multiple conf * remove uncessary .yaml * update cmd metadata * update conf * update saml handler for multiple idps * pinning of xmlschema * pin werkzeug * disable pytests for todo refactor * disbale whole file gsoc_testing * fix conf * resolve comments * resolve comments * manual conflict resolve ui_mscolab_connect_dialog.ui file * resolve flake8 * set SSL certificate verification enablement (#2062) * ssl verification enablement for SSO * add hint * Remove testing SP (#2066) * remove testing sp * remove documentation auth_client_sp * Create documentation for SSO integration through SAML (#2064) * create documentation sso integration * added into makefile components * change dir images * resolve comments, add sample files * resolve comments * change cookies dir of web browser (#2063) * change cookies dir of web browser * Update mslib/msui/msui_web_browser.py Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> --------- Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> * improve accessibility saml2 urls (#2068) * improve accessibility saml2 urls * resolve comments --------- Co-authored-by: Nilupul Manodya <57173445+nilupulmanodya@users.noreply.github.com> Co-authored-by: Matthias Riße Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> Co-authored-by: nilupulmanodya --- .gitignore | 3 +- NOTICE | 11 + conftest.py | 3 + docs/components.rst | 5 +- docs/conf_sso_test_msscolab.rst | 117 ++ .../sso_via_saml_conf/ss_add_mappers_btn.png | Bin 0 -> 28897 bytes .../sso_via_saml_conf/ss_add_realam_btn.png | Bin 0 -> 16842 bytes .../sso_via_saml_conf/ss_add_realam_name.png | Bin 0 -> 13433 bytes .../sso_via_saml_conf/ss_admin_login.png | Bin 0 -> 70624 bytes .../sso_via_saml_conf/ss_client_select.png | Bin 0 -> 11394 bytes .../ss_create_client_btn.png | Bin 0 -> 33672 bytes .../sso_via_saml_conf/ss_docker_run_cmd.png | Bin 0 -> 26433 bytes .../sso_via_saml_conf/ss_enable_mappers.png | Bin 0 -> 30170 bytes .../sso_via_saml_conf/ss_enable_usr_reg.png | Bin 0 -> 37882 bytes .../sso_via_saml_conf/ss_gen_keys_crts.png | Bin 0 -> 151822 bytes .../ss_interface_keycloak.png | Bin 0 -> 146570 bytes .../sso_via_saml_conf/ss_left_nav_client.png | Bin 0 -> 30726 bytes .../ss_left_nav_realm_settings.png | Bin 0 -> 26484 bytes .../ss_set_attribute_name1.png | Bin 0 -> 33655 bytes .../ss_set_attribute_name2.png | Bin 0 -> 38125 bytes .../ss_set_client_protocol.png | Bin 0 -> 19657 bytes .../sso_via_saml_conf/ss_view_mappers.png | Bin 0 -> 42752 bytes .../config/mscolab/mscolab_settings.py.sample | 6 + .../mscolab/mss_saml2_backend.yaml.samlple | 116 ++ .../mscolab/setup_saml2_backend.py.sample | 68 + docs/sso_via_saml_mscolab.rst | 560 ++++++++ localbuild/meta.yaml | 5 + mslib/mscolab/conf.py | 86 ++ mslib/mscolab/models.py | 4 +- mslib/mscolab/mscolab.py | 303 +++++ mslib/mscolab/server.py | 195 ++- mslib/mscolab/utils.py | 1 + mslib/msidp/README.md | 3 + mslib/msidp/__init__.py | 25 + mslib/msidp/htdocs/login.mako | 29 + mslib/msidp/idp.py | 1166 +++++++++++++++++ mslib/msidp/idp_conf.py | 210 +++ mslib/msidp/idp_user.py | 135 ++ mslib/msidp/idp_uwsgi.py | 1111 ++++++++++++++++ mslib/msidp/templates/root.mako | 37 + mslib/msui/mscolab.py | 65 +- mslib/msui/msui_web_browser.py | 105 ++ mslib/msui/qt5/ui_mscolab_connect_dialog.py | 105 +- mslib/msui/ui/ui_mscolab_connect_dialog.ui | 185 ++- mslib/static/templates/errors/404.html | 5 + mslib/static/templates/errors/500.html | 5 + .../static/templates/idp/available_idps.html | 74 ++ .../templates/idp/idp_login_success.html | 43 + tests/_test_mscolab/test_server.py | 5 +- tests/utils.py | 3 +- 50 files changed, 4693 insertions(+), 101 deletions(-) create mode 100644 docs/conf_sso_test_msscolab.rst create mode 100644 docs/images/sso_via_saml_conf/ss_add_mappers_btn.png create mode 100644 docs/images/sso_via_saml_conf/ss_add_realam_btn.png create mode 100644 docs/images/sso_via_saml_conf/ss_add_realam_name.png create mode 100644 docs/images/sso_via_saml_conf/ss_admin_login.png create mode 100644 docs/images/sso_via_saml_conf/ss_client_select.png create mode 100644 docs/images/sso_via_saml_conf/ss_create_client_btn.png create mode 100644 docs/images/sso_via_saml_conf/ss_docker_run_cmd.png create mode 100644 docs/images/sso_via_saml_conf/ss_enable_mappers.png create mode 100644 docs/images/sso_via_saml_conf/ss_enable_usr_reg.png create mode 100644 docs/images/sso_via_saml_conf/ss_gen_keys_crts.png create mode 100644 docs/images/sso_via_saml_conf/ss_interface_keycloak.png create mode 100644 docs/images/sso_via_saml_conf/ss_left_nav_client.png create mode 100644 docs/images/sso_via_saml_conf/ss_left_nav_realm_settings.png create mode 100644 docs/images/sso_via_saml_conf/ss_set_attribute_name1.png create mode 100644 docs/images/sso_via_saml_conf/ss_set_attribute_name2.png create mode 100644 docs/images/sso_via_saml_conf/ss_set_client_protocol.png create mode 100644 docs/images/sso_via_saml_conf/ss_view_mappers.png create mode 100644 docs/samples/config/mscolab/mss_saml2_backend.yaml.samlple create mode 100644 docs/samples/config/mscolab/setup_saml2_backend.py.sample create mode 100644 docs/sso_via_saml_mscolab.rst create mode 100644 mslib/msidp/README.md create mode 100644 mslib/msidp/__init__.py create mode 100644 mslib/msidp/htdocs/login.mako create mode 100644 mslib/msidp/idp.py create mode 100644 mslib/msidp/idp_conf.py create mode 100644 mslib/msidp/idp_user.py create mode 100644 mslib/msidp/idp_uwsgi.py create mode 100644 mslib/msidp/templates/root.mako create mode 100644 mslib/msui/msui_web_browser.py create mode 100644 mslib/static/templates/errors/404.html create mode 100644 mslib/static/templates/errors/500.html create mode 100644 mslib/static/templates/idp/available_idps.html create mode 100644 mslib/static/templates/idp/idp_login_success.html diff --git a/.gitignore b/.gitignore index 69fbc2c15..6a11f5109 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ build/ mss.egg-info/ tutorials/recordings tutorials/cursor_image.png - +__pycache__/ +instance/ diff --git a/NOTICE b/NOTICE index 3504c7039..a28a0596b 100755 --- a/NOTICE +++ b/NOTICE @@ -130,3 +130,14 @@ License: https://github.com/PaulSchweizer/qt-json-view/blob/master/LICENSE (MIT Package for working with JSON files in PyQt5. Obtained from Github (https://github.com/PaulSchweizer/qt-json-view), on 23/7/2021. + +Identity Provider +----------------- + +We utilize example files from the pysaml2 library to set up the configuration for our local Identity Provider (IdP). +Obtained from GitHub (https://github.com/IdentityPython/pysaml2/tree/master/example/idp2) on 13/07/2023 + +Copyright: 2018 Roland Hedberg + +License: https://github.com/IdentityPython/pysaml2/blob/master/LICENSE (Apache License 2.0) +Further Information: https://pysaml2.readthedocs.io/en/ diff --git a/conftest.py b/conftest.py index f145f6dc2..870c3b99d 100644 --- a/conftest.py +++ b/conftest.py @@ -182,6 +182,9 @@ def pytest_generate_tests(metafunc): """ enable_basic_http_authentication = False + +# enable login by identity provider +USE_SAML2 = False ''' ROOT_FS = fs.open_fs(constants.ROOT_DIR) if not ROOT_FS.exists('mscolab'): diff --git a/docs/components.rst b/docs/components.rst index 41e205067..a1103fd77 100644 --- a/docs/components.rst +++ b/docs/components.rst @@ -10,5 +10,6 @@ Components mscolab gentutorials mssautoplot - - + conf_auth_client_sp_idp + conf_sso_test_msscolab + sso_via_saml_mscolab diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst new file mode 100644 index 000000000..85ee7054f --- /dev/null +++ b/docs/conf_sso_test_msscolab.rst @@ -0,0 +1,117 @@ +Configuration MSS Colab Server with Testing IdP for SSO +======================================================= +Testing IDP (`mslib/msidp`) is specifically designed for testing the Single Sign-On (SSO) process with the mscolab server using PySAML2. + +Here is documentation that explains the configuration of the MSS Colab Server with the testing IdP. + +Getting started +--------------- + +To set up a local identity provider with the mscolab server, you'll first need to generate the required keys and certificates for both the Identity Provider and the mscolab server. Follow these steps to configure the system: + + 1. Initial Steps + 2. Generate Keys and Certificates + 3. Enable USE_SAML2 + 4. Generate Metadata Files + 5. Start the Identity Provider + 6. Start the mscolab Server + 7. Test the Single Sign-On (SSO) Process + + +1. Initial Steps +---------------- +Before getting started, you should correctly activate the environments, set the correct Python path as explained in the mss instructions : https://github.com/Open-MSS/MSS/tree/develop#readme + + + +2. Generate Keys, Certificates, and backend_saml files +------------------------------------------------------ + +This involves generating both `.key` files and `.crt` files for both the Identity provider and mscolab server and `backend_saml.yaml` file. + +Before running the command make sure to set `USE_SAML2 = False` in your `mscolab_settings.py` file, You can accomplish this by following these steps: + +- Add to the `PYTHONPATH` where your `mscolab_settings.py`. +- Add `USE_SAML2 = False` in your `mscolab_settings.py` file. + +.. note:: + If you set `USE_SAML2 = True` without keys and certificates, this will not execute. So, make sure to set `USE_SAML2 = False` before executing the command. + +If everything is correctly set, you can generate keys and certificates simply by running + +.. code:: text + + $ mscolab sso_conf --init_sso_crts + +.. note:: + This process generating keys and certificates for both Identity provider and mscolab server by default, If you need configure with different keys and certificates for the Identity provider, You should manually update the path of `SERVER_CERT` with the path of the generated .crt file for Identity provider, and `SERVER_KEY` with the path of the generated .key file for the Identity provider in the file `MSS/mslib/idp/idp_conf.py`. + + +3. Enable USE_SAML2 +------------------- + +To enable SAML2-based login (identity provider-based login), + +- To start the process update `USE_SAML2 = True` in your `mscolab_settings.py` file. + +.. note:: + After enabling the `USE_SAML2` option, the subsequent step involves adding the `CONFIGURED_IDPS` dictionary for the MSS Colab Server. This dictionary must contain keys for each active Identity Provider, denoted by their `idp_identity_name`, along with their respective `idp_name`. Once this dictionary is configured, it should be utilized to update several aspects of the mscolab server, including the SAML2Client configuration in the .yml file. This ensures seamless integration with the enabled IDPs. By default, configuration has been set up for the localhost IDP, and any additional configurations required should be performed by the developer. + +4. Generate metadata files +-------------------------- + +This involves generating necessary metadata files for both the identity provider and the service provider. You can generate them by simply running the below command. + +.. note:: + Before executing this, you should set `USE_SAML2=True` as described in the third step(Enable USE_SAML2). + +.. code:: text + + $ mscolab sso_conf --init_sso_metadata + + +5. Start Identity provider +-------------------------- + +Once you set certificates and metada files you can start mscolab server and local identity provider. To start local identity provider, simply execute: + +.. code:: text + + $ msidp + + +6. Start the mscolab Server +--------------------------- + +Before Starting the mscolab server, make sure to do necessary database migrations. + +When this is the first time you setup a mscolab server, you have to initialize the database by: + +.. code:: text + + $ mscolab db --init + +.. note:: + An existing database maybe needs a migration, have a look for this on our documentation. + + https://mss.readthedocs.io/en/stable/mscolab.html#data-base-migration + +When migrations finished, you can start mscolab server using the following command: + +.. code:: text + + $ mscolab start + + +7. Testing Single Sign-On (SSO) process +--------------------------------------- + +* Once you have successfully launched the server and identity provider, you can begin testing the Single Sign-On (SSO) process. +* Start MSS PyQt application: + +.. code:: text + + $ msui + +* Login with identity provider through Qt Client application. +* To log in to the mscolab server through the identity provider, you can use the credentials specified in the ``PASSWD`` section of the ``MSS/mslib/msidp/idp.py`` file. Look for the relevant section in the file to find the necessary login credentials. diff --git a/docs/images/sso_via_saml_conf/ss_add_mappers_btn.png b/docs/images/sso_via_saml_conf/ss_add_mappers_btn.png new file mode 100644 index 0000000000000000000000000000000000000000..e30ac3db7f7b11d513f5af739ea70a443c1131df GIT binary patch literal 28897 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
  • ^^p9K*H)9hcM9~M5F8xS)L7;j%wqm(^j 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 bKAl{=w6WQzn-0jJkfyGz{S5Q;#hd>I`dzu6 literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_admin_login.png b/docs/images/sso_via_saml_conf/ss_admin_login.png new file mode 100644 index 0000000000000000000000000000000000000000..f735bb469a2b5681d5632bb30d1da48eb6e837c0 GIT binary patch literal 70624 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 DI3OVA literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_create_client_btn.png b/docs/images/sso_via_saml_conf/ss_create_client_btn.png new file mode 100644 index 0000000000000000000000000000000000000000..357c7bbe7daba6ef27a8bcda49db46c597acebb0 GIT binary patch literal 33672 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 literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_docker_run_cmd.png b/docs/images/sso_via_saml_conf/ss_docker_run_cmd.png new file mode 100644 index 0000000000000000000000000000000000000000..839900461f63dbd792a79855079f133046ed1944 GIT binary patch literal 26433 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 Wi?ofmm*3l5(Nxn@Eqh}1{=Wd~^}SC3 literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_enable_mappers.png b/docs/images/sso_via_saml_conf/ss_enable_mappers.png new file mode 100644 index 0000000000000000000000000000000000000000..072da72146e3e3730017916ab902b208915f762e GIT binary patch literal 30170 zcmdqIbx>T*_AU&BK=9xYG`PEKf;$9vcXxM!yF<{RA-KEi;O@@gIyek+dC$3T-gACm z)xGsqeSdvjQ#HG1x_7VcUj3}Kp1pRsf}A)K{3mz_2nZxe2@ypIi1&985Re}}z`Xr( zX+Cc8_66-EB&q!2t$2Sh4tuNPI*Y11E7_SkyBRo|K$zLu*_hBf89ADm*gBcpIiJ7l z6nJYy`Bx)hM-u~Q3p-n4WeXb<2o@$5R(d9GdKPwK7A77RRvu;+Vp=A4Rwjn00#yhI zVhBl*ugdP3Cu?rb7%F&!p!2h$crmnuNiQUJ=K{C5qFlu!ieI_T7|_Wi_kmQhaTF5Y zM3f_9a4Eh2hxnAk*>L25freZzm&-;hZs>eKv98G989=(cqTs}1x2v=^&==pw^ zSV8Bq7Bk@;h`P0Tc$$1>j6kjVPB=%al)oaxutbz=z4NIPsX~7DcUHQa7`Bec-{p)k zItc74G*53fQU6?~?iCChZY6+e>Zd8EIk7jEVfSRS)DyCcaBrYFF zl%!M@dgi`91iJDu+6mYEV-0yCiigxf{?0`SVu^c_MeQr`nFGA@hk}Wo{x+@thwKd8 zDM~J1?p*{E>dEl*&ZxZV)I$f$%dXLw^o!wd--pM%E)K67scXQj9gFx^BC#HoR}+)? z(lfu#Tzs{Bzk3P(GT7ff+RZFgjAcPSRObAnK7fm9wkA8?=1i5TIg2MnR?EQ()7@}! zq~#)cCf;3PFU4j#j}vi}%ch_^`tfn~v*zIFAs;X9c+2aBsKl$8@O#ag`Td8ZDzm|F z)5MbBXwKYTaMjT;ET%seyNoR2`Nf`EQ))D`BrMBH8%TPh9j5-hPa?n#DkRduaD4R! zm{2hzsuXHQ1|&K}O^3J&(UHxx>Am@nw{(vQ9-X#ZlfhQ%1nmH&7Uc3)m%<-GN z&&#O3S*#i4c()D|e5kM!_|VFB0IN04%}x7rrbl6BM8w30^K2B-gdA|A>B3bfOJ0f+ zJSKwaxAO(Abpz%g+6_Lf6=qZCPg2vHb_W_H!7UmnERAf&`j;zxhKDNq$;{C!ok!BU@XL7bmwAA#9_;aCqt`lPS zs9hu#8HpO0-NW0D1=!lvV}0F^KH|JFYthSM@$2y=QWgx+`6@|ZMJhha=CR?@^H|b1wo0M^|8!N*hY!t`+ zp!&=eb~EC;+qe4CQ`tf)bYVq-TUi;Z*Y4*g9l8~ud}7H%l|F@Euh{*y?)cf6$**n4!G>G?dEePA z2W+cpr%5_7SmXZwPu)xHwCSvt#FWz9tL)4bsP#dTc1T8QpoPP`2PP<|7K7N1p@5qW zriU!Jq|xm-j-BUd9M{*5oKZ}z1WqI{!r8V$!nhLl)#V5kQ*pxs?}2wGO$gy; z#?OqVlZ6mlqPqfT21fkE9_|9?p5fqVk=Y?H<72NB3ETZAftL;d4?H5;wj5|A zRPahX{7gn$G2jGwN`#{M1#_`6>oxe!YmBVO00m~^iAeNWrz#{17@JeX`cq4AJSOK$ zs38N2`-`g(XH3x`C1uHEIcE+Ey{M}i-op5qX@BO#!Q2iPDdk;mpjnODx@Y!oFPNFM zjl%9att%I;`J#>t%>hIO%g~(oP}$|n=2diSIF#b;e8QjNHoQA9gFn)rTkp;4LA-{H zY&`5$?@`8fzn3{{rx^FpX-m?kitc^p8Ki5rP!sJg2F6{y#n7Q-s|0w zUT40bM%%VdaxlCI1T=QWixyU85P*SS>p&qls?ms9185JUpL=bJGM^K-^Am*=ZDA1><< zK%w+zjMWb*i7yA&1@3EUlxKy`beY-8=?ipEyBj+)6KGsLcAk=Q{pxo6bJ@969oW zhvF!|Qq=xuoof97ZfOHcH{9L=H3D^}N4yw8{T9-lWf+QRq`c|SME7D*V(wM?v{5mmz%TRs9=eb)hAHw#NuhOY9j2Ouvq z7E2Y|M|nc23>pu>iSYn%^J|6U1grhDJNWo2f>`gWldWXx8Z+jCaWwHhX`lOb$ zBMtk-7D4zW-XCR+D;}_Vy*ef4dNe@xECJl;OSGM%Zfs7TWCfeXQm!2xGf6;!IqnL#sK)?C@0JFjt{8;oh}nkDr4$=lB^5c z4AzX#+s9tXTJLX51&lA0bszg+)O*I>Yf1v@bj6lbKX_@|PkefC6Rp3^DZKP?+s5&Cw)01^^~unDcDYvk z-q60?)X2x=Ll%^+{BL7Usp;*)yaG1as?!r1Ujf~CWE(}z^Dk%nEtAB1YQD&xab#c^ zgJVBKt$+q+x_#d}C0tX%qmFs_yE(y2@so0R{H@|bwO==yR`6?1ggfdD&rP51QgnW0 z?ViQ@1M;D=bQy+jbLLSKgnnm+8eEo1)tZgh8qRUs`bi@8a5C(CDJhyAzpOfEXZ0}qd}^QFPsV5De6oyQ2GzD)W6JiJ23g#k{>Z^$7IeZm+| z9e!x>+Vtd4IYv?Tprzbz{~FZdQleE!c!SrlncR_c(fd<|4?M+3QHRoX6T?sUFrvT` zEQiY!#_#QaB|5A$Kp#8@A=h7`aKsaeqPzFvgCdEK#3L3{Vq4Lb~}9$m8mtAIrZ~NsIYo{hBxIZL9_VM5vq`1r@BYE2XG zcSO(7rlLEa;qY6&#PxWJGgLMYT`}%=OeN|^lFrPf^fR<2G*equ^x2h3LnN#+ z>In$U>d-&43MS&ZupB-~mLgu16HzCV>*y$WQRLd(KNmGN=YsJ#^C2dUsk(r!Q65V0 zTpR5$zG)ta&xkknI#B6pe*#H6!0ZE6qKWCIGkQ8o?rb!?p8*L?85ipBGKuW^nw3h5 z*s2f>mpT}rm77pK8nZM25f{tXombS!yyV&j3pJuoEe1(Ks{O4l$eM;l|w7#rC~dO`dnYV z8XJ+A`2lb8BtXq{ck}NhMU|9}C0do|>A9RkZtJk8tt?{l6fhXXS@!OTP_og@k9*%0 zGG^LsD9NvH$+Cv}p>C-vBa5lHB@#~Qh?XefUGrP8G<}V0%|Dko-A_n&VpFigPl7Jmd8m>Nd1^akmBJS+D-kIpHHYWB!^v10DI??vVJQLnXt16v@IOHshc6Ei& z)9b29fBnIByH#$o;!!IkyDG6gbh%N*i6a^Ag&P!gU{&HLce3?e%yWAu>&eLOqYFLHoLeJ5U(^RqqBeYpQFjv%$>)2TZ?89v zt$(B0kkV~h_cKld{ff-|&Yn8kqQjD49WpJ_{#CeC}%t%kDxz_-W1^}R`+JD_u4S>pMLfLfEof27s5n5U%t z^u*S65F=l?8xJK{-7%%Wy@;S|rTYSCTw1=SERi{sw;!%g@r0{J?E)*{aSane*`F;_Wz zI6=qbZEU|1PYK_1E9QMA$mC=&GsD-v%+u_3%+zlAo=@GUf10X#H4@Xbg3Xhf|82~Q z?$y8yKEDTvgyXd*#4D=Dmn%H;!Gqe%ajxJ|@2dhVJO%&&S_6l-T6y86s=f>n#d0}r z0Jp-m)R_!$1zTdt%}6z|Hb&Go@TInR$Q^D686U>V;l@Uu2${5YlT; zm)Ejr-hvG8=A<;wiL24_Bv6cr`P6I>do7mZQ#Qy4n}+X|Za&8pq;;{jx7k= zx9#L|Azi8rDjHR`(@dWk=`iY^_?l#{nW0>q1<@5gnw|xRgxuV^LwfcR!EaGks@Pn_ zZa3AX+wI&)q{icAqHo?tN;Gvvjr{aAd6(KHrvE+Y6s5RiwhU&S^VpL5o{k>=e6O_f z_A19u{m+2}QhP>C7G}{}rJ^d=eXf5x*NG}Le|O|=Yw(alg(!2+b|C5YYJ&KQD>9mr z#8#%mB66>%Le!hhn7yBejbg+`v_ zCu}wb$L@?I@zbcSZ`Nv#0K%v<{_NQvJ|qOzC5MGdZol#Cbw*QEt~9gyDaUr; ztMOV{z;%|{e)1i!0^L$!%fdqj-|x%$E-C2flm1_#yxeZ|sIBw?7fM?9rV@-4y4HQ* zHQLQbHw%o@3rL;J&SJd{NxBucfL~<14d1DRflk0FH+%u!xi*^=;Qb%WBeJ8 zY>Ht^ed(~xlM12-=PouhJ@4RY>b6}L72B|lgs)M$HIWEY99_hpflMQVcV?sUmTgy> z9=5Tkyo!%Y5F98(!%yYK;W8{;5+s*>o<%4)Jt0u-hZ=*&=Pf^+rY#kSF3a#_CYU3a z94G>z@$@4pw|hQw(7yzdgU_#k-~UFMFun%y9EAnf7?qKjZ!I_CcenwF>hOx4zN#(s z8(-X?v5c#Md(GciFJ8~2_xvWaQPm8YGuwvH)l=t#2BwF0#pS$xyiiRyc-jPeGx}@y znSYO{Gl;F&Uku5CK9wIZpLtx`dgPFzXu5eZNqa49?Q0Qypg=dRMbBP>S3F7=L(EwT zbbM*tH(~}0j#P!i%oNKMfXkdj$kH6H7{BP3r?lN^2v_tj*i0opkk@(YukR{7pj1-8O{RJBB9xp*3%EkqN2xZb40!HQuZj7XUJnjnu z91D)uxbtbdtewW2Cjr_~4Lh6Zk&5-2AVA$d)uGQ_=-f{QSh3Ek3~tuY8H+^?l7!JF z6Qo?ttIslaO5tU0+OVOkWB2B&guaiS0yB?(-G`gzXk+OY$(C0_^@BOZT7T^A z*_bg-NP<<^6GC4lp-7@i{k_LYEuZ>=!W$pc)>XDTllI~`KA5BPPmk&7Z4Z)Hijerr zf{D9T(Pyte%rkCuAox#(gakM0|6HY*#r)&ypaS&S9REaAA|hl#-|YW?UUhKK%nAw& zoc>MtA8Y2QbU0mj-uzM>2!?=!gpAet=X{2RtvUmT;=#80b$tKP19#LYc9j!xr(@-0 zxjwR~scDcuX-F_3G7=$~M#Xb^?jH~RHEt03OfameiEB88K7%k6O>%C%-781pqwio0 zfrW0-A0!xRFOaq;B9=~}J9MG?XN%(f*mwW?!NN58e#cN}mH_M@c@nb!$2LkfW`apx zy*ctG-dBUWxGU1zhxCe|$eo%>U}gR&;fq7Iul>fS?y2&OMIV}^V`CEOpJwI?^n$iU zb%IH#^*K3MS@{abrb*6zP_S`Pv>=k>qA26b;l#d0JBC_gip}PiwpTHg8qI9W(?2&A zvAs%iI>XcEt&69}=KHl=hs-VW?K_3(zztmgTB1;3`1O!j1EmWDAh*Ivf1PQd;sd2$ zqJXmnR#@jV2`|=!@%r?)s0*Bl@3*4%E#h#)&5VC$d3Z?uw&r2s5#1M5GPZj1t1{W{ z>)M2FzML5i1Y3@E1Pj`j7V4c`>qW=ftF-|eDB2VNUVn(#TJ2jasamv!rD}85c|&eE z!3)Y5OwAeMW6_4^j>Mhr1!k}0+-@gK=J%mno7v%{BOSZORKT+3q(@QyZ@*qU$HJC- zwoUc{+J02|VCCK!=(xz$bF8i%PtE~)jnSBtppvuOIxCt#ays@Z$x+%4RO)}_6LPI_ zoXPo4zOHT=%h&aT{wMJFS_HP$e!uS_)K5v6>Oqd0y|}NSA{!3@;*eg-Q)7@*F9wmA zxieSs#8;5#SV=bc6R$5gOL{%o<0Vl{>7U)FXX`HE6$&OeHlmHnvwQ;z%hWS)fwAEVt? z>IO2J#Uh-5W7uX>>50w_Ybi1wMmZSqeO4Zq1CXSmpZ44I1STh=d?tZ6=!)400fAyP zDY;8CnJR4zc7L-MUwTM>cliB`J>MUZ)$glBvHAz;5S3r*vEKG54ug15saRlWF@*$Ac>KzbZ@MsBVw|oj4(3s!`(?S+ECqmzI`D z1L4T=FdeyrP(>vqpw@%Er}#?snTf(+`&s8Jrfm|ABEQ*y{w7gq|C) zXWMPqfQn8atUbitaDpfiC;`U8H4u|2TsB1s?WJr@Pb{w`PFAXaZLpY8abXX>qFa<+SAM%BwAMut&AH zbOVY;-^H2uFZDub-&<+u=ju?W>dp8uAw{Uo$2!{oQbXAhqgK}l!IamoMg%|LU8F7a ze7OVzEbL`csgUo=SKl9;vFE-BBwSMvP8?9f_vqC|aDu08F|nPr8c*I;i@k7g?D72* z;L1b?dgTDej?dN)XI33L4T%U2szO1%4!1YGz0kK|AA5!ZJ7aOZ`lyi!J4KS_h##K} zVzOh&rX*g@i#>wLe9|tcz%V&6=kk$;_J&t9s&<{ z7{>iflW-VA?|QQMwcc`W`uC4NH@hTqd%Aa!N@ehwuz7)e+uIjFCSc*jN8mVe>nYzbrd1Y{vVds@hq|c(g*oZ4>EoW zV8!v*Hsb8yAbG{|N9RP&$aMn2Cc{aRvAzFv z4tt`c+W)Vc<-bAvKjmKrNzmm;S#$PH9*F zN`aXE=rak)D{8S^)^z({6C}$jL82f@xV>JZk)IB7#;5*kqypYG-oK_YowKY@LSiAm zQGUtxuRs14N&+T-^S>qfAJxAEOgM8s|H~Lk`dJ2rhZXSqG!D-sF_7Mse^oNy_{E$> zasK&szNatR`+7CQ+i5w4P~{_PJR;TBG}d0!K{qXZ#?E z_~HCt6Th6?=8Vd<2!dg2Iz88bdBO;MwosF^C(zmcI~PENEWb9($RKur&GMBSjHb9k z6%b9DF=Ma{S>|@;>quzFMl^c)!G&UIW15~mmO2q3p0>F5VnTVN{q6RC%z19iK9f|c zl=HcFi|M03xW3CFK>>DcrCf--4oo|q9TWe(|FK<#mV$eJcLW=99?9F|4;HMhkQ+XY z{y(E@sfsJZhKy>m)`mi)UyN(*Or8Vol^dm>u`3S=kAa;>)ialTREc^7nwX2@+a-dE zZr*s88x8XV{mD5jmM?M)y0zO0#S}WQcR#-}@U95@1TVp{0~}m9Lnp+yuoe^cJYs4b zPYo(4?iC-R4G^u<(50uzIeeK@7#Fh1hJnH$Q-1eirT5;mP^hQ?e?$@vMgtpd_H~a& zq-1`XFRnHFt<@Z$MG`UJmNa2Cqbe_~B9K;)NB+tC4sSnXaNZ90v?ryK6^g0u#&o8Z zKsxBE(ed+my!EbLp8N(j^%>z<$AeK?%^pibp8~^oitQiI8ZcuAvf(q457w6`)`EDE z(&O-TCrh!eCVb%!HXpKHsKzohI9Q$7+0GN9bM}@Fn9tvW+B69suB?h6`OBU%X1-Jv zPNFw;0r+R~UED&p&S8qv8DCy^4-N+M8{-D)fkUi&_a=)oS^I1aWQrZgZZajGv)0m+ z6(YS;8*A^jUA$@C4D4SifKD7?0@v@*Dd`RGlF784)CzrGb=jylFY6{0MS#>*4vxKI z4A@oxt&R=QZcBUHw=LXNi0zna;}e`cyEpLmlO5*KWO`U%q%b62`aPz-<&@tw5$&&vjIE&o%)ZLpPni|r6-3P0G zl6?)tW;r)Ju}ZW*0#hW*g73X&7oa(D0BzMCA9UVQFvKT_H%8s4EWY$KoZr`baIO<+ z1-5B0qP1NZ`EKCKmh+@@HJIQnM09qsc2MkUW1ynO`xU_2+Y@@ghnG!%3j}F69%FAnOWz+jjC(Kzs|J}sTuixN6rEH@T182 z-7uWXX~-?fYbMC<5Q_lyfB_v^i7{2SJ1X#kuZVL`v8+~4Jg(wuKSNrhuR6WQPXw=~ zoQ9!)*91?%RQBsGc7?5VXHKZb&6-0yY3=WA-}T;i?ZJfv_&N^LA96SnWf1gMLRL0{ zqu2E&jD`D`(`@;r4g2i9uTZ?}kEpiPt3t>=?hu02?GI3MM3{R)sofHMzCD2panXUqQAaO_){gG((uge!3o}-xP=b&3Ux5>>iLt}B z-d2vn9l4QKx9JaSFLf}YeA558Zx{m_>yq<^T}-sb5%{c5kZI3CiJGI|Yb#AWWjol7 z+<*Y){-_Zrh9tdtYB>O2dQ5tnf=6V#E-Dj^Z@oq+ z2}u3DT}o8?7ev|_4a+2ZVm=r?p5x1XAhsB>{WTj}b7ue19*Iic0R>0l{-9?vxd9uBRws6Ywue+%WZhs3aaQ$3o^c- z_Yav}UIord*qGU$wuiX*@2s(3f$G`@**1!w^xRn2Z+<824+hDDm4QSQ&u^iX`q0c1 zQnnVqKerRBQrDK);+m@?^N!h*WTE^3sB>HHgm5GKmE3E*Z))km)PC?g0O%hyGnV3i zs9I5i$FF+E4tqK0KWtvs5hfvKMj?KNe*wf&vY>12_qW(` z+3Xi-M;PFw)w1v8r&preuMnK`R5qx0dj+*rcaH>R%07ZTb4fFqH`debo$8k9Db(Cn z=^9`5ML?eQ7ru4~yvRUfFA)n(HIQm(&6~-4qHR|DC5~x?)_Gp;?-QFo>;q+2kO)x* z2geD!zf`4Zhcv^=gbzPuyX~&IUss@B6gK21%=h(}6Fu`|_lfNUEA~4pAt<@yMpTVr z9!{r>f||-A)>JR)*B8hAytZpkpHV!u8rHJ`yoDyhL6=OBy&ExTn5b-n=q4_>MWIaF z!J5~1d%_#r%e^Wbt0-WvzSSr&4=H6K3+sS zlKAQG-FL1tm_l3Kr@Pu0@(zUKPcDucVa(HC#6H;M${$F2+~^>um**II4QTc&y*o6O zqiS?KJ=C_LHELK@H>F-qn@@IS0*_&9B(#1iw}h#oCG%KnbbNNmFUTOJwoA6cp{U_F z8{t)>UQoWhAu~$)y78Y=l3dDPec^^{x0*2%p<^|p+sh|p4RgC7(<pO|YAL)Ox**;Pbpv3OhR@gW&%=(8EI@%*LY12;q zG~*YOC+Hf9eEv0=2L(BfZTl)q^Xfsd!)*GD01r>AzA~n2eZL*gR_VRk?nl>i{oiQ`;w)!%YS8_40WbN_Z_3+ zYd@jG)^g?=be$S_BN4w*XC6G$9zr^AR<3v~V0*%?sHchA`B;BbKR~AW_hk9&70@TF zc|9SpW3%4i*q-JJ&CZRg5U~2@-2*phU0IEIV~k?$OmqqEp&r-;E8Zp~*ke?@3&`Si z)0`K7+r!=Q9Kp)}hvQywwLd{iAN#lG{noAX|IGT08p;0x<4}V|MSy}*cgSxCGyb}m zBP~o~dB&}jPd1kS=iAtSNvi!N&2GO`tEy4CxL$t+G1HH{k zL!r(-;6w)M?U{d_f49!^OtX`COZ0vEO!%EJJY?G)g}Qh{IDM15`|+sd1c(Oo(W6V`PO-uU z8=Cs2Q}+@{;OOO)v(-XKs-CU*%58fZdeQ~NO)Gi8&dTjaL%(0Y3x9gevRdHfXO~Mw z%DX1Sc{iVam|0dWoQFM@tX!#n+YLTR4rFH60m2>Icc%~^n`houD5G|! z?z}ehb=wM=(*NfAPKH-IQQ$4px>_7{cPjAJr#3Da0sHcFRae;=`Yj)n%NHF|*p$`n zOy+T4ec(G0qk+851G*qwmtq`8{(50{EH!@&1qlg6Lu8pQfowzATv@C4@yhx@@{Qir z6{rC3l|AplXzLET@G)pKplgA0vNqvr>{amO?w(*!TJ{>IBQ5butHnl*es?2X+6^ir zhKsNN;~GY1Y~jX9Q#m@R&RTJH3O?lh*=w|pgCCC*%ff?b2Y;ACo-&{*p1PBLmccB_j{$vOmxv489!TrlYZv@(kRv!7J-#=qn z5-fgg&fXb1hv?HKT?Rysy7g(CeD+#AR?l*~;(=87Ws7MjxFPBueUFhU-Z2{pWWH+> z^wS?TqiwdXH@fl;IXmx^>mQ|js2L=yNVy>W%=M!*6%Ee20w&xm=tDHZZbUo0kC=0= z2nBEVF(n8zJ)>SeCDJ;Z_c|n(bTG`z+VEU4;E-OXszZ?l|9=k&{mNzIV9Ppxs=pTqF1T-BR6LP^zrz)O*9#0w+=-TGTq0-9UTmr}T|T|>s->gs#0Md+y?5}b^`BEh*oo5&6uVr7`0Y~Rch^|x=5JTpK)>Yr(F7gQ$xd?k0MRP*Rp=w7q ziKwSMvW+?YSSvOP>p=TDKi`X^z({MV#nf5c@XA93MYq*QCW4l!9=H(VLcTxFd-R|> zwsKakE~QvJDxhi;S){3hLY&QC8l%{z`mKSey;-)$po4%pNHDLwPh10VH*62XJ{04! zrqE({LE42jT)(K$gI_vx+}?oU#tleqFZ!t2lX|RX6pk1}F2B-!U6O?){riS!U}bPv zX?qckV^Ga(vS5L#1o!)2+=e8dxF1gccNI0}QPr{z*?WB;0x)u^cXf(&5zThc^nR-9 z99&1gHJS~2UP`JY`8=ZPP-2W?dhG?XpA^B~tW+lS%`g?#sFs_hQtz^1)eN!3&Sa!6 zHocY-{sbnyv~AqWPPu!53zTLP{@dAyVf&mzA$}Oh+mtdYTr|m$iW?=Nig?tWbGkRp zILX}3*zop5C|#T14lnC`xekG$UXR$f*AUJ1n9Rgf6h2xxOvTkJ(rIY)I`1Rl+M<=pe?nuV+EXMEYGIJJr`6W67p#j2$^>oyRrn(bdk#F7E0 zrk};vi>6}hE%O%h8UK6B;CauiA%5PfLysZG$Sfu+(T6#NaeMHw{rl$PHJ(B5Y#8#b{!Rn@S2Yq7DuU%r&+FoL} z!umN8YhsFtVr|x0G#GYXaw!U1m18!xcjPVLBJiLTb+n;RrfvNEyB7%OjR|XbZa#u5 zQsJ3{yP&&y#aJ4@^ug?TXx+Pk!5`ISI+xy{Q|N-Raruk=VJnh%(-~hm?_NLt4J;m` z+Fo+Pn{yS z$5fj_fElDx3RaZfG86>q3!QW*qOo=Z<2}=UKG7gyODuo!i}C)@3mvRP*!R^fvQ6Za zr=$<|@4LDeh&ls(F~*=bC9wR|4D;=b(K@O;AQ3n+2lq}SuH&h!3(I3&&fbFD4`{d5 z*21Qia^oSw^FLoyH>Iu6leP6N~<7)6ENC^}cpLv{J)?j!?W4 z(tEQRa)v<|djbfA4sxmZUZ_opY^i|0iHF2v3>fLJQmOW>@pRNq?|%b-0J>}8jHT*d zDELa*@++YJVucSUeo`SFkC~jTdZ813~D*l^Q|-(}~SpGI(IKXN`O)VqEzvgM7a zkCFe>NIq&@szuazH*dd}%y9ow9Zs9Pf}U#{8deZrFcS(o0a45K)9z@Oe4b|&55vD2 zn5jrH)<1NLtO>0#J%Ycy&X59cz+OJo@OV#?tk}T4$})KTQe)*5%xa1FHb9gUrqFY` zX$j+{PKCPOasQ~Lq@7Lf@zwR6QiB*G{zh^jU`Mb4UB6~C?kQ>vYt#+ew`|{cGoRbx z`Ug)^LInr1pKqLph2LL(gMeWpqnS~kC?AZgF>V|VVb2Yw*8!nA=!G{U697!`wDsAk zO!^HI@+^k+veL*=MWMl*skq2p4<40eSc0-xZbwvcLfO0_2YVQN$zB;o(&J8nE6z4r zjP&A zi$2r6Q}=2k_uzc5RA^LvjfY81^6JU#B(Cpz)mJzkiQ_OOBI_ZZcra`v8A^SDnAb4A z>6LohrpbU{^2Qn%ft2S%@o=ga!dlp=*0FjTPSvQ?=_q3~XueI=B_r)8_(RLt+`k*V z+y&jrdU#xBKAXO>BsJJ>rsf{teT60sNLvKU1g2>_lYL9CO{7PUvri~*WhkEb{zk=L za_)S}P=o&V@@%Kh?OilJoKD%&6`Q^H5wu!InNUt(#$IaFZk?Y zt6tyfh$A!~FZ6dflL;8%vczK4Ew|WEj{k7n&B?n3Zg8R_WHRKg za-~d{!e|AR`QO~@Rfe8k!pvvU%K+NOV?AE}BJIrSfHZnPs@O-ku-uHo>D|#sZufs0 z!if_H+`IFRzZAMRtU&zGbamL&Bpgm|8~v>({}IJMt0`@fEfZ%UAP*DX$-5W10@ok4 zy8&sCTCrg$Iv2dtKz?TZF1_v76fvcjn(xFwmDw+~d&ulhn5Z5frkup$s+`zSdzTIQ zo$f8v#95jsHy{ZbW0#!rE!ck#L=(5EA+^*$h6k`VD*b$L2=Qr)u!qIQKQ*nHKN{p^ zU+V%6NIxGjS14{v$y^@GXHa@8{e<{Q4-rT2bzv$mAV9s3G-QcHtFE&yu z&hFEo#>0koEFJMSvAH5q9Hu9i7-jFb7jX95&6Z3%FM$Hg0d9|CA5jhRX7Pke&R|lc zY}{=h$Y2n4gL7)p1+pJ(*U9LC9jESg1@1W>tp}&13F z7ISZLc>YODwyn9%&v%?2YrX{Fxn9BG&k%doE*5bvgj#sB0}Fi{f~&HG%62*#dIRlp zcXaE<)ZapexzX$@>f-3t5T!$|jVu>c3~M>eHmx42?9uQqH(jlv&dcCQx%y<98$*=V z#=h?w9RHORVbRk(ir1!RN4KNvmwp@*qYn@(*L`P^*$@4uW(1eaR{5FsY-PqYE5UcQ zE!$UnV0^GUn-1@y15;U(^5NKh_U`mAF$%up>mo3S8PVNHfWiySc6Z&_-iJ{}>|m#n zT+#9!|BQcT4$8iDBqLSsVnZ4&|KUM174t@8Bwklq2l1CQ ze1AsUxW$*+?!t8*EFjH#*3JSW$y@B-3H3zH-0ZnIZqAN2(BL#Cye9l-H zgZ{=B>FEkuIHg)u_k%{r{x^L+k?9cWENslWR7#=lKZL@HgYUil z_UlUP1QYe|C%YXtH+hoK&j?`&`H;*c-}=4_gs3o+1m=bLe~9phdr!LOZ@A2UvA|wd z0r?(7IV#sbY{;JqehA`SVj6BsYc}>fo~kIckf|xKlTKT1%kS|@yb7M;4i%ROPBrD? z1$LufTJUz%Nn{poT=cPICCW=~5(#9<0)zSq%9nf%mh#K-uX?(nDP)ofe|<`#m{(K( zuh&bb*hfqNcSqj_f(Qps?`Y!?64rMoL;5yylXbBK~(b zmH~Uc|Le7Bquwvte@iC0+;MWET_pb+rcyc`sq^;a_{a5u1N822VUnQ<%`xH~`M=v) zpri{tlX_ksh*(-C8!kx7Pv{@bq8!YXdQqzu)tt$5(#v;8Wi(xEy%%va-d#k?ZgIx2 zGlA(->m2^IHE|_e*5u*`yqfUKYlEjMs6B84K`F3!9D(e1thUw+K@^!^lPj~gZU+TFCw|Nb49Vmuqdbh2H=4SuZMGd%M&@yY zE@q=Oy(X4?M3*u+R|Us`Z!HbkDBBDeCf3=*QKsL8CFjrK3gb+=RF?D96C4W*6%ek+ z2coR+f@j7}Ha_(EWp~3m=L+xMQG3e~%RP>Ure7?xVk*GX`zMzq`*Mv`iY+A`_S{P$(gS?O=+Hk zVKQ>jd9shWo(c79%cJNp0j}9zsq`O0?q^L$N|3Q8QI?{$pSh_FY z2PKd_rzcI@gnz@5&5)jeyK^$gf)4W;*>M{)-pK!v7yoxIK&+oqa#;a*Q*sRSuy>$g{y)McJI%o4v4))IB*h=#6>KJ#0lj;D_Z_nz(D zU^O~UGs50y9SAGN=4#DpxpSV<-P$0JY=G0GvS1ZGj$}9>J{Aq2Vmm1ne?YxV_qii) zd+aZ%vxJ;7!98L3#D7*4e#^cl7N+Rv{mMxsZ<6;^l}AH*PVlCi6e;{(ITP_snhKR-K*?oY2IU=Iy@l7A)iL;yI?mA_PZYGDn z8*Z>Hy`6)E7iB5xRg`Zf!ty)|)Yc3grUD2F$foAKWDp5>F2J}+pHUt3R~LnkTH~wD zf=sXAYV8}SkHC05tNs?QPF5<3ywfRI(6mo}Q{^Dwny2MmG|iTgcY-pE=)Pe8g|wdG z&hMNO&!-#hg;IvC#l}$`^$b#mGEwMUA*&ec#*oyG@tuNa4!b5+ABkTyp)RF^VHOBn zjgs6&e!4*noXVzav-GsUs~K^|iyp8bnzS65*Efpl`C!)JkmLFyler$U4($~V7oD=DU z0zvhpbKQ=Zk@d*L7p1fqQoB6)oe!WK{@7Hq!-=BVp64V-K&L?Q6!lDB=ep zu;ccS7B{ntSwhUmIUdB#ih0_5!)57J-fKbU=39!Gwf^BZ^ve9+u3gkslrxW}cG?o0Lv)4`kf&{qa z=`8R{7~39^aEs%)j~WvfA%&EVCo-PU%m<988b$b1#5yt^NvU$6*U12)7f^= zuZs-cjlR5nvCE50E^0VtLzR~4PB#xUw3aFUW7iPm^sljz6+2O?exqusWT$}={4t$P zc;``m5sb^o+x}Oupwl%@*88!KBIp*6v7fJh&5CQ}zxCa6uxYKBW;2zxFA-IO1!9wF62<{rBk>J`8+#zV=(rD9kGyT5j z&G(%-Yn?SSYyQmq>b<&l?X|0F*RFeC&;3*-24@*(I{C5{Yqh4oNCkiMO}?dQ2qFja z1rb*nf6l!Z3@@Lppl>&!9wT)nETw%#BYEU2*ycMmqo|jvtXyeM>bANnO-Ji%9Dskl z%AJIK{34R+*M1f`ncG&*-5E+dcq*0nGQSbA#GeZ=|7arlrPS-=&LLknHVG%I+Jn$xog(#I&VrAkm4vS~ zbRpTOo9XYy8X_!2eKwt+z#m9BG3lzru@`>1(5Z#=Aa33pQO-sPi;XO_(d1TBccjsB zurU+s|3p|*^$c0(45eG=$e)N^pDi#22WLGV=c+pqyx4Q6B3Zj(T_d`ig;L__k8Y+K z^pGEf0O3xVVphwwqAeRUTD?KA4#3&r&lh+6_2^3p1)Y@w<;E|2Ii-v(l@faF{4XK? zZ59d`cwb>N18jq=$IaxHSD|7t@VC@vwu<|H+sf46o1Cfz9<%wGi;!H4eXbwd2d6`@ zH==xC!m4Y;FgBz&&feLYu46nbR){}fwAmaN(z_%^-?X}>p7X+JT2bWg33PYY+`C9t}8b|x_2>5y{1z_t#3INK{F&44wHGckUE}PWf?GfFX zBG>EeLGZYu6?AJzfoa_2yV+ks1;yoo4bSj#cgLkv5Q)XPeo0-8 zs~fY{owVAEGKae#lz>-X-;)?9K)YlKN_Fq!-^f_1mX5Yi+qz(V7Q$ISh5!oA?^Fa& z>x37W(FPa9wbixC91`n$ry!>?X0la_!cT*lR&W?0$Z=gSi&f9m>idDHyNm@$-D%62 z$c?MmkdN)CQFGM06}WrPpmv++q3Qmz&(l4fRf*wP++TjxZ_(|2Jm&ENcj4#(1V%fv zeye7dJjxYNQ*6?iTpX>G)A^rpIp@Up$NTBu=J zXd!z&1ElV_yrKL!K0tfiM;qy*f&PRiY(@%Jo(~;wV@&1d7A-o2V)Dv4)@uWmqVOOC)^E0d z6p*)^2kXi#+AEwKy!qBwM}lnXltXEVCE7OD|B&YhNbrgD(9WNSi~5O=!CcSy!YH94{k~okXef-ADm8pGAd< z19d}#TQWaqW(e$la520z?mMcD|~l782@q}-?7YWciz<`?rCwn?H9%mXT0Jsz%R z5=wDYTwl2LnYpdIiAEi!KV8ff*W`o6nQ%m7wvLYYG!uw1c$p!wGreX}BD@W|AC9T| zDTuavBAnKv1k>?wOWbUNs#b-#%Y6kU#X1hSNkN9c!uY-j1&kw<5zatCRw!n?cR+qX1IQGuD^Vb>8sybx;>%0o=y5XfXr^|7>JbHz1sk>|3JtW4@^zoNI2(qE4-9xzu5b$jR)OE9%;>V(4+P90b_>=BP+nj10F-C>0V_;?^Cn$HF=Wt{_X zMTRFR`Cm_4#`vwPQi+j-95QY-k&k%DYKK1OZg%}GEw<~z4j*UjK!$1TxO9I`BvqZ$ zMCl-PBE!LhY9^Pf?*xKekIiaH*X5{kRWf=r?!7SLsq~`eukX!%$Bw)2(nBQu1vLP> zg9uzPu-KB)<+H~yz}az9wQckLV}`Hw;16$15J_r^%Py?f08ezS5RW$DE4SnHRUkPi z<>v>wjxRISj%1IzzTEr8lNWJ7F3zu*vS2T`wW+!n9I>w$lVi$sM7#UF?v+;k?wM*Y z^E3~+!Nc)B=sAZ_ z#wnxp(Lu39LDS{RtOf5(O2Rq*q}Q$LNiyy`b^hzVReE?2H@12=G{-Q5F-zI?9Rt6- zPS$HYn9R3ir+dI_rzQ%quZx7b*DghrPFMk6GCT+^)#6V+Y}D;-dh;zU_tGZr0|JkG z_UhY6R%rpX8*MB-2LkF-6G&T)I49b%d?$xfX=16Y!1oX@@7=+V>F5Uxlc&ECLw+0N ze3OK9v9@!qlBU%9+cY?p_pgita#dQ)4sg|AzX*1%YB=Q8_Y`c#CRTE{;!HO`cn+6l z{b~`MF|!>_KNmF)@z~iSlLWD{fnMfp)}K|I-QSRCWtQB?)4}vK0Gu_8ZoY@KW3k`JOynw=p<< z^ZjMjvZq;g<->i|_foB1c0Yqskcu_N>}A#!riK*55wsni&&g)&RKII~1dV7!AD2j? zmQYuW;f9{PmHUONn@O};TEdDGQSk0Y&}93@t0InrcVX$Atp<(!2{*-|`xh3yq0ej$ zc=*2*O1%2iTNzk-pB>FnyQhnNG##Hwojo$K-t95l?t$UOE*cflPa#1RxUukw|Grg1 z@}W%T(}*4!t4}z=!8F0|DKWTA=RXyC5xoN<8QjEtK9i9F)NFfw+HeRf6s$Kh-AQYL zBG%Rfk`!B#HXyUOfr2p@=x5#bjr!G;cq@dAa*JdAXC`n&pB0P8732mD?&|${{UXTr zO)-vh^Gsk+z4NP9E0F}m7$~`R-0pHo&iJW*!Nx+&RRpyV;L+%kqGGJGJfkzBy?bzaQHpd0#lCHUql|vrZ%1dw=a~ z+TD2d-S9%xC^J=yR16(v0l6C+A!q_BW}ZjlgK@mVHj-!Co6O&Uwqe8OGLl7*{{zlH4;``qpor{8U&ZwCd0BOIN#@Qpr#Odq7TK zH+z7H>udn*>DmsO1kQPJz`K@NcC1{Y$T!ICHToY=(W0QWzdClZbBzjkfS4o^S4SU& zFFiWI><0MyqKR`5z5=NOX`0-&Hi4nWMYqGg9zijv;5k3f)=$3I>CiPE=!3|_%xsMi zj((+YFbO5yyVYU`%wU?&@oV~u^^Y=$!jD11Df6D#gk{4CqE{QyO*z*6i#@xm)2XZS zth%}?%W_ETldrBT>C&kI2-(!{Tu$T0!D9&EK)`kYMCsG!${>>=5 z+`B4c zcIyb%8oNJTkbiv3{jLhFd-}lj30O96r+b07R2wAfD5SYuD*U|1FMD(lIYI{5^3RD3 zjhy(Z;oO{%cV;S#9KYGg;NlXKtMI(bp%|oIgffHAzKdq5*rO zVfyE zXvf5?*6#KloNW>MM8DB%!V?~C3buBIThZvI?YnG&B-6E6NXEG_6JgYgYEK>=6@hpr;frVCC5z_FO}JVs zw_X&B0q*zB;1iuz6$qQDfi9rcIrOHXSz1NksxE-&${>cFCB^F;0X`V8| z!RRG-62=@-QTfOyW_KdDm(z=$D^97y9u80l#KtVXo)gzAhmN46Nd4?xy!<4^L)fz`M5I{A5zY*^@BK*pd zc#PY>M$%mtJ~3*lMGhNsSpVG6CkvdI3f;&oarw;{BT~kiX97NsXZ*qcM5qG8ZzV`{ zE%yc!?t9B#ijHDGbc5iBG=pG^9>=BJFU*=9lb)#xeNP`t)QntrfiwBzJ&zWXEkr>q zCOsg2H*i+YvI~rMS}rD0@S64sKs!SJM9dLH8^%!j5dpi!IkJ5`HSo*2i|o!_HM8=B zHL{Si^j>;@Ie?s5(=(q^9(YyIxkc)r63e*p+WWZ9o`sY%;ha4ESb=plj!OT06~V8o zvU;QNvr{w=np`ps(Nk~D(FQV>VK1TLl0mhln;R8I&__gi4Gg@T=+bi7QY8jHz)@@l z`P)rGKaNlCN2qn&iW<~BDumG)Y6Pt{So>vStUe^S=bMq&eihmIp_w|*wfeloYw1-u zxI`Ks=77z+bvWXyI`dEXk%;+68i`YL=AUTk!PGs9$hUOPH;ABqh5sgTlaPOtIOzSz z!LL8$E}1j@4?AP{@5>;Bq~RZ=s96_y64v?@v?QL$q~z&Ef{G6&G&J!1SKw!BYugEm z`@che+1Vo(d(^ddT7TitQjNcG=r@CwB=rhK%k1@z{~&%0N3>JLLDKP5*>Xyi>W4ym zdt{2)w`lFY9vpqcnzKhRTc3?`@68OfWnp3K^AP_IABVR(?RWIG^O8(PE?CxvE@&hM zE@%N5BBO^&56}dkjzu`?M7A|XI2HvK| zZ+7{!iaUqbKHdHpv>viHu4C%ZC`|sgR^mQoz3e)ymgRcw0F8bV&u%vPSl0D$K7F22 z0eGQ%jx@`sWT2UpUu0OgI{s{^+F1<(nd()SpHfc$;3%Whk@t$Gq(95PQ9V| ziP{W?qgGUuIf#26qeQnJWuYSuvhHjU^%Gmhoy&t1ir$of`=XTuS8(WfiNv4Q{&IjDp;9KAz+#`n< z`b|1n71ut{y@$$~rvmY!R|4@XfDD36rW5gi%xy5F5n}D^@#4ECLODC36)cai6EfaH zwpN&Q@$6{X$&0T2rbpp#2}zLHGVj?7Cec8S)af+T9OAD~(GdOjk;?~37ILwx6dlM{B6Bb=-GIcX_(oFDZk7w|L_l(Z$Xd zw?GdIOXgquhc65kONrB+4SEJI0!3T`&FOk!d-qkpj|?F!EpDu?`py=EaW*n=*+E`3*bNa|y`TO&NOAD(C3&YDd7Y?VW zl~JzL?D#>c2XqDn$D&=>vUO2(uKp#Mo@{HYnz{WOBg8O(>T<_H!}^}SM7tpCK*(M72I<$ zIBGc3+AG!KJ#X6}r#lRY01Ph@mu?k8>cm7No)qJ30_p7uSbiW~W8N?265Cco)sUtJ zu1l~6H=u+(@Vz`$v~fowMo}dbrfgIZ75H?5xz_6{RfK!}sn}Q~!85yGN0YAwD=oQ< zR{=oX=S%fgC_wh36_uz%r3k(9BrlVzf0PM?dkWQlcsOok_>s+vT*o68dRFjMM3n#8 z23oT4#rp6wp|3C57QnT=v{^xSx5fkFvAAX@VcaD=8_LL99nz4?wt$snHnAhxCqER} zj;Qs41#4RGi+iGY#4aT>aN2-#IIXb}8HC~I5=bVfP6s%&96$@;mu`=<=Kg*0s3fa4 z{8H<>@ODQSt!G9ftxfj910tfoz7e2NnsLT`FiY&<<@1iB%o%6U9MCor#1*%(1Bx%S zdqqPWO#sWbzt6N%F+Xc;Iy?Qg9~uRfsY%&>n(ghaDXhK$IG;QvPpdn`99wC^hv&MB z9uc7Co9@I%%x7x-5){vR0|%{g51cnP>|8qn4p9mAfyD8KOQ~gNGKw!x8K`q+bIhSe zkgh#H?wg#v>39pygCDdaoaLhQ@f9@@fnM^gBYd^H*QdN8XR#Hl-4DZoEUP@nhFA_Ra+jj$Uq(UE91yaW((i8WdLpsQ)_j zb!usNnHNJA(+PF8qV(o<{8CC+=jd01eL+!`~FDNyhZW8xMcyR^FH;KsJq4qCaieU(F%o2pqjt7A?tu3r+<6D!1q$PW&jCab&i)!1>7p579abK5lL08duA|1>Y>UG>8uPK}1ikNks z^qpXvDzP>?hnryiaM3YY3?ro_l`qvn6AdjFSuOyQS5~h29{m%S`=#@fv6!ZnNc1 zeAXn0c70R@<}xFZwK{BK47`A7+FAG2Hrld%C(dgKtN7ArdV(2RMm=z29sh`C5dLY- zsWk;|x6;FV>DitwhoIj}_kK1Prw@M0OeqWD;|=0p#yy!yFvqPhvWUyK$oS!?j3wUu zveAC)T*mW3?Z*y(AFh*v?S!<3s1yDzH;cdrQFv8zK&xaa@wAt`_Ntwd+^k{2svdTl zOLE%E;;Z=_upKz>vO9PnH28yOj!1av zv#~;55T*ArOCFhFyrV?l5%_1~ue3)vC3@b=m7aK%`}fh+K`}(+$Ih=iJgMAM14gp+ zWubiHT~Fp;fA&|m@m%z#5^|&BM#AaljG@kPEywSam#qKdW2t}hu|9Xm`DXs50q89{ z`d0sjrV;gn7iIj;wXo9a{pkv=2rIBXKAK#meB-k7Ak*-^SrcyJV%hUcf(PBxB^udT z9{=#MM&XDem5m;cmED_dzY4)!gR7{vs}V@otxTIBtkXZ4j8?GWi&hKxRaQ2?q}_=2 zbSw&xeGAZs<{(pIdBO+98fN{hIPq4N4?J|;Jn)u}puV?#cr5_%}iT#*20SMi*gd5*t z<+(6k9r1iEkFSij79T9q58>(X1XCC&t#=ObX852ius*OI%M#T!8-NJX9y@{PggfnQl;qFU} zM?b2VBk^O2VSa8{@-hHinMekQM9jx%2SuwRpGV@XyR{GY2{OAr|YXI zx1qlAO6ohVLOXFhj;yetgT_bs6R-5Wa5DQINjA1}d*AsSg0c^=SsIrn&bxn57zFX8 zb_(StKH;CY>+D=eb(m46bM#vq+#S=8IDQO}knEm)9?`vYrQ6vU5EYYuV>EgT5nC)*xZT4mv6-YYtS$XBm9`s*ZsM0H9$J#4bQ7kfmnG?&Hy$adYgT zmghQgfHay04(7PFWCD%ot6ypzn2f-rzaZCB$8pG2t*l1rZa3*YYU&94u?LgVcUA?O zN4f03_*f|xPNtyzt*hdr(1p_u41_Xql@mIV;tt+zxf&^>!tU4AV}aI}MH@j(obHC= zU+X`OveWuGJkJe4a>IGb70fQL8gK)KF-G{&U~KT;8u+;Lxn-qG#vLIrgX+4C^4Cys-UwU!fG0=aR&M;FY)Av?dCvez$?P$3? zaJX?XUax*_>$^v0(^8U>CsH|uRz3)$>xr1JH5!J2U=%Zxk{QZVB64;rewB^D$+eVH zccWWK*9}8MUEu;b;&k1=XqC;&dPoI54*G!@WQr)(c82Ct(tDyI!}jnH@KI3x?_%SJ zyC|Sy|1a;)+X@`$(AWa{A9gicJyhhs%^KQNcEW!Pbisf6Sl2=Z4d15Dn1EO*z5fe% zMT<>1I#&2Dr^#?WFd}dO^Ahxt4!O4EEjXK??C}MgKI9=6e{ZR7IoFGg3iu%bF=A4w ze<#0Cdi1h>3$gGwy&7LNmQ3_i!jigPp;T}XeuAa7T~l?L#(!xkX5jVK@Fg55f-+0Y z<#ayC7LG5@9op$Vz~S%@t=eC|5Iytg7r7L~pa6xs5)=UwzDk;jC|1TjBKs))7*p%_ z{c{nZ@Cill&ky1y$DG(yv%zwqM;siJ2L52|R`0syOAo7f0fL3%{;DUYCGchG zP?To*w2S*aejZZ$$WZ*GTxDa`FMP6-=HUdoUbZ{^=ZwJa_BjOg+GBYJ85+xz?_?q+ zPZLyYv;umR0bxb_PQ@JZb^{X;N;{uMstbIoM@R#g#h(NOG4o@4$EpgvYqRsJB3;W2 z@Y*H8R%={RcA_q;yF3x7dB)o`r50}QRb&sgh85V$s!QGZZcvr=<}rJ1f#DdO1-@Ek zO*SJE9zT{ZN%B0~DU90p?3gMQn2TpQu?3x->Dag0jew0K~k|%_#P1~o; z(3?@=xHWO)b|4B}Oh9MyJ|YXLS!T!6!9*nN`V#4V}F`PRP=KOZ#fZ2+4oj&f4Wrni`HH>4Iz5hn>7r6 zlqU*hFINX%$^nrZlarH&!bmGDtL0wCXDX%}!P@$3S}5_W@j2()6}!1$Q5^va3pFq! z7za%J?ATry4?WXa9WCm*c9~r*f&;%aeIJ;OB|lFSx|GtcQsnT=ZuPLA{66rapr9bh zB8s&K)xOPK)rxb~=Cmuy5>H6z^*@28lhyUl1ynH(CN&(T7D=yDC992A?svH^NGzH; z{>D6n`rRWtb<}JjKAj-D}grFeDWSrv2 zriaZRLiFF7$NsayENvw)uj(I!gM!JO*ODtMSbr?=sK}VR9@YO*d;huZ|JTZCp(_gl z+feJl@sZc1dPqK?hWa4r`Vi2H{m&t)lQoX42Z~TJ132QPguPh0+>lgaIcHR7-dQg5 z7|ef8ldyAYH-ZK;vd`>6Z^$?4XSX0!F;tRPGVd3);g^4EmBuVo3SW1*1nD@!5P$Yu z)CKWE#Dd8h|NK#C-_#Ru^6yNEk7WPJkoQ-f#Xmi>RP^@p!h05CcHgT}_V(;<$A7tz zj_8X%hi06;AOH4M_&umPgPr^>q+p*X|AsH{1UI>i6LuG#rSA%9ie+YU-{=@(IN9>- z)M@(DB*;8oc|@aH&2rJ1jmErs_~ZMrEr8FGE(>PlBUdE<%_>w?Tm&=n$c~9hye1l6 zq7F!Y0j~4maHQ82t~HFBX8&{2IIor)MDnELoDAX$qqVuppr*WrKO5@nV0Z$wFf~p$JYjfbRwQQI?*E)(6edTqWCNk=EP|G{$7q zrU*V(tyE^^_oz^wvwMM6$JKI$@E;Ry!!MO5O7{$87rSwJnpzYl7%1#mfFZ2@uM@#& z9kR+9%ozH|;OCwp`v;T`e=y|bdds@d@f2lFf!-w6C>SSKx zblk{Q01g52C`iPWl1GKT%LW80I~9eFFy z_AL0S2Y&nN=dfX7h6Jl#YB~V~KItGqk=iN&1Mc#rF-sijss$oapt*vs_}9B<)qPnP z%0k?M20gsS+x4z}tU`c}$Y^4jQVm6NVa=w0L*4g-R3P`xg^py~^yI!0UbHm^tNIQP zqBxe^E_1;l3RU{vfqJeRE@qq&zPF+vtWwt!wR#Ec<%Or-Cw5`RY_z>uUtWYf&pcVirD;@toWWG+ z0*eAS6Jkb4g}M>Qml?$2u#m$#VGC>{Xl_`UWm?{b-)aK74|NpDc?mqfIsKVT-Vf%a zy92T$tC?6Bbd+}15b9`h%E5^*nW6K`-8G^OmT?Fglk@h=FbQwW5U~5q0L$|Atoxkv z#^s)^e@hJ(g!)5bv2yj`E+mM7UK`dQbpdH^?QP$nug zgC+FS2mb>>Xw&i~^|Oa>IN}eEUsH39Wx@t*m5z7J4}@VAhMWjPc(4qKWO6zK=d{ zVih{0CW>q=uVS6I&M$&LXK3fit49hwFZ)#}Y5KOq%;U}L*IFA%zAw~)KtbCzC_@bx zDgRArMcOTJ%;XNV=T9h~r`0Er%RWw9s^&3PPR2xwUu2pMwi{k6EU}t`br9H8)`d0^1g7RC>J6C~NZpL=lVs&@CS=4@;isJ&qa+t+vB|z4lHK z@UxpAL6Qy5dSR^q>(lu<{}cnP0`_JC)E~uVT+1KOIgL4{b|Wm zc<5j6HH37=qTEJZ!La?&)@h?e@S;g30^{jNfCIG{sDyVd+c0`KI&f!#2yHFsk|6a> zu)DHDS6y_*c`kkVuUB^m4FVDrSlY$z6Af&3)XQnkZ*2Ppm}iUhVR&bEY)-zy*)wn+ zli4t^PqCj?M)+?~pS_Q$W;O|j%~*gEzOK<5YuG^m}%yJYpi2fvhF&uG%D367~F@LrejtKwj zLB?wSue>gKR`dUyJ!aL^jo3;egicwdYO)8B53#TWeSG>)mkPMp2+@TB)yjD2goK1& zGcy$=B+x)L(Vdm5ucX!0@wZ1(nORt(R9{`lqC&s_u@kGgCu|JiBA{qyMi;z}RK^UG zWME>#$BDS#RsU1Ra{A3m3oc&#KvbVuZ}!Ka;;vkg$Y*>i_R0+x_n(#$T++6}eGf05JeSqM>-95N#aCaNrZScWeW`Kbu z`R{wXRa>?7KD^!grE9u>bFFW8pL>4i1S=~_;b4+rA|WB+$ViK;A|WA%BOyIIeS!9L z=DAAbbo`s*aM6lEo^}A)Ge$5NE~b&oXl+e%pBbBIM@W(c?8%w-!ZarbF$rhdR~Bp z^bScz{Ij}i+Tn_eD}FC!|JluIo&n~oPqMq@7MS$(llbCIToRQ(-@TK@!y>Snf93tV z5dT@;FRstZDm~Ai`^rW${JM;JCF|ba3`ayGv2TeL2E*Khl_<7>HqNFl&dzOuHO&d^ zeebCLl^7gdeX~#E|H?TlYWRO=H==zdU;is-(w}pF{VT4ySt|d<38{|L{})H`(H#H3 zxJZ1ikpJSOFi9l-#r07CZ?3Ka>mfieklbL*a5g61>h_Mv4k#)n7OLE$$4tUw+4;?7 zI?o^Sc3>~y``Nq@+SQ^&Wbc?miOcHFHiSAHvU)foWkktQBsI_N!u{S2K2`fr#~um+ zDk$rl^&!W;kx~aS>|CNJkUy?HN6~|+F#H{&{eqsh%d}G*fc51@>#6SU)CsfXG?T(J z-8U-z-3#^Ggqru)=s~G7c|C83N(cTh0U!(n$E>0lvAb8kIGXBRJ=Q;Sj=IGbh zxrKkz+ZdHKF<5?;it*zTjT?Eiz4q5uhf|csg_hm8(>3*`JS{VG46CV4UzdceEaq(W z*u05obC^aYLD@;M%(H`iGo8OT2K-pRl-3H}Z^7!XwmITx`+7^=ek>&Z-H{5I z*`*Gg4tVC*q{_KvH8$sIMd6njVY2H1UJx{Sp6S)><2k`{Ur{38{W6kmx6K`2ait%Z zvw-a`6|&&Lfm$K9-B6{$N^!8_&z6bBETcI{_LV&}($stS#6vMg9;vt7Mr_Ut(QtSB zYSLkUW3A;q_E$T`lh(QPYMna$#f)TZ(7BR;5*avMVzK}~UmUnzP|?28tf4!P=M@9P z5A9HOGM_nMcjsjeu-PGF@(#?<72s_3c;i6p)um@a%IAVZ2S@=jAwn z)v>3r6SBhuz?pLM=4~^E)pF(>WrcjO*h5m%3X}IEY6fVuPwK?^J=4_D*W+K|@*eAi zg-eNvX05V&uRMa zC2jGHzd|Hadf_h9c)#W?{Z@QWwo{EOV#cT?)0UOal;uM$^wlb*O8CM3)UV~1q`iy;XZNRpSMo17d<>o1v&+CDk0Z zp1vjw5ppa6+-g{|5ol*FAln2@t=;ie&#}qXVZEzG$LEY&bDD6>43_l(;L>4k<;~JW zSQx7;)Q6I-^oPp5DmBbxVyT7e5+~*kz0y`6lbnLI`{&{Z3`eP|gJxXUvtVeIf@hH8 z2bLrKFT)im+?MgweD#C5;-}C~xvzPDU%vwZoP=F&10e2dKZ}J;2~Llr&#`N5Sq7Q$ zm(6Y$$LJ%|?dfJB_7Wr92pbFYV}xyx%wVwlxeEeS7943 z*?~))9zD*r@Q$UG75CswJ|+W z5KQ-ld-PG~UvNT_H_MkxCjebg;U8ax@B9YitYa@%)bdsQoOfmnHfoqoxX{fmdFpu@ zy2D_%4RAA-725q&I{A`LEU=wRLM^bZ8~gD*iJw>Q%oH)tk`E4LR9IdpGxYMzN#El< z7+=_4p#xV(lY^-_uag{j0Fwk4tHRbJp^Tf=Ua_9R)H^8;;1jl^ATTd_`U3Mh7dlkr zhk!_1kG~mWmE6S3tEhF419-vjM4ig*wp<4e^tLu01I7IC^4w&ZT8p3?nZ(n%ZobQf z+iP=9t<|-e&ia%l%Wzp5Sldvf)y;j^4O_NP=aTB$lwTe+*_p3eMo37~CGF;{(>@qS z3jTW(Lv4+w)Q*g5iG|bc4HN(yy0{!O#fmkv*5*t3{C_Hjw}m8-+V`i+@jLgwuoOmF z3}v?p)k9R0OJiwU88vcZls=1nou)fLDkw9@DMn{DvGEVh-#*f7z~^DQ$E>J3u^Z;+ z4OyUf_wd;FWQ*01@=&f*cfY-UbL3h*_5&>}S<3J1=C!loOA`XZ?8jU|`m_=iYEp?G zvowx-&mtPL^maUEF(Hz}?h#DGOCL4MFi{bq_4x|drV_m zi1#mrnqJ&uqdPU#<(Oxlz?iey!8PS9es{YZA`sQkyA0+rejApv62(-(FT@SToFBt& zgvZp!v5Q2Om&=&*s+SbX+hIQ@*i(d7LX36V>wPW8%h^v_>SEf_W`9dY{F%L|6Hb)) zgb=hKO98a&zE>~Oe}2*<*g(x>`P@srL??r~oxDSapC!L9&#dOvhw@O9gP0Gc!U@e7 z1zsAuQ#EIQZUp{;kwFMc%cBKWw)@?B3$Hf@rRYpd7dqj&-c5wi{i4`I82D%#9VQn ziDa-S(qkM=OtqDl$KH_o=_~MpoO(gZBZV`$5H!Du^G&_Au?<{+f&%ot;QL-oE(L#2 z(emnd=(hlYIkU$%N-kRj4}3b|0vgrcRry-lIw6@tJ<-!v?GJRgOV@flA&JZq&iv{=qiF;|&8+AXctA;`*`# zlA#}ckj#6{jfN{UloX8I*lE{fc|hxYqr{{KBR{Bn07n&@495hhi@0UkZ)*Kgf?dvG>)?eAcRQ*4UMZ;PW-mMl@*-ut`0FkYdHw4-{U)3;h# z?vBUyizPLWH-7gnN13q6ddz5@aP8zw(8jhwrA({2N=rWyz z59Gtu4R2aEYH?1^*FuK}gHB^>=DeC~CKKdrH%@?k_qb)a zk?wDryklWJchR|~HLv}L4p;P+Br>r%A}g$L?2iWrZn%9$Y9YYrTedT3SQe#QjowS8Vv8y+XZ_?~Qy6!JBMP|~XN~2|dFj#28j=As%zm$?q?`3`hi4UoIDqP4q_n&FR>(!GqgK3@ z#1Bmc1=DS|J7NtWLOOBt|BL{J^N)R^zsUa z-Q(t!TYy0WRG>Chm;!j{K)yUOJ3XY%dSu}^L)GwALH76|9H+5O>uO^`ZhGzZXW4z- z)x8VVPLQ=nvB`4LW;iKIKoGy712;JfhRPE2GFJ$>f&w5Iq z1*Mumz@|E`UVAg>lc&dm(zhGbZ6YbDl@qVpji^lKAU$}9gE!_rZyh-!Mf4LPtG7Q5 z#!$3H{QY6}ROhh99O>YtWKM77kBRl~pgUa0gl+fi=gU{A=9*()#l$XnrpF!{xsc`= zmnH6GNt4)3I5D&YfR{yI?&>=4*{nspGhMp35OuA3Oj7R}Nj_aVWGxi^o!S^edrGdR zZXJG41}rVB?>FmsJ+ygI2?_F6J-}HOP*5kb$h(c}f?aQi+Z-P252=&I=d`quIs*v< z-rd9v|C2Uh>u9(emZ>x@HAPA&%oKX*$HFEKq? zZFNgW{cnTs%O$MwfA;2oJG%nIEh?qBW3wrh89WI4rAGvHroHqIs8CF5866beo&$Zm zm`?a6{1{-JKD1xuNmRqbT)`QGj!oA_4DODt+ZZ`0{k8S>218THJ)U%fRUy8e@i zfY^ql8dt*55_KbI4MDgiWApsQ0%wby_v)VhOF_d87dAwEk$9a~$hty^e(R9iLgUl* zDWS7o_QUI`Il!TM(~X8?J*)FV`E73gJj&>K?T|v_zVBjf%N9 zd}WT5bWNiT%S0-ExFl`+3v?D<(rs(CchKURE4+C(+t9E%e@$4H7Osny$Yfsc7kz2V(YuDHnwDaH$MM_Rt>r?%L2C#eF_XBYdfpBq zULfx`5Cx5zLGDLKfALLPJ;VR#jfMO2NTydl6p^#i8t2D%zqQC@9Ncb^k#mscUmU-?NSQ04R)WVw~g+<=AP0gin&JgOk2olH}jGdXdwj*&lcQu z1evQ}GQ?94*EvW5cjo4YxC(c@pLsTg-PUg5KZx*zvAYt|OB^jNPPTse7IL>mMJm{C zx^tW&bmhg%;7&TyZoxYQaL~BjK2~c#Uv-!YZfQhEHXs%26%%QQQM9wYks(9XxF{zR z1lzDk#7Er|u$fW=L=9Sm;}PLOrz7nGhC*OT?f%4ab6Z1JH_}$hYL%gvZq69XC2w#e zQv3XV2E(HX(QOK5;`+^9o1jO_8B)ZtHB4eQ`$IW0A6T1M@*l2{IkmKQ`lix9dAJ?# z&xLo4gCNyu-JVvcHo~Hr(}EN`aFIV44xd0hz-z%!1LPycm!mGF4~fY%b>6oOHdYM% z$OuW!YWvSEsyXwIb27d)D2k8fF07sW{nPwb{!T>kUN7zF?7km4>bsc{7@gq$HtLWl z=i==z6HIZ!B6KK*hCfZQT|kTJBG%k9g7C+*-2^3ec-|yTaFg%W1?9SA!3NP^=U@ke zj=}rb{Zm=#%~2{}&ptGUi3Eqjx^=Y$5GIms<+fkhyL;7E+VfFXg5~<70MZBIhE+cEW3#u1^Nk4E2uV{!bn?vuWoa}7Fk#Ht6LUu z^_XEYS9Y_306Vs=X!+mu3CWdgWx9F~@gDLe`%djCBqw+htu~T;hQsKkgF{TWT-KTR z*1G^V8?ypNE#40AB$(NP&suJeUK8|u{$ce-Fg8nB`aP|%Hs)ND=SYdFAL@)1*(!|J zg%WooBA3rg!O?+XP`N~(n0Ds6Ta*T5e?(46#qy$G9%fIcV!^W9)@!~BwR4}w z9FW4%V<>@m#W>{w{n7>bUQ>QaGq65B(2O`TC^Piddp;q9YH>aAsY%_*urX(W?S|U4 zj9=cD6GttJ=LTo*OU;I}-g*hl921*jD*oKWGjjj9u`7}6Z^QPWB z8Ngz(hWBQMF>O^km9LH!o@4>BqN5sI({19WHrSQ!ooSSn$+G#<*-*9pAQ-mut&b(L11E}Lcmgk;NXVM6KKXEp0k10UQ9-{qI+m1( z9fa{2$NfyTfGeS`JxVnvGsoH}wD$DR2yEA3mQ23q=&jhFgqx`f6DQ31f%(XfT+HO1 zC+KgaL*zQ@Z&$F7SJ*t;ziA9w>9J)ZU)^$+>nbSTv?7OUT!@iA+k>P?*BKl$4jC(l&FsX9O2o zxnoYHm>3?l@4{(pJ5?gGu@?dJA%+Mxn8_P_)l00hhxo=-=)7V}8>l@LUHsMzy$&TC z(ihyhbBHVg#tXX)SUtqGG`^ox((RZq=S7*fFI2N2(}!bFvMk79Vvvcrrl~`G)sHS| za+c`Qgn?M%rx*QdNi}DDA6a*dsbkeD;9@(%C4(x&e983se%H^?7j+{Q)gq+ zV}UFF%zLuWGg7PT!A(*uO|5J)cRCMejsy>OVc$0uq8F|A=I&lHplQQPTG`mselg2} zNoCh7pNV|-*T5CHYwdT42&&t7h`EQIJKV3V3!_1z+Ui%(2{|^>)zQLQ6J<>W@uW(B z%Vx&hbHL^5;I(V$5Gg9a*_0Kt48^asHw0PG!_m&ppSg zkdSfy0@)@{pIg`c{iq?Q6{Yg@Qw`3|;{Zd0X`*9Mo>79@ECRZOH zwKp5B$I+@f$SE2M9?-j(}7br)5s~J*g7z1{seHbebz#6 zBj9q0YdVd5l5C*L>6!F&1!k)><=y4(Km{h+3}J8gWb3(n++vM24YMQH1xgB^S0~@N z{I*L~cW~W1_}6iN##WGD6Am{XPd{lrd*9o$KW?|Hci_{;JNat%zFcy)Okw^3Py;`B zYyM?J7r4tzGU?;eRHUWLB!$!qQk1ewjlLP8_> zEUzMImH29>l&0n=mJXa%v?T8p>>3Q+D;MyA3;6p% zs}!N0OUlPk3?)0#O?kEgNAenuA?q+1PISTu4nzSWc~$?r0C0*&o~>2#(NM8z za*ojBGAY=J)Fkb0q;y>=Rp};OL~?T2+u+1(T_@qDfYW4NURDn@in% zT3lvv3tUGQQ!e-Jc+gR^qX5BGt0cbZOJYPL{K!_!-D`B9=wvUu%r>3Npq;IwrTNT? z*@Yaoj7@r*3_rMZYdN9biPnN9;qObsA??ZLd3D(eZRm> zza0u0m~q~&fF|=9Z~A?7H4O<0wWh?)9u~ftDq2Dox&qJuV_+sT1hOkLJ}I39^It6H zYtbf1dGXd2AL9+cl3WZiUde-+SwiucX*3(Tq-ow~7Zop$chw`Q^bN6!B`MC0U-LGq>8)PMex6??^7Z)sG(AOs5z)x=I%V$aTn_kLF zI4WCdo8Q%v$*&HuLBaAg%bpaHAso$C&6Yzqr`r4USC0JqCV*aaHUIO%6KR0b@(km! zXe0mG6rDT-UqQ@%U$o7R{63yLn0KO42LpukSgAks@qcIm&chqacAdQ-+rO2&`_xw( zQy|7TQ3Lthg7kB{w3v5}~B$;S@JiEx-qZ?mczivPHQyr$H9FuWNO zvSV;Hfa8HdvZ52D%-TU9elC6b1f49%im6|dlL5C^=*OJAXh}Dbl2AOOYP5b^a(>dnX{+RK`y3t<&dD; zov8H^uq_BtK~^*qIJU@u^>rnc0VC{iC)+^T}E{5XkI(JU%L;TX%8B& zmO~DKYa8Nb8CKf##RFLxc&%lw3`q^?YBh1Xen-6C8!ciUR#J-LGayH)d}m6H2YHA# z#>LH33%E4dZP~5~vXk2k*>l1+U{Gg}Q5UCCw8Cdl@h`j*Js)np{sIFi__;T9hiz$Sjln~(H6^jvsJ@aVyS1Y+ zRO0|^{P$)!{`K=i)WVgb4%|vsW3VDYZVaHn zN?DysuKBt=dQ@s5IizN7Nt7?exO-Uk{r#_0cQ|Z4;#i!H4*C9L!XD8X&Skc;DoHd@ za0Y9MxL(v}_uL1CuCGR5G&3Q9X>C~y^>$%Qaj=Go3A_OPn`7yd<8b=TM8a^n{5zG0vqr4S7q)5XnnpSVB3^A~i8WT*z}U8% zn8-@g!Nk&e^3bztlixSdWFUkFV=ur6_01dC>ld|5SYe&vsk>?uG*(HY{ynI4@2w^h1mNR&T=S(V0u{!|nJs;ju z=dDsXLwSomsIFy&{{rVdix5HmQsk~SrP!CT)}UVE#^l~mukTA3g!<0EMDfG#M)k}j( ziYO||ON1eL>bi0D}RR60!! z-IFtW&>8h~9x$T3RR=$T^HC#@+vyQ0&*sNEMoKRC_-xa7{QSr2)MJg}jl>lW9`yH} z&z8q`xHDQl9IQA!wq&l6iFEp-Xk;zPjZNOBrIt=fRd5)TvQL!2*N* zlW)}&tPamn^YR4oW(Qk{6Vb=3B*ekE6mR+IjXwvY87&JqP~vnUJ|1mK#1iB+h_cyS zqzMv4Pz7LMGxhuVrCuB!;K8dWryO5gbYWs<8wLxy-h)}Ain?hYxrzyiJjIB^*!Gi^J$SB%SY7QOcOyQn4VPYOV6S?mW&kx?~a4b z#MjrdUT$A=!=rpZ_mKbULnU^aQMfE3^2J0k(Rt~MthPW9@7sBBnC}>LvE3ZIu;7SK zefKR0SFsMnm8K+Iwi8BE2S)gV|9G73Ts$++6n3V2qHkjc34ryyTrt zx9Up|aA-bGTdEkovE>NON)?#69-bJ{TZn@KafOxAo8p5O%XX4ga4c& z@}vugu7xPL+KS^fNdXNPNjH-;FRcTouj4}H|9%4skWdkiZe+y)Ln3GL9}4suXjq+F=nQ5z^T`Op0KvBMXK#Fm&t^03{?4B z^$k^@7K_EE{)U-8oQUVuE&(sNR8#ML4mfSv3{BbSE5%ahEh7*%cAcZ<3JRwQoWb)ph|(v+~hP`&!tggjUPLJxF0Gd_c{ z&Zj@WuEuHTtqW{qd>OloW*cqGU_lIPz?^%?)FCu7jvj6-( zRJq9U#prbH#auRM_!VX9PbD8;6n7sPkr7-*7yU0Mm^JjO?eBYenT-aIFA;%)$#_zV zQI6Sy%mvOJG;i7-wcUlQ`RR4q_?}r5!OaD~;U}`p*5x$*rBcQT#ID1~5JsP%Eg)|`Q=1@8~890S)tGYX*C zf>rr!vl6+fjpOZ*CuV%M6@6}A!<3X1B8MMDGvT0Xk8y;mA8)b9=5s=5y_Jh++h6T8 zVgGW@Li)%`>g~C*Jxa5S)SDBopYK1M<7!$vs5a>tS@EYk2>p4h`EKrQm7U?(#N|b# zjNwvoIN6vJ;WX8|@yN}fZcZG>`4^VUzp}0z;2=w)a2poahlp0e05$K5^;q75%DhQo z$HvvW=XaBd<7PM0O2Zb2IX%cRh(J6_L8pF}&=Dp1 z=Ylnd#JacE;`KYE$uQWb9X>I7uP?gCh3%ngTDe<$TGY1aqF-&8=Pwhz?Sz_FF>K z{Si)e;`KG0*W;VgT>r`D)W;K!(<$vqJpvy<%H+wQ2mO|bPb(LgWI-F|q;OmmEuwFj zwq6P|#1tiW+1xatdsq4%ai;;!>ECVUzwgCbz5~qOa1k{9r=K_#$=Fv*!YXNfqF#Ll58c(itTz}tOVhM5)<(c@ z$ii7ha?R>;y>~Ps6qRnk?93JW%zNsvZ~8=)DdT;ft_N-MTE0hljUKK_rb=ExJ`SY7maj>VAT!8UZoJ`U>azJ`!G)iw6Xg?A z8@DI4V#6WBA{da0vj#>l*=iB1sOGN+!2Qdl6`k0o^lN<^&V1UYJ)EgxXWIr&b?xa5 zB0m3o54@vV)vgRhX~xxGMJ=4D;su{?{K3|I1|&We(tiT~-iLUgSMk18cWMnk1T%;h zQmmTZd>TEPgJ(#4jj~^T8a+|Is+jO$F&bmLC?S|WPUz;jInP%1dC`Y+0)9>@$is>* zQNj&fe;^-~mq35wX*+6N67A?`uqas#%1f5~e6A3kJN?$=z#V90M%LJUv7_fL`#u01 z*1FtVPYAO|cz)Rvav;e;Q=sder&lif5P4Ii6cyjTh_xAJctuBf(fweqYP||W4YJ$( z_QiE8|MKoo_(oXx&s*-Il}5Vu2+Fb3@J^JwTh*3p9Q83Lx?aVAoufjIoiDedB;ts> zOcTTIcUBT4<6Mp`YM!_DYO`(wW)1u;-??x>mfb{H?OoeNOP4CER^R-(cm^6-)18Q@ z3PhSWg_fq(M8W3`56#`@YLMf0Fh?J~c6-?M@roS$TQ)*iDeYmJuTZeqb9q~jX zLjb5F9+Z|T3JTcP&^L5U?Sdb2v)b!4C{xE$TWSQix3zZIndcN z%>Gu+#_}X?xfg5*UcE<^GXZ9UaWILthg7}q3`R>_RUWj~Um?s6d#vj_tWR+;Ox2rz zPd5>;Vl-T{-SpnTu3g_b9TQ70y_qw9|I`3W)Z3o28z*+fMs$!@UkM>4;f@CPSf<{YKO|A1Om5(+!IEaFR zV#xAE|M~OhEW^M4DpLmj$UjNkw)90_{4-^2?f>m9;X7}X@I;fie|8RgslNdkzT8hu zM?aGN)lLi$dxufKR-tKX!C=tyeUtqFzZ2$z)0j{HlSvHB#8%uD*gGYwg%VFTV4c{w zq{BxV0c7bUk!z_ip%Lvm?5g3+ydwl<)$q0jF=3FFcW0CBjYQddr!ib6I5mT(SiU74 zldK8~) zs69@?aD_sMzu_(5kb=POcso^wQyb5Y{j&+C?vn;8AocfDH@=6?R~)Yx3Mb?$7Lh5l zNs$&HG2??!utq5Xc+olAvVe!HNCRaqT7k)+Ejg9~d0ohK#O*O8@#8>D(*wQ$asMgA zBq{c=`&9P}vBp4JN_K z(_M%LwjN+|?*N6%M9aca?ZFjC=%XTeS&n$68SC3|Ybrtgs=QyH3;S-3 za8GG^*M&*@_sro}tq;_J&ek%UODU7p7w__ILe~m-Gfa03q;$ASKQOy18tR_&^g*U# z?Ly7Yzb7s=UTkycHYrsaTTu%9`Jot_?=tDRCFD%o$kzhn*Z*oC_if63>i2dIsLqDa zpam{O`Ivx0WL!VGvv8+7m8)X%)yX?1l2_71G4Y_h-}NY5s)BXLzRD+ zymkksnqR^6`J|riOx#49{rH0y(-~hT^P>-{Qli-k=_dR=Z2-iqt-tBKJ4wXs*%aDz z$K$QOkcX1n=u5V~i(xa)1&7UHP_sR8W;n5YC}ywu^;Qnod2>WF1Rk+ngQnb#WqV|@ z9(FWMaZQ1D3;9{k97ozL+Mg>i47V7pQD5N+*0EH>u)IORoxRRcJ+WYVJpR&@IK-8f zT<}Kc1T~W2${2|OTb;N9}eRx0YV+*Y_7P>69*=L(E){H# ze2iyx`-391O?T{}5Pw;Fo(W5u`d*p_?oc_gb#Jy%M21qN^3lq!z}r++ZT=Omgl`Ld z68m(LJyXP4!J%UXv9w0&;I~DyhyM&;dJ68ZBDP|}e_y4lc=q9Ekbq)XR*thx)L8XA zcq-phh5WK>wRoghlMPt2jR1_ixN3!e4s>&GccQQR8 z5bKodf@ds^b%3LwAj>pEHf67!7~&Dzm&<%6w8-_VKu&ypl*nh~=u!>Ub$hJC7$&*;Wg!Z`E_W8ySQFJzbuna*^`+l+HxIA-? ztNeVJ{Nd35k4G%Olh@`FoD*i-@y5#=KrqHT;+%(q#?KKrzv9nI+-=F&g?m+Jh(?F3hrG}XpCR$fblpL^PzMv|^Lk8i^Zk1T&d>f_trRBX=P1)gb_7>ggS zN~!L-Ph+is=W6C`xjx4GZ#Ki74koZNzwT+o-#Q^bd1b7g(unUrh@^?mJq}{<(pc+& z6V5$}MB&u5ZQQ=j@yONpeDTyvb#FAXSklqLvmEk>9#%cx@Or^+dmJv6Y0j$bh@a`0 z25$M&gxS36^a3gZwfj=2iNE+JuzIxx7Jfkwj)?s5eyT99bwpuV{gSm!w>EkJrgo@( z$(>5ht6E`v+=trQU92o}>PT-vO;d`g!JyZlth(@}NWDn z1h5sVzB=J%ktX$En7W~fcHhb71=<#{xycF9iQSuTS#DDug^B*B1Kj#Cl!#Crq^^78 z=>7m$Tx#DA2ojjsUZ2y(Ta4wET$L~yj=G!^(|28CA>nNbEtrk}t#z{Wam$4op-(P8 zlWsGyd7WR|WJ6QK+ph;}Dl<&MAq67>v@Vucz~-|}ycM9TRB`0Gt~nLMDM3d|H}ra5 zn=bNi25U<>$(g@W`m#^@$Lp=}NLJ2X7~DE-NAm!xJm#{$%!SgL^>C8*13=*6aB!`oZ{EXd-9p~dcrx%!(a_}6H*4#sl=v%q<&v}a7BUBDmmVBf@tuvtbbLfqou zc~Y4m$YwADS=W3j)&!V0;1NOAIo!XJoDrAOW;L=--$y={1S~~g|BE}f0tz#yUu`3_lzJ`b=82J^IOLmv!$AJZGqx-9?w`?*~^;)o7YPFBr%q^HTm4 zeqD}c9lk=`K&CdN7oI@X#dDsCYQXSt!m10zQ);gK8>_DS-$#2c<=$;6P8uziNYS;_ zPd7xM5_#0$mdcnsj{bETD{=|e`nN15Cz8ZGMSZg^v8`B3`KH+qPye&OqBoBNfibN= zX9hj-hCiE=H@CyVC9k*_TSqD-7epd8%DGT2|F-bRk4f^uyT-Q=(LrP?8Z}28?Fgs- zpBOCX2?5%VHH@{7Xk`5ZgIoJ5PtdNhz);~Y))4)N>OvwTap6Yp#T6B-!NI{ODBnE| zpaF97@;!5NHym5Ce*+iu{#KOdxHnn4^{%L>$Snm|v|Y?*aMuhtRuL-zi^yja8!BT_vj2x-Rn3x!QnLXI;t!mb%XHP(w zRc(|d<7w1+a*6p?3)ODvZ~uY-klbI4E9>gYSHI#+>f_^+7^esV36*G7L2c+{P$ku{|7-HDV{)e4BdZ~-wQs441O7fnUDqlqMF!NP+or%_u^`7 zj#X*qKX~mO>D%{+1kJDCyWbU}S|+A+oiW|L4|Zg=3tkU?0?xu*z6jhAQ^y*=7Vnc! zFRyimbj!#R)1a0aj0vsn4?V1cN>TQ-&efarI-z|Ez%2jv_{7GG-D1>mTA!6+D91&r z!eO}m2EAs$l7JI5BelGH=}+dEWW-Kr4)NPX{Xk$&X2DhG&7R;o?j%RdsWn?vk>CD? z=Yl=c)4ZROHbCfm)X}XqtN(meseBy#5slycv04{wYj0am2$lu z)ICSp7TG#}v8+35SZOY?xK z=8(2ohKI(7=EZyeC+o;_RWX%Zmsbtil}P&<74aohvnBIL%@3vY+H3Zx;_4yqo~x=C z2hynK%Hq9w_vxMI!Tp1@@9qa8c@~{DxAYol2ggPFn0$4b+x0~{F;jltr5~eNZX47rFquU#o9#Q!ieSZ3KC0GW`JBmWD|m7J@0k;ubo%J z`W|t-?F*gjzdsqXw8j@?kjbmAIh(ff6*n}`w}ydlhn@&sLI(ral$(SHv6t&TPR*Hf z6bFjnNu~qE_1^aIt@(k66nWwQp#_i-nwtqs=1fE{PZIBTDd225tDw`VC;d#Ce|jIP zrX9Q9^%@eqMB$y}6V6oX>ika4_(7)T@(kpjMQU?jz&H#9#Rv~7@Sk@_9*P)e>$eBF zdWqBhNNB7ebv+2ATUe>a;NP=29Kd{MH~yz~C}}LQs6B>sF-EsPV*6HHu31k97#ME?qNp)_Lk0ot#pLU3#6JnKR;Z>t>*2e#$x?fNIu+n=2N%7H~as* z>UbmDYFcJaY7qw%&;Gkx&Go&9pMNZpKRzK|s$l%q?3~x{WOET)0JEOUFY{l%rO5O? zV-OA{;CNs}@22w{Ow($L6}&Sc0Ni%Gkr_V7G`t^?U`oH>lOgT9I4kZtx2_L!YSo2u zSVX61Q?i=urC|u!eUbY;1YdHUbXqBDdAxKC%ciU{93<2KQSZmApR90(Er~0tkS?n^ zN9PKS&*plG1iFLo88S*LrS>oL-YK))ec3rW&u};Jd^#b#zhV_LIQH!koV}WW*!lLPchAFQRGkH4MpfsJl~(qDgW~w9@R7*Sr=9Fh~y@M zH-$2FL?g-?*>X>{+~0q-!8Ah6%9{B+v10kp-%q6U!nB2u0ZQFY1}^2gy&%!EY;m+t zuMLBNg8-TLM6p{jD^OJ%ae--5J^~A816vPsj`| zz$YQ7)Kork&C%53-RbW^X$YC|BNke#w)gfk3Vu9s0t;tWIeYWm8MFsHvw5yh=nt^Z z+N$RDSW0zW8=B=q5+eSDMCg3@>F5(O_4{B~llKe!rpZ?XCzePuiTbj&Ga>axp2g^5 z%{(gFW%?xCzp~5?UINE7RVO22bS{~`Bs_Dqz1wcg0thX6K&n%1=+k|N0D9)9`^sao+JQ+xU?Ou)-CUh?pkHDzx(M6CL{o0{sO5Mp)eP z<%F?=CVOx}blP^WAj|oSKc=aJo)|gxulmJIzMo=i+yjlLTqb%DL>IRRB(%jRKm6)d zKA!@fC#n^6V@V?A@b+Y@;Tl7GV(TX8tA`E|33r`p=Qua^XR+9!3c0u#S z>Q!tMvVu^bVh~+R2)Oh&9KA*`PRyA2Bzl|#>T+|WSJ$L?el@+!bu@f|IQ!-2$M$Z# z>K?@pr?Ln|#33r?wzr7pnr^FJOan8N?}g{ufx!OOdBRM^pY6f1fcJ&jUG2+;;S6;h zJYF*WO#Hlqs<&8#h_bzPwg_H4ue=&Nq&4SkfXQ=wCEErV;k*ime}B5%t2G8)q}KTz z>axPpl04|5>fUu#k2(J7abR|&I9kGPn|W7KlUFDs_^H^kST)@?NuHCUvW)m$l*tOY z|5c*ADA!SUV*EgEj3gGjZbJLK0ET~DuPE$B#SbFpW}k>j@Zv}!B7w)9pvw>mY2px+ zzwO;>odzEqn{OCSU-bRi<_+v~RDpejl&baREULvMa!7s!a6HvR~AVM2x$$alb zlK+T_3g7Ho67h4EDO|jCXwrhBgsqyz+w4kAaW z+eoCY%LR+By4jR*Kr%~e3s2ENTO3W-0i7;zg{E~B$&bW7#AcIYwFj1p`pGh}ot?

    5*aMCr@0bH=hm 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 literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_gen_keys_crts.png b/docs/images/sso_via_saml_conf/ss_gen_keys_crts.png new file mode 100644 index 0000000000000000000000000000000000000000..17ce5a3404493b4dd39ebc79a4521d81c072a50c GIT binary patch literal 151822 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¥%GshRk)lK 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 IM6N<$f}|)4mH+?% literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_left_nav_client.png b/docs/images/sso_via_saml_conf/ss_left_nav_client.png new file mode 100644 index 0000000000000000000000000000000000000000..da7d46172909cffb6cef22bb2e64e12f40ec96cf GIT binary patch literal 30726 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 literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_set_attribute_name1.png b/docs/images/sso_via_saml_conf/ss_set_attribute_name1.png new file mode 100644 index 0000000000000000000000000000000000000000..231bff865a7818c2e0d7aa696d2aeef2c21363a0 GIT binary patch literal 33655 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 T^__qI9#}~+xpzfx^*{X&c;k~g literal 0 HcmV?d00001 diff --git a/docs/images/sso_via_saml_conf/ss_set_attribute_name2.png b/docs/images/sso_via_saml_conf/ss_set_attribute_name2.png new file mode 100644 index 0000000000000000000000000000000000000000..2873dd7d5d9c7bff8e18d782e2bcc2208772e704 GIT binary patch literal 38125 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ð-QMqI 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 literal 0 HcmV?d00001 diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index 48bfc3951..c54707c39 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -85,8 +85,14 @@ STUB_CODE = """ """ +# enable login by identity provider +USE_SAML2 = False + # looks for a given category forn a operation ending with GROUP_POSTFIX # e.g. category = Tex will look for TexGroup # all users in that Group are set to the operations of that category # having the roles in the TexGroup GROUP_POSTFIX = "Group" + +# dir where mscolab single sign process files are stored +MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') diff --git a/docs/samples/config/mscolab/mss_saml2_backend.yaml.samlple b/docs/samples/config/mscolab/mss_saml2_backend.yaml.samlple new file mode 100644 index 000000000..22c0cd67b --- /dev/null +++ b/docs/samples/config/mscolab/mss_saml2_backend.yaml.samlple @@ -0,0 +1,116 @@ +name: Saml2 +config: + entityid_endpoint: true + mirror_force_authn: no + memorize_idp: no + use_memorized_idp_when_force_authn: no + send_requester_id: no + enable_metadata_reload: no + + # SP Configuration for localhost_test_idp + localhost_test_idp: + name: "MSS Colab Server - Testing IDP(localhost)" + description: "MSS Collaboration Server with Testing IDP(localhost)" + key_file: path/to/key_sp.key # Will be set from the mscolab server + cert_file: path/to/crt_sp.crt # Will be set from the mscolab server + verify_ssl_cert: true # Specifies if the SSL certificates should be verified. + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + metadata: + local: [path/to/idp.xml] # Will be set from the mscolab server + + entityid: http://localhost:5000/proxy_saml2_backend.xml + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + + + # # SP Configuration for IDP 2 + # sp_config_idp_2: + # name: "MSS Colab Server - Testing IDP(localhost)" + # description: "MSS Collaboration Server with Testing IDP(localhost)" + # key_file: mslib/mscolab/app/key_sp.key + # cert_file: mslib/mscolab/app/crt_sp.crt + # organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + # contact_person: + # - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + # - {contact_type: support, email_address: support@example.com, given_name: Support} + + # metadata: + # local: [mslib/mscolab/app/idp.xml] + + # entityid: http://localhost:5000/proxy_saml2_backend.xml + # accepted_time_diff: 60 + # service: + # sp: + # ui_info: + # display_name: + # - lang: en + # text: "Open MSS" + # description: + # - lang: en + # text: "Mission Support System" + # information_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # privacy_statement_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # keywords: + # - lang: en + # text: ["MSS"] + # - lang: en + # text: ["OpenMSS"] + # logo: + # text: "https://open-mss.github.io/assets/logo.png" + # width: "100" + # height: "100" + # authn_requests_signed: true + # want_response_signed: true + # want_assertion_signed: true + # allow_unknown_attributes: true + # allow_unsolicited: true + # endpoints: + # assertion_consumer_service: + # - [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + # discovery_response: + # - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + # name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + # name_id_format_allow_create: true \ No newline at end of file diff --git a/docs/samples/config/mscolab/setup_saml2_backend.py.sample b/docs/samples/config/mscolab/setup_saml2_backend.py.sample new file mode 100644 index 000000000..23d1cea61 --- /dev/null +++ b/docs/samples/config/mscolab/setup_saml2_backend.py.sample @@ -0,0 +1,68 @@ +import os +import sys +import warnings +import yaml +from saml2 import SAMLError +from saml2.client import Saml2Client +from saml2.config import SPConfig +from urllib.parse import urlparse + + +class setup_saml2_backend: + from mslib.mscolab.conf import mscolab_settings + + CONFIGURED_IDPS = [ + # configure your idps here + { + 'idp_identity_name': 'localhost_test_idp', # make sure to use underscore for the blanks + 'idp_data': { + 'idp_name': 'Testing Identity Provider', # this name is used on the Login page to connect to the Provider. + } + }, + + ] + + if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml"): + with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: + yaml_data = yaml.safe_load(fobj) + # go through configured IDPs and set conf file paths for particular files + for configured_idp in CONFIGURED_IDPS: + # set CRTs and metadata paths for the localhost_test_idp + if 'localhost_test_idp' == configured_idp['idp_identity_name']: + yaml_data["config"]["localhost_test_idp"]["key_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key' # set path to your mscolab key file + yaml_data["config"]["localhost_test_idp"]["cert_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt' # set path to your mscolab certiticate file + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml' # set path to your idp metadata xml file + + # configuration localhost_test_idp Saml2Client + try: + if not os.path.exists(yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0]): + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"] = [] + warnings.warn("idp.xml file does not exists !\ + Ignore this warning when you initializeing metadata.") + + localhost_test_idp = SPConfig().load(yaml_data["config"]["localhost_test_idp"]) + sp_localhost_test_idp = Saml2Client(localhost_test_idp) + + configured_idp['idp_data']['saml2client'] = sp_localhost_test_idp + for url_pair in (yaml_data["config"]["localhost_test_idp"] + ["service"]["sp"]["endpoints"]["assertion_consumer_service"]): + saml_url, binding = url_pair + path = urlparse(saml_url).path + configured_idp['idp_data']['assertion_consumer_endpoints'] = \ + configured_idp['idp_data'].get('assertion_consumer_endpoints', []) + [path] + + except SAMLError: + warnings.warn("Invalid Saml2Client Config with localhost_test_idp ! Please configure with\ + valid CRTs metadata and try again.") + sys.exit() + + # if multiple IdPs exists, development should need to implement accordingly below + """ + if 'idp_2'== configured_idp['idp_identity_name']: + # rest of code + # set CRTs and metadata paths for the idp_2 + # configuration idp_2 Saml2Client + """ diff --git a/docs/sso_via_saml_mscolab.rst b/docs/sso_via_saml_mscolab.rst new file mode 100644 index 000000000..69277ff14 --- /dev/null +++ b/docs/sso_via_saml_mscolab.rst @@ -0,0 +1,560 @@ +SSO via SAML Integration Guide for MSColab Server +================================================= + +In this documentation, you will go through the following topics. + + 1. Introduction + + 2. Configuring an existing IdP + + * Private key and certificate + + * Configuring MSColab settings + + * MSColab configurations + * Establish pysaml2, Saml2Client for the MSColab server + + * Configuration `mss_saml2_backend.yaml` file + + * Access SAML2Client metadata of MSColab + + * Guide to IDP Configuration + + 3. Configuration example through Keycloak 13.0.1 + + * Setting Up Keycloak + + * Installation and run Keycloak + * Setup Keycloak IdP + + * Configure MSColab server + + * Configuration in MSColab settings for Keycloak + * Configuration `mss_saml2_backend.yaml` file + + 4. Configuration Multiple IDPs + +1. Introduction +*************** +This documentation will explain how to configure MSColab with an existing IdP or multiple IdPs, along with examples of implementation. + +If you are not aware of how the SAML process works in the MSColab server, it is highly recommended to set up msidp and test it with MSColab as an initial step before configuring existing 3rd party IdPs. + +.. note:: + You can find instructions to set up msidp by `conf_sso_test_msscolab.rst`. + + +2. Configuring an existing IdP +****************************** + +To configure an existing IdP, you will need a signed certificate and a private key for the MSColab server. Additionally, you will require metadata for the IdP to complete the configuration. + +Furthermore, you will need to configure saml2 setup in your `setup_saml2_backend.py` file and configure settings in your `mscolab_settings.py` file. On development you need only to use your PYTHONPATH and `setup_saml2_backend.py`, `mscolab_settings.py` in that path. + +.. note:: + When you want to set a parameter or change a default add it to that file, + + eg:- + + $ more mscolab_settings.py + + USE_SAML2 = True + +Also, you should be careful to return the attributes `username` and `email` address accordingly from the IdP along with the SAML response. + +Private key and certificate +--------------------------- + +You can store your private key and certificate in any highly secure location. To configure MSColab for SSO, you just need to specify the paths to your certificate and key files in the configuration. + +Private key and certificates path can be setup by your `mss_saml2_backend.yaml` file or when you Establishing Saml2Client for the MSColab server in your `setup_saml2_backend.py` file. + + +Configuring MSColab settings +---------------------------- + +MSColab configurations +###################### + +This section provides a guide for implementing MSColab with a single IdP. You can make the necessary changes in your `mscolab_settings.py` or `conf.py` file and your `setup_saml2_backend.py`. + +.. note:: + Sensible defaults of MSColab are opinionated. All these are defined in conf.py and those which you want to change you can add to a mscolab_settings.py in your search path. + +Before running the MSColab server, ensure `USE_SAML` is set to `True` in your `mscolab_settings.py`. + +.. code:: text + + # enable login by identity provider + USE_SAML2 = True + +To enabling login via the Identity Provider; need to implement `mss_saml2_backend.yaml` with paths for .crt and .key files, configure mscolab_settings.py, and configure `setup_saml2_backend.py` + +In this implementation, as we are enabling only one IdP, there is no need to configure the default testing IdP (msidp). You can disable it simply by removing ``localhost_test_idp`` from the list of ``CONFIGURED_IDPS`` in your `setup_saml2_backend.py` file. Additionally, remember to add your ``idp_identity_name`` and ``idp_name`` accordingly. + + +.. code:: text + + # idp settings + class setup_saml2_backend: + CONFIGURED_IDPS = [ + { + 'idp_identity_name': 'localhost_test_idp', # make sure to use underscore for the blanks + 'idp_data': { + 'idp_name': 'Testing Identity Provider', # this name is used on the Login page to connect to the Provider + } + }, + ,] + + +.. note:: + Please refer to the sample template `setup_saml2_backend.py.sample` located in the `docs/samples/config/mscolab` directory. + + Idp_identity_name refers to the specific name used to identify the particular Identity Provider within the MSColab server. This name should be used in the `mss_saml2_backend.yaml` file when configuring your IdP, as well as in the MSColab server configurations. It's important to note that this name is not visible to end users + + Remember to use underscore for the blanks in your `idp_identity_name`. + + Idp_name refers to the name of the Identity Provider that will be displayed in the MSColab server web interface for end users to select when configuring SSO. + + +Establish pysaml2, Saml2Client for the MSColab server +##################################################### + +You should establish a Saml2Client, a component designed for handling SAML 2.0 authentication flows. This Saml2Client will be configured to work seamlessly with the MSColab server, ensuring that authentication requests and responses are handled correctly. + +You should do implementation by your `setup_saml2_backend.py` file. + +.. code:: text + + # if multiple 3rd party exists, development should need to implement accordingly below + """ + if 'idp_2'== configured_idp['idp_identity_name']: + # rest of code + # set CRTs and metadata paths for the idp_2 + # configuration idp_2 Saml2Client + """ + +After completing these steps, you can proceed to configure the `mss_saml2_backend.yaml` file. + +Configuration mss_saml2_backend.yaml file +----------------------------------------- + +You should create a new attribute using the ``idp_identity_name`` defined in the previous step. Afterward, you will need to create the necessary attributes in the `.yaml` file accordingly. If need, you can also update these attributes using the server + +Please refer the yaml file template (`mss_saml2_backend.yaml.samlple`) in the directory of `docs/samples/config/mscolab` to generating your IdP file. + +.. code:: text + + # SP Configuration for IDP 2 + sp_config_idp_2: + name: "MSS Colab Server - Testing IDP(localhost)" + description: "MSS Collaboration Server with Testing IDP(localhost)" + key_file: mslib/mscolab/app/key_sp.key + cert_file: mslib/mscolab/app/crt_sp.crt + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + metadata: + local: [mslib/mscolab/app/idp.xml] + entityid: http://localhost:5000/proxy_saml2_backend.xml + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + - [http://localhost:8083/idp2/acs/redirect, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + +.. note:: + Make sure to update + entityid : 'idp_identity_name' + Assertion_consumer_service : with the urls of assertion consumer services functionalities URL that going to implement next step, may be better to explain here + + Key_file : if need can be update through the server + Cert_file : if need can be update through the server + Metadata.local : if need can be update through the server + + +Access SAML2Client metadata of MSColab +-------------------------------------- + +While the core purpose of IdPs is to authenticate users and provide information to relying parties, the responses can vary based on configuration, protocol, user attributes, consent, and customization. Therefore, responses from different IdPs can indeed be different, and developers and administrators should be aware of these variations when integrating with different identity providers. However, in the MSColab server, we implemented an easy way to access metadata from an endpoint. You can access it easily by using the specified url, which is configured based on the settings of your SAML2 client in your `setupsaml2backend.py` and `saml2backend.yaml` file. This streamlined approach simplifies the process and eliminates the need for manual development of endpoints and functionalities specific to each IdP. + +.. note:: + URL to access metadata endpoint for particular IdP: + ``/metadata/`` + +Guide to IDP Configuration +-------------------------- + +In the SSO process through the MSColab server, the username is obtained as ``givenName``, and the email address is obtained as ``email``. Therefore, when configuring the IdP, it is necessary to configure it accordingly to ensure the correct return of the givenName attribute and the email address along with the SAML response. + + +3. Configuration example through Keycloak 13.0.1 +************************************************ + +Setting Up Keycloak +------------------- + +Installation and run Keycloak +############################# + +Via local installation + 1. Download the file (requires java, wget installed): + + .. code:: text + + cd $HOME && \ wget -c keycloak_13_0_1.tar.gz https://github.com/keycloak/keycloak/releases/download/13.0.1/keycloak-13.0.1.tar.gz -O - | tar -xz + +| + + 2. Navigate to the KeyCloak binaries folder: + + .. code:: text + + cd keycloak-13.0.1/bin + +| + + 3. And start it up: + + .. code:: text + + ./standalone.sh + +| + +Via Docker (requires Docker installed) + + .. note:: + + You can define KEYCLOAK_USER and KEYCLOAK_PASSWORD as you wish. Recommends using tools like pwgen to generate strong and random passwords. + + * Open your terminal and run + + .. code:: text + + docker run -p 8080:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=pwgen_password quay.io/keycloak/keycloak:13.0.1 + +| + + .. image:: images/sso_via_saml_conf/ss_docker_run_cmd.png + :width: 400 + + + +Setup Keycloak IdP +################## + +Access Keycloak + Once you successfully install and start keycloak, you can Access keycloak interface through a particular port using your web browser. + eg:- http://localhost:8080 + + .. image:: images/sso_via_saml_conf/ss_interface_keycloak.png + :width: 800 + +Login as an admin + You can go to the admin console and login as an admin by providing the above provided credentials. + + .. image:: images/sso_via_saml_conf/ss_admin_login.png + :width: 400 + +Create realm + Once successfully logged in you should create a realm to configure IdP. You can create a realm by clicking `Add realm` button. + + .. image:: images/sso_via_saml_conf/ss_add_realam_btn.png + :width: 300 + + You need to provide a name for your realm and create. + + .. image:: images/sso_via_saml_conf/ss_add_realam_name.png + :width: 800 + +Create a client specifically for SAML + + Once you successfully created a realm, lets create a client specifically for SAML. + + First you should navigate into the client section using your left navigation. + + .. image:: images/sso_via_saml_conf/ss_left_nav_client.png + :width: 200 + + In the client section you can see `create` button in the top right corner. + + Create a new client by clicking `create` button in the top right corner. + + .. image:: images/sso_via_saml_conf/ss_create_client_btn.png + :width: 800 + + .. note:: + When creating client ID, it should be same as the issuer ID of the MSColab server. + In here, the MSColab server used different issuer IDs for the particular idp_iedentity_name, and issued it by url bellow + + http://127.0.0.1:8083/metadata/idp_identityname/ + + + Also make sure to select Client Protocol as saml. + .. image:: images/sso_via_saml_conf/ss_set_client_protocol.png + :width: 800 + + After creating a SAML client, make sure you set Valid Redirect URIs to match our Service Provider. + + Eg:- + http://127.0.0.1:8083/* + + http://localhost:8083/* + + + Generate keys and certificates + + To generate keys and certificates first navigate into saml keys tab and click `Generate new keys` button. + .. image:: images/sso_via_saml_conf/ss_gen_keys_crts.png + :width: 800 + + You can copy generated keys and certificates by clicking top of the key and certificate. After clicked you should need to create .crt and .key file accordingly. + + .. note:: + In here when you creating .key and .crt make sure to begin creating file structure accordingly. + + Eg:- + .key file + + ----BEGIN RSA PRIVATE KEY----- + + Key key key key key key key + + -----END RSA PRIVATE KEY----- + + | + + .crt file + + -----BEGIN CERTIFICATE----- + + Crt crt crt crt + + -----END CERTIFICATE----- + + + Configure keycloak IdP for endusers + + You can enable user registration through enabling, Realm Settings>login>User-registration + + First go to Realm settings through left navigation, + + .. image:: images/sso_via_saml_conf/ss_left_nav_realm_settings.png + :width: 200 + + Then goto `Login` tab and enable User registration. + + .. image:: images/sso_via_saml_conf/ss_enable_usr_reg.png + :width: 800 + + Add email and givenName into mappers + + .. note:: + In the MSColab server, we take the attribute name for email as `email` and for the username as `givenName`. Therefore, we need to implement mappers accordingly for the Keycloak end. + + In this example, We need to add the Keycloak built-in email mapper and givenName mapper to obtain it in our MSColab server through the SAML response with correct attribute names. + + eg:- + + clients>yourcreatedCliet>Mappers>Add Builtin Protocol Mapper enable email + + First navigate into client section through left navigation. + + .. image:: images/sso_via_saml_conf/ss_left_nav_client.png + :width: 200 + + Select client we created already + + .. image:: images/sso_via_saml_conf/ss_client_select.png + :width: 800 + + Go to the Mapper section tab, and Click `Add Builtin` button to add Mappers. + + .. image:: images/sso_via_saml_conf/ss_add_mappers_btn.png + :width: 800 + + Since we need email address and givenName, enable those and click `add selected` button. + + .. image:: images/sso_via_saml_conf/ss_enable_mappers.png + :width: 800 + + Then you can see Added mappers in your interface + + .. image:: images/sso_via_saml_conf/ss_view_mappers.png + :width: 800 + + + Set SAML Attribute Names as `email` and `givenName`. + + .. image:: images/sso_via_saml_conf/ss_set_attribute_name1.png + :width: 800 + + .. image:: images/sso_via_saml_conf/ss_set_attribute_name2.png + :width: 800 + + Export IdP metadata + + When all sorted you need to export metadata file from the keycloak, + + http://localhost:8080/auth/realms/saml-example-realm/protocol/saml/descripto + + Since we're going to import the file with the name as "key_cloak_v_13_idp.xml" in this example, We should store it with the same name. + + +Configure MSColab server +######################## + +Configuration in MSColab settings for Keycloak + This involves Updating your `conf.py` file or `mcolab_settigns.py`, and update your `conf.py` file or `setup_saml2_backend.py`. + + 1. Set USE_SAML = True in your mcolab_settigns.py + + .. code:: text + + # enable login by identity provider + USE_SAML2 = True + + 2. Insert Keycloak into list of CONFIGURE_IDP in your setup_saml2_backend.py + + .. code:: text + + # idp settings + class setup_saml2_backend: + CONFIGURED_IDPS = [ + { + 'idp_identity_name': 'key_cloak_v_13', # make sure to use underscore for the blanks + 'idp_data': { + 'idp_name': 'Keycloak V 13', # this name is used on the Login page to connect to the Provider + } + }, + ,] + + .. note:: + Make sure to insert idp_identity_name as above ('key_cloak_v_13'), which used in this example. + +Configuration mss_saml2_backend.yaml file + + Create your mss_saml2_backend.yaml file in your ``MSCOLAB_SSO_DIR``. + + .. code:: text + + name: Saml2 + config: + entityid_endpoint: true + mirror_force_authn: no + memorize_idp: no + use_memorized_idp_when_force_authn: no + send_requester_id: no + enable_metadata_reload: no + + + # SP Configuration for localhost_test_idp + key_cloak_v_13: + name: "Keycloak Testing IDP" + description: "Keycloak 13.0.1" + key_file: path/to/key_sp.key # Will be set from the mscolab server + cert_file: path/to/crt_sp.crt # Will be set from the mscolab server + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + + metadata: + local: [path/to/idp.xml] # Will be set from the mscolab server + + + entityid: http://127.0.0.1:8083/metadata_keycloak/ + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/keycloak_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + + + .. note:: + make sure to set same issuer ID in your saml_2.yaml file correctly + eg:- entityid: http://127.0.0.1:8083/metadata/ + + .. note:: + may be can be occured invalid redirect url problem, since we defined localhost in keycloak admin, and using 127.0..... be careful to set it correctly. + + eg:- + assertion_consumer_service: + - [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + - [http://localhost:8083/localhost_test_idp/acs/redirect,] + + +4. Configuration Multiple IDPs +****************************** + +As we have already implemented one IdP, we can extend the list of IdPs and implement functions specific to each IdP as needed. diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 3953fb99e..77aae4cd9 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -19,6 +19,7 @@ build: - mswms_demodata = mslib.mswms.demodata:main - mscolab = mslib.mscolab.mscolab:main - mssautoplot = mslib.utils.mssautoplot:main + - msidp = mslib.msidp.idp:main requirements: build: @@ -92,6 +93,9 @@ requirements: - email_validator - keyring - dbus-python + - flask-login + - pysaml2 + - libxmlsec1 test: imports: @@ -101,6 +105,7 @@ test: - mswms_demodata -h - msui -h - mscolab -h + - msidp -h about: summary: 'A web service based tool to plan atmospheric research flights.' diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 8ffd5bb83..998afa4d8 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -27,6 +27,13 @@ import os import logging import secrets +import sys +import warnings +import yaml +from saml2 import SAMLError +from saml2.client import Saml2Client +from saml2.config import SPConfig +from urllib.parse import urlparse class default_mscolab_settings: @@ -107,6 +114,15 @@ class default_mscolab_settings: # filepath to md file with gdpr GDPR = None + # enable login by identity provider + USE_SAML2 = False + + # SSL certificates verification during SSO. + VERIFY_SSL_CERT = True + + # dir where mscolab single sign process files are stored + MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') + mscolab_settings = default_mscolab_settings() @@ -116,3 +132,73 @@ class default_mscolab_settings: mscolab_settings.__dict__.update(user_settings.__dict__) except ImportError as ex: logging.warning(u"Couldn't import mscolab_settings (ImportError:'%s'), using dummy config.", ex) + +try: + from setup_saml2_backend import setup_saml2_backend + logging.info("Using user defined saml2 settings") +except ImportError as ex: + logging.warning(u"Couldn't import setup_saml2_backend (ImportError:'%s'), using dummy config.", ex) + + class setup_saml2_backend: + # idp settings + CONFIGURED_IDPS = [ + { + 'idp_identity_name': 'localhost_test_idp', + 'idp_data': { + 'idp_name': 'Testing Identity Provider', + } + + }, + # { + # 'idp_identity_name': 'idp2', + # 'idp_data': { + # 'idp_name': '2nd Identity Provider', + # } + # }, + ] + if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml"): + with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: + yaml_data = yaml.safe_load(fobj) + # go through configured IDPs and set conf file paths for particular files + for configured_idp in CONFIGURED_IDPS: + # set CRTs and metadata paths for the localhost_test_idp + if 'localhost_test_idp' == configured_idp['idp_identity_name']: + yaml_data["config"]["localhost_test_idp"]["key_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key' + yaml_data["config"]["localhost_test_idp"]["cert_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt' + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml' + + # configuration localhost_test_idp Saml2Client + try: + if not os.path.exists(yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0]): + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"] = [] + warnings.warn("idp.xml file does not exists !\ + Ignore this warning when you initializeing metadata.") + + localhost_test_idp = SPConfig().load(yaml_data["config"]["localhost_test_idp"]) + localhost_test_idp.verify_ssl_cert = mscolab_settings.VERIFY_SSL_CERT + sp_localhost_test_idp = Saml2Client(localhost_test_idp) + + configured_idp['idp_data']['saml2client'] = sp_localhost_test_idp + for url_pair in (yaml_data["config"]["localhost_test_idp"] + ["service"]["sp"]["endpoints"]["assertion_consumer_service"]): + saml_url, binding = url_pair + path = urlparse(saml_url).path + configured_idp['idp_data']['assertion_consumer_endpoints'] = \ + configured_idp['idp_data'].get('assertion_consumer_endpoints', []) + [path] + + except SAMLError: + warnings.warn("Invalid Saml2Client Config with localhost_test_idp ! Please configure with\ + valid CRTs metadata and try again.") + sys.exit() + + # if multiple IdPs exists, development should need to implement accordingly below, + # make sure to set SSL certificates verification enablement. + """ + if 'idp_2'== configured_idp['idp_identity_name']: + # rest of code + # set CRTs and metadata paths for the idp_2 + # configuration idp_2 Saml2Client + """ diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 6a87e8a7a..63339db4d 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -44,14 +44,16 @@ class User(db.Model): confirmed = db.Column(db.Boolean, nullable=False, default=False) confirmed_on = db.Column(db.DateTime, nullable=True) permissions = db.relationship('Permission', cascade='all,delete,delete-orphan', backref='user') + authentication_backend = db.Column(db.String(255), nullable=False, default='local') - def __init__(self, emailid, username, password, confirmed=False, confirmed_on=None): + def __init__(self, emailid, username, password, confirmed=False, confirmed_on=None, authentication_backend='local'): self.username = username self.emailid = emailid self.hash_password(password) self.registered_on = datetime.datetime.now() self.confirmed = confirmed self.confirmed_on = confirmed_on + self.authentication_backend = authentication_backend def __repr__(self): return f'' diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index d61a24ad0..ab56e5c20 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -32,6 +32,9 @@ import shutil import sys import secrets +import subprocess +import time +import git from mslib import __version__ from mslib.mscolab.conf import mscolab_settings @@ -93,6 +96,264 @@ def handle_db_seed(): print("Database seeded successfully!") +def handle_mscolab_certificate_init(): + print('generating CRTs for the mscolab server......') + + try: + cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", + f"{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key", + "-nodes", "-x509", "-days", "365", "-batch", "-subj", + "/CN=localhost", "-out", f"{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt"] + subprocess.run(cmd, check=True) + print("generated CRTs for the mscolab server.") + return True + except subprocess.CalledProcessError as error: + print(f"Error while generating CRTs for the mscolab server: {error}") + return False + + +def handle_local_idp_certificate_init(): + print('generating CRTs for the local identity provider......') + + try: + cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", + f"{mscolab_settings.MSCOLAB_SSO_DIR}/key_local_idp.key", + "-nodes", "-x509", "-days", "365", "-batch", "-subj", + "/CN=localhost", "-out", f"{mscolab_settings.MSCOLAB_SSO_DIR}/crt_local_idp.crt"] + subprocess.run(cmd, check=True) + print("generated CRTs for the local identity provider") + return True + except subprocess.CalledProcessError as error: + print(f"Error while generated CRTs for the local identity provider: {error}") + return False + + +def handle_mscolab_backend_yaml_init(): + saml_2_backend_yaml_content = """name: Saml2 +config: + entityid_endpoint: true + mirror_force_authn: no + memorize_idp: no + use_memorized_idp_when_force_authn: no + send_requester_id: no + enable_metadata_reload: no + + # SP Configuration for localhost_test_idp + localhost_test_idp: + name: "MSS Colab Server - Testing IDP(localhost)" + description: "MSS Collaboration Server with Testing IDP(localhost)" + key_file: path/to/key_sp.key # Will be set from the mscolab server + cert_file: path/to/crt_sp.crt # Will be set from the mscolab server + verify_ssl_cert: true # Specifies if the SSL certificates should be verified. + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + metadata: + local: [path/to/idp.xml] # Will be set from the mscolab server + + entityid: http://localhost:5000/proxy_saml2_backend.xml + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + - [http://localhost:8083/localhost_test_idp/acs/redirect, + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + + + # # SP Configuration for IDP 2 + # sp_config_idp_2: + # name: "MSS Colab Server - Testing IDP(localhost)" + # description: "MSS Collaboration Server with Testing IDP(localhost)" + # key_file: mslib/mscolab/app/key_sp.key + # cert_file: mslib/mscolab/app/crt_sp.crt + # organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + # contact_person: + # - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + # - {contact_type: support, email_address: support@example.com, given_name: Support} + + # metadata: + # local: [mslib/mscolab/app/idp.xml] + + # entityid: http://localhost:5000/proxy_saml2_backend.xml + # accepted_time_diff: 60 + # service: + # sp: + # ui_info: + # display_name: + # - lang: en + # text: "Open MSS" + # description: + # - lang: en + # text: "Mission Support System" + # information_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # privacy_statement_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # keywords: + # - lang: en + # text: ["MSS"] + # - lang: en + # text: ["OpenMSS"] + # logo: + # text: "https://open-mss.github.io/assets/logo.png" + # width: "100" + # height: "100" + # authn_requests_signed: true + # want_response_signed: true + # want_assertion_signed: true + # allow_unknown_attributes: true + # allow_unsolicited: true + # endpoints: + # assertion_consumer_service: + # - [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + # - [http://localhost:8083/idp2/acs/redirect, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + # discovery_response: + # - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + # name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + # name_id_format_allow_create: true +""" + try: + file_path = f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml" + with open(file_path, "w", encoding="utf-8") as file: + file.write(saml_2_backend_yaml_content) + return True + except (FileNotFoundError, PermissionError) as error: + print(f"Error while generated backend .yaml for the local mscolabserver: {error}") + return False + + +def handle_mscolab_metadata_init(repo_exists): + ''' + This will generate necessary metada data file for sso in mscolab through localhost idp + + Before running this function: + - Ensure that USE_SAML2 is set to True. + - Generate the necessary keys and certificates and configure them in the .yaml + file for the local IDP. + ''' + print('generating metadata file for the mscolab server') + + try: + command = ["python", "mslib/mscolab/mscolab.py", "start"] if repo_exists else ["mscolab", "start"] + process = subprocess.Popen(command) + + # Add a small delay to allow the server to start up + time.sleep(10) + + cmd_curl = ["curl", "http://localhost:8083/metadata/localhost_test_idp", + "-o", f"{mscolab_settings.MSCOLAB_SSO_DIR}/metadata_sp.xml"] + subprocess.run(cmd_curl, check=True) + process.kill() + print('mscolab metadata file generated succesfully') + return True + + except subprocess.CalledProcessError as error: + print(f"Error while generating metadata file for the mscolab server: {error}") + return False + + +def handle_local_idp_metadata_init(repo_exists): + print('generating metadata for localhost identity provider') + + try: + if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml"): + os.remove(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml") + + idp_conf_path = "mslib/msidp/idp_conf.py" + + if not repo_exists: + import site + site_packages_path = site.getsitepackages()[0] + idp_conf_path = os.path.join(site_packages_path, "mslib/msidp/idp_conf.py") + + cmd = ["make_metadata", idp_conf_path] + + with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml", + "w", encoding="utf-8") as output_file: + subprocess.run(cmd, stdout=output_file, check=True) + print("idp metadata file generated succesfully") + return True + except subprocess.CalledProcessError as error: + # Delete the idp.xml file when the subprocess fails + if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml"): + os.remove(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml") + print(f"Error while generating metadata for localhost identity provider: {error}") + return False + + +def handle_sso_crts_init(): + """ + This will generate necessary CRTs files for sso in mscolab through localhost idp + """ + print("\n\nmscolab sso conf initiating......") + if os.path.exists(mscolab_settings.MSCOLAB_SSO_DIR): + shutil.rmtree(mscolab_settings.MSCOLAB_SSO_DIR) + create_files() + if not handle_mscolab_certificate_init(): + print('Error while handling mscolab certificate.') + return + + if not handle_local_idp_certificate_init(): + print('Error while handling local idp certificate.') + return + + if not handle_mscolab_backend_yaml_init(): + print('Error while handling mscolab backend YAML.') + return + + print('\n\nAll CRTs and mscolab backend saml files generated successfully !') + + +def handle_sso_metadata_init(repo_exists): + print('\n\ngenerating metadata files.......') + if not handle_mscolab_metadata_init(repo_exists): + print('Error while handling mscolab metadata.') + return + + if not handle_local_idp_metadata_init(repo_exists): + print('Error while handling idp metadata.') + return + + print("\n\nALl necessary metadata files generated successfully") + + def main(): parser = argparse.ArgumentParser() parser.add_argument("-v", "--version", help="show version", action="store_true", default=False) @@ -119,6 +380,15 @@ def main(): action="store_true") database_parser.add_argument("--add_all_to_all_operation", help="adds all users into all other operations", action="store_true") + sso_conf_parser = subparsers.add_parser("sso_conf", help="single sign on process configurations") + sso_conf_parser = sso_conf_parser.add_mutually_exclusive_group(required=True) + sso_conf_parser.add_argument("--init_sso_crts", + help="Generate all the essential CRTs required for the Single Sign-On process " + "using the local Identity Provider", + action="store_true") + sso_conf_parser.add_argument("--init_sso_metadata", help="Generate all the essential metadata files required " + "for the Single Sign-On process using the local Identity Provider", + action="store_true") args = parser.parse_args() @@ -130,6 +400,13 @@ def main(): print("Version:", __version__) sys.exit() + try: + _ = git.Repo(os.path.dirname(os.path.realpath(__file__)), search_parent_directories=True) + repo_exists = True + + except git.exc.InvalidGitRepositoryError: + repo_exists = False + updater = Updater() if args.update: updater.on_update_available.connect(lambda old, new: updater.update_mss()) @@ -186,6 +463,32 @@ def main(): for email in args.delete_users_by_file.readlines(): delete_user(email.strip()) + elif args.action == "sso_conf": + if args.init_sso_crts: + confirmation = confirm_action( + "This will reset and initiation all CRTs and SAML yaml file as default. " + "Are you sure to continue? (y/[n]):") + if confirmation is True: + handle_sso_crts_init() + if args.init_sso_metadata: + confirmation = confirm_action( + "Are you sure you executed --init_sso_crts before running this? (y/[n]):") + if confirmation is True: + confirmation = confirm_action( + """ + This will generate necessary metada data file for sso in mscolab through localhost idp + + Before running this function: + - Ensure that USE_SAML2 is set to True. + - Generate the necessary keys and certificates and configure them in the .yaml + file for the local IDP. + + Are you sure you set all correctly as per the documentation? (y/[n]): + """ + ) + if confirmation is True: + handle_sso_metadata_init(repo_exists) + if __name__ == '__main__': main() diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index ee1b190ad..2cb0e7c01 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -34,14 +34,17 @@ import sqlalchemy.exc from itsdangerous import URLSafeTimedSerializer, BadSignature from flask import g, jsonify, request, render_template, flash -from flask import send_from_directory, abort, url_for +from flask import send_from_directory, abort, url_for, redirect from flask_mail import Mail, Message from flask_cors import CORS from flask_httpauth import HTTPBasicAuth from validate_email import validate_email +from saml2.metadata import create_metadata_string +from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST +from flask.wrappers import Response -from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Change, MessageType, User +from mslib.mscolab.conf import mscolab_settings, setup_saml2_backend +from mslib.mscolab.models import Change, MessageType, User, db from mslib.mscolab.sockets_manager import setup_managers from mslib.mscolab.utils import create_files, get_message_dict from mslib.utils import conditional_decorator @@ -191,6 +194,40 @@ def wrapper(*args, **kwargs): return wrapper +def get_idp_entity_id(selected_idp): + """ + Finds the entity_id from the configured IDPs + :return: the entity_id of the idp or None + """ + for config in setup_saml2_backend.CONFIGURED_IDPS: + if selected_idp == config['idp_identity_name']: + idps = config['idp_data']['saml2client'].metadata.identity_providers() + only_idp = idps[0] + entity_id = only_idp + return entity_id + return None + + +def create_or_update_idp_user(email, username, token, authentication_backend): + try: + user = User.query.filter_by(emailid=email).first() + + if not user: + user = User(email, username, password=token, confirmed=False, confirmed_on=None, + authentication_backend=authentication_backend) + db.session.add(user) + db.session.commit() + + else: + user.authentication_backend = authentication_backend + user.hash_password(token) + db.session.add(user) + db.session.commit() + return True + except (sqlalchemy.exc.OperationalError): + return False + + @APP.route('/') def home(): return render_template("/index.html") @@ -201,10 +238,19 @@ def hello(): if request.authorization is not None: if mscolab_settings.__dict__.get('enable_basic_http_authentication', False): auth.login_required() - return "Mscolab server" - return "Mscolab server" + return json.dumps({ + 'message': "Mscolab server", + 'USE_SAML2': mscolab_settings.USE_SAML2 + }) + return json.dumps({ + 'message': "Mscolab server", + 'USE_SAML2': mscolab_settings.USE_SAML2 + }) else: - return "Mscolab server" + return json.dumps({ + 'message': "Mscolab server", + 'USE_SAML2': mscolab_settings.USE_SAML2 + }) @APP.route('/token', methods=["POST"]) @@ -684,6 +730,143 @@ def reset_request(): return render_template('errors/403.html'), 403 +if mscolab_settings.USE_SAML2: + # setup idp login config + setup_saml2_backend() + + # set routes for SSO + @APP.route('/available_idps/', methods=['GET']) + def available_idps(): + """ + This function checks if IDP (Identity Provider) is enabled in the mscolab_settings module. + If IDP is enabled, it retrieves the configured IDPs from setup_saml2_backend.CONFIGURED_IDPS + and renders the 'idp/available_idps.html' template with the list of configured IDPs. + """ + configured_idps = setup_saml2_backend.CONFIGURED_IDPS + return render_template('idp/available_idps.html', configured_idps=configured_idps), 200 + + @APP.route("/idp_login/", methods=['POST']) + def idp_login(): + """Handle the login process for the user by selected IDP""" + selected_idp = request.form.get('selectedIdentityProvider') + sp_config = None + for config in setup_saml2_backend.CONFIGURED_IDPS: + if selected_idp == config['idp_identity_name']: + sp_config = config['idp_data']['saml2client'] + break + + try: + _, response_binding = sp_config.config.getattr("endpoints", "sp")[ + "assertion_consumer_service" + ][0] + entity_id = get_idp_entity_id(selected_idp) + _, binding, http_args = sp_config.prepare_for_negotiated_authenticate( + entityid=entity_id, + response_binding=response_binding, + ) + if binding == BINDING_HTTP_REDIRECT: + headers = dict(http_args["headers"]) + return redirect(str(headers["Location"]), code=303) + return Response(http_args["data"], headers=http_args["headers"]) + except (NameError, AttributeError): + return render_template('errors/403.html'), 403 + + def create_acs_post_handler(config): + """ + Create acs_post_handler function for the given idp_config. + """ + def acs_post_handler(): + """ + Function to handle SAML authentication response. + """ + try: + outstanding_queries = {} + binding = BINDING_HTTP_POST + authn_response = config['idp_data']['saml2client'].parse_authn_request_response( + request.form["SAMLResponse"], binding, outstanding=outstanding_queries + ) + email = None + username = None + + try: + email = authn_response.ava["email"][0] + username = authn_response.ava["givenName"][0] + token = generate_confirmation_token(email) + except (NameError, AttributeError, KeyError): + try: + # Initialize an empty dictionary to store attribute values + attributes = {} + + # Loop through attribute statements + for attribute_statement in authn_response.assertion.attribute_statement: + for attribute in attribute_statement.attribute: + attribute_name = attribute.name + attribute_value = \ + attribute.attribute_value[0].text if attribute.attribute_value else None + attributes[attribute_name] = attribute_value + + # Extract the email and givenname attributes + email = attributes["email"] + username = attributes["givenName"] + token = generate_confirmation_token(email) + except (NameError, AttributeError, KeyError): + return render_template('errors/403.html'), 403 + + if email is not None and username is not None: + idp_user_db_state = create_or_update_idp_user(email, + username, token, idp_config['idp_identity_name']) + if idp_user_db_state: + return render_template('idp/idp_login_success.html', token=token), 200 + return render_template('errors/500.html'), 500 + return render_template('errors/500.html'), 500 + except (NameError, AttributeError, KeyError): + return render_template('errors/403.html'), 403 + return acs_post_handler + + # Implementation for handling configured SAML assertion consumer endpoints + for idp_config in setup_saml2_backend.CONFIGURED_IDPS: + for assertion_consumer_endpoint in idp_config['idp_data']['assertion_consumer_endpoints']: + # Dynamically add the route for the current endpoint + APP.add_url_rule(f'/{assertion_consumer_endpoint}/', assertion_consumer_endpoint, + create_acs_post_handler(idp_config), methods=['POST']) + + @APP.route('/idp_login_auth/', methods=['POST']) + def idp_login_auth(): + """Handle the SAML authentication validation of client application.""" + try: + data = request.get_json() + token = data.get('token') + email = confirm_token(token, expiration=1200) + if email: + user = check_login(email, token) + if user: + random_token = secrets.token_hex(16) + user.hash_password(random_token) + db.session.add(user) + db.session.commit() + return json.dumps({ + "success": True, + 'token': random_token, + 'user': {'username': user.username, 'id': user.id, 'emailid': user.emailid} + }) + return jsonify({"success": False}), 401 + return jsonify({"success": False}), 401 + except TypeError: + return jsonify({"success": False}), 401 + + @APP.route("/metadata/", methods=['GET']) + def metadata(idp_identity_name): + """Return the SAML metadata XML for the requested IDP""" + for config in setup_saml2_backend.CONFIGURED_IDPS: + if idp_identity_name == config['idp_identity_name']: + sp_config = config['idp_data']['saml2client'] + metadata_string = create_metadata_string( + None, sp_config.config, 4, None, None, None, None, None + ).decode("utf-8") + return Response(metadata_string, mimetype="text/xml") + return render_template('errors/404.html'), 404 + + def start_server(app, sockio, cm, fm, port=8083): create_files() sockio.run(app, port=port) diff --git a/mslib/mscolab/utils.py b/mslib/mscolab/utils.py index e9d41d48f..74e429127 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -76,3 +76,4 @@ def os_fs_create_dir(dir): def create_files(): os_fs_create_dir(mscolab_settings.MSCOLAB_DATA_DIR) os_fs_create_dir(mscolab_settings.UPLOAD_FOLDER) + os_fs_create_dir(mscolab_settings.MSCOLAB_SSO_DIR) diff --git a/mslib/msidp/README.md b/mslib/msidp/README.md new file mode 100644 index 000000000..360f587f5 --- /dev/null +++ b/mslib/msidp/README.md @@ -0,0 +1,3 @@ +# Identity Provider with PySAML2 Integration + +This repository contains an Identity Provider (IdP) implementation that enables single sign-on (SSO) authentication using PySAML2. diff --git a/mslib/msidp/__init__.py b/mslib/msidp/__init__.py new file mode 100644 index 000000000..4c998f670 --- /dev/null +++ b/mslib/msidp/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msidp + ~~~~~~~~~~~ + + init file of msidp + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" diff --git a/mslib/msidp/htdocs/login.mako b/mslib/msidp/htdocs/login.mako new file mode 100644 index 000000000..6d72acd80 --- /dev/null +++ b/mslib/msidp/htdocs/login.mako @@ -0,0 +1,29 @@ +<%inherit file="root.mako"/> + +

    Please log in

    +

    + To register it's quite simple: enter a valid username and a password +

    + +
    + + + + +
    + +
    +
    +
    +
    + +
    + +
    +
    + +
    + + +
    diff --git a/mslib/msidp/idp.py b/mslib/msidp/idp.py new file mode 100644 index 000000000..55d04c051 --- /dev/null +++ b/mslib/msidp/idp.py @@ -0,0 +1,1166 @@ +# pylint: skip-file +# -*- coding: utf-8 -*- +""" + mslib.msidp.idp.py + ~~~~~~~~~~~~~~~~~~ + + MSS Identity provider implementation. + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +# Additional Info: +# This file is imported from +# https://github.com/IdentityPython/pysaml2/blob/master/example/idp2/idp.py +# and customized as MSS requirements. Pylint has been disabled for this imported file. + +# Parts of the code + +import argparse +import base64 +import ssl +import importlib +import logging +import os +import re +import time +import sys + +from mslib import msidp +from http.cookies import SimpleCookie +from hashlib import sha1 +from urllib.parse import parse_qs +import saml2.xmldsig as ds + +from saml2 import ( + BINDING_HTTP_ARTIFACT, + BINDING_HTTP_POST, + BINDING_HTTP_REDIRECT, + BINDING_PAOS, BINDING_SOAP, + BINDING_URI, + server, + time_util +) +from saml2.authn import is_equal +from saml2.authn_context import ( + PASSWORD, + UNSPECIFIED, + AuthnBroker, + authn_context_class_ref +) +from saml2.httputil import ( + BadRequest, + NotFound, + Redirect, + Response, + ServiceError, + Unauthorized, + get_post, + geturl +) +from saml2.ident import Unknown +from saml2.metadata import create_metadata_string +from saml2.profile import ecp +from saml2.s_utils import PolicyError, UnknownPrincipal, UnsupportedBinding, exception_trace, rndstr +from saml2.sigver import encrypt_cert_from_item, verify_redirect_signature +from werkzeug.serving import run_simple as WSGIServer + +from mslib.msidp.idp_user import EXTRA +from mslib.msidp.idp_user import USERS +from mako.lookup import TemplateLookup +from mslib.mscolab.conf import mscolab_settings + +logger = logging.getLogger("saml2.idp") +logger.setLevel(logging.WARNING) + +DOCS_SERVER_PATH = os.path.dirname(os.path.abspath(msidp.__file__)) +LOOKUP = TemplateLookup( + directories=[os.path.join(DOCS_SERVER_PATH, "templates"), os.path.join(DOCS_SERVER_PATH, "htdocs")], + module_directory=os.path.join(mscolab_settings.DATA_DIR, 'msidp_modules'), + input_encoding="utf-8", + output_encoding="utf-8", +) + + +class Cache: + def __init__(self): + self.user2uid = {} + self.uid2user = {} + + +def _expiration(timeout, tformat="%a, %d-%b-%Y %H:%M:%S GMT"): + """ + :param timeout: + :param tformat: + :return: + """ + if timeout == "now": + return time_util.instant(tformat) + elif timeout == "dawn": + return time.strftime(tformat, time.gmtime(0)) + else: + # validity time should match lifetime of assertions + return time_util.in_a_while(minutes=timeout, format=tformat) + + +# ----------------------------------------------------------------------------- + + +def dict2list_of_tuples(d): + return [(k, v) for k, v in d.items()] + + +# ----------------------------------------------------------------------------- + + +class Service: + def __init__(self, environ, start_response, user=None): + self.environ = environ + logger.debug("ENVIRON: %s", environ) + self.start_response = start_response + self.user = user + + def unpack_redirect(self): + if "QUERY_STRING" in self.environ: + _qs = self.environ["QUERY_STRING"] + return {k: v[0] for k, v in parse_qs(_qs).items()} + else: + return None + + def unpack_post(self): + post_data = get_post(self.environ) + _dict = parse_qs(post_data if isinstance(post_data, str) else post_data.decode("utf-8")) + logger.debug("unpack_post:: %s", _dict) + try: + return {k: v[0] for k, v in _dict.items()} + except Exception: + return None + + def unpack_soap(self): + try: + query = get_post(self.environ) + return {"SAMLRequest": query, "RelayState": ""} + except Exception: + return None + + def unpack_either(self): + if self.environ["REQUEST_METHOD"] == "GET": + _dict = self.unpack_redirect() + elif self.environ["REQUEST_METHOD"] == "POST": + _dict = self.unpack_post() + else: + _dict = None + logger.debug("_dict: %s", _dict) + return _dict + + def operation(self, saml_msg, binding): + logger.debug("_operation: %s", saml_msg) + if not (saml_msg and "SAMLRequest" in saml_msg): + resp = BadRequest("Error parsing request or no request") + return resp(self.environ, self.start_response) + else: + # saml_msg may also contain Signature and SigAlg + if "Signature" in saml_msg: + try: + kwargs = { + "signature": saml_msg["Signature"], + "sigalg": saml_msg["SigAlg"], + } + except KeyError: + resp = BadRequest("Signature Algorithm specification is missing") + return resp(self.environ, self.start_response) + else: + kwargs = {} + + try: + kwargs["encrypt_cert"] = encrypt_cert_from_item(saml_msg["req_info"].message) + except KeyError: + pass + + try: + kwargs["relay_state"] = saml_msg["RelayState"] + except KeyError: + pass + + return self.do(saml_msg["SAMLRequest"], binding, **kwargs) + + def artifact_operation(self, saml_msg): + if not saml_msg: + resp = BadRequest("Missing query") + return resp(self.environ, self.start_response) + else: + # exchange artifact for request + request = IdpServerSettings_.IDP.artifact2message(saml_msg["SAMLart"], "spsso") + try: + return self.do(request, BINDING_HTTP_ARTIFACT, saml_msg["RelayState"]) + except KeyError: + return self.do(request, BINDING_HTTP_ARTIFACT) + + def response(self, binding, http_args): + resp = None + if binding == BINDING_HTTP_ARTIFACT: + resp = Redirect() + elif http_args["data"]: + resp = Response(http_args["data"], headers=http_args["headers"]) + else: + for header in http_args["headers"]: + if header[0] == "Location": + resp = Redirect(header[1]) + + if not resp: + resp = ServiceError("Don't know how to return response") + + return resp(self.environ, self.start_response) + + def do(self, query, binding, relay_state="", encrypt_cert=None): + pass + + def redirect(self): + """Expects a HTTP-redirect request""" + + _dict = self.unpack_redirect() + return self.operation(_dict, BINDING_HTTP_REDIRECT) + + def post(self): + """Expects a HTTP-POST request""" + + _dict = self.unpack_post() + return self.operation(_dict, BINDING_HTTP_POST) + + def artifact(self): + # Can be either by HTTP_Redirect or HTTP_POST + _dict = self.unpack_either() + return self.artifact_operation(_dict) + + def soap(self): + """ + Single log out using HTTP_SOAP binding + """ + logger.debug("- SOAP -") + _dict = self.unpack_soap() + logger.debug("_dict: %s", _dict) + return self.operation(_dict, BINDING_SOAP) + + def uri(self): + _dict = self.unpack_either() + return self.operation(_dict, BINDING_SOAP) + + def not_authn(self, key, requested_authn_context): + ruri = geturl(self.environ, query=False) + + kwargs = dict(authn_context=requested_authn_context, key=key, redirect_uri=ruri) + # Clear cookie, if it already exists + kaka = delete_cookie(self.environ, "idpauthn") + if kaka: + kwargs["headers"] = [kaka] + return do_authentication(self.environ, self.start_response, **kwargs) + + +# ----------------------------------------------------------------------------- + + +REPOZE_ID_EQUIVALENT = "uid" +FORM_SPEC = """
    + + +
    """ + + +# ----------------------------------------------------------------------------- +# === Single log in ==== +# ----------------------------------------------------------------------------- + + +class AuthenticationNeeded(Exception): + def __init__(self, authn_context=None, *args, **kwargs): + Exception.__init__(*args, **kwargs) + self.authn_context = authn_context + + +class SSO(Service): + def __init__(self, environ, start_response, user=None): + Service.__init__(self, environ, start_response, user) + self.binding = "" + self.response_bindings = None + self.resp_args = {} + self.binding_out = None + self.destination = None + self.req_info = None + self.op_type = "" + + def verify_request(self, query, binding): + """ + :param query: The SAML query, transport encoded + :param binding: Which binding the query came in over + """ + resp_args = {} + if not query: + logger.info("Missing QUERY") + resp = Unauthorized("Unknown user") + return resp_args, resp(self.environ, self.start_response) + + if not self.req_info: + self.req_info = IdpServerSettings_.IDP.parse_authn_request(query, binding) + + logger.info("parsed OK") + _authn_req = self.req_info.message + logger.debug("%s", _authn_req) + + try: + self.binding_out, self.destination = IdpServerSettings_.IDP.pick_binding( + "assertion_consumer_service", + bindings=self.response_bindings, + entity_id=_authn_req.issuer.text, + request=_authn_req, + ) + except Exception as err: + logger.error("Couldn't find receiver endpoint: %s", err) + raise + + logger.debug("Binding: %s, destination: %s", self.binding_out, self.destination) + + resp_args = {} + try: + resp_args = IdpServerSettings_.IDP.response_args(_authn_req) + _resp = None + except UnknownPrincipal as excp: + _resp = IdpServerSettings_.IDP.create_error_response(_authn_req.id, self.destination, excp) + except UnsupportedBinding as excp: + _resp = IdpServerSettings_.IDP.create_error_response(_authn_req.id, self.destination, excp) + + return resp_args, _resp + + def do(self, query, binding_in, relay_state="", encrypt_cert=None, **kwargs): + """ + :param query: The request + :param binding_in: Which binding was used when receiving the query + :param relay_state: The relay state provided by the SP + :param encrypt_cert: Cert to use for encryption + :return: A response + """ + try: + resp_args, _resp = self.verify_request(query, binding_in) + except UnknownPrincipal as excp: + logger.error("UnknownPrincipal: %s", excp) + resp = ServiceError(f"UnknownPrincipal: {excp}") + return resp(self.environ, self.start_response) + except UnsupportedBinding as excp: + logger.error("UnsupportedBinding: %s", excp) + resp = ServiceError(f"UnsupportedBinding: {excp}") + return resp(self.environ, self.start_response) + + if not _resp: + identity = USERS[self.user].copy() + # identity["eduPersonTargetedID"] = get_eptid(IDP, query, session) + logger.info("Identity: %s", identity) + + if REPOZE_ID_EQUIVALENT: + identity[REPOZE_ID_EQUIVALENT] = self.user + try: + try: + metod = self.environ["idp.authn"] + except KeyError: + pass + else: + resp_args["authn"] = metod + + _resp = IdpServerSettings_.IDP.create_authn_response( + identity, userid=self.user, encrypt_cert_assertion=encrypt_cert, **resp_args + ) + except Exception as excp: + logging.error(exception_trace(excp)) + resp = ServiceError(f"Exception: {excp}") + return resp(self.environ, self.start_response) + + logger.info("AuthNResponse: %s", _resp) + if self.op_type == "ecp": + kwargs = {"soap_headers": [ecp.Response( + assertion_consumer_service_url=self.destination)]} + else: + kwargs = {} + + http_args = IdpServerSettings_.IDP.apply_binding( + self.binding_out, f"{_resp}", self.destination, relay_state, response=True, **kwargs + ) + + logger.debug("HTTPargs: %s", http_args) + return self.response(self.binding_out, http_args) + + @staticmethod + def _store_request(saml_msg): + logger.debug("_store_request: %s", saml_msg) + key = sha1(saml_msg["SAMLRequest"].encode()).hexdigest() + # store the AuthnRequest + IdpServerSettings_.IDP.ticket[key] = saml_msg + return key + + def redirect(self): + """This is the HTTP-redirect endpoint""" + + logger.info("--- In SSO Redirect ---") + saml_msg = self.unpack_redirect() + + try: + _key = saml_msg["key"] + saml_msg = IdpServerSettings_.IDP.ticket[_key] + self.req_info = saml_msg["req_info"] + del IdpServerSettings_.IDP.ticket[_key] + except KeyError: + try: + self.req_info = IdpServerSettings_.IDP.parse_authn_request(saml_msg["SAMLRequest"], + BINDING_HTTP_REDIRECT) + except KeyError: + resp = BadRequest("Message signature verification failure") + return resp(self.environ, self.start_response) + + if not self.req_info: + resp = BadRequest("Message parsing failed") + return resp(self.environ, self.start_response) + + _req = self.req_info.message + + if "SigAlg" in saml_msg and "Signature" in saml_msg: + # Signed request + issuer = _req.issuer.text + _certs = IdpServerSettings_.IDP.metadata.certs(issuer, "any", "signing") + verified_ok = False + for cert_name, cert in _certs: + if verify_redirect_signature(saml_msg, IdpServerSettings_.IDP.sec.sec_backend, cert): + verified_ok = True + break + if not verified_ok: + resp = BadRequest("Message signature verification failure") + return resp(self.environ, self.start_response) + + if self.user: + saml_msg["req_info"] = self.req_info + if _req.force_authn is not None and _req.force_authn.lower() == "true": + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + else: + return self.operation(saml_msg, BINDING_HTTP_REDIRECT) + else: + saml_msg["req_info"] = self.req_info + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + else: + return self.operation(saml_msg, BINDING_HTTP_REDIRECT) + + def post(self): + """ + The HTTP-Post endpoint + """ + logger.info("--- In SSO POST ---") + saml_msg = self.unpack_either() + + try: + _key = saml_msg["key"] + saml_msg = IdpServerSettings_.IDP.ticket[_key] + self.req_info = saml_msg["req_info"] + del IdpServerSettings_.IDP.ticket[_key] + except KeyError: + self.req_info = IdpServerSettings_.IDP.parse_authn_request(saml_msg["SAMLRequest"], BINDING_HTTP_POST) + _req = self.req_info.message + if self.user: + if _req.force_authn is not None and _req.force_authn.lower() == "true": + saml_msg["req_info"] = self.req_info + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + else: + return self.operation(saml_msg, BINDING_HTTP_POST) + else: + saml_msg["req_info"] = self.req_info + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + else: + return self.operation(saml_msg, BINDING_HTTP_POST) + + # def artifact(self): + # # Can be either by HTTP_Redirect or HTTP_POST + # _req = self._store_request(self.unpack_either()) + # if isinstance(_req, basestring): + # return self.not_authn(_req) + # return self.artifact_operation(_req) + + def ecp(self): + # The ECP interface + logger.info("--- ECP SSO ---") + resp = None + + try: + authz_info = self.environ["HTTP_AUTHORIZATION"] + if authz_info.startswith("Basic "): + try: + _info = base64.b64decode(authz_info[6:]) + except TypeError: + resp = Unauthorized() + else: + try: + (user, passwd) = _info.split(":") + if is_equal(PASSWD[user], passwd): + resp = Unauthorized() + self.user = user + self.environ["idp.authn"] = IdpServerSettings_.AUTHN_BROKER.get_authn_by_accr(PASSWORD) + except ValueError: + resp = Unauthorized() + else: + resp = Unauthorized() + except KeyError: + resp = Unauthorized() + + if resp: + return resp(self.environ, self.start_response) + + _dict = self.unpack_soap() + self.response_bindings = [BINDING_PAOS] + # Basic auth ?! + self.op_type = "ecp" + return self.operation(_dict, BINDING_SOAP) + + +# ----------------------------------------------------------------------------- +# === Authentication ==== +# ----------------------------------------------------------------------------- + + +def do_authentication(environ, start_response, authn_context, key, redirect_uri, headers=None): + """ + Display the login form + """ + logger.debug("Do authentication") + auth_info = IdpServerSettings_.AUTHN_BROKER.pick(authn_context) + + if len(auth_info): + method, reference = auth_info[0] + logger.debug("Authn chosen: %s (ref=%s)", method, reference) + return method(environ, start_response, reference, key, redirect_uri, headers) + else: + resp = Unauthorized("No usable authentication method") + return resp(environ, start_response) + + +# ----------------------------------------------------------------------------- + + +PASSWD = { + "testuser": "qwerty", + "roland": "dianakra", + "babs": "howes", + "upper": "crust", + "testuser2": "abcd1234", + "testuser3": "ABCD1234", +} + + +def username_password_authn(environ, start_response, reference, key, redirect_uri, headers=None): + """ + Display the login form + """ + logger.info("The login page") + + kwargs = dict(mako_template="login.mako", template_lookup=LOOKUP) + if headers: + kwargs["headers"] = headers + + resp = Response(**kwargs) + + argv = { + "action": "/verify", + "login": "", + "password": "", + "key": key, + "authn_reference": reference, + "redirect_uri": redirect_uri, + } + logger.info("do_authentication argv: %s", argv) + return resp(environ, start_response, **argv) + + +def verify_username_and_password(dic): + # verify username and password + username = dic["login"][0] + password = dic["password"][0] + if PASSWD[username] == password: + return True, username + else: + return False, None + + +def do_verify(environ, start_response, _): + query_str = get_post(environ) + if not isinstance(query_str, str): + query_str = query_str.decode("ascii") + query = parse_qs(query_str) + + logger.debug("do_verify: %s", query) + + try: + _ok, user = verify_username_and_password(query) + except KeyError: + _ok = False + user = None + + if not _ok: + resp = Unauthorized("Unknown user or wrong password") + else: + uid = rndstr(24) + IdpServerSettings_.IDP.cache.uid2user[uid] = user + IdpServerSettings_.IDP.cache.user2uid[user] = uid + logger.debug("Register %s under '%s'", user, uid) + + kaka = set_cookie("idpauthn", "/", uid, query["authn_reference"][0]) + + lox = f"{query['redirect_uri'][0]}?id={uid}&key={query['key'][0]}" + logger.debug("Redirect => %s", lox) + resp = Redirect(lox, headers=[kaka], content="text/html") + + return resp(environ, start_response) + + +def not_found(environ, start_response): + """Called if no URL matches.""" + resp = NotFound() + return resp(environ, start_response) + + +# ----------------------------------------------------------------------------- +# === Single log out === +# ----------------------------------------------------------------------------- + + +# def _subject_sp_info(req_info): +# # look for the subject +# subject = req_info.subject_id() +# subject = subject.text.strip() +# sp_entity_id = req_info.message.issuer.text.strip() +# return subject, sp_entity_id + + +class SLO(Service): + def do(self, request, binding, relay_state="", encrypt_cert=None, **kwargs): + + logger.info("--- Single Log Out Service ---") + try: + logger.debug("req: '%s'", request) + req_info = IdpServerSettings_.IDP.parse_logout_request(request, binding) + except Exception as exc: + logger.error("Bad request: %s", exc) + resp = BadRequest(f"{exc}") + return resp(self.environ, self.start_response) + + msg = req_info.message + if msg.name_id: + lid = IdpServerSettings_.IDP.ident.find_local_id(msg.name_id) + logger.info("local identifier: %s", lid) + if lid in IdpServerSettings_.IDP.cache.user2uid: + uid = IdpServerSettings_.IDP.cache.user2uid[lid] + if uid in IdpServerSettings_.IDP.cache.uid2user: + del IdpServerSettings_.IDP.cache.uid2user[uid] + del IdpServerSettings_.IDP.cache.user2uid[lid] + # remove the authentication + try: + IdpServerSettings_.IDP.session_db.remove_authn_statements(msg.name_id) + except KeyError as exc: + logger.error("Unknown session: %s", exc) + resp = ServiceError("Unknown session: %s", exc) + return resp(self.environ, self.start_response) + + resp = IdpServerSettings_.IDP.create_logout_response(msg, [binding]) + + if binding == BINDING_SOAP: + destination = "" + response = False + else: + binding, destination = IdpServerSettings_.IDP.pick_binding("single_logout_service", + [binding], "spsso", req_info) + response = True + + try: + hinfo = IdpServerSettings_.IDP.apply_binding(binding, f"{resp}", + destination, relay_state, response=response) + except Exception as exc: + logger.error("ServiceError: %s", exc) + resp = ServiceError(f"{exc}") + return resp(self.environ, self.start_response) + + # _tlh = dict2list_of_tuples(hinfo["headers"]) + delco = delete_cookie(self.environ, "idpauthn") + if delco: + hinfo["headers"].append(delco) + logger.info("Header: %s", (hinfo["headers"],)) + + if binding == BINDING_HTTP_REDIRECT: + for key, value in hinfo["headers"]: + if key.lower() == "location": + resp = Redirect(value, headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + resp = ServiceError("missing Location header") + return resp(self.environ, self.start_response) + else: + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# Manage Name ID service +# ---------------------------------------------------------------------------- + + +class NMI(Service): + def do(self, query, binding, relay_state="", encrypt_cert=None): + logger.info("--- Manage Name ID Service ---") + req = IdpServerSettings_.IDP.parse_manage_name_id_request(query, binding) + request = req.message + + # Do the necessary stuff + name_id = IdpServerSettings_.IDP.ident.handle_manage_name_id_request( + request.name_id, request.new_id, request.new_encrypted_id, request.terminate + ) + + logger.debug("New NameID: %s", name_id) + + _resp = IdpServerSettings_.IDP.create_manage_name_id_response(request) + + # It's using SOAP binding + hinfo = IdpServerSettings_.IDP.apply_binding(BINDING_SOAP, f"{_resp}", "", relay_state, response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# === Assertion ID request === +# ---------------------------------------------------------------------------- + + +# Only URI binding +class AIDR(Service): + def do(self, aid, binding, relay_state="", encrypt_cert=None): + logger.info("--- Assertion ID Service ---") + + try: + assertion = IdpServerSettings_.IDP.create_assertion_id_request_response(aid) + except Unknown: + resp = NotFound(aid) + return resp(self.environ, self.start_response) + + hinfo = IdpServerSettings_.IDP.apply_binding(BINDING_URI, f"{assertion}", response=True) + + logger.debug("HINFO: %s", hinfo) + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + def operation(self, _dict, binding, **kwargs): + logger.debug("_operation: %s", _dict) + if not _dict or "ID" not in _dict: + resp = BadRequest("Error parsing request or no request") + return resp(self.environ, self.start_response) + + return self.do(_dict["ID"], binding, **kwargs) + + +# ---------------------------------------------------------------------------- +# === Artifact resolve service === +# ---------------------------------------------------------------------------- + + +class ARS(Service): + def do(self, request, binding, relay_state="", encrypt_cert=None): + _req = IdpServerSettings_.IDP.parse_artifact_resolve(request, binding) + + msg = IdpServerSettings_.IDP.create_artifact_response(_req, _req.artifact.text) + + hinfo = IdpServerSettings_.IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# === Authn query service === +# ---------------------------------------------------------------------------- + + +# Only SOAP binding +class AQS(Service): + def do(self, request, binding, relay_state="", encrypt_cert=None): + logger.info("--- Authn Query Service ---") + _req = IdpServerSettings_.IDP.parse_authn_query(request, binding) + _query = _req.message + + msg = IdpServerSettings_.IDP.create_authn_query_response(_query.subject, + _query.requested_authn_context, _query.session_index) + + logger.debug("response: %s", msg) + hinfo = IdpServerSettings_.IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# === Attribute query service === +# ---------------------------------------------------------------------------- + + +# Only SOAP binding +class ATTR(Service): + def do(self, request, binding, relay_state="", encrypt_cert=None): + logger.info("--- Attribute Query Service ---") + + _req = IdpServerSettings_.IDP.parse_attribute_query(request, binding) + _query = _req.message + + name_id = _query.subject.name_id + uid = name_id.text + logger.debug("Local uid: %s", uid) + identity = EXTRA[uid] + + # Comes in over SOAP so only need to construct the response + args = IdpServerSettings_.IDP.response_args(_query, [BINDING_SOAP]) + msg = IdpServerSettings_.IDP.create_attribute_response(identity, name_id=name_id, **args) + + logger.debug("response: %s", msg) + hinfo = IdpServerSettings_.IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# Name ID Mapping service +# When an entity that shares an identifier for a principal with an identity +# provider wishes to obtain a name identifier for the same principal in a +# particular format or federation namespace, it can send a request to +# the identity provider using this protocol. +# ---------------------------------------------------------------------------- + + +class NIM(Service): + def do(self, query, binding, relay_state="", encrypt_cert=None): + req = IdpServerSettings_.IDP.parse_name_id_mapping_request(query, binding) + request = req.message + # Do the necessary stuff + try: + name_id = IdpServerSettings_.IDP.ident.handle_name_id_mapping_request( + request.name_id, request.name_id_policy) + except Unknown: + resp = BadRequest("Unknown entity") + return resp(self.environ, self.start_response) + except PolicyError: + resp = BadRequest("Unknown entity") + return resp(self.environ, self.start_response) + + info = IdpServerSettings_.IDP.response_args(request) + _resp = IdpServerSettings_.IDP.create_name_id_mapping_response(name_id, **info) + + # Only SOAP + hinfo = IdpServerSettings_.IDP.apply_binding(BINDING_SOAP, f"{_resp}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# Cookie handling +# ---------------------------------------------------------------------------- + + +def info_from_cookie(kaka): + logger.debug("KAKA: %s", kaka) + if kaka: + cookie_obj = SimpleCookie(kaka) + morsel = cookie_obj.get("idpauthn", None) + if morsel: + try: + data = base64.b64decode(morsel.value) + if not isinstance(data, str): + data = data.decode("ascii") + key, ref = data.split(":", 1) + return IdpServerSettings_.IDP.cache.uid2user[key], ref + except (KeyError, TypeError): + return None, None + else: + logger.debug("No idpauthn cookie") + return None, None + + +def delete_cookie(environ, name): + kaka = environ.get("HTTP_COOKIE", "") + logger.debug("delete KAKA: %s", kaka) + if kaka: + cookie_obj = SimpleCookie(kaka) + morsel = cookie_obj.get(name, None) + cookie = SimpleCookie() + cookie[name] = "" + cookie[name]["path"] = "/" + logger.debug("Expire: %s", morsel) + cookie[name]["expires"] = _expiration("dawn") + return tuple(cookie.output().split(": ", 1)) + return None + + +def set_cookie(name, _, *args): + cookie = SimpleCookie() + + data = ":".join(args) + if not isinstance(data, bytes): + data = data.encode("ascii") + + data64 = base64.b64encode(data) + if not isinstance(data64, str): + data64 = data64.decode("ascii") + + cookie[name] = data64 + cookie[name]["path"] = "/" + cookie[name]["expires"] = _expiration(5) # 5 minutes from now + logger.debug("Cookie expires: %s", cookie[name]["expires"]) + return tuple(cookie.output().split(": ", 1)) + + +# ---------------------------------------------------------------------------- + + +# map urls to functions +AUTHN_URLS = [ + # sso + (r"sso/post$", (SSO, "post")), + (r"sso/post/(.*)$", (SSO, "post")), + (r"sso/redirect$", (SSO, "redirect")), + (r"sso/redirect/(.*)$", (SSO, "redirect")), + (r"sso/art$", (SSO, "artifact")), + (r"sso/art/(.*)$", (SSO, "artifact")), + # slo + (r"slo/redirect$", (SLO, "redirect")), + (r"slo/redirect/(.*)$", (SLO, "redirect")), + (r"slo/post$", (SLO, "post")), + (r"slo/post/(.*)$", (SLO, "post")), + (r"slo/soap$", (SLO, "soap")), + (r"slo/soap/(.*)$", (SLO, "soap")), + # + (r"airs$", (AIDR, "uri")), + (r"ars$", (ARS, "soap")), + # mni + (r"mni/post$", (NMI, "post")), + (r"mni/post/(.*)$", (NMI, "post")), + (r"mni/redirect$", (NMI, "redirect")), + (r"mni/redirect/(.*)$", (NMI, "redirect")), + (r"mni/art$", (NMI, "artifact")), + (r"mni/art/(.*)$", (NMI, "artifact")), + (r"mni/soap$", (NMI, "soap")), + (r"mni/soap/(.*)$", (NMI, "soap")), + # nim + (r"nim$", (NIM, "soap")), + (r"nim/(.*)$", (NIM, "soap")), + # + (r"aqs$", (AQS, "soap")), + (r"attr$", (ATTR, "soap")), +] + +NON_AUTHN_URLS = [ + # (r'login?(.*)$', do_authentication), + (r"verify?(.*)$", do_verify), + (r"sso/ecp$", (SSO, "ecp")), +] + + +# ---------------------------------------------------------------------------- + + +def metadata(environ, start_response): + try: + path = IdpServerSettings_.args.path[:] + if path is None or len(path) == 0: + path = os.path.dirname(os.path.abspath(__file__)) + if path[-1] != "/": + path += "/" + metadata = create_metadata_string( + path + IdpServerSettings_.args.config, + IdpServerSettings_.IDP.config, + IdpServerSettings_.args.valid, + IdpServerSettings_.args.cert, + IdpServerSettings_.args.keyfile, + IdpServerSettings_.args.id, + IdpServerSettings_.args.name, + IdpServerSettings_.args.sign, + ) + start_response("200 OK", [("Content-Type", "text/xml")]) + return [metadata] + except Exception as ex: + logger.error("An error occured while creating metadata: %s", ex.message) + return not_found(environ, start_response) + + +def staticfile(environ, start_response): + try: + path = IdpServerSettings_.args.path[:] + if path is None or len(path) == 0: + path = os.path.dirname(os.path.abspath(__file__)) + if path[-1] != "/": + path += "/" + path += environ.get("PATH_INFO", "").lstrip("/") + path = os.path.realpath(path) + if not path.startswith(IdpServerSettings_.args.path): + resp = Unauthorized() + return resp(environ, start_response) + start_response("200 OK", [("Content-Type", "text/xml")]) + return open(path).read() + except Exception as ex: + logger.error("An error occured while creating metadata: %s", ex.message) + return not_found(environ, start_response) + + +def application(environ, start_response): + """ + The main WSGI application. Dispatch the current request to + the functions from above and store the regular expression + captures in the WSGI environment as `myapp.url_args` so that + the functions from above can access the url placeholders. + If nothing matches, call the `not_found` function. + :param environ: The HTTP application environment + :param start_response: The application to run when the handling of the + request is done + :return: The response as a list of lines + """ + + path = environ.get("PATH_INFO", "").lstrip("/") + + if path == "idp.xml": + return metadata(environ, start_response) + + kaka = environ.get("HTTP_COOKIE", None) + logger.info(" PATH: %s", path) + + if kaka: + logger.info("= KAKA =") + user, authn_ref = info_from_cookie(kaka) + if authn_ref: + environ["idp.authn"] = IdpServerSettings_.AUTHN_BROKER[authn_ref] + else: + try: + query = parse_qs(environ["QUERY_STRING"]) + logger.debug("QUERY: %s", query) + user = IdpServerSettings_.IDP.cache.uid2user[query["id"][0]] + except KeyError: + user = None + + url_patterns = AUTHN_URLS + if not user: + logger.info("-- No USER --") + # insert NON_AUTHN_URLS first in case there is no user + url_patterns = NON_AUTHN_URLS + url_patterns + + for regex, callback in url_patterns: + match = re.search(regex, path) + if match is not None: + try: + environ["myapp.url_args"] = match.groups()[0] + except IndexError: + environ["myapp.url_args"] = path + + logger.debug("Callback: %s", callback) + if isinstance(callback, tuple): + cls = callback[0](environ, start_response, user) + func = getattr(cls, callback[1]) + + return func() + return callback(environ, start_response, user) + + if re.search(r"static/.*", path) is not None: + return staticfile(environ, start_response) + return not_found(environ, start_response) + + +# ---------------------------------------------------------------------------- + +class IdpServerSettings: + def __init__(self): + self.AUTHN_BROKER = AuthnBroker() + self.IDP = None + self.args = None + + +IdpServerSettings_ = IdpServerSettings() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-p", dest="path", help="Path to configuration file.", + default="./idp_conf.py") + parser.add_argument( + "-v", + dest="valid", + help="How long, in days, the metadata is valid from " "the time of creation", + ) + parser.add_argument("-c", dest="cert", help="certificate") + parser.add_argument("-i", dest="id", help="The ID of the entities descriptor") + parser.add_argument("-k", dest="keyfile", help="A file with a key to sign the metadata with") + parser.add_argument("-n", dest="name") + parser.add_argument("-s", dest="sign", action="store_true", help="sign the metadata") + parser.add_argument("-m", dest="mako_root", default="./") + parser.add_argument("-config", dest="config", default="idp_conf", help="configuration file") + + IdpServerSettings_.args = parser.parse_args() + + try: + CONFIG = importlib.import_module(IdpServerSettings_.args.config) + except ImportError as e: + logger.error("Idp_conf cannot be imported : %s, Trying by setting the system path...", e) + sys.path.append(os.path.join(DOCS_SERVER_PATH)) + CONFIG = importlib.import_module(IdpServerSettings_.args.config) + + IdpServerSettings_.AUTHN_BROKER.add(authn_context_class_ref(PASSWORD), username_password_authn, 10, CONFIG.BASE) + IdpServerSettings_.AUTHN_BROKER.add(authn_context_class_ref(UNSPECIFIED), "", 0, CONFIG.BASE) + + IdpServerSettings_.IDP = server.Server(IdpServerSettings_.args.config, cache=Cache()) + IdpServerSettings_.IDP.ticket = {} + + HOST = CONFIG.HOST + PORT = CONFIG.PORT + + sign_alg = None + digest_alg = None + try: + sign_alg = CONFIG.SIGN_ALG + except AttributeError: + pass + try: + digest_alg = CONFIG.DIGEST_ALG + except AttributeError: + pass + ds.DefaultSignature(sign_alg, digest_alg) + + ssl_context = None + _https = "" + if CONFIG.HTTPS: + _https = "using HTTPS" + # Creating an SSL context + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + ssl_context.load_cert_chain(CONFIG.SERVER_CERT, CONFIG.SERVER_KEY) + SRV = WSGIServer(HOST, PORT, application, ssl_context=ssl_context) + + logger.info("Server starting") + print(f"IDP listening on {HOST}:{PORT}{_https}") + try: + SRV.start() + except KeyboardInterrupt: + SRV.stop() + + +if __name__ == "__main__": + main() diff --git a/mslib/msidp/idp_conf.py b/mslib/msidp/idp_conf.py new file mode 100644 index 000000000..0a94ec978 --- /dev/null +++ b/mslib/msidp/idp_conf.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msidp.idp_conf.py + ~~~~~~~~~~~~~~~~~~~~~~~ + + SAML2 IDP configuration with bindings, endpoints, and authentication contexts. + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +# Parts of the code + +import os.path + +from saml2 import BINDING_HTTP_ARTIFACT +from saml2 import BINDING_HTTP_POST +from saml2 import BINDING_HTTP_REDIRECT +from saml2 import BINDING_SOAP +from saml2 import BINDING_URI +from saml2.saml import NAME_FORMAT_URI +from saml2.saml import NAMEID_FORMAT_PERSISTENT +from saml2.saml import NAMEID_FORMAT_TRANSIENT +from saml2.sigver import get_xmlsec_binary + +XMLSEC_PATH = get_xmlsec_binary() + +# CRTs and metadata files can be generated through the mscolab server. +# if configured that way CRTs DIRs should be same in both IDP and mscolab server. +BASE_DIR = os.path.expanduser("~") +DATA_DIR = os.path.join(BASE_DIR, "colabdata") +MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') + +BASEDIR = os.path.abspath(os.path.dirname(__file__)) + + +def full_path(local_file): + """Return the full path by joining the BASEDIR and local_file.""" + return os.path.join(BASEDIR, local_file) + + +def sso_dir_path(local_file): + """Return the full path by joining the MSCOLAB_SSO_DIR and local_file.""" + return os.path.join(MSCOLAB_SSO_DIR, local_file) + + +HOST = 'localhost' +PORT = 8088 + +HTTPS = True + +if HTTPS: + BASE = f"https://{HOST}:{PORT}" +else: + BASE = f"http://{HOST}:{PORT}" + +# HTTPS cert information +SERVER_CERT = f"{MSCOLAB_SSO_DIR}/crt_local_idp.crt" +SERVER_KEY = f"{MSCOLAB_SSO_DIR}/key_local_idp.key" +CERT_CHAIN = "" +SIGN_ALG = None +DIGEST_ALG = None +# SIGN_ALG = ds.SIG_RSA_SHA512 +# DIGEST_ALG = ds.DIGEST_SHA512 + + +CONFIG = { + "entityid": f"{BASE}/idp.xml", + "description": "My IDP", + # "valid_for": 168, + "service": { + "aa": { + "endpoints": { + "attribute_service": [ + (f"{BASE}/attr", BINDING_SOAP) + ] + }, + "name_id_format": [NAMEID_FORMAT_TRANSIENT, + NAMEID_FORMAT_PERSISTENT] + }, + "aq": { + "endpoints": { + "authn_query_service": [ + (f"{BASE}/aqs", BINDING_SOAP) + ] + }, + }, + "idp": { + "name": "Rolands IdP", + "sign_response": True, + "sign_assertion": True, + "endpoints": { + "single_sign_on_service": [ + (f"{BASE}/sso/redirect", BINDING_HTTP_REDIRECT), + (f"{BASE}/sso/post", BINDING_HTTP_POST), + (f"{BASE}/sso/art", BINDING_HTTP_ARTIFACT), + (f"{BASE}/sso/ecp", BINDING_SOAP) + ], + "single_logout_service": [ + (f"{BASE}/slo/soap", BINDING_SOAP), + (f"{BASE}/slo/post", BINDING_HTTP_POST), + (f"{BASE}/slo/redirect", BINDING_HTTP_REDIRECT) + ], + "artifact_resolve_service": [ + (f"{BASE}/ars", BINDING_SOAP) + ], + "assertion_id_request_service": [ + (f"{BASE}/airs", BINDING_URI) + ], + "manage_name_id_service": [ + (f"{BASE}/mni/soap", BINDING_SOAP), + (f"{BASE}/mni/post", BINDING_HTTP_POST), + (f"{BASE}/mni/redirect", BINDING_HTTP_REDIRECT), + (f"{BASE}/mni/art", BINDING_HTTP_ARTIFACT) + ], + "name_id_mapping_service": [ + (f"{BASE}/nim", BINDING_SOAP), + ], + }, + "policy": { + "default": { + "lifetime": {"minutes": 15}, + "attribute_restrictions": None, # means all I have + "name_form": NAME_FORMAT_URI, + # "entity_categories": ["swamid", "edugain"] + }, + }, + "name_id_format": [NAMEID_FORMAT_TRANSIENT, + NAMEID_FORMAT_PERSISTENT] + }, + }, + "debug": 1, + "key_file": sso_dir_path("./key_local_idp.key"), + "cert_file": sso_dir_path("./crt_local_idp.crt"), + "metadata": { + "local": [sso_dir_path("./metadata_sp.xml")], + }, + "organization": { + "display_name": "Organization Display Name", + "name": "Organization name", + "url": "http://www.example.com", + }, + "contact_person": [ + { + "contact_type": "technical", + "given_name": "technical", + "sur_name": "technical", + "email_address": "technical@example.com" + }, { + "contact_type": "support", + "given_name": "Support", + "email_address": "support@example.com" + }, + ], + # This database holds the map between a subject's local identifier and + # the identifier returned to a SP + "xmlsec_binary": XMLSEC_PATH, + # "attribute_map_dir": "../attributemaps", + "logging": { + "version": 1, + "formatters": { + "simple": { + "format": "[%(asctime)s] [%(levelname)s] [%(name)s.%(funcName)s] %(message)s", + }, + }, + "handlers": { + "stderr": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + "level": "DEBUG", + "formatter": "simple", + }, + }, + "loggers": { + "saml2": { + "level": "DEBUG" + }, + }, + "root": { + "level": "DEBUG", + "handlers": [ + "stderr", + ], + }, + }, +} + +CAS_SERVER = "https://cas.umu.se" +CAS_VERIFY = f"{BASE}/verify_cas" +PWD_VERIFY = f"{BASE}/verify_pwd" + +AUTHORIZATION = { + "CAS": {"ACR": "CAS", "WEIGHT": 1, "URL": CAS_VERIFY}, + "UserPassword": {"ACR": "PASSWORD", "WEIGHT": 2, "URL": PWD_VERIFY} +} diff --git a/mslib/msidp/idp_user.py b/mslib/msidp/idp_user.py new file mode 100644 index 000000000..6d43edd1e --- /dev/null +++ b/mslib/msidp/idp_user.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msidp.idp_user.py + ~~~~~~~~~~~~~~~~~~~~~~~ + + User data and additional attributes for test users and affiliates. + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +# Parts of the code + +USERS = { + "testuser": { + "sn": "Testsson", + "givenName": "Test", + "eduPersonAffiliation": "student", + "eduPersonScopedAffiliation": "student@example.com", + "eduPersonPrincipalName": "test@example.com", + "uid": "testuser", + "eduPersonTargetedID": ["one!for!all"], + "c": "SE", + "o": "Example Co.", + "ou": "IT", + "initials": "P", + "co": "co", + "mail": "mail", + "noreduorgacronym": "noreduorgacronym", + "schacHomeOrganization": "example.com", + "email": "test@example.com", + "displayName": "Test Testsson", + "labeledURL": "http://www.example.com/test My homepage", + "norEduPersonNIN": "SE199012315555", + "postaladdress": "postaladdress", + "cn": "cn", + }, + "testuser2": { + "sn": "Testsson2", + "givenName": "Test2", + "eduPersonAffiliation": "student", + "eduPersonScopedAffiliation": "student2@example.com", + "eduPersonPrincipalName": "test2@example.com", + "uid": "testuser2", + "eduPersonTargetedID": ["one!for!all"], + "c": "SE", + "o": "Example Co.", + "ou": "IT", + "initials": "P", + "co": "co", + "mail": "mail", + "noreduorgacronym": "noreduorgacronym", + "schacHomeOrganization": "example.com", + "email": "test2@example.com", + "displayName": "Test Testsson", + "labeledURL": "http://www.example.com/test My homepage", + "norEduPersonNIN": "SE199012315555", + "postaladdress": "postaladdress", + "cn": "cn", + }, + "testuser3": { + "sn": "Testsson3", + "givenName": "Test3", + "eduPersonAffiliation": "student", + "eduPersonScopedAffiliation": "student3@example.com", + "eduPersonPrincipalName": "test3@example.com", + "uid": "testuser3", + "eduPersonTargetedID": ["one!for!all"], + "c": "SE", + "o": "Example Co.", + "ou": "IT", + "initials": "P", + "co": "co", + "mail": "mail", + "noreduorgacronym": "noreduorgacronym", + "schacHomeOrganization": "example.com", + "email": "test3@example.com", + "displayName": "Test Testsson", + "labeledURL": "http://www.example.com/test My homepage", + "norEduPersonNIN": "SE199012315555", + "postaladdress": "postaladdress", + "cn": "cn", + }, + "roland": { + "sn": "Hedberg", + "givenName": "Roland", + "email": "roland@example.com", + "eduPersonScopedAffiliation": "staff@example.com", + "eduPersonPrincipalName": "rohe@example.com", + "uid": "rohe", + "eduPersonTargetedID": ["one!for!all"], + "c": "SE", + "o": "Example Co.", + "ou": "IT", + "initials": "P", + "mail": "roland@example.com", + "displayName": "P. Roland Hedberg", + "labeledURL": "http://www.example.com/rohe My homepage", + "norEduPersonNIN": "SE197001012222", + }, + "babs": { + "surname": "Babs", + "givenName": "Ozzie", + "email": "babs@example.com", + "eduPersonAffiliation": "affiliate" + }, + "upper": { + "surname": "Jeter", + "givenName": "Derek", + "email": "upper@example.com", + "eduPersonAffiliation": "affiliate" + }, +} + +EXTRA = { + "roland": { + "eduPersonEntitlement": "urn:mace:swamid.se:foo:bar", + "schacGender": "male", + "schacUserPresenceID": "skype:pepe.perez", + } +} diff --git a/mslib/msidp/idp_uwsgi.py b/mslib/msidp/idp_uwsgi.py new file mode 100644 index 000000000..9c2ab0ba0 --- /dev/null +++ b/mslib/msidp/idp_uwsgi.py @@ -0,0 +1,1111 @@ +# pylint: skip-file +# -*- coding: utf-8 -*- +""" + mslib.msidp.idp_uwsgi.py + ~~~~~~~~~~~~~~~~~~~~~~~~ + + WSGI application for IDP + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +# Additional Info: +# This file is imported from +# https://github.com/IdentityPython/pysaml2/blob/master/example/idp2/idp_uwsgi.py +# and customized as MSS requirements. Pylint has been disabled for this imported file. + +# Parts of the code + +import argparse +import base64 +from hashlib import sha1 +import importlib +import logging +import os +import re +import time +import socket + +from Cookie import SimpleCookie +from urlparse import parse_qs +from saml2 import ( + BINDING_HTTP_ARTIFACT, + BINDING_HTTP_POST, + BINDING_HTTP_REDIRECT, + BINDING_PAOS, + BINDING_SOAP, + BINDING_URI, + server, + time_util +) +from saml2.authn import is_equal +from saml2.authn_context import PASSWORD, UNSPECIFIED, AuthnBroker, authn_context_class_ref +from saml2.httputil import ( + BadRequest, + NotFound, + Redirect, + Response, + ServiceError, + Unauthorized, + get_post, + geturl +) +from saml2.ident import Unknown +from saml2.metadata import create_metadata_string +from saml2.profile import ecp +from saml2.s_utils import PolicyError, UnknownPrincipal, exception_trace, UnsupportedBinding, rndstr +from saml2.sigver import encrypt_cert_from_item, verify_redirect_signature + +from mslib.msidp.idp_user import EXTRA +from mslib.msidp.idp_user import USERS +from mako.lookup import TemplateLookup + + +logger = logging.getLogger("saml2.idp") + + +class Cache: + """ + A cache class for mapping users to UIDs and vice versa. + """ + def __init__(self): + self.user2uid = {} + self.uid2user = {} + + +def _expiration(timeout, tformat="%a, %d-%b-%Y %H:%M:%S GMT"): + """ + :param timeout: + :param tformat: + :return: + """ + if timeout == "now": + return time_util.instant(tformat) + elif timeout == "dawn": + return time.strftime(tformat, time.gmtime(0)) + else: + # validity time should match lifetime of assertions + return time_util.in_a_while(minutes=timeout, format=tformat) + + +def get_eptid(idp, req_info, session): + """ + Get the EPTID (Entity-Participant Target ID) based on the provided parameters. + """ + return idp.eptid.get(idp.config.entityid, req_info.sender(), + session["permanent_id"], session["authn_auth"]) + + +def dict2list_of_tuples(dictionary): + """ + Convert a dictionary to a list of tuples. + """ + return [(k, v) for k, v in dictionary.items()] + + +class Service: + """ + Service class for handling SAML operations + """ + def __init__(self, environ, start_response, user=None): + self.environ = environ + logger.debug("ENVIRON: %s", environ) + self.start_response = start_response + self.user = user + + def unpack_redirect(self): + """ + Unpacks and parses a HTTP-redirect request + """ + if "QUERY_STRING" in self.environ: + _qs = self.environ["QUERY_STRING"] + return {k: v[0] for k, v in parse_qs(_qs).items()} + return None + + def unpack_post(self): + """ + Unpacks and parses a HTTP-POST request. + """ + _dict = parse_qs(get_post(self.environ)) + logger.debug("unpack_post:: %s", _dict) + try: + return {k: v[0] for k, v in _dict.items()} + except Exception: + return None + + def unpack_soap(self): + """ + Unpacks and parses a SOAP request. + """ + try: + query = get_post(self.environ) + return {"SAMLRequest": query, "RelayState": ""} + except Exception: + return None + + def unpack_either(self): + """ + Unpacks and retrieves data from either a GET or POST request. + """ + if self.environ["REQUEST_METHOD"] == "GET": + _dict = self.unpack_redirect() + elif self.environ["REQUEST_METHOD"] == "POST": + _dict = self.unpack_post() + else: + _dict = None + logger.debug("_dict: %s", _dict) + return _dict + + def operation(self, saml_msg, binding): + """ + Performs the SAML operation based on the provided SAML message and binding. + """ + logger.debug("_operation: %s", saml_msg) + if not saml_msg or "SAMLRequest" not in saml_msg: + resp = BadRequest("Error parsing request or no request") + return resp(self.environ, self.start_response) + else: + try: + _encrypt_cert = encrypt_cert_from_item(saml_msg["req_info"].message) + return self.do(saml_msg["SAMLRequest"], binding, + saml_msg["RelayState"], encrypt_cert=_encrypt_cert) + except KeyError: + # Can live with no relay state + return self.do(saml_msg["SAMLRequest"], binding) + + def artifact_operation(self, saml_msg): + """ + Handles artifact-based operations. + """ + if not saml_msg: + resp = BadRequest("Missing query") + return resp(self.environ, self.start_response) + else: + # exchange artifact for request + request = IDP.artifact2message(saml_msg["SAMLart"], "spsso") + try: + return self.do(request, BINDING_HTTP_ARTIFACT, saml_msg["RelayState"]) + except KeyError: + return self.do(request, BINDING_HTTP_ARTIFACT) + + def response(self, binding, http_args): + """ + Generates the response based on the specified binding and HTTP arguments. + """ + if binding == BINDING_HTTP_ARTIFACT: + resp = Redirect() + else: + resp = Response(http_args["data"], headers=http_args["headers"]) + return resp(self.environ, self.start_response) + + def do(self, query, binding, relay_state="", encrypt_cert=None): + """ + Performs the SAML operation based on the provided query + """ + pass + + def redirect(self): + """Expects a HTTP-redirect request""" + + _dict = self.unpack_redirect() + return self.operation(_dict, BINDING_HTTP_REDIRECT) + + def post(self): + """Expects a HTTP-POST request""" + + _dict = self.unpack_post() + return self.operation(_dict, BINDING_HTTP_POST) + + def artifact(self): + """ + Handles the artifact operation, which can be either through HTTP_Redirect or HTTP_POST. + """ + # Can be either by HTTP_Redirect or HTTP_POST + _dict = self.unpack_either() + return self.artifact_operation(_dict) + + def soap(self): + """ + Single log out using HTTP_SOAP binding + """ + logger.debug("- SOAP -") + _dict = self.unpack_soap() + logger.debug("_dict: %s", _dict) + return self.operation(_dict, BINDING_SOAP) + + def uri(self): + """ + Handles the URI operation. + """ + _dict = self.unpack_either() + return self.operation(_dict, BINDING_SOAP) + + def not_authn(self, key, requested_authn_context): + """ + Handles the case when the user is not authenticated. + """ + ruri = geturl(self.environ, query=False) + return do_authentication( + self.environ, self.start_response, authn_context=requested_authn_context, + key=key, redirect_uri=ruri + ) + + +# ----------------------------------------------------------------------------- + +REPOZE_ID_EQUIVALENT = "uid" +FORM_SPEC = """
    + + +
    """ + +# ----------------------------------------------------------------------------- +# === Single log in ==== +# ----------------------------------------------------------------------------- + + +class AuthenticationNeeded(Exception): + """ + Exception raised when authentication is required. + """ + def __init__(self, authn_context=None, *args, **kwargs): + Exception.__init__(*args, **kwargs) + self.authn_context = authn_context + + +class SSO(Service): + """ + Single Sign-On (SSO) service. + """ + def __init__(self, environ, start_response, user=None): + Service.__init__(self, environ, start_response, user) + self.binding = "" + self.response_bindings = None + self.resp_args = {} + self.binding_out = None + self.destination = None + self.req_info = None + self.op_type = "" + + def verify_request(self, query, binding): + """ + :param query: The SAML query, transport encoded + :param binding: Which binding the query came in over + """ + resp_args = {} + if not query: + logger.info("Missing QUERY") + resp = Unauthorized("Unknown user") + return resp_args, resp(self.environ, self.start_response) + + if not self.req_info: + self.req_info = IDP.parse_authn_request(query, binding) + + logger.info("parsed OK") + _authn_req = self.req_info.message + logger.debug("%s", _authn_req) + + try: + self.binding_out, self.destination = IDP.pick_binding( + "assertion_consumer_service", bindings=self.response_bindings, + entity_id=_authn_req.issuer.text + ) + except Exception as err: + logger.error("Couldn't find receiver endpoint: %s", err) + raise + + logger.debug("Binding: %s, destination: %s", self.binding_out, self.destination) + + resp_args = {} + try: + resp_args = IDP.response_args(_authn_req) + _resp = None + except UnknownPrincipal as excp: + _resp = IDP.create_error_response(_authn_req.id, self.destination, excp) + except UnsupportedBinding as excp: + _resp = IDP.create_error_response(_authn_req.id, self.destination, excp) + + return resp_args, _resp + + def do(self, query, binding_in, relay_state="", encrypt_cert=None): + """ + :param query: The request + :param binding_in: Which binding was used when receiving the query + :param relay_state: The relay state provided by the SP + :param encrypt_cert: Cert to use for encryption + :return: A response + """ + try: + resp_args, _resp = self.verify_request(query, binding_in) + except UnknownPrincipal as excp: + logger.error("UnknownPrincipal: %s", excp) + resp = ServiceError(f"UnknownPrincipal: {excp}") + return resp(self.environ, self.start_response) + except UnsupportedBinding as excp: + logger.error("UnsupportedBinding: %s", excp) + resp = ServiceError(f"UnsupportedBinding: {excp}") + return resp(self.environ, self.start_response) + + if not _resp: + identity = USERS[self.user].copy() + # identity["eduPersonTargetedID"] = get_eptid(IDP, query, session) + logger.info("Identity: %s", identity) + + if REPOZE_ID_EQUIVALENT: + identity[REPOZE_ID_EQUIVALENT] = self.user + try: + try: + metod = self.environ["idp.authn"] + except KeyError: + pass + else: + resp_args["authn"] = metod + + _resp = IDP.create_authn_response(identity, userid=self.user, + encrypt_cert=encrypt_cert, **resp_args) + except Exception as excp: + logging.error(exception_trace(excp)) + resp = ServiceError(f"Exception: {excp}") + return resp(self.environ, self.start_response) + + logger.info("AuthNResponse: %s", _resp) + if self.op_type == "ecp": + kwargs = {"soap_headers": [ecp.Response( + assertion_consumer_service_url=self.destination)]} + else: + kwargs = {} + + http_args = IDP.apply_binding( + self.binding_out, f"{_resp}", self.destination, relay_state, response=True, **kwargs + ) + + logger.debug("HTTPargs: %s", http_args) + return self.response(self.binding_out, http_args) + + def _store_request(self, saml_msg): + logger.debug("_store_request: %s", saml_msg) + key = sha1(saml_msg["SAMLRequest"]).hexdigest() + # store the AuthnRequest + IDP.ticket[key] = saml_msg + return key + + def redirect(self): + """This is the HTTP-redirect endpoint""" + + logger.info("--- In SSO Redirect ---") + saml_msg = self.unpack_redirect() + + try: + _key = saml_msg["key"] + saml_msg = IDP.ticket[_key] + self.req_info = saml_msg["req_info"] + del IDP.ticket[_key] + except KeyError: + try: + self.req_info = IDP.parse_authn_request( + saml_msg["SAMLRequest"], BINDING_HTTP_REDIRECT) + except KeyError: + resp = BadRequest("Message signature verification failure") + return resp(self.environ, self.start_response) + + _req = self.req_info.message + + if "SigAlg" in saml_msg and "Signature" in saml_msg: # Signed + # request + issuer = _req.issuer.text + _certs = IDP.metadata.certs(issuer, "any", "signing") + verified_ok = False + for cert in _certs: + if verify_redirect_signature(saml_msg, IDP.sec.sec_backend, cert): + verified_ok = True + break + if not verified_ok: + resp = BadRequest("Message signature verification failure") + return resp(self.environ, self.start_response) + + if self.user: + if _req.force_authn: + saml_msg["req_info"] = self.req_info + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + else: + return self.operation(saml_msg, BINDING_HTTP_REDIRECT) + else: + saml_msg["req_info"] = self.req_info + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + else: + return self.operation(saml_msg, BINDING_HTTP_REDIRECT) + + def post(self): + """ + The HTTP-Post endpoint + """ + logger.info("--- In SSO POST ---") + saml_msg = self.unpack_either() + self.req_info = IDP.parse_authn_request(saml_msg["SAMLRequest"], BINDING_HTTP_POST) + _req = self.req_info.message + if self.user: + if _req.force_authn: + saml_msg["req_info"] = self.req_info + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + else: + return self.operation(saml_msg, BINDING_HTTP_POST) + else: + saml_msg["req_info"] = self.req_info + key = self._store_request(saml_msg) + return self.not_authn(key, _req.requested_authn_context) + + # def artifact(self): + # # Can be either by HTTP_Redirect or HTTP_POST + # _req = self._store_request(self.unpack_either()) + # if isinstance(_req, basestring): + # return self.not_authn(_req) + # return self.artifact_operation(_req) + + def ecp(self): + """ + The ECP interface + """ + logger.info("--- ECP SSO ---") + resp = None + + try: + authz_info = self.environ["HTTP_AUTHORIZATION"] + if authz_info.startswith("Basic "): + try: + _info = base64.b64decode(authz_info[6:]) + except TypeError: + resp = Unauthorized() + else: + try: + (user, passwd) = _info.split(":") + if is_equal(PASSWD[user], passwd): + resp = Unauthorized() + self.user = user + self.environ["idp.authn"] = AUTHN_BROKER.get_authn_by_accr(PASSWORD) + except ValueError: + resp = Unauthorized() + else: + resp = Unauthorized() + except KeyError: + resp = Unauthorized() + + if resp: + return resp(self.environ, self.start_response) + + _dict = self.unpack_soap() + self.response_bindings = [BINDING_PAOS] + # Basic auth ?! + self.op_type = "ecp" + return self.operation(_dict, BINDING_SOAP) + + +# ----------------------------------------------------------------------------- +# === Authentication ==== +# ----------------------------------------------------------------------------- + + +def do_authentication(environ, start_response, authn_context, key, redirect_uri): + """ + Display the login form + """ + logger.debug("Do authentication") + auth_info = AUTHN_BROKER.pick(authn_context) + + if len(auth_info) >= 0: + method, reference = auth_info[0] + logger.debug("Authn chosen: %s (ref=%s)", method, reference) + return method(environ, start_response, reference, key, redirect_uri) + resp = Unauthorized("No usable authentication method") + return resp(environ, start_response) + + +# ----------------------------------------------------------------------------- + +PASSWD = {"daev0001": "qwerty", "haho0032": "qwerty", + "roland": "dianakra", "babs": "howes", "upper": "crust"} + + +def username_password_authn(environ, start_response, reference, key, redirect_uri): + """ + Display the login form + """ + logger.info("The login page") + headers = [] + + resp = Response(mako_template="login.mako", template_lookup=LOOKUP, headers=headers) + + argv = { + "action": "/verify", + "login": "", + "password": "", + "key": key, + "authn_reference": reference, + "redirect_uri": redirect_uri, + } + logger.info("do_authentication argv: %s", argv) + return resp(environ, start_response, **argv) + + +def verify_username_and_password(dic): + """ + Verifies the username and password stored in the dictionary. + """ + # verify username and password + if PASSWD[dic["login"][0]] == dic["password"][0]: + return True, dic["login"][0] + else: + return False, "" + + +def do_verify(environ, start_response, _): + """ + Verifies the username and password provided in the POST request. + """ + query = parse_qs(get_post(environ)) + + logger.debug("do_verify: %s", query) + + try: + _ok, user = verify_username_and_password(query) + except KeyError: + _ok = False + user = None + + if not _ok: + resp = Unauthorized("Unknown user or wrong password") + else: + uid = rndstr(24) + IDP.cache.uid2user[uid] = user + IDP.cache.user2uid[user] = uid + logger.debug("Register %s under '%s'", user, uid) + + kaka = set_cookie("idpauthn", "/", uid, query["authn_reference"][0]) + + lox = f"{query['redirect_uri'][0]}?id={uid}&key={query['key'][0]}" + logger.debug("Redirect => %s", lox) + resp = Redirect(lox, headers=[kaka], content="text/html") + + return resp(environ, start_response) + + +def not_found(environ, start_response): + """Called if no URL matches.""" + resp = NotFound() + return resp(environ, start_response) + + +# ----------------------------------------------------------------------------- +# === Single log out === +# ----------------------------------------------------------------------------- + +# def _subject_sp_info(req_info): +# # look for the subject +# subject = req_info.subject_id() +# subject = subject.text.strip() +# sp_entity_id = req_info.message.issuer.text.strip() +# return subject, sp_entity_id + + +class SLO(Service): + """ + Single Log Out Service. + """ + def do(self, request, binding, relay_state="", encrypt_cert=None): + logger.info("--- Single Log Out Service ---") + try: + _, body = request.split("\n") + logger.debug("req: '%s'", body) + req_info = IDP.parse_logout_request(body, binding) + except Exception as exc: + logger.error("Bad request: %s", exc) + resp = BadRequest(f"{exc}") + return resp(self.environ, self.start_response) + + msg = req_info.message + if msg.name_id: + lid = IDP.ident.find_local_id(msg.name_id) + logger.info("local identifier: %s", lid) + if lid in IDP.cache.user2uid: + uid = IDP.cache.user2uid[lid] + if uid in IDP.cache.uid2user: + del IDP.cache.uid2user[uid] + del IDP.cache.user2uid[lid] + # remove the authentication + try: + IDP.session_db.remove_authn_statements(msg.name_id) + except KeyError as exc: + logger.error("ServiceError: %s", exc) + resp = ServiceError(f"{exc}") + return resp(self.environ, self.start_response) + + resp = IDP.create_logout_response(msg, [binding]) + + try: + hinfo = IDP.apply_binding(binding, f"{resp}", "", relay_state) + except Exception as exc: + logger.error("ServiceError: %s", exc) + resp = ServiceError(f"{exc}") + return resp(self.environ, self.start_response) + + # _tlh = dict2list_of_tuples(hinfo["headers"]) + delco = delete_cookie(self.environ, "idpauthn") + if delco: + hinfo["headers"].append(delco) + logger.info("Header: %s", (hinfo["headers"],)) + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# Manage Name ID service +# ---------------------------------------------------------------------------- + + +class NMI(Service): + """ + Manage Name ID Service. + """ + def do(self, query, binding, relay_state="", encrypt_cert=None): + logger.info("--- Manage Name ID Service ---") + req = IDP.parse_manage_name_id_request(query, binding) + request = req.message + + # Do the necessary stuff + name_id = IDP.ident.handle_manage_name_id_request( + request.name_id, request.new_id, request.new_encrypted_id, request.terminate + ) + + logger.debug("New NameID: %s", name_id) + + _resp = IDP.create_manage_name_id_response(request) + + # It's using SOAP binding + hinfo = IDP.apply_binding(BINDING_SOAP, f"{_resp}", "", relay_state, response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# === Assertion ID request === +# ---------------------------------------------------------------------------- + + +class AIDR(Service): + """ + Only URI binding + """ + def do(self, aid, binding, relay_state="", encrypt_cert=None): + logger.info("--- Assertion ID Service ---") + + try: + assertion = IDP.create_assertion_id_request_response(aid) + except Unknown: + resp = NotFound(aid) + return resp(self.environ, self.start_response) + + hinfo = IDP.apply_binding(BINDING_URI, f"{assertion}", response=True) + + logger.debug("HINFO: %s", hinfo) + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + def operation(self, _dict, binding, **kwargs): + logger.debug("_operation: %s", _dict) + if not _dict or "ID" not in _dict: + resp = BadRequest("Error parsing request or no request") + return resp(self.environ, self.start_response) + + return self.do(_dict["ID"], binding, **kwargs) + + +# ---------------------------------------------------------------------------- +# === Artifact resolve service === +# ---------------------------------------------------------------------------- + + +class ARS(Service): + """Artifact Resolution Service.""" + def do(self, request, binding, relay_state="", encrypt_cert=None): + _req = IDP.parse_artifact_resolve(request, binding) + + msg = IDP.create_artifact_response(_req, _req.artifact.text) + + hinfo = IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# === Authn query service === +# ---------------------------------------------------------------------------- + + +class AQS(Service): + """ + Only SOAP binding + """ + def do(self, request, binding, relay_state="", encrypt_cert=None): + logger.info("--- Authn Query Service ---") + _req = IDP.parse_authn_query(request, binding) + _query = _req.message + + msg = IDP.create_authn_query_response(_query.subject, + _query.requested_authn_context, _query.session_index) + + logger.debug("response: %s", msg) + hinfo = IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# === Attribute query service === +# ---------------------------------------------------------------------------- + + +class ATTR(Service): + """ + Only SOAP binding + """ + def do(self, request, binding, relay_state="", encrypt_cert=None): + logger.info("--- Attribute Query Service ---") + + _req = IDP.parse_attribute_query(request, binding) + _query = _req.message + + name_id = _query.subject.name_id + uid = name_id.text + logger.debug("Local uid: %s", uid) + identity = EXTRA[self.user] + + # Comes in over SOAP so only need to construct the response + args = IDP.response_args(_query, [BINDING_SOAP]) + msg = IDP.create_attribute_response(identity, name_id=name_id, **args) + + logger.debug("response: %s", msg) + hinfo = IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# Name ID Mapping service +# When an entity that shares an identifier for a principal with an identity +# provider wishes to obtain a name identifier for the same principal in a +# particular format or federation namespace, it can send a request to +# the identity provider using this protocol. +# ---------------------------------------------------------------------------- + + +class NIM(Service): + """ + Name ID Mapping Service + """ + def do(self, query, binding, relay_state="", encrypt_cert=None): + req = IDP.parse_name_id_mapping_request(query, binding) + request = req.message + # Do the necessary stuff + try: + name_id = IDP.ident.handle_name_id_mapping_request(request.name_id, + request.name_id_policy) + except Unknown: + resp = BadRequest("Unknown entity") + return resp(self.environ, self.start_response) + except PolicyError: + resp = BadRequest("Unknown entity") + return resp(self.environ, self.start_response) + + info = IDP.response_args(request) + _resp = IDP.create_name_id_mapping_response(name_id, **info) + + # Only SOAP + hinfo = IDP.apply_binding(BINDING_SOAP, f"{_resp}", "", "", response=True) + + resp = Response(hinfo["data"], headers=hinfo["headers"]) + return resp(self.environ, self.start_response) + + +# ---------------------------------------------------------------------------- +# Cookie handling +# ---------------------------------------------------------------------------- +def info_from_cookie(kaka): + """ + Extracts user information and reference from the provided cookie. + """ + logger.debug("KAKA: %s", kaka) + if kaka: + cookie_obj = SimpleCookie(kaka) + morsel = cookie_obj.get("idpauthn", None) + if morsel: + try: + key, ref = base64.b64decode(morsel.value).split(":") + return IDP.cache.uid2user[key], ref + except (TypeError, KeyError): + return None, None + else: + logger.debug("No idpauthn cookie") + return None, None + + +def delete_cookie(environ, name): + """ + Deletes the specified cookie from the provided environ. + """ + kaka = environ.get("HTTP_COOKIE", "") + logger.debug("delete KAKA: %s", kaka) + if kaka: + cookie_obj = SimpleCookie(kaka) + morsel = cookie_obj.get(name, None) + cookie = SimpleCookie() + cookie[name] = "" + cookie[name]["path"] = "/" + logger.debug("Expire: %s", morsel) + cookie[name]["expires"] = _expiration("dawn") + return tuple(cookie.output().split(": ", 1)) + return None + + +def set_cookie(name, _, *args): + """ + Sets a cookie with the specified name and values. + """ + cookie = SimpleCookie() + cookie[name] = base64.b64encode(":".join(args)) + cookie[name]["path"] = "/" + cookie[name]["expires"] = _expiration(5) # 5 minutes from now + logger.debug("Cookie expires: %s", cookie[name]["expires"]) + return tuple(cookie.output().split(": ", 1)) + + +# ---------------------------------------------------------------------------- + +# map urls to functions +AUTHN_URLS = [ + # sso + (r"sso/post$", (SSO, "post")), + (r"sso/post/(.*)$", (SSO, "post")), + (r"sso/redirect$", (SSO, "redirect")), + (r"sso/redirect/(.*)$", (SSO, "redirect")), + (r"sso/art$", (SSO, "artifact")), + (r"sso/art/(.*)$", (SSO, "artifact")), + # slo + (r"slo/redirect$", (SLO, "redirect")), + (r"slo/redirect/(.*)$", (SLO, "redirect")), + (r"slo/post$", (SLO, "post")), + (r"slo/post/(.*)$", (SLO, "post")), + (r"slo/soap$", (SLO, "soap")), + (r"slo/soap/(.*)$", (SLO, "soap")), + # + (r"airs$", (AIDR, "uri")), + (r"ars$", (ARS, "soap")), + # mni + (r"mni/post$", (NMI, "post")), + (r"mni/post/(.*)$", (NMI, "post")), + (r"mni/redirect$", (NMI, "redirect")), + (r"mni/redirect/(.*)$", (NMI, "redirect")), + (r"mni/art$", (NMI, "artifact")), + (r"mni/art/(.*)$", (NMI, "artifact")), + (r"mni/soap$", (NMI, "soap")), + (r"mni/soap/(.*)$", (NMI, "soap")), + # nim + (r"nim$", (NIM, "soap")), + (r"nim/(.*)$", (NIM, "soap")), + # + (r"aqs$", (AQS, "soap")), + (r"attr$", (ATTR, "soap")), +] + +NON_AUTHN_URLS = [ + # (r'login?(.*)$', do_authentication), + (r"verify?(.*)$", do_verify), + (r"sso/ecp$", (SSO, "ecp")), +] + +# ---------------------------------------------------------------------------- + + +def metadata(environ, start_response): + """ + Generates and serves the metadata XML based on the provided environment and start_response. + """ + try: + path = args.path + if path is None or len(path) == 0: + path = os.path.dirname(os.path.abspath(__file__)) + if path[-1] != "/": + path += "/" + metadata = create_metadata_string( + path + args.config, IDP.config, args.valid, args.cert, + args.keyfile, args.id, args.name, args.sign + ) + start_response("200 OK", [("Content-Type", "text/xml")]) + return metadata + except Exception as ex: + logger.error("An error occured while creating metadata:", ex.message) + return not_found(environ, start_response) + + +def staticfile(environ, start_response): + """ + Serves a static file based on the provided environment and start_response. + """ + try: + path = args.path + if path is None or len(path) == 0: + path = os.path.dirname(os.path.abspath(__file__)) + if path[-1] != "/": + path += "/" + path += environ.get("PATH_INFO", "").lstrip("/") + path = os.path.realpath(path) + if not path.startswith(args.path): + resp = Unauthorized() + return resp(environ, start_response) + start_response("200 OK", [("Content-Type", "text/xml")]) + return open(path).read() + except Exception as ex: + logger.error("An error occured while creating metadata: %s", str(ex)) + return not_found(environ, start_response) + + +def application(environ, start_response): + """ + The main WSGI application. Dispatch the current request to + the functions from above and store the regular expression + captures in the WSGI environment as `myapp.url_args` so that + the functions from above can access the url placeholders. + If nothing matches, call the `not_found` function. + :param environ: The HTTP application environment + :param start_response: The application to run when the handling of the + request is done + :return: The response as a list of lines + """ + + path = environ.get("PATH_INFO", "").lstrip("/") + + if path == "metadata": + return metadata(environ, start_response) + + kaka = environ.get("HTTP_COOKIE", None) + logger.info(" PATH: %s", path) + + if kaka: + logger.info("= KAKA =") + user, authn_ref = info_from_cookie(kaka) + if authn_ref: + environ["idp.authn"] = AUTHN_BROKER[authn_ref] + else: + try: + query = parse_qs(environ["QUERY_STRING"]) + logger.debug("QUERY: %s", query) + user = IDP.cache.uid2user[query["id"][0]] + except KeyError: + user = None + + url_patterns = AUTHN_URLS + if not user: + logger.info("-- No USER --") + # insert NON_AUTHN_URLS first in case there is no user + url_patterns = NON_AUTHN_URLS + url_patterns + + for regex, callback in url_patterns: + match = re.search(regex, path) + if match is not None: + try: + environ["myapp.url_args"] = match.groups()[0] + except IndexError: + environ["myapp.url_args"] = path + + logger.debug("Callback: %s", callback) + if isinstance(callback, tuple): + cls = callback[0](environ, start_response, user) + func = getattr(cls, callback[1]) + return func() + return callback(environ, start_response, user) + + if re.search(r"static/.*", path) is not None: + return staticfile(environ, start_response) + return not_found(environ, start_response) + + +# ---------------------------------------------------------------------------- + +# allow uwsgi or gunicorn mount +# by moving some initialization out of __name__ == '__main__' section. +# uwsgi -s 0.0.0.0:8088 --protocol http --callable application --module idp + +args = type("Config", (object,), {}) +args.config = "idp_conf" +args.mako_root = "./" +args.path = None + +AUTHN_BROKER = AuthnBroker() +AUTHN_BROKER.add(authn_context_class_ref(PASSWORD), + username_password_authn, 10, f"http://{socket.gethostname()}") +AUTHN_BROKER.add(authn_context_class_ref(UNSPECIFIED), "", 0, f"http://{socket.gethostname()}") +CONFIG = importlib.import_module(args.config) +IDP = server.Server(args.config, cache=Cache()) +IDP.ticket = {} + +# ---------------------------------------------------------------------------- + +if __name__ == "__main__": + from wsgiref.simple_server import make_server + + parser = argparse.ArgumentParser() + parser.add_argument("-p", dest="path", help="Path to configuration file.") + parser.add_argument( + "-v", dest="valid", + help="How long, in days,the metadata is valid from " "the time of creation" + ) + parser.add_argument("-c", dest="cert", help="certificate") + parser.add_argument("-i", dest="id", help="The ID of the entities descriptor") + parser.add_argument("-k", dest="keyfile", help="A file with a key to sign the metadata with") + parser.add_argument("-n", dest="name") + parser.add_argument("-s", dest="sign", action="store_true", help="sign the metadata") + parser.add_argument("-m", dest="mako_root", default="./") + parser.add_argument(dest="config") + args = parser.parse_args() + + _rot = args.mako_root + LOOKUP = TemplateLookup( + directories=[f"{_rot}templates", f"{_rot}htdocs"], + module_directory=f"{_rot}modules", + input_encoding="utf-8", + output_encoding="utf-8", + ) + + HOST = CONFIG.HOST + PORT = CONFIG.PORT + + SRV = make_server(HOST, PORT, application) + print(f"IdP listening on {HOST}:{PORT}") + SRV.serve_forever() +else: + _rot = args.mako_root + LOOKUP = TemplateLookup( + directories=[f"{_rot}templates", f"{_rot}htdocs"], + module_directory=f"{_rot}modules", + input_encoding="utf-8", + output_encoding="utf-8", + ) diff --git a/mslib/msidp/templates/root.mako b/mslib/msidp/templates/root.mako new file mode 100644 index 000000000..20d9d7d88 --- /dev/null +++ b/mslib/msidp/templates/root.mako @@ -0,0 +1,37 @@ +<% self.seen_css = set() %> +<%def name="css_link(path, media='')" filter="trim"> + % if path not in self.seen_css: + + % endif + <% self.seen_css.add(path) %> + +<%def name="css()" filter="trim"> + ${css_link('/static/css/main.css', 'screen')} + +<%def name="pre()" filter="trim"> +
    + +<%def name="post()" filter="trim"> +
    + +
    + + ## + +IDP test login + ${self.css()} + + + + ${pre()} +## ${comps.dict_to_table(pageargs)} +##

    +${next.body()} +${post()} + + diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 69cb36b03..bc60da8e2 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -38,6 +38,7 @@ import fs import requests import re +import webbrowser import urllib.request from urllib.parse import urljoin @@ -149,12 +150,14 @@ def __init__(self, parent=None, mscolab=None): self.add_mscolab_urls() self.mscolab_url_changed(self.urlCb.currentText()) - # connect login, adduser, connect buttons + # connect login, adduser, connect, login with idp, auth token submit buttons self.connectBtn.clicked.connect(self.connect_handler) self.connectBtn.setFocus() self.disconnectBtn.clicked.connect(self.disconnect_handler) self.disconnectBtn.hide() self.loginBtn.clicked.connect(self.login_handler) + self.loginWithIDPBtn.clicked.connect(self.idp_login_handler) + self.idpAuthTokenSubmitBtn.clicked.connect(self.idp_auth_token_submit_handler) self.addUserBtn.clicked.connect(lambda: self.stackedWidget.setCurrentWidget(self.newuserPage)) # enable login button only if email and password are entered @@ -231,6 +234,20 @@ def connect_handler(self): self.loginEmailLe.setEnabled(True) self.loginPasswordLe.setEnabled(True) + try: + idp_enabled = json.loads(r.text)["USE_SAML2"] + except (json.decoder.JSONDecodeError, KeyError): + idp_enabled = False + + if idp_enabled: + # Hide user creatiion seccion if IDP login enabled + self.addUserBtn.setHidden(True) + self.clickNewUserLabel.setHidden(True) + + else: + # Hide login by identity provider if IDP login disabled + self.loginWithIDPBtn.setHidden(True) + self.mscolab_server_url = url self.auth = auth save_password_to_keyring("MSCOLAB_AUTH_" + url, auth[0], auth[1]) @@ -326,6 +343,52 @@ def login_handler(self): self.save_user_credentials_to_config_file(data["email"], data["password"]) self.mscolab.after_login(data["email"], self.mscolab_server_url, r) + def idp_login_handler(self): + """Handle IDP login Button""" + url_idp_login = f'{self.mscolab_server_url}/available_idps' + webbrowser.open(url_idp_login, new=2) + self.stackedWidget.setCurrentWidget(self.idpAuthPage) + + def idp_auth_token_submit_handler(self): + """Handle IDP authentication token submission""" + url_idp_login_auth = f'{self.mscolab_server_url}/idp_login_auth' + user_token = self.idpAuthPasswordLe.text() + + try: + data = {'token': user_token} + response = requests.post(url_idp_login_auth, json=data, timeout=(2, 10)) + if response.status_code == 401: + self.set_status("Error", 'Invalid token or token expired. Please try again') + self.stackedWidget.setCurrentWidget(self.loginPage) + + elif response.status_code == 200: + _json = json.loads(response.text) + token = _json["token"] + user = _json["user"] + + data = { + "email": user["emailid"], + "password": token, + } + + s = requests.Session() + s.auth = self.auth + s.headers.update({'x-test': 'true'}) + url = f'{self.mscolab_server_url}/token' + + r = s.post(url, data=data, timeout=(2, 10)) + if r.status_code == 401: + raise requests.exceptions.ConnectionError + if r.text == "False": + # show status indicating about wrong credentials + self.set_status("Error", 'Invalid token. Please enter correct token') + else: + self.mscolab.after_login(data["email"], self.mscolab_server_url, r) + self.set_status("Success", 'Succesfully logged into mscolab server') + + except requests.exceptions.RequestException as error: + logging.error("unexpected error: %s %s %s", type(error), url, error) + def save_user_credentials_to_config_file(self, emailid, password): try: save_password_to_keyring(service_name=self.mscolab_server_url, username=emailid, password=password) diff --git a/mslib/msui/msui_web_browser.py b/mslib/msui/msui_web_browser.py new file mode 100644 index 000000000..11fb3b018 --- /dev/null +++ b/mslib/msui/msui_web_browser.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msui.msui_web_browser.py + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + MSUIWebBrowser can be used for localhost usage and testing purposes. + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import os +import sys + +from PyQt5.QtCore import QUrl, QTimer +from PyQt5.QtWidgets import QMainWindow, QPushButton, QToolBar, QApplication +from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile + +from mslib.msui.constants import MSUI_CONFIG_PATH + + +class MSUIWebBrowser(QMainWindow): + def __init__(self, url: str): + super().__init__() + + self.web_view = QWebEngineView(self) + self.setCentralWidget(self.web_view) + + self._url = url + self.profile = QWebEngineProfile().defaultProfile() + self.profile.setPersistentCookiesPolicy(QWebEngineProfile.ForcePersistentCookies) + self.browser_storage_folder = os.path.join(MSUI_CONFIG_PATH, 'webbrowser', '.cookies') + self.profile.setPersistentStoragePath(self.browser_storage_folder) + + self.back_button = QPushButton("← Back", self) + self.forward_button = QPushButton("→ Forward", self) + self.refresh_button = QPushButton("🔄 Refresh", self) + + self.back_button.clicked.connect(self.web_view.back) + self.forward_button.clicked.connect(self.web_view.forward) + self.refresh_button.clicked.connect(self.web_view.reload) + + toolbar = QToolBar() + toolbar.addWidget(self.back_button) + toolbar.addWidget(self.forward_button) + toolbar.addWidget(self.refresh_button) + self.addToolBar(toolbar) + + self.web_view.load(QUrl(self._url)) + self.setWindowTitle("MSS Web Browser") + self.resize(800, 600) + self.show() + + def closeEvent(self, event): + ''' + Delete all cookies when closing the web browser + ''' + self.profile.cookieStore().deleteAllCookies() + + +if __name__ == "__main__": + ''' + This function will be moved to handle accordingly the test cases. + The 'connection' variable determines when the web browser should be + closed, typically after the user logged in and establishes a connection + ''' + + CONNECTION = False + + def close_qtwebengine(): + ''' + Close the main window + ''' + main.close() + + def check_connection(): + ''' + Schedule the close_qtwebengine function to be called asynchronously + ''' + if CONNECTION: + QTimer.singleShot(0, close_qtwebengine) + + # app = QApplication(sys.argv) + app = QApplication(['', '--no-sandbox']) + WEB_URL = "https://www.google.com/" + main = MSUIWebBrowser(WEB_URL) + + QTimer.singleShot(0, check_connection) + + sys.exit(app.exec_()) diff --git a/mslib/msui/qt5/ui_mscolab_connect_dialog.py b/mslib/msui/qt5/ui_mscolab_connect_dialog.py index 31c5ac0b4..93f8ee09b 100644 --- a/mslib/msui/qt5/ui_mscolab_connect_dialog.py +++ b/mslib/msui/qt5/ui_mscolab_connect_dialog.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'mslib/msui/ui/ui_mscolab_connect_dialog.ui' +# Form implementation generated from reading ui file 'ui_mscolab_connect_dialog.ui' # -# Created by: PyQt5 UI code generator 5.15.7 +# Created by: PyQt5 UI code generator 5.12.3 # -# 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. +# WARNING! All changes made in this file will be lost! from PyQt5 import QtCore, QtGui, QtWidgets @@ -15,10 +14,8 @@ class Ui_MSColabConnectDialog(object): def setupUi(self, MSColabConnectDialog): MSColabConnectDialog.setObjectName("MSColabConnectDialog") MSColabConnectDialog.resize(478, 271) - self.verticalLayout = QtWidgets.QVBoxLayout(MSColabConnectDialog) - self.verticalLayout.setContentsMargins(12, 10, 10, 10) - self.verticalLayout.setSpacing(5) - self.verticalLayout.setObjectName("verticalLayout") + self.gridLayout_4 = QtWidgets.QGridLayout(MSColabConnectDialog) + self.gridLayout_4.setObjectName("gridLayout_4") self.horizontalLayout_2 = QtWidgets.QHBoxLayout() self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.urlLabel = QtWidgets.QLabel(MSColabConnectDialog) @@ -36,12 +33,12 @@ def setupUi(self, MSColabConnectDialog): self.disconnectBtn.setObjectName("disconnectBtn") self.horizontalLayout_2.addWidget(self.disconnectBtn) self.horizontalLayout_2.setStretch(1, 1) - self.verticalLayout.addLayout(self.horizontalLayout_2) + self.gridLayout_4.addLayout(self.horizontalLayout_2, 0, 0, 1, 1) self.line = QtWidgets.QFrame(MSColabConnectDialog) self.line.setFrameShape(QtWidgets.QFrame.HLine) self.line.setFrameShadow(QtWidgets.QFrame.Sunken) self.line.setObjectName("line") - self.verticalLayout.addWidget(self.line) + self.gridLayout_4.addWidget(self.line, 1, 0, 1, 1) self.stackedWidget = QtWidgets.QStackedWidget(MSColabConnectDialog) self.stackedWidget.setObjectName("stackedWidget") self.loginPage = QtWidgets.QWidget() @@ -49,30 +46,33 @@ def setupUi(self, MSColabConnectDialog): self.gridLayout_3 = QtWidgets.QGridLayout(self.loginPage) self.gridLayout_3.setContentsMargins(100, 0, 100, 0) self.gridLayout_3.setObjectName("gridLayout_3") - self.addUserBtn = QtWidgets.QPushButton(self.loginPage) - self.addUserBtn.setAutoDefault(False) - self.addUserBtn.setObjectName("addUserBtn") - self.gridLayout_3.addWidget(self.addUserBtn, 4, 1, 1, 1) self.loginBtn = QtWidgets.QPushButton(self.loginPage) self.loginBtn.setAutoDefault(True) self.loginBtn.setObjectName("loginBtn") self.gridLayout_3.addWidget(self.loginBtn, 3, 0, 1, 2) - self.loginPasswordLe = QtWidgets.QLineEdit(self.loginPage) - self.loginPasswordLe.setEchoMode(QtWidgets.QLineEdit.Password) - self.loginPasswordLe.setObjectName("loginPasswordLe") - self.gridLayout_3.addWidget(self.loginPasswordLe, 2, 0, 1, 2) - self.loginEmailLe = QtWidgets.QLineEdit(self.loginPage) - self.loginEmailLe.setObjectName("loginEmailLe") - self.gridLayout_3.addWidget(self.loginEmailLe, 1, 0, 1, 2) - self.clickNewUserLabel = QtWidgets.QLabel(self.loginPage) - self.clickNewUserLabel.setObjectName("clickNewUserLabel") - self.gridLayout_3.addWidget(self.clickNewUserLabel, 4, 0, 1, 1) + self.addUserBtn = QtWidgets.QPushButton(self.loginPage) + self.addUserBtn.setAutoDefault(False) + self.addUserBtn.setObjectName("addUserBtn") + self.gridLayout_3.addWidget(self.addUserBtn, 4, 1, 1, 1) self.loginTopicLabel = QtWidgets.QLabel(self.loginPage) font = QtGui.QFont() font.setPointSize(16) self.loginTopicLabel.setFont(font) self.loginTopicLabel.setObjectName("loginTopicLabel") self.gridLayout_3.addWidget(self.loginTopicLabel, 0, 0, 1, 2, QtCore.Qt.AlignHCenter) + self.clickNewUserLabel = QtWidgets.QLabel(self.loginPage) + self.clickNewUserLabel.setObjectName("clickNewUserLabel") + self.gridLayout_3.addWidget(self.clickNewUserLabel, 4, 0, 1, 1) + self.loginWithIDPBtn = QtWidgets.QPushButton(self.loginPage) + self.loginWithIDPBtn.setObjectName("loginWithIDPBtn") + self.gridLayout_3.addWidget(self.loginWithIDPBtn, 5, 0, 1, 2) + self.loginEmailLe = QtWidgets.QLineEdit(self.loginPage) + self.loginEmailLe.setObjectName("loginEmailLe") + self.gridLayout_3.addWidget(self.loginEmailLe, 1, 0, 1, 2) + self.loginPasswordLe = QtWidgets.QLineEdit(self.loginPage) + self.loginPasswordLe.setEchoMode(QtWidgets.QLineEdit.Password) + self.loginPasswordLe.setObjectName("loginPasswordLe") + self.gridLayout_3.addWidget(self.loginPasswordLe, 2, 0, 1, 2) self.stackedWidget.addWidget(self.loginPage) self.newuserPage = QtWidgets.QWidget() self.newuserPage.setObjectName("newuserPage") @@ -147,12 +147,46 @@ def setupUi(self, MSColabConnectDialog): spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) self.verticalLayout_4.addItem(spacerItem) self.stackedWidget.addWidget(self.httpAuthPage) - self.verticalLayout.addWidget(self.stackedWidget) + self.idpAuthPage = QtWidgets.QWidget() + self.idpAuthPage.setEnabled(True) + self.idpAuthPage.setObjectName("idpAuthPage") + self.layoutWidget = QtWidgets.QWidget(self.idpAuthPage) + self.layoutWidget.setGeometry(QtCore.QRect(0, 20, 451, 141)) + self.layoutWidget.setObjectName("layoutWidget") + self.idpAuthGridLayout = QtWidgets.QGridLayout(self.layoutWidget) + self.idpAuthGridLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) + self.idpAuthGridLayout.setContentsMargins(0, 0, 0, 0) + self.idpAuthGridLayout.setObjectName("idpAuthGridLayout") + self.idpAuthTokenLabel = QtWidgets.QLabel(self.layoutWidget) + self.idpAuthTokenLabel.setObjectName("idpAuthTokenLabel") + self.idpAuthGridLayout.addWidget(self.idpAuthTokenLabel, 0, 0, 1, 1) + self.idpAuthPasswordLe = QtWidgets.QLineEdit(self.layoutWidget) + self.idpAuthPasswordLe.setText("") + self.idpAuthPasswordLe.setEchoMode(QtWidgets.QLineEdit.Normal) + self.idpAuthPasswordLe.setObjectName("idpAuthPasswordLe") + self.idpAuthGridLayout.addWidget(self.idpAuthPasswordLe, 0, 1, 1, 1) + self.idpAuthTokenSubmitBtn = QtWidgets.QPushButton(self.layoutWidget) + self.idpAuthTokenSubmitBtn.setObjectName("idpAuthTokenSubmitBtn") + self.idpAuthGridLayout.addWidget(self.idpAuthTokenSubmitBtn, 1, 1, 1, 1) + spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.idpAuthGridLayout.addItem(spacerItem1, 3, 0, 1, 2) + self.idpAuthTopicLabel = QtWidgets.QLabel(self.idpAuthPage) + self.idpAuthTopicLabel.setEnabled(True) + self.idpAuthTopicLabel.setGeometry(QtCore.QRect(0, 0, 456, 15)) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.idpAuthTopicLabel.sizePolicy().hasHeightForWidth()) + self.idpAuthTopicLabel.setSizePolicy(sizePolicy) + self.idpAuthTopicLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.idpAuthTopicLabel.setObjectName("idpAuthTopicLabel") + self.stackedWidget.addWidget(self.idpAuthPage) + self.gridLayout_4.addWidget(self.stackedWidget, 2, 0, 1, 1) self.line_2 = QtWidgets.QFrame(MSColabConnectDialog) self.line_2.setFrameShape(QtWidgets.QFrame.HLine) self.line_2.setFrameShadow(QtWidgets.QFrame.Sunken) self.line_2.setObjectName("line_2") - self.verticalLayout.addWidget(self.line_2) + self.gridLayout_4.addWidget(self.line_2, 3, 0, 1, 1) self.statusHL = QtWidgets.QHBoxLayout() self.statusHL.setContentsMargins(-1, 0, -1, -1) self.statusHL.setObjectName("statusHL") @@ -161,10 +195,10 @@ def setupUi(self, MSColabConnectDialog): self.statusLabel.setObjectName("statusLabel") self.statusHL.addWidget(self.statusLabel) self.statusHL.setStretch(0, 1) - self.verticalLayout.addLayout(self.statusHL) + self.gridLayout_4.addLayout(self.statusHL, 4, 0, 1, 1) self.retranslateUi(MSColabConnectDialog) - self.stackedWidget.setCurrentIndex(2) + self.stackedWidget.setCurrentIndex(3) QtCore.QMetaObject.connectSlotsByName(MSColabConnectDialog) MSColabConnectDialog.setTabOrder(self.urlCb, self.connectBtn) MSColabConnectDialog.setTabOrder(self.connectBtn, self.loginEmailLe) @@ -186,15 +220,16 @@ def retranslateUi(self, MSColabConnectDialog): self.connectBtn.setText(_translate("MSColabConnectDialog", "Connect")) self.connectBtn.setShortcut(_translate("MSColabConnectDialog", "Return")) self.disconnectBtn.setText(_translate("MSColabConnectDialog", "Disconnect")) - self.addUserBtn.setToolTip(_translate("MSColabConnectDialog", "Add new user to the server")) - self.addUserBtn.setText(_translate("MSColabConnectDialog", "Add user")) self.loginBtn.setToolTip(_translate("MSColabConnectDialog", "Login using entered credentials")) self.loginBtn.setText(_translate("MSColabConnectDialog", "Login")) self.loginBtn.setShortcut(_translate("MSColabConnectDialog", "Return")) - self.loginPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Password")) - self.loginEmailLe.setPlaceholderText(_translate("MSColabConnectDialog", "Email ID")) - self.clickNewUserLabel.setText(_translate("MSColabConnectDialog", "Click here if new user")) + self.addUserBtn.setToolTip(_translate("MSColabConnectDialog", "Add new user to the server")) + self.addUserBtn.setText(_translate("MSColabConnectDialog", "Add user")) self.loginTopicLabel.setText(_translate("MSColabConnectDialog", "Login Details:")) + self.clickNewUserLabel.setText(_translate("MSColabConnectDialog", "Click here if new user")) + self.loginWithIDPBtn.setText(_translate("MSColabConnectDialog", "Login by Identity Provider")) + self.loginEmailLe.setPlaceholderText(_translate("MSColabConnectDialog", "Email ID")) + self.loginPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Password")) self.newUsernameLe.setPlaceholderText(_translate("MSColabConnectDialog", "John Doe")) self.newPasswordLabel.setText(_translate("MSColabConnectDialog", "Password:")) self.newConfirmPasswordLabel.setText(_translate("MSColabConnectDialog", "Confirm Password:")) @@ -207,4 +242,8 @@ def retranslateUi(self, MSColabConnectDialog): self.httpTopicLabel.setText(_translate("MSColabConnectDialog", "HTTP Server Authentication")) self.httpPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Server Auth Password")) self.httpPasswordLabel.setText(_translate("MSColabConnectDialog", "Password:")) + self.idpAuthTokenLabel.setText(_translate("MSColabConnectDialog", "Token")) + self.idpAuthPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Identity Provider Auth Token")) + self.idpAuthTokenSubmitBtn.setText(_translate("MSColabConnectDialog", "Submit")) + self.idpAuthTopicLabel.setText(_translate("MSColabConnectDialog", "Identity Provider Authentication")) self.statusLabel.setText(_translate("MSColabConnectDialog", "Status:")) diff --git a/mslib/msui/ui/ui_mscolab_connect_dialog.ui b/mslib/msui/ui/ui_mscolab_connect_dialog.ui index e4fa62990..595eee1dd 100644 --- a/mslib/msui/ui/ui_mscolab_connect_dialog.ui +++ b/mslib/msui/ui/ui_mscolab_connect_dialog.ui @@ -13,24 +13,9 @@ Connect to MSColab - - - 5 - - - 12 - - - 10 - - - 10 - - - 10 - - - + + + @@ -73,17 +58,17 @@ - + Qt::Horizontal - + - 2 + 3 @@ -99,19 +84,6 @@ 0 - - - - Add new user to the server - - - Add user - - - false - - - @@ -128,20 +100,28 @@ - - - - QLineEdit::Password + + + + Add new user to the server - - Password + + Add user + + + false - - - - Email ID + + + + + 16 + + + + Login Details: @@ -152,15 +132,27 @@ - - - - - 16 - - + + - Login Details: + Login by Identity Provider + + + + + + + Email ID + + + + + + + QLineEdit::Password + + + Password @@ -341,16 +333,101 @@ + + + true + + + + + 0 + 20 + 451 + 141 + + + + + QLayout::SetDefaultConstraint + + + + + Token + + + + + + + + + + QLineEdit::Normal + + + Identity Provider Auth Token + + + + + + + Submit + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + true + + + + 0 + 0 + 456 + 15 + + + + + 0 + 0 + + + + Identity Provider Authentication + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + - + Qt::Horizontal - + 0 diff --git a/mslib/static/templates/errors/404.html b/mslib/static/templates/errors/404.html new file mode 100644 index 000000000..1169620e8 --- /dev/null +++ b/mslib/static/templates/errors/404.html @@ -0,0 +1,5 @@ +
    +

    404 - Page Not Found

    +
    +

    The resource requested could not be found in this server.

    +
    diff --git a/mslib/static/templates/errors/500.html b/mslib/static/templates/errors/500.html new file mode 100644 index 000000000..6edc9e489 --- /dev/null +++ b/mslib/static/templates/errors/500.html @@ -0,0 +1,5 @@ +
    +

    500 - Sorry Unexpected Error

    +
    +

    We are currently investigating the issue and will work on fixing it. If you encounter any problem, please consider filing an issue and providing a detailed description of the issue you are facing. We appreciate your cooperation and patience while we address this matter. We'll be back soon with a solution.

    +
    diff --git a/mslib/static/templates/idp/available_idps.html b/mslib/static/templates/idp/available_idps.html new file mode 100644 index 000000000..ce361c22e --- /dev/null +++ b/mslib/static/templates/idp/available_idps.html @@ -0,0 +1,74 @@ +{% extends "theme.html" %} {% block body %} +
    + +
    +

    Choose Identity Provider

    +
    +
      + + {% for idp in configured_idps %} +
    • + +
    • + {% endfor %} + +
    + +
    + +
    + +
    + {% endblock %} diff --git a/mslib/static/templates/idp/idp_login_success.html b/mslib/static/templates/idp/idp_login_success.html new file mode 100644 index 000000000..fefabd304 --- /dev/null +++ b/mslib/static/templates/idp/idp_login_success.html @@ -0,0 +1,43 @@ +{% extends "theme.html" %} {% block body %} +
    + +
    +
    +

    Congratulations! You have successfully logged in to the mscolab server using Identity Provider.

    +

    Please proceed to log in using the user interface by bellow token.

    +

    Token : {{token}} +
    + +

    + +
    + + +
    + {% endblock %} diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index c1bd4b460..37dfb7140 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -79,8 +79,9 @@ def test_home(self): def test_hello(self): with self.app.test_client() as test_client: response = test_client.get('/status') - assert response.status_code == 200 - assert b"Mscolab server" in response.data + data = json.loads(response.text) + assert "Mscolab server" in data['message'] + assert True or False in data['USE_SAML2'] def test_register_user(self): with self.app.test_client(): diff --git a/tests/utils.py b/tests/utils.py index d2a07be3c..5313d6fbd 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -186,7 +186,8 @@ def mscolab_ping_server(port): url = f"http://127.0.0.1:{port}/status" try: r = requests.get(url, timeout=(2, 10)) - if r.text == "Mscolab server": + data = json.loads(r.text) + if data['message'] == "Mscolab server" and isinstance(data['USE_SAML2'], bool): return True except requests.exceptions.ConnectionError: return False From d19dde1c899d29b3837be589b8ff46761fc4086e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 8 Nov 2023 13:30:44 +0100 Subject: [PATCH 071/176] Replace SIGKILL with SIGTERM (#2075) SIGTERM is the less invasive one, allowing the process to gracefully shutdown on its own. --- mslib/mscolab/mscolab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index ab56e5c20..7519d9897 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -280,7 +280,7 @@ def handle_mscolab_metadata_init(repo_exists): cmd_curl = ["curl", "http://localhost:8083/metadata/localhost_test_idp", "-o", f"{mscolab_settings.MSCOLAB_SSO_DIR}/metadata_sp.xml"] subprocess.run(cmd_curl, check=True) - process.kill() + process.terminate() print('mscolab metadata file generated succesfully') return True From 3435c92483fb3e93f2d9c384fbead519c234484b Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Tue, 14 Nov 2023 00:46:55 +0530 Subject: [PATCH 072/176] correct file name sample (#2089) --- ...s_saml2_backend.yaml.samlple => mss_saml2_backend.yaml.sample} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/samples/config/mscolab/{mss_saml2_backend.yaml.samlple => mss_saml2_backend.yaml.sample} (100%) diff --git a/docs/samples/config/mscolab/mss_saml2_backend.yaml.samlple b/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample similarity index 100% rename from docs/samples/config/mscolab/mss_saml2_backend.yaml.samlple rename to docs/samples/config/mscolab/mss_saml2_backend.yaml.sample From c089c558c4b15039408c92d528553a5433f134fc Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Tue, 14 Nov 2023 00:51:30 +0530 Subject: [PATCH 073/176] set XMLSEC_PATH based on env (#2091) --- mslib/msidp/idp_conf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mslib/msidp/idp_conf.py b/mslib/msidp/idp_conf.py index 0a94ec978..86ced60a0 100644 --- a/mslib/msidp/idp_conf.py +++ b/mslib/msidp/idp_conf.py @@ -36,9 +36,8 @@ from saml2.saml import NAME_FORMAT_URI from saml2.saml import NAMEID_FORMAT_PERSISTENT from saml2.saml import NAMEID_FORMAT_TRANSIENT -from saml2.sigver import get_xmlsec_binary -XMLSEC_PATH = get_xmlsec_binary() +XMLSEC_PATH = os.path.join(os.environ["CONDA_PREFIX"], "bin", "xmlsec1") # CRTs and metadata files can be generated through the mscolab server. # if configured that way CRTs DIRs should be same in both IDP and mscolab server. From d297a00887873fc9ede1009c464e920ac97de4f6 Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Tue, 14 Nov 2023 13:04:56 +0530 Subject: [PATCH 074/176] refactor print to logging (#2092) --- mslib/mscolab/mscolab.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index 7519d9897..0d33b17dd 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -105,7 +105,7 @@ def handle_mscolab_certificate_init(): "-nodes", "-x509", "-days", "365", "-batch", "-subj", "/CN=localhost", "-out", f"{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt"] subprocess.run(cmd, check=True) - print("generated CRTs for the mscolab server.") + logging.info("generated CRTs for the mscolab server.") return True except subprocess.CalledProcessError as error: print(f"Error while generating CRTs for the mscolab server: {error}") @@ -121,7 +121,7 @@ def handle_local_idp_certificate_init(): "-nodes", "-x509", "-days", "365", "-batch", "-subj", "/CN=localhost", "-out", f"{mscolab_settings.MSCOLAB_SSO_DIR}/crt_local_idp.crt"] subprocess.run(cmd, check=True) - print("generated CRTs for the local identity provider") + logging.info("generated CRTs for the local identity provider") return True except subprocess.CalledProcessError as error: print(f"Error while generated CRTs for the local identity provider: {error}") @@ -281,7 +281,7 @@ def handle_mscolab_metadata_init(repo_exists): "-o", f"{mscolab_settings.MSCOLAB_SSO_DIR}/metadata_sp.xml"] subprocess.run(cmd_curl, check=True) process.terminate() - print('mscolab metadata file generated succesfully') + logging.info('mscolab metadata file generated succesfully') return True except subprocess.CalledProcessError as error: @@ -308,7 +308,7 @@ def handle_local_idp_metadata_init(repo_exists): with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml", "w", encoding="utf-8") as output_file: subprocess.run(cmd, stdout=output_file, check=True) - print("idp metadata file generated succesfully") + logging.info("idp metadata file generated succesfully") return True except subprocess.CalledProcessError as error: # Delete the idp.xml file when the subprocess fails From be3e51361f568bb45fe17bc05ebc82597041b1b3 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 14 Nov 2023 15:21:38 +0100 Subject: [PATCH 075/176] feature prepared for py311 (#1923) * prepared for py311 * skipped needs review * enables worker restart * worker restart * skip test * because of deprecation of grid_b and change to visible we need 3.7 * disabled fixture * fixed deprecation * disabled test without asserts * skip test * module added * test skipped * test skipped * test skipped * skip tests * trying other ports * flake8 * skip test * undo test changes * skip test * typo * flake8 * set start condition * skip early * check added --- .github/workflows/testing.yml | 2 +- conftest.py | 1 - localbuild/meta.yaml | 6 +++--- tests/_test_msui/test_mss.py | 4 ++-- tests/_test_msui/test_wms_control.py | 3 +-- tests/_test_utils/test_auth.py | 6 ++++++ 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 6dbf7822e..f39b34fb3 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -96,7 +96,7 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-${{ inputs.branch_name }}-env \ - && pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests \ + && pytest -vv -n 6 --dist loadfile --max-worker-restart 4 tests \ || (for i in {1..5} \ ; do pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ && break \ diff --git a/conftest.py b/conftest.py index 870c3b99d..af13ad090 100644 --- a/conftest.py +++ b/conftest.py @@ -246,7 +246,6 @@ def fail_if_open_message_boxes_left(): except RuntimeError: pass - @pytest.fixture(scope="session", autouse=True) def configure_testsetup(request): if Display is not None: diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 77aae4cd9..e625ebb2b 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -40,7 +40,7 @@ requirements: - chameleon - execnet - fastkml =0.11 - - shapely <2.0.0 + - shapely >=2.0.0 - pygeoif <1.0.0 - isodate - lxml @@ -48,8 +48,8 @@ requirements: - hdf4 - pillow - pytz - - pyqt >=5, <5.13 - - qt >=5.10, <5.13 + - pyqt >=5.15.0 + - qt >=5.15.0 - requests >=2.31.0 - scipy - skyfield >=1.12 diff --git a/tests/_test_msui/test_mss.py b/tests/_test_msui/test_mss.py index 5e4cba87e..222e4470a 100644 --- a/tests/_test_msui/test_mss.py +++ b/tests/_test_msui/test_mss.py @@ -24,13 +24,13 @@ See the License for the specific language governing permissions and limitations under the License. """ - - +import pytest import sys from PyQt5 import QtWidgets, QtTest, QtCore from mslib.msui import mss +@pytest.mark.skip(reason='needs review, assert missing') def test_mss_rename_message(): application = QtWidgets.QApplication(sys.argv) main_window = mss.MSSMainWindow() diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 06aeb1674..ab2f53c48 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -151,10 +151,9 @@ def test_invalid_url(self, mockbox): self.query_server(f"http://???127.0.0.1:{self.port}") assert mockbox.critical.call_count == 1 + @pytest.mark.skip("problem in urllib3") @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_connection_error(self, mockbox): - if sys.version_info.major == 3: - pytest.skip("problem in urllib3") """ assert that a message box informs about server troubles """ diff --git a/tests/_test_utils/test_auth.py b/tests/_test_utils/test_auth.py index 257e8f5fe..f9418ff98 100644 --- a/tests/_test_utils/test_auth.py +++ b/tests/_test_utils/test_auth.py @@ -48,12 +48,16 @@ def test_keyring(): def test_get_auth_from_url_and_name(): + # set start condition to prevent definitions from a test earlier + constants.AUTH_LOGIN_CACHE == {} # empty http_auth definition server_url = "http://example.com" http_auth = config_loader(dataset="MSS_auth") assert http_auth == {} data = auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=False) assert data == (None, None) + # checking if the test setup changes this + assert constants.AUTH_LOGIN_CACHE == {} # auth username and url defined auth_username = 'mss' create_msui_settings_file(f'{{"MSS_auth": {{"http://example.com": "{auth_username}"}}}}') @@ -63,6 +67,8 @@ def test_get_auth_from_url_and_name(): data = auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=False) # no password yet assert data == (auth_username, None) + # checking if the test setup changes this + assert constants.AUTH_LOGIN_CACHE == {} # store a password auth.save_password_to_keyring(server_url, auth_username, "password") # return the test password From 83b39851af2809c03490f154ac2e4c4407129d71 Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Tue, 14 Nov 2023 20:07:22 +0530 Subject: [PATCH 076/176] move hardcoded path to os.path.join (#2090) --- mslib/mscolab/mscolab.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index 0d33b17dd..70ff78c2a 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -101,9 +101,10 @@ def handle_mscolab_certificate_init(): try: cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", - f"{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key", + os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "key_mscolab.key"), "-nodes", "-x509", "-days", "365", "-batch", "-subj", - "/CN=localhost", "-out", f"{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt"] + "/CN=localhost", "-out", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, + "crt_mscolab.crt")] subprocess.run(cmd, check=True) logging.info("generated CRTs for the mscolab server.") return True @@ -117,9 +118,9 @@ def handle_local_idp_certificate_init(): try: cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", - f"{mscolab_settings.MSCOLAB_SSO_DIR}/key_local_idp.key", + os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "key_local_idp.key"), "-nodes", "-x509", "-days", "365", "-batch", "-subj", - "/CN=localhost", "-out", f"{mscolab_settings.MSCOLAB_SSO_DIR}/crt_local_idp.crt"] + "/CN=localhost", "-out", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "crt_local_idp.crt")] subprocess.run(cmd, check=True) logging.info("generated CRTs for the local identity provider") return True @@ -250,7 +251,7 @@ def handle_mscolab_backend_yaml_init(): # name_id_format_allow_create: true """ try: - file_path = f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml" + file_path = os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "mss_saml2_backend.yaml") with open(file_path, "w", encoding="utf-8") as file: file.write(saml_2_backend_yaml_content) return True @@ -271,14 +272,15 @@ def handle_mscolab_metadata_init(repo_exists): print('generating metadata file for the mscolab server') try: - command = ["python", "mslib/mscolab/mscolab.py", "start"] if repo_exists else ["mscolab", "start"] + command = ["python", os.path.join("mslib", "mscolab", "mscolab.py"), + "start"] if repo_exists else ["mscolab", "start"] process = subprocess.Popen(command) # Add a small delay to allow the server to start up time.sleep(10) cmd_curl = ["curl", "http://localhost:8083/metadata/localhost_test_idp", - "-o", f"{mscolab_settings.MSCOLAB_SSO_DIR}/metadata_sp.xml"] + "-o", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "metadata_sp.xml")] subprocess.run(cmd_curl, check=True) process.terminate() logging.info('mscolab metadata file generated succesfully') @@ -293,27 +295,27 @@ def handle_local_idp_metadata_init(repo_exists): print('generating metadata for localhost identity provider') try: - if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml"): - os.remove(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml") + if os.path.exists(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")): + os.remove(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")) - idp_conf_path = "mslib/msidp/idp_conf.py" + idp_conf_path = os.path.join("mslib", "msidp", "idp_conf.py") if not repo_exists: import site site_packages_path = site.getsitepackages()[0] - idp_conf_path = os.path.join(site_packages_path, "mslib/msidp/idp_conf.py") + idp_conf_path = os.path.join(site_packages_path, "mslib", "msidp", "idp_conf.py") cmd = ["make_metadata", idp_conf_path] - with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml", + with open(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml"), "w", encoding="utf-8") as output_file: subprocess.run(cmd, stdout=output_file, check=True) logging.info("idp metadata file generated succesfully") return True except subprocess.CalledProcessError as error: # Delete the idp.xml file when the subprocess fails - if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml"): - os.remove(f"{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml") + if os.path.exists(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")): + os.remove(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")) print(f"Error while generating metadata for localhost identity provider: {error}") return False From e8977a0571173d7c8ba5df75d21c95126c978e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 15 Nov 2023 19:00:59 +0100 Subject: [PATCH 077/176] Update actions used in the workflows (#2098) Updates actions/checkout to v4 and actions/setup-python to v4. Some trailing whitespace was removed by my editor as well. --- .github/workflows/build_docs_gallery.yml | 4 ++-- .github/workflows/python-flake8.yml | 4 ++-- .github/workflows/testing.yml | 6 +++--- .github/workflows/testing_gsoc.yml | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build_docs_gallery.yml b/.github/workflows/build_docs_gallery.yml index 2a2dea4db..b5732cc43 100644 --- a/.github/workflows/build_docs_gallery.yml +++ b/.github/workflows/build_docs_gallery.yml @@ -1,6 +1,6 @@ name: Build Gallery -on: +on: pull_request: inputs: branch_name: @@ -27,7 +27,7 @@ jobs: steps: - name: Trust My Directory run: git config --global --add safe.directory /__w/MSS/MSS - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Create gallery timeout-minutes: 25 diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index 0123278fb..b398d2561 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -22,9 +22,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.10" - name: Lint with flake8 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index f39b34fb3..4e6807b59 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,6 +1,6 @@ name: Pytest MSS -on: +on: workflow_call: inputs: xdist: @@ -35,7 +35,7 @@ jobs: - name: Trust My Directory run: git config --global --add safe.directory /__w/MSS/MSS - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Check for changed dependencies run: | @@ -104,7 +104,7 @@ jobs: - name: Collect coverage if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && inputs.xdist == 'no'}} - env: + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd $GITHUB_WORKSPACE \ diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml index 6504ba9c0..17c5cf4a5 100644 --- a/.github/workflows/testing_gsoc.yml +++ b/.github/workflows/testing_gsoc.yml @@ -28,7 +28,7 @@ jobs: - name: Trust My Directory run: git config --global --add safe.directory /__w/MSS/MSS - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Check for changed dependencies run: | @@ -93,7 +93,7 @@ jobs: - name: Collect coverage if: ${{ success() }} - env: + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd $GITHUB_WORKSPACE \ From 225bc07b8121686d73eb6357e92ff9e7e7455a9a Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Wed, 15 Nov 2023 23:31:31 +0530 Subject: [PATCH 078/176] add missing newline after last line (#2096) --- docs/samples/config/mscolab/mss_saml2_backend.yaml.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample b/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample index 22c0cd67b..5284a3890 100644 --- a/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample +++ b/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample @@ -113,4 +113,4 @@ config: # discovery_response: # - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] # name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' - # name_id_format_allow_create: true \ No newline at end of file + # name_id_format_allow_create: true From 6d3c69456433fc14cd971f5d550bfd80bb05b30a Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Sun, 19 Nov 2023 04:21:20 +0530 Subject: [PATCH 079/176] improve clarity VERIFY_SSL_CERT config option (#2094) * improve clarity VERIFY_SSL_CERT config option * add missing line .sample --- docs/samples/config/mscolab/mscolab_settings.py.sample | 3 +++ mslib/mscolab/conf.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index c54707c39..85169f711 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -94,5 +94,8 @@ USE_SAML2 = False # having the roles in the TexGroup GROUP_POSTFIX = "Group" +# Enable SSL certificate verification during SSO between MSColab and IdP +ENABLE_SSO_SSL_CERT_VERIFICATION = True + # dir where mscolab single sign process files are stored MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 998afa4d8..d2b647cc7 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -117,8 +117,8 @@ class default_mscolab_settings: # enable login by identity provider USE_SAML2 = False - # SSL certificates verification during SSO. - VERIFY_SSL_CERT = True + # Enable SSL certificate verification during SSO between MSColab and IdP + ENABLE_SSO_SSL_CERT_VERIFICATION = True # dir where mscolab single sign process files are stored MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') @@ -178,7 +178,7 @@ class setup_saml2_backend: Ignore this warning when you initializeing metadata.") localhost_test_idp = SPConfig().load(yaml_data["config"]["localhost_test_idp"]) - localhost_test_idp.verify_ssl_cert = mscolab_settings.VERIFY_SSL_CERT + localhost_test_idp.verify_ssl_cert = mscolab_settings.ENABLE_SSO_SSL_CERT_VERIFICATION sp_localhost_test_idp = Saml2Client(localhost_test_idp) configured_idp['idp_data']['saml2client'] = sp_localhost_test_idp From 2f885521695195f5b58d85e17d27da8a1225c623 Mon Sep 17 00:00:00 2001 From: Sanskar Sharma Date: Sun, 19 Nov 2023 04:33:43 +0530 Subject: [PATCH 080/176] fix: conftest_flake8 (#2102) --- conftest.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/conftest.py b/conftest.py index af13ad090..2b0f2103b 100644 --- a/conftest.py +++ b/conftest.py @@ -29,7 +29,6 @@ import os import sys import mock -import warnings from PyQt5 import QtWidgets # Disable pyc files sys.dont_write_bytecode = True @@ -125,10 +124,10 @@ def pytest_generate_tests(metafunc): # mscolab data directory MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') -# In the unit days when Operations get archived because not used +# In the unit days when Operations get archived because not used ARCHIVE_THRESHOLD = 30 -# To enable logging set to True or pass a logger object to use. +# To enable logging set to True or pass a logger object to use. SOCKETIO_LOGGER = True # To enable Engine.IO logging set to True or pass a logger object to use. @@ -236,8 +235,6 @@ def fail_if_open_message_boxes_left(): summary = "\n".join([f"PyQt5.QtWidgets.QMessageBox.{box()._extract_mock_name()}: {box.mock_calls[:-1]}" for box in [q, i, c, w] if box.call_count > 0]) pytest.fail(f"An unhandled message box popped up during your test!\n{summary}") - - # Try to close all remaining widgets after each test for qobject in set(QtWidgets.QApplication.topLevelWindows() + QtWidgets.QApplication.topLevelWidgets()): try: @@ -246,6 +243,7 @@ def fail_if_open_message_boxes_left(): except RuntimeError: pass + @pytest.fixture(scope="session", autouse=True) def configure_testsetup(request): if Display is not None: From 1431027e9145dafc68a73994e4264610e5f28579 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Mon, 20 Nov 2023 08:56:55 +0100 Subject: [PATCH 081/176] refactoring of tutorials, automated image creation for comparison (#2097) * create automated all images used by tutorials * improved obj name visualization * kml tutorial refactored * hide in tutorial mode the creation of images * kde wants pageup for window maximize * flake8 * adopted tutorial_wms to the new picture setup simplified a dry run * tutorial_waypoints adopted to new images * tutorial_remotesensing adopted to new images * tutorial_hexagoncontro adopt to new images * tutorial_satellitetrack adopt to new images * tutorial_performancesettings adopt to new images * dry_run removed * output canvas image * string fixed * function to find a region added * tutorial_views adopted to new images * flake8 * further elements added * adopted to new images * mamba base updated * table navigation improved * refactored moved picture function * removed all pictures * tutorial_mscolab improved * flake8 * remove args.tutorials * optional dry_run removed * updated documentation * test for tutorial mode added * docs updated * changed test to a subset of images --- docs/gentutorials.rst | 17 +- localbuild/meta.yaml | 1 + mslib/msui/msui.py | 4 +- mslib/msui/msui_mainwindow.py | 96 ++++++- requirements.d/tutorials.txt | 10 +- tests/_test_msui/test_msui.py | 34 ++- tutorials/pictures/__init__.py | 46 --- tutorials/pictures/cursor_image.png | Bin 717 -> 0 bytes .../hexagoncontrol/linux/add_hexagon.png | Bin 1515 -> 0 bytes .../hexagoncontrol/linux/center_latitude.png | Bin 1525 -> 0 bytes .../pictures/hexagoncontrol/linux/radius.png | Bin 819 -> 0 bytes .../hexagoncontrol/linux/remove_hexagon.png | Bin 1727 -> 0 bytes .../pictures/kml/linux/add_kml_files.png | Bin 1409 -> 0 bytes tutorials/pictures/kml/linux/changecolor.png | Bin 1526 -> 0 bytes tutorials/pictures/kml/linux/colored_line.png | Bin 155 -> 0 bytes tutorials/pictures/kml/linux/kmloverlay.png | Bin 1460 -> 0 bytes .../pictures/kml/linux/mergeandexport.png | Bin 2781 -> 0 bytes .../pictures/kml/linux/pick_screen_color.png | Bin 1653 -> 0 bytes tutorials/pictures/kml/linux/remove_files.png | Bin 1338 -> 0 bytes .../pictures/kml/linux/select_all_files.png | Bin 1386 -> 0 bytes .../pictures/kml/linux/unselect_all_files.png | Bin 1628 -> 0 bytes .../mscolab/linux/active_operations.png | Bin 1271 -> 0 bytes tutorials/pictures/mscolab/linux/add_user.png | Bin 1006 -> 0 bytes tutorials/pictures/mscolab/linux/addop_ok.png | Bin 1080 -> 0 bytes .../pictures/mscolab/linux/chat_previous.png | Bin 1006 -> 0 bytes .../pictures/mscolab/linux/chat_send.png | Bin 750 -> 0 bytes tutorials/pictures/mscolab/linux/connect.png | Bin 1099 -> 0 bytes .../mscolab/linux/connect_to_mscolab.png | Bin 991 -> 0 bytes .../pictures/mscolab/linux/emailid_taken.png | Bin 3493 -> 0 bytes tutorials/pictures/mscolab/linux/file.png | Bin 401 -> 0 bytes .../mscolab/linux/johndoe_profile.png | Bin 1267 -> 0 bytes tutorials/pictures/mscolab/linux/login.png | Bin 1249 -> 0 bytes .../mscolab/linux/manageusers_add.png | Bin 593 -> 0 bytes .../linux/manageusers_left_selectall.png | Bin 905 -> 0 bytes .../mscolab/linux/manageusers_modify.png | Bin 877 -> 0 bytes .../linux/manageusers_right_selectall.png | Bin 915 -> 0 bytes .../pictures/mscolab/linux/name_version.png | Bin 1429 -> 0 bytes .../pictures/mscolab/linux/openviews.png | Bin 1388 -> 0 bytes .../mscolab/linux/overwrite_waypoints.png | Bin 2823 -> 0 bytes .../pictures/mscolab/linux/refresh_window.png | Bin 1730 -> 0 bytes .../pictures/mscolab/linux/server_options.png | Bin 1568 -> 0 bytes .../pictures/mscolab/linux/topview_point2.png | Bin 702 -> 0 bytes .../mscolab/linux/work_asynchronously.png | Bin 2190 -> 0 bytes tutorials/pictures/options.png | Bin 443 -> 0 bytes .../linux/aircraft_weight.png | Bin 1599 -> 0 bytes .../linux/maximum_takeoff_weight.png | Bin 2594 -> 0 bytes .../performancesettings/linux/select.png | Bin 754 -> 0 bytes .../linux/selecttoopencontrol.png | Bin 2210 -> 0 bytes .../linux/show_performance.png | Bin 1852 -> 0 bytes .../linux/take_off_time.png | Bin 1183 -> 0 bytes .../pictures/remotesensing/linux/azimuth.png | Bin 794 -> 0 bytes .../remotesensing/linux/drawtangent.png | Bin 2021 -> 0 bytes .../remotesensing/linux/elevation.png | Bin 1085 -> 0 bytes .../remotesensing/linux/showangle.png | Bin 1994 -> 0 bytes .../pictures/satellitetrack/linux/load.png | Bin 672 -> 0 bytes .../linux/predicted_satellite_overpasses.png | Bin 2667 -> 0 bytes .../pictures/views/linux/add_waypoint.png | Bin 686 -> 0 bytes .../pictures/views/linux/delete_waypoint.png | Bin 639 -> 0 bytes .../pictures/views/linux/get_capabilities.png | Bin 1913 -> 0 bytes .../pictures/views/linux/horizontal_wind.png | Bin 2622 -> 0 bytes tutorials/pictures/views/linux/layers.png | Bin 1375 -> 0 bytes .../pictures/views/linux/move_waypoint.png | Bin 941 -> 0 bytes .../pictures/views/linux/secondary_axis.png | Bin 772 -> 0 bytes .../views/linux/selecttoopencontrol.png | Bin 2224 -> 0 bytes .../pictures/views/linux/sideview_point1.png | Bin 478 -> 0 bytes .../pictures/views/linux/topview_point2.png | Bin 644 -> 0 bytes .../pictures/views/linux/vertical_axis.png | Bin 684 -> 0 bytes .../views/linux/vertical_velocity.png | Bin 2398 -> 0 bytes tutorials/pictures/views/linux/wms_url.png | Bin 1123 -> 0 bytes tutorials/pictures/views/linux/zoom.png | Bin 729 -> 0 bytes tutorials/pictures/views/win/add_waypoint.png | Bin 686 -> 0 bytes .../pictures/views/win/delete_waypoint.png | Bin 639 -> 0 bytes .../pictures/views/win/move_waypoint.png | Bin 941 -> 0 bytes .../pictures/views/win/secondary_axis.png | Bin 772 -> 0 bytes .../views/win/selecttoopencontrol.png | Bin 730 -> 0 bytes .../pictures/views/win/vertical_axis.png | Bin 684 -> 0 bytes tutorials/pictures/views/win/zoom.png | Bin 729 -> 0 bytes .../pictures/waypoints/linux/europe_cyl.png | Bin 1550 -> 0 bytes tutorials/pictures/wms/linux/add_waypoint.png | Bin 726 -> 0 bytes tutorials/pictures/wms/linux/auto_update.png | Bin 1397 -> 0 bytes .../linux/checkbox_unselected_divergence.png | Bin 1958 -> 0 bytes tutorials/pictures/wms/linux/clear_cache.png | Bin 1405 -> 0 bytes tutorials/pictures/wms/linux/clear_filter.png | Bin 661 -> 0 bytes tutorials/pictures/wms/linux/clone.png | Bin 765 -> 0 bytes tutorials/pictures/wms/linux/cloudcover.png | Bin 4919 -> 0 bytes .../pictures/wms/linux/delete_layers.png | Bin 370 -> 0 bytes .../pictures/wms/linux/deleteselected.png | Bin 1476 -> 0 bytes .../pictures/wms/linux/divergence_layer.png | Bin 2490 -> 0 bytes .../pictures/wms/linux/equivalent_layer.png | Bin 3348 -> 0 bytes tutorials/pictures/wms/linux/europe_cyl.png | Bin 1700 -> 0 bytes .../pictures/wms/linux/get_capabilities.png | Bin 2176 -> 0 bytes tutorials/pictures/wms/linux/home.png | Bin 595 -> 0 bytes .../pictures/wms/linux/horizontalwind.png | Bin 5122 -> 0 bytes .../pictures/wms/linux/initialization.png | Bin 1155 -> 0 bytes tutorials/pictures/wms/linux/insert.png | Bin 758 -> 0 bytes tutorials/pictures/wms/linux/layer_filter.png | Bin 1163 -> 0 bytes tutorials/pictures/wms/linux/layers.png | Bin 1585 -> 0 bytes tutorials/pictures/wms/linux/level.png | Bin 651 -> 0 bytes .../pictures/wms/linux/makeroundtrip.png | Bin 616 -> 0 bytes .../pictures/wms/linux/move_waypoint.png | Bin 941 -> 0 bytes .../pictures/wms/linux/multilayering.png | Bin 1364 -> 0 bytes tutorials/pictures/wms/linux/next.png | Bin 565 -> 0 bytes tutorials/pictures/wms/linux/options.png | Bin 1001 -> 0 bytes tutorials/pictures/wms/linux/pan.png | Bin 545 -> 0 bytes tutorials/pictures/wms/linux/previous.png | Bin 590 -> 0 bytes tutorials/pictures/wms/linux/redraw.png | Bin 589 -> 0 bytes tutorials/pictures/wms/linux/remove.png | Bin 1072 -> 0 bytes .../pictures/wms/linux/remove_waypoint.png | Bin 642 -> 0 bytes tutorials/pictures/wms/linux/retrieve.png | Bin 1171 -> 0 bytes tutorials/pictures/wms/linux/reverse.png | Bin 950 -> 0 bytes tutorials/pictures/wms/linux/save.png | Bin 526 -> 0 bytes .../wms/linux/selecttoopencontrol.png | Bin 2210 -> 0 bytes tutorials/pictures/wms/linux/star_filter.png | Bin 466 -> 0 bytes tutorials/pictures/wms/linux/transparent.png | Bin 1282 -> 0 bytes .../wms/linux/unselected_divergence_layer.png | Bin 1503 -> 0 bytes .../pictures/wms/linux/unstar_filter.png | Bin 630 -> 0 bytes tutorials/pictures/wms/linux/use_cache.png | Bin 1200 -> 0 bytes tutorials/pictures/wms/linux/valid.png | Bin 716 -> 0 bytes tutorials/pictures/wms/linux/wms_url.png | Bin 1116 -> 0 bytes tutorials/pictures/wms/linux/zoom.png | Bin 740 -> 0 bytes tutorials/pictures/wms/win/auto_update.png | Bin 544 -> 0 bytes tutorials/pictures/wms/win/clear_cache.png | Bin 535 -> 0 bytes tutorials/pictures/wms/win/clone.png | Bin 370 -> 0 bytes tutorials/pictures/wms/win/cloudcover.png | Bin 2004 -> 0 bytes tutorials/pictures/wms/win/delete_layers.png | Bin 1195 -> 0 bytes tutorials/pictures/wms/win/deleteselected.png | Bin 490 -> 0 bytes .../pictures/wms/win/divergence_layer.png | Bin 2141 -> 0 bytes .../wms/win/equivalent_potential_layer.png | Bin 2441 -> 0 bytes tutorials/pictures/wms/win/europe_cyl.png | Bin 835 -> 0 bytes .../pictures/wms/win/get_capabilities.png | Bin 610 -> 0 bytes tutorials/pictures/wms/win/horizontalwind.png | Bin 2084 -> 0 bytes tutorials/pictures/wms/win/initialization.png | Bin 468 -> 0 bytes tutorials/pictures/wms/win/insert.png | Bin 378 -> 0 bytes tutorials/pictures/wms/win/layer_filter.png | Bin 491 -> 0 bytes tutorials/pictures/wms/win/layers.png | Bin 606 -> 0 bytes tutorials/pictures/wms/win/level.png | Bin 660 -> 0 bytes tutorials/pictures/wms/win/multilayering.png | Bin 600 -> 0 bytes tutorials/pictures/wms/win/options.png | Bin 392 -> 0 bytes tutorials/pictures/wms/win/remove.png | Bin 399 -> 0 bytes tutorials/pictures/wms/win/retrieve.png | Bin 451 -> 0 bytes tutorials/pictures/wms/win/reverse.png | Bin 395 -> 0 bytes .../pictures/wms/win/selecttoopencontrol.png | Bin 895 -> 0 bytes tutorials/pictures/wms/win/star_filter.png | Bin 561 -> 0 bytes tutorials/pictures/wms/win/star_layer.png | Bin 594 -> 0 bytes tutorials/pictures/wms/win/transparent.png | Bin 466 -> 0 bytes tutorials/pictures/wms/win/use_cache.png | Bin 461 -> 0 bytes tutorials/pictures/wms/win/valid.png | Bin 398 -> 0 bytes tutorials/pictures/wms/win/view.png | Bin 411 -> 0 bytes tutorials/pictures/wms/win/wms_url.png | Bin 549 -> 0 bytes tutorials/pictures/wms/win/zoom.png | Bin 740 -> 0 bytes tutorials/tutorial_hexagoncontrol.py | 46 +-- tutorials/tutorial_kml.py | 147 +++------- tutorials/tutorial_mscolab.py | 138 +++++---- tutorials/tutorial_performancesettings.py | 30 +- tutorials/tutorial_remotesensing.py | 28 +- tutorials/tutorial_satellitetrack.py | 21 +- tutorials/tutorial_views.py | 268 ++++++++---------- tutorials/tutorial_waypoints.py | 30 +- tutorials/tutorial_wms.py | 242 +++++++--------- tutorials/tutorials.batch | 22 +- tutorials/utils/__init__.py | 23 +- 161 files changed, 604 insertions(+), 599 deletions(-) delete mode 100644 tutorials/pictures/__init__.py delete mode 100644 tutorials/pictures/cursor_image.png delete mode 100644 tutorials/pictures/hexagoncontrol/linux/add_hexagon.png delete mode 100644 tutorials/pictures/hexagoncontrol/linux/center_latitude.png delete mode 100644 tutorials/pictures/hexagoncontrol/linux/radius.png delete mode 100644 tutorials/pictures/hexagoncontrol/linux/remove_hexagon.png delete mode 100644 tutorials/pictures/kml/linux/add_kml_files.png delete mode 100644 tutorials/pictures/kml/linux/changecolor.png delete mode 100644 tutorials/pictures/kml/linux/colored_line.png delete mode 100644 tutorials/pictures/kml/linux/kmloverlay.png delete mode 100644 tutorials/pictures/kml/linux/mergeandexport.png delete mode 100644 tutorials/pictures/kml/linux/pick_screen_color.png delete mode 100644 tutorials/pictures/kml/linux/remove_files.png delete mode 100644 tutorials/pictures/kml/linux/select_all_files.png delete mode 100644 tutorials/pictures/kml/linux/unselect_all_files.png delete mode 100644 tutorials/pictures/mscolab/linux/active_operations.png delete mode 100644 tutorials/pictures/mscolab/linux/add_user.png delete mode 100644 tutorials/pictures/mscolab/linux/addop_ok.png delete mode 100644 tutorials/pictures/mscolab/linux/chat_previous.png delete mode 100644 tutorials/pictures/mscolab/linux/chat_send.png delete mode 100644 tutorials/pictures/mscolab/linux/connect.png delete mode 100644 tutorials/pictures/mscolab/linux/connect_to_mscolab.png delete mode 100644 tutorials/pictures/mscolab/linux/emailid_taken.png delete mode 100644 tutorials/pictures/mscolab/linux/file.png delete mode 100644 tutorials/pictures/mscolab/linux/johndoe_profile.png delete mode 100644 tutorials/pictures/mscolab/linux/login.png delete mode 100644 tutorials/pictures/mscolab/linux/manageusers_add.png delete mode 100644 tutorials/pictures/mscolab/linux/manageusers_left_selectall.png delete mode 100644 tutorials/pictures/mscolab/linux/manageusers_modify.png delete mode 100644 tutorials/pictures/mscolab/linux/manageusers_right_selectall.png delete mode 100644 tutorials/pictures/mscolab/linux/name_version.png delete mode 100644 tutorials/pictures/mscolab/linux/openviews.png delete mode 100644 tutorials/pictures/mscolab/linux/overwrite_waypoints.png delete mode 100644 tutorials/pictures/mscolab/linux/refresh_window.png delete mode 100644 tutorials/pictures/mscolab/linux/server_options.png delete mode 100644 tutorials/pictures/mscolab/linux/topview_point2.png delete mode 100644 tutorials/pictures/mscolab/linux/work_asynchronously.png delete mode 100644 tutorials/pictures/options.png delete mode 100644 tutorials/pictures/performancesettings/linux/aircraft_weight.png delete mode 100644 tutorials/pictures/performancesettings/linux/maximum_takeoff_weight.png delete mode 100644 tutorials/pictures/performancesettings/linux/select.png delete mode 100644 tutorials/pictures/performancesettings/linux/selecttoopencontrol.png delete mode 100644 tutorials/pictures/performancesettings/linux/show_performance.png delete mode 100644 tutorials/pictures/performancesettings/linux/take_off_time.png delete mode 100644 tutorials/pictures/remotesensing/linux/azimuth.png delete mode 100644 tutorials/pictures/remotesensing/linux/drawtangent.png delete mode 100644 tutorials/pictures/remotesensing/linux/elevation.png delete mode 100644 tutorials/pictures/remotesensing/linux/showangle.png delete mode 100644 tutorials/pictures/satellitetrack/linux/load.png delete mode 100644 tutorials/pictures/satellitetrack/linux/predicted_satellite_overpasses.png delete mode 100644 tutorials/pictures/views/linux/add_waypoint.png delete mode 100644 tutorials/pictures/views/linux/delete_waypoint.png delete mode 100644 tutorials/pictures/views/linux/get_capabilities.png delete mode 100644 tutorials/pictures/views/linux/horizontal_wind.png delete mode 100644 tutorials/pictures/views/linux/layers.png delete mode 100644 tutorials/pictures/views/linux/move_waypoint.png delete mode 100644 tutorials/pictures/views/linux/secondary_axis.png delete mode 100644 tutorials/pictures/views/linux/selecttoopencontrol.png delete mode 100644 tutorials/pictures/views/linux/sideview_point1.png delete mode 100644 tutorials/pictures/views/linux/topview_point2.png delete mode 100644 tutorials/pictures/views/linux/vertical_axis.png delete mode 100644 tutorials/pictures/views/linux/vertical_velocity.png delete mode 100644 tutorials/pictures/views/linux/wms_url.png delete mode 100644 tutorials/pictures/views/linux/zoom.png delete mode 100644 tutorials/pictures/views/win/add_waypoint.png delete mode 100644 tutorials/pictures/views/win/delete_waypoint.png delete mode 100644 tutorials/pictures/views/win/move_waypoint.png delete mode 100644 tutorials/pictures/views/win/secondary_axis.png delete mode 100644 tutorials/pictures/views/win/selecttoopencontrol.png delete mode 100644 tutorials/pictures/views/win/vertical_axis.png delete mode 100644 tutorials/pictures/views/win/zoom.png delete mode 100644 tutorials/pictures/waypoints/linux/europe_cyl.png delete mode 100644 tutorials/pictures/wms/linux/add_waypoint.png delete mode 100644 tutorials/pictures/wms/linux/auto_update.png delete mode 100644 tutorials/pictures/wms/linux/checkbox_unselected_divergence.png delete mode 100644 tutorials/pictures/wms/linux/clear_cache.png delete mode 100644 tutorials/pictures/wms/linux/clear_filter.png delete mode 100644 tutorials/pictures/wms/linux/clone.png delete mode 100644 tutorials/pictures/wms/linux/cloudcover.png delete mode 100644 tutorials/pictures/wms/linux/delete_layers.png delete mode 100644 tutorials/pictures/wms/linux/deleteselected.png delete mode 100644 tutorials/pictures/wms/linux/divergence_layer.png delete mode 100644 tutorials/pictures/wms/linux/equivalent_layer.png delete mode 100644 tutorials/pictures/wms/linux/europe_cyl.png delete mode 100644 tutorials/pictures/wms/linux/get_capabilities.png delete mode 100644 tutorials/pictures/wms/linux/home.png delete mode 100644 tutorials/pictures/wms/linux/horizontalwind.png delete mode 100644 tutorials/pictures/wms/linux/initialization.png delete mode 100644 tutorials/pictures/wms/linux/insert.png delete mode 100644 tutorials/pictures/wms/linux/layer_filter.png delete mode 100644 tutorials/pictures/wms/linux/layers.png delete mode 100644 tutorials/pictures/wms/linux/level.png delete mode 100644 tutorials/pictures/wms/linux/makeroundtrip.png delete mode 100644 tutorials/pictures/wms/linux/move_waypoint.png delete mode 100644 tutorials/pictures/wms/linux/multilayering.png delete mode 100644 tutorials/pictures/wms/linux/next.png delete mode 100644 tutorials/pictures/wms/linux/options.png delete mode 100644 tutorials/pictures/wms/linux/pan.png delete mode 100644 tutorials/pictures/wms/linux/previous.png delete mode 100644 tutorials/pictures/wms/linux/redraw.png delete mode 100644 tutorials/pictures/wms/linux/remove.png delete mode 100644 tutorials/pictures/wms/linux/remove_waypoint.png delete mode 100644 tutorials/pictures/wms/linux/retrieve.png delete mode 100644 tutorials/pictures/wms/linux/reverse.png delete mode 100644 tutorials/pictures/wms/linux/save.png delete mode 100644 tutorials/pictures/wms/linux/selecttoopencontrol.png delete mode 100644 tutorials/pictures/wms/linux/star_filter.png delete mode 100644 tutorials/pictures/wms/linux/transparent.png delete mode 100644 tutorials/pictures/wms/linux/unselected_divergence_layer.png delete mode 100644 tutorials/pictures/wms/linux/unstar_filter.png delete mode 100644 tutorials/pictures/wms/linux/use_cache.png delete mode 100644 tutorials/pictures/wms/linux/valid.png delete mode 100644 tutorials/pictures/wms/linux/wms_url.png delete mode 100644 tutorials/pictures/wms/linux/zoom.png delete mode 100644 tutorials/pictures/wms/win/auto_update.png delete mode 100644 tutorials/pictures/wms/win/clear_cache.png delete mode 100644 tutorials/pictures/wms/win/clone.png delete mode 100644 tutorials/pictures/wms/win/cloudcover.png delete mode 100644 tutorials/pictures/wms/win/delete_layers.png delete mode 100644 tutorials/pictures/wms/win/deleteselected.png delete mode 100644 tutorials/pictures/wms/win/divergence_layer.png delete mode 100644 tutorials/pictures/wms/win/equivalent_potential_layer.png delete mode 100644 tutorials/pictures/wms/win/europe_cyl.png delete mode 100644 tutorials/pictures/wms/win/get_capabilities.png delete mode 100644 tutorials/pictures/wms/win/horizontalwind.png delete mode 100644 tutorials/pictures/wms/win/initialization.png delete mode 100644 tutorials/pictures/wms/win/insert.png delete mode 100644 tutorials/pictures/wms/win/layer_filter.png delete mode 100644 tutorials/pictures/wms/win/layers.png delete mode 100644 tutorials/pictures/wms/win/level.png delete mode 100644 tutorials/pictures/wms/win/multilayering.png delete mode 100644 tutorials/pictures/wms/win/options.png delete mode 100644 tutorials/pictures/wms/win/remove.png delete mode 100644 tutorials/pictures/wms/win/retrieve.png delete mode 100644 tutorials/pictures/wms/win/reverse.png delete mode 100644 tutorials/pictures/wms/win/selecttoopencontrol.png delete mode 100644 tutorials/pictures/wms/win/star_filter.png delete mode 100644 tutorials/pictures/wms/win/star_layer.png delete mode 100644 tutorials/pictures/wms/win/transparent.png delete mode 100644 tutorials/pictures/wms/win/use_cache.png delete mode 100644 tutorials/pictures/wms/win/valid.png delete mode 100644 tutorials/pictures/wms/win/view.png delete mode 100644 tutorials/pictures/wms/win/wms_url.png delete mode 100644 tutorials/pictures/wms/win/zoom.png diff --git a/docs/gentutorials.rst b/docs/gentutorials.rst index d1f0e59ff..ace49c7f2 100644 --- a/docs/gentutorials.rst +++ b/docs/gentutorials.rst @@ -42,7 +42,7 @@ System Requirements Keep the following things in mind before running a script * You should have only an **US keyboard layout**. If you have a different keyboard layout, you just need to change it to - US keyboard! + US keyboard! Typewriting of urls in a DE keyboard layout does not write `:` and `//`. * The **cursor.py** python file will run only on Linux and not on Windows for grabbing the mouse pointer image. * The screenrecorder.py works only in **Full HD Screens**. @@ -70,9 +70,6 @@ This will install all the dependencies required for running of the tutorials. **On Linux additionally** :: $ sudo apt-get install scrot - $ sudo apt-get install python3-tk - $ sudo apt-get install python3-dev - $ sudo apt-get install libx11-dev libxext-dev libxfixes-dev libxi-dev Now, just go into the **../MSS/tutorials/** directory :: @@ -84,6 +81,9 @@ Now, just go into the **../MSS/tutorials/** directory :: You must go into the tutorials directcory and then run the .py files. And always remember to add the PYTHONPATH to ........../MSS/ directory. +You have also to set the MSUI_CONFIG_PATH to a tmp directory. The comparison images are created below this directory. +The user's msui_settings.conf is not changed. + You cannot just do like this :: $ python MSS/tutorials/sreenrecorder.py # This will be problematic. @@ -117,7 +117,7 @@ Each python file inside MSS/tutorials can be run directly like :: (mssdev)~/..MSS/tutorials/ $ python screenrecorder.py -For recording anything on your screen. The videos will be then saved to `MSS/tutorials/Screen Recordings/` +For recording anything on your screen. The videos will be then saved to `MSS/tutorials/recordings/` For all the tutorials, you can do the same, example :: @@ -128,6 +128,11 @@ For all the tutorials, you can do the same, example :: The `MSS/tutorials/textfiles` contain descriptions of the tutorial videos in text format, these later can be converted to audio files by `audio.py` script after adding certain #ToDOs there. +When you want to run the tutorial by your IDE you can disable the screenrecording by `dry_run=True` +in the `start` function. Development on a 4K display is then possible too. + +For running the `tutorial_mscolab.py` you must provide a cleaned database and a mcolab server running on default port. + **Note** In tutorials development, when creating a class of Screen Recorder as :: @@ -196,5 +201,5 @@ batch scripts ~~~~~~~~~~~~~ Two batch scripts can be used to create tutorials. -start_tutorial.sh is to create one tutorial and tutorials.batch +`start_tutorial.sh` is to create one tutorial and `tutorials.batch` is used to create all tutorials compressed to gifcycles. diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index e625ebb2b..e475c87cf 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -93,6 +93,7 @@ requirements: - email_validator - keyring - dbus-python + - python-slugify - flask-login - pysaml2 - libxmlsec1 diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index 60aed4346..c9559c965 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -52,7 +52,7 @@ sys.path.append(constants.MSUI_CONFIG_PATH) -def main(): +def main(tutorial_mode=False): try: prefix = os.environ["CONDA_DEFAULT_ENV"] except KeyError: @@ -158,7 +158,7 @@ def notify(QObject, QEvent): application.setWindowIcon(QtGui.QIcon(icons('128x128'))) application.setApplicationDisplayName("MSUI") application.setAttribute(QtCore.Qt.AA_DisableWindowContextHelpButton) - mainwindow = MSUIMainWindow() + mainwindow = MSUIMainWindow(tutorial_mode=tutorial_mode) if version.parse(__version__) >= version.parse('9.0.0') and version.parse(__version__) < version.parse('10.0.0'): from mslib.utils.migration.config_before_nine import read_config_file as read_config_file_before_nine from mslib.utils.migration.config_before_nine import config_loader as config_loader_before_nine diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index 37659527f..d1c7a3cd2 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -38,6 +38,7 @@ import sys import fs +from slugify import slugify from mslib import __version__ from mslib.msui.qt5 import ui_mainwindow as ui from mslib.msui.qt5 import ui_about_dialog as ui_ab @@ -51,6 +52,7 @@ from mslib.utils.qt import get_open_filenames, get_save_filename, show_popup from mslib.utils.config import read_config_file, config_loader from PyQt5 import QtGui, QtCore, QtWidgets +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas # Add config path to PYTHONPATH so plugins located there may be found sys.path.append(constants.MSUI_CONFIG_PATH) @@ -132,8 +134,9 @@ class MSUI_ShortcutsDialog(QtWidgets.QDialog, ui_sh.Ui_ShortcutsDialog): Dialog showing shortcuts for all currently open windows """ - def __init__(self): + def __init__(self, tutorial_mode=False): super().__init__(QtWidgets.QApplication.activeWindow()) + self.tutorial_mode = tutorial_mode self.setupUi(self) self.current_shortcuts = None self.treeWidget.itemDoubleClicked.connect(self.double_clicked) @@ -227,10 +230,12 @@ def fill_list(self): self.treeWidget.setCurrentItem(header) for objectName in self.current_shortcuts[widget].keys(): description, text, _, shortcut, obj = self.current_shortcuts[widget][objectName] + if obj is None: + continue item = QtWidgets.QTreeWidgetItem(header) item.source_object = obj itemText = description if self.cbDisplayType.currentText() == 'Tooltip' \ - else text if self.cbDisplayType.currentText() == 'Text' else objectName + else text if self.cbDisplayType.currentText() == 'Text' else obj.objectName() item.setText(0, f"{itemText}: {shortcut}") item.setToolTip(0, f"ToolTip: {description}\nText: {text}\nObjectName: {objectName}") header.addChild(item) @@ -241,8 +246,9 @@ def get_shortcuts(self): Iterates through all top level widgets and puts their shortcuts in a dictionary """ shortcuts = {} - for qobject in QtWidgets.QApplication.topLevelWidgets(): + for qobject in QtWidgets.QApplication.allWidgets(): actions = [] + # QAction actions.extend([ (action.parent().window() if hasattr(action.parent(), "window") else action.parent(), action.toolTip(), action.text().replace("&&", "%%").replace("&", "").replace("%%", "&"), @@ -250,9 +256,13 @@ def get_shortcuts(self): ",".join([shortcut.toString() for shortcut in action.shortcuts()]), action) for action in qobject.findChildren( QtWidgets.QAction) if len(action.shortcuts()) > 0 or self.cbNoShortcut.checkState()]) + + # QShortcut actions.extend([(shortcut.parentWidget().window(), shortcut.whatsThis(), "", shortcut.objectName(), shortcut.key().toString(), shortcut) for shortcut in qobject.findChildren(QtWidgets.QShortcut)]) + + # QAbstractButton actions.extend([(button.window(), button.toolTip(), button.text().replace("&&", "%%").replace("&", "") .replace("%%", "&"), button.objectName(), button.shortcut().toString() if button.shortcut() else "", button) @@ -260,17 +270,41 @@ def get_shortcuts(self): self.cbNoShortcut.checkState()]) # Additional objects which have no shortcuts, if requested + # QComboBox actions.extend([(obj.window(), obj.toolTip(), obj.currentText(), obj.objectName(), "", obj) for obj in qobject.findChildren(QtWidgets.QComboBox) if self.cbNoShortcut.checkState()]) + + # QAbstractSpinBox, QLineEdit actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) for obj in qobject.findChildren(QtWidgets.QAbstractSpinBox) + qobject.findChildren(QtWidgets.QLineEdit) if self.cbNoShortcut.checkState()]) + # QPlainTextEdit, QTextEdit actions.extend([(obj.window(), obj.toolTip(), obj.toPlainText(), obj.objectName(), "", obj) for obj in qobject.findChildren(QtWidgets.QPlainTextEdit) + qobject.findChildren(QtWidgets.QTextEdit) if self.cbNoShortcut.checkState()]) + # QLabel + actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QLabel) + if self.cbNoShortcut.checkState()]) + + # FigureCanvas + actions.extend([(obj.window(), "", obj.figure.axes[0].get_title(), obj.objectName(), "", obj) + for obj in qobject.findChildren(FigureCanvas) + if self.cbNoShortcut.checkState()]) + + # QMenu + actions.extend([(obj.window(), obj.toolTip(), obj.title(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QMenu) + if self.cbNoShortcut.checkState()]) + + # QMenuBar + actions.extend([(obj.window(), "menubar", "menubar", obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QMenuBar) + if self.cbNoShortcut.checkState()]) + if not any(action for action in actions if action[3] == "actionShortcuts"): actions.append((qobject.window(), "Show Current Shortcuts", "Show Current Shortcuts", "Show Current Shortcuts", "Alt+S", None)) @@ -279,11 +313,46 @@ def get_shortcuts(self): "Search for interactive text in the UI", "Search for interactive text in the UI", "Ctrl+F", None)) + if "://" in constants.MSUI_CONFIG_PATH: + # Todo remove all os.path dependencies, when needed use getsyspath + pix_dir = fs.path.combine(constants.MSUI_CONFIG_PATH, 'tutorial_images') + try: + _fs = fs.open_fs(pix_dir) + except fs.errors.CreateFailed: + dir_path, name = fs.path.split(pix_dir) + _fs = fs.open_fs(dir_path) + _fs.makedir(name) + else: + pix_dir = os.path.join(constants.MSUI_CONFIG_PATH, 'tutorial_images') + if not os.path.exists(pix_dir): + os.makedirs(pix_dir) for item in actions: - if item[0] not in shortcuts: - shortcuts[item[0]] = {} - shortcuts[item[0]][item[3].strip()] = item[1:] - + if len(item[2]) > 0: + # These are twice defined, but only one can be used for highlighting + if (item[2] in ['Pan', 'Home', 'Forward', + 'Back', 'Zoom', 'Save', 'Mv WP', + 'Ins WP', 'Del WP'] and isinstance(item[5], QtWidgets.QAction) or + len(item[0].objectName()) == 0): + continue + + if item[0] not in shortcuts: + shortcuts[item[0]] = {} + shortcuts[item[0]][item[5]] = item[1:] + if self.tutorial_mode: + try: + prefix = item[0].objectName() + attr = item[2] + if item[5] is None: + continue + pixmap = item[5].grab() + pix_name = slugify(f"{prefix}-{attr}") + if pix_name.startswith("Search") is False: + pix_file = f"{pix_name}.png" + _fs = fs.open_fs(pix_dir) + pix_file = os.path.join(_fs.getsyspath("."), pix_file) + pixmap.save(pix_file, 'png') + except AttributeError: + pass return shortcuts def filter_shortcuts(self, text="Nothing", rerun=True): @@ -361,8 +430,9 @@ class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): signal_permission_revoked = QtCore.pyqtSignal(int) signal_render_new_permission = QtCore.pyqtSignal(int, str) - def __init__(self, mscolab_data_dir=None, *args): + def __init__(self, mscolab_data_dir=None, tutorial_mode=False, *args): super().__init__(*args) + self.tutorial_mode = tutorial_mode self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('32x32'))) # This code is required in Windows 7 to use the icon set by setWindowIcon in taskbar @@ -948,18 +1018,22 @@ def show_shortcuts(self, search_mode=False): if QtWidgets.QApplication.activeWindow() == self.shortcuts_dlg: return - self.shortcuts_dlg = MSUI_ShortcutsDialog() if not self.shortcuts_dlg else self.shortcuts_dlg + self.shortcuts_dlg = MSUI_ShortcutsDialog( + tutorial_mode=self.tutorial_mode) if not self.shortcuts_dlg else self.shortcuts_dlg # In case the dialog gets deleted by QT, recreate it try: self.shortcuts_dlg.setModal(True) except RuntimeError: - self.shortcuts_dlg = MSUI_ShortcutsDialog() + self.shortcuts_dlg = MSUI_ShortcutsDialog(tutorial_mode=self.tutorial_mode) self.shortcuts_dlg.setParent(QtWidgets.QApplication.activeWindow(), QtCore.Qt.Dialog) self.shortcuts_dlg.reset_highlight() self.shortcuts_dlg.fill_list() - self.shortcuts_dlg.show() + if self.tutorial_mode: + self.shortcuts_dlg.hide() + else: + self.shortcuts_dlg.show() self.shortcuts_dlg.cbAdvanced.setHidden(True) self.shortcuts_dlg.cbHighlight.setHidden(True) diff --git a/requirements.d/tutorials.txt b/requirements.d/tutorials.txt index 5e45ae893..6ea8aefab 100644 --- a/requirements.d/tutorials.txt +++ b/requirements.d/tutorials.txt @@ -1,9 +1,9 @@ # This file may be used to create an environment using: # $ conda create --name --file # platform: linux-64 -mouseinfo=0.1.3=pypi_0 -opencv=4.5.2=py39hf3d152e_0 +mouseinfo=0.1.3 +opencv=4.5.5 playsound=1.3.0=pypi_0 -pyautogui=0.9.48=py39hde42818_1 -pyscreeze=0.1.27=pyhd8ed1ab_0 -python-mss=6.1.0=pyhd3deb0d_0 +pyautogui=0.9.54 +pyscreeze=0.1.29 +python-mss=9.0.1 diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 49752a3f4..d3f2feb94 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -36,7 +36,7 @@ from urllib.request import urlopen from PyQt5 import QtWidgets, QtTest from mslib import __version__ -from tests.constants import ROOT_DIR, POSIX +from tests.constants import ROOT_DIR, POSIX, MSUI_CONFIG_PATH from mslib.msui import msui from mslib.msui import msui_mainwindow as msui_mw from tests.utils import ExceptionMock @@ -66,6 +66,38 @@ def test_main(): assert pytest_wrapped_e.typename == "SystemExit" +class Test_MSS_TutorialMode(): + def setup_method(self): + self.application = QtWidgets.QApplication(sys.argv) + self.application.setApplicationDisplayName("MSUI") + self.main_window = msui_mw.MSUIMainWindow(tutorial_mode=True) + self.main_window.create_new_flight_track() + self.main_window.show() + self.main_window.shortcuts_dlg = msui_mw.MSUI_ShortcutsDialog( + tutorial_mode=True) + self.main_window.show_shortcuts(search_mode=True) + self.tutorial_dir = fs.path.combine(MSUI_CONFIG_PATH, 'tutorial_images') + + def teardown_method(self): + self.main_window.hide() + QtWidgets.QApplication.processEvents() + self.application.quit() + QtWidgets.QApplication.processEvents() + + def test_tutorial_dir(self): + dir_name, name = fs.path.split(self.tutorial_dir) + with fs.open_fs(dir_name) as _fs: + assert _fs.exists(name) + # seems we don't have a window manager in the test environment on github + # checking only for a few + with (fs.open_fs(self.tutorial_dir) as _fs): + common_images = _fs.listdir('/') + assert 'menufile-file.png' in common_images + assert 'msuimainwindow-operation-archive.png' in common_images + assert 'msuimainwindow-work-asynchronously.png' in common_images + assert 'msuimainwindow-connect.png' in common_images + + class Test_MSS_AboutDialog(): def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) diff --git a/tutorials/pictures/__init__.py b/tutorials/pictures/__init__.py deleted file mode 100644 index f78acf224..000000000 --- a/tutorials/pictures/__init__.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - mslib.tutorials.pictures - ~~~~~~~~~~~~~~~~~~~~~~~~ - - This module provides functions to read images for the different tutorials for comparison - - This file is part of MSS. - - :copyright: Copyright 2016-2022 by the MSS team, see AUTHORS. - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" -import os -import sys - -use_platform = sys.platform -if sys.platform in ('linux', 'linux2', 'darwin'): - use_platform = 'linux' - -TUTORIALS = ["hexagoncontrol", - "kml", - "mscolab", - "performancesettings", - "remotesensing", - "satellitetrack", - "views", - "waypoints", - "wms"] - - -def picture(tutorial="wms", name="layers.png"): - if tutorial in TUTORIALS: - return os.path.join(os.path.abspath(os.path.normpath(os.path.dirname(__file__))), tutorial, use_platform, name) diff --git a/tutorials/pictures/cursor_image.png b/tutorials/pictures/cursor_image.png deleted file mode 100644 index c4b15bd918712fbc4c9473cba3fa92507e99a5fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 717 zcmV;;0y6!HP)@3GEm__0sT?$Q>q=8(pG*5@*G*SE!lLsE&_qg|-&+~mA?%`E9F{NQIbjXy3 zRcUyxb_(KY0QgMPd2@IXW)91cq^m#dhj`KD741<(LPM5M2;PsYZ^B$vxoEX(Q$qzRzwy6Czt6B84z zlD5m`mdFqhF%09_qtU43^ZB-wxPV6@A_D^hO>WzETaYsceLi24@9pieu&|&d5{b{2 zWreAg(|c9OnL}Q$_l&30X%-h3T}Y%A$fXKg&tx*hVlgd|NPIF)^A)wSdTE|v*Hhqn zCX-=lX-P{alV9p%#Z_@G#2s)d&@UoChlhtx=pGLu!rIyzhG8^)=r~Sge0<#A+S*ES z!Ep37fQ}UaL?RJXRb^vigZ1@w{C@xUnVFfl^*}`+3;aGW@M#stKUydhlKJ_0DV0ip z4-O6_6bi}c=%^G5g`+?q5CU|-58UKBJlq-^z`(HYPFgl8XCfJ9JaT&)!Eru z8z=zK}o0WJxo&=u7VzG~#o0~E+G9q(xb8>ii_zUQv*0T#UKMha; zT|{Jcb@dxC2n2e2dtVfb#iOaIDX}a|3Wb6M0)Z#j0eFFXJ3BjRP16Q}yTA<~xU#bH zb$@?fc6WCrl}i0k6s5ma0MvTX6$}O+Q)`~D4!z~^c%Dv9PQJ5k`@N=V&w)F%FpHFP zR=05;YW?Y{@BaX0TDTNkMW(_@#??~^Tx$OTM;HdR1e)4}00000NkvXXu0mjfR5VD( diff --git a/tutorials/pictures/hexagoncontrol/linux/add_hexagon.png b/tutorials/pictures/hexagoncontrol/linux/add_hexagon.png deleted file mode 100644 index 73d060ede5a53781a92d3b53516db99602822919..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1515 zcmVZ=NZ1WQ|V5R_L4BvA;@yl#@)AG;8xPRB>BcKrVAz2BZa_xtwVJ!ctu zy&hgCn7nKO|21Ae!}uwHvI5`mKiWH#uHG`iF;?_6iC!Xx?6Bd=+Og)|``VuOJ=Vs# zSVvzl&UHD9Oy5iEdfv4E0v%tg|3%>B1>d0lXxUNCTqJE4q#`k}X3z%Zd z;!O5het35nXL%tdrTNS^{;u{+D`MQNi1a`DFvb`cE@g%aG9ug^ZLA0fuT?pXh5;SO zh>j!vti>mKP<_%g`}O5ogb+ggmsj(MfJ3)?HT^9`F_TQdFI|BUYD)33bXk8%s!}U{ z-!tDD?ADi~hf%ZT?NO2EjB{015cs6rM+n{7#UJgNSk8?o+V)LRd_9KJ}7MnB124~#q(P*T_E1gD$pH@>!9x~jP=0h$!t3(L3 z2t2vb7t{zLRJMV~iMdKgG$ipU!`(kE0N;ahidCNuAMqH&42v-=BSx81hU=5~7Qs0x zDyk&PdcxP$2qDew?eCHvbEeuZ%$A~|sQhZY)215;6)v@ONvNgC8=IUc!}oT&TP{AK zr0JWRoW`swrVQI{tf1MV2rExP6E!xaPa8mMw(xRCbv3V$0if$wm-9d~zo z0)VP~>$YnD6;pD*O=-da096T}ZoKDvsJOJW^xDA?CIEmXK@6A4r|fFdkR5=xa?@R>z`nKQ0aDWug4@!h$Yv_4`1dn|!=MgLU$&dxaHL z^l)E4@6xoL6`gv6vi|UaQ%~M+#s!5qol1(o)~>-AbvIYuY6s9%#I4Mqxi>X6b+g%y zs6$NvAeTQNocZ>{0e6?>7Ks4>VAe+;HXjv~$n~I;mhAtzhDzuV6yjVW*iqi0GbrnX z37PO&F#n$fP*vJcF3i*g2TwOvR;$2}*_SeMlkQa+!{>~#wsj3mxGdF(;#VGXPX57fB<4o` z7^{qnN*Cj~>$Xoi88FFhmUmd*w2=9x7-MW1Ajl4C+~CG#jrSMY`*_d`I7DY3_P!qJ zLXr~$g+yZhchilZ>0r+=HVuYGqj}=(d4*7yG-JVoJq^1&p7z22)4z1vjpCba{dxec zZ4~D7H#LRFtW9 z7+|OYHyR6A#EFU(1yYeFMPyS6fm{bjS(DcvVM!@;)G||M%J=KOchC9GIo~<=&U*xe z5d7~!{I3(35tvm0U`D7=bzuI-N9*6l62F_^$jRw=j~I92&AiiFrrcInVYsCvHQLw3 zn#nM>u=fZ`u6XlKk$NBTewsSa`(pSR@9L;T1c%UL+r4N20FnxxKss$f##NAB+ro~y zfkkHWYsB?}AC5$LTT|bKskh|x!Rc3FNN_Q+Xlzh0mjF)|2>{v)Q%(pvr!Nc|L0POR zg`FWn6CnG&Fwxh>h^lXBv+~OujR+ysmgd8ZI+?QCo}sH}VCfmp6CotwC%71C5p|4M zESCRi5kjvk&xE_NFn!F_Ei&to1R;b<<4pY0Zm0TN>1sK1tHwi=#u-~D+(!r@^zdw` zyNwAQGcb1IWd0&Wf)r1tHc)6R7R#M`521VxdC>_`V{Wt^m87A^V6j+XIqh|a-R6W} z86j&*^C7Lhgb+gL^@H>K^$?Vrn%wXK3aZ4<`d9b zy#Kqn`P7hXp-`9;X{4-83FUT%U#)Mdzq-C{@8+{F00025Oy2W&Ri3c1c&o*vEuq;2 z?k}toH3~1g3wIyqE9^JtCV2w;git8FxpV&5MEIRN6J}zuwNNM&ru&m8?=CLNh)Q}M zkW<<39rN{I$<$J zBw`*L0_lAIL)C_J*v=IUGC`BE+J8}V!}H+@el`G?HkJjzZ!#jEZZBoKO9{?Ln4 zzI*XFj;j`(ol-*+V@=fqWiCS(004U1CGy4}18lPZ0ARSE?9$y0U^trmM@yE{zAd^g zTN`Yk#z~_alDuQ9bJxvLdnpG1BJpp{BoGK=YePdiGdjQekfXXm)ue9Y7`}awla8Oe zRlJfxhLUZ@sS{bJ2&SPyQYaK74^rvK=+kKQuHQNo03-kyl*xJl0N?{dy7q#-KbOTa z|3WvK713k+9_R8s>$!!Go4Vw3)K*s|MxeVg_>;6*VLkPA zFBO2}xN{(fn0M@ajcf?;LDWz!mQ7v!7zU-%Zsr141{oC6n}rqq005wkVPx^sy20@# z0{{SqVXvOm$pCO1r(0TTJ$X<)002Ezmw0t53v*vz=B;xVp2?MpZeix7J1TF2Cnu=w z!dC_29t9}-JL+p+$kkN`avVNnS~&Ra$P@Je01#&XreH>VLC*lpssJ#n bf(m~EnOaq3j^;q100000NkvXXu0mjf3QW9b diff --git a/tutorials/pictures/hexagoncontrol/linux/radius.png b/tutorials/pictures/hexagoncontrol/linux/radius.png deleted file mode 100644 index 392202ad2767eca336fdbbecc43ed58b96f1dec0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 819 zcmV-31I+x1P)7#`0FFs(UGp(Ad0SPBXX3JMBKDr#AV zl&`t{n>4e;oeP@6v?z200|NtJ;`;aRU)?{usafr0Ue=8JB$>sg=-{cxPq`x)7#J8B zm^p-0Tw*=s@7%r5z`*e1+^j@56HOH*6+Mr#El(gyKAxGHXs4wpr)H5b@9uwAh~T{m zj><&`7#JANHyRl=o@aoG+NzhHU|?YQeP>01hmnS=vWm8K=+ujU@jK%G?@#x3&D-+E z-p+u5fq_F@KeBV%h1+*ebt!EsnS6?Yf#KJQmV^nvQ#W0IaAjlsy$w5winf1ydkb%P zZoGW^?u}y$>b+H&aXRADqA&>w2}uRx(l@DVCI<;IFfcGko4Xl`u`@CXIXfG@eDvrS z1HpBN7AzwzecFXUQV!Emy~F3Xqt%gjg7UJy^Z*3`APj-Tm*{sjKe){Qu|mv1!ZBplg>?Qha^t_`|;p4FBJ5 zU%2HJ0|NsC!{3MJ&piA7pMhCORZoHY$M>Iv9KpaK7E_({abm+-&WNhCx6S5ej&6bN zpFM(=pl+;PnE896r@EY$?`+xB1UWR#{K2(N5^Dm@ZJpdw_nL>>F)}bPF#LbHzt&e@ xMO9tfsrP?LSE39;J>zIq4h5r*fPq2b2ms%U2F+S=To(WU002ovPDHLkV1m^Qf-V36 diff --git a/tutorials/pictures/hexagoncontrol/linux/remove_hexagon.png b/tutorials/pictures/hexagoncontrol/linux/remove_hexagon.png deleted file mode 100644 index 7895fb75e86c2aea1dd0fd65120731bb82c787de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1727 zcmV;w20;0VP)ZG9LIlq!8u+{*kEi!Fh-0ZLFnj?L}Y-k`2q~8qlsZz37DQJ#RDXgWaSHl#F0Wz z=s~mONLVN&zGPxq5|m&?gbduqB-nPZHf;;y@pwEQe~Oz|(BciH z*KXtZ>g;-HKxW;*TwM@uyW;rx@${=dzns>5*4t~i@#D!H--|CL=KngLhxjiR+v*zt zAR?Q0pLf)%g`PkE+>D6W^G)80SzaPnvERJS6)H0#qM`T+@8rF^qi0B6TqGeYi!Wcy zjhpEybQRB3?)|P72~`cA(n_TO5MXz8rZ zjt=y2bCm|hm2{w~WuR8e~K_$oKg6^EPkL|dC4>=adWol>eThya@z8&9kB zq`hLbr_H1X*Oa&Pkj_h+0w*v2Kuzh*@&tF!r%SGO8GBk!Z47h@&%H*@ZIDWm&Js!~ z)mJ1s%hSH2wDtW(Q~Z;w+l|Kd%C$bu&y?xtAXTmv`n*;B6X^{Se*S(71Xdm|kY zm{Qu16~y8{yrrg1PrK@OKJF0mMiZr!QXqs7awSa;92*{Q5{CuTqVq4P2_eR^r|tbR z>j)u)kk)rXIm-@o5u(`}0g4?zG1bHIJijfMnQ={uhu@aVgp{vz2*|2uYNT`j67HP0 zTgX=%ri)W*jD+aRR|_7>tRqBIIFIvKUK?}S*PDH(CRUI^s$47N*i9Ao%f;S?6MJ}6 zDsrQ2rNk#JR2q_*;WZTXS2PNL8O0E}w2vAH73>pcJf=jOeIT z@}tXY=l7oQQl9rjL}wQPXKqr%k*DkiZH|M+z_z`w|6l;^xlDSEOogfg=_P_yF&#U% z)+qOc+xJzaEziZGs*?*vJg9s@umcg%MIg{MYWfg40ATL=-2jNA0_zaiY?c*?@dyzI z=RNZ|X-?{^@?#nKjTQ(~#;6K2OPln4XuhTS@yf4899TargZsCDP-RTlft-(8sQGs1 zg`E5^BjOi1Afm5Q=~(^do>MYqj2IEoet|ObtK9U;--sFejHs6Z}J~)rrRZQ6__n@(IejbzW= z^Sh}ur5n=K&%L`&#^Ni|cZGeP^!k^u^py<$`(c5hbLX#b2#*V9toBJN&K8$0^YfTK z`&Dglq>%AoG+-RjFqU4ghkH<#6VJNt3vWLDjN{nW-An(6ZZ)0%niHV#p29UYUl#4Tw^G;qpdw)F>>H6?l>EThn`d=*cmE@B|5M#d&)46f3BSgsKLOr- V^AcR43WWdw002ovPDHLkV1iW4Wd8sF diff --git a/tutorials/pictures/kml/linux/add_kml_files.png b/tutorials/pictures/kml/linux/add_kml_files.png deleted file mode 100644 index bd20403f71914658f961225470d0174484ae69b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1409 zcmV-{1%CR8P)uN5+yRgUeF@B6dG!zGg{Q-Xw0PIFeXX} z>E5__AwYsbq_jk(wOJ&JKy!x`*&ISLmbJlfhH2>eb>=zm`^@{k-}8On zIRqTXVGaXB2+ej4vkBsC7ck41^ZldgLz>~Wbg#sctxX~Y9DVQ6WODwEe=nH#>qbw{0chC{uUM)&!wQU9!II=a?v?Xjj&=*|ttFuTaJkIf>- zYEoP<%>Qr;06=$cIJTvD+^S^DK|gE)cH1h!K4eE$a?pYqmO80_nTW4m?Ow^R*33}V z1Vve5bb;TQ()5)J003w!mNtkkbeUdjs)RL^6^!X=y^vHjYC!*4763gYdX39W3pDqC ztAO9TC41kREBx{SBd2|Earb&JS10GCp?m9ko&!BQ-283gD;_Q+mt_%$BpO=)OIra# z2o2r&V)O3ay=B=Oob)6>=$6Q4amhoYh0BFMg+;F;fI$FYs**_%00=e)5D9<=@>y7H zk#2n=ceC_y3pG7@OB1%`J%$wVDWarw%9VJK%D4l zV%8mL{RNM{#0KJ!lzp~;(2{YYpCa6Ry+)~fecNNN}uNRBh`>KYa|9r-Qft|Nx zor(wb1^dI?EmKVW<2U$)>vBrJ^o4b$^HxW@GRD{?5pS001DX ztdK?Q+O^t&h&cwOCx;QNl+u}>_UVn@;nDF)8w|&7nB3?`qN4i%&~;wa92L(bPWJ<( zU&vt?z!CIY2^g#@`QHD7{jZXUL=rPOmVCM5CfF}qPLh_KskuKmkK*gI7@N|+(dR$# zi`u1!f~Zs~gCp*`&KJuNLMo-omgc(1U=^w>8395_sZxC={F5vRdSFxm+%PsOauF8wCIuJv=?{T3f>rm*SW` zrK6VX4*&pz<^0M3-aFoO10vsz>k$YV;rR_L=8fDVw|jK>a9cr6xxSGlEEc2kM9y!6 zxUNr{omcl{|59U4on~H5Nh?#Ev#U6qd3htD*MjRQjyj_V1A*8;@zZx93nKWUxAa)!Qp8I*6 zzCHI;K>b!dq1+ znmDMJvpMd4UR?GPW3VwApfbVH!2nin*W?W_<#I5~LkJ;6;eAsa*Y){2Hz$(oicKF^ z$h2teuaX@hFVg64%hngtk7vf%Sx68xnrC*|{}(aWzyJUL|DD^I>;3-?oft430-=x8 P00000NkvXXu0mjfrvsg4 diff --git a/tutorials/pictures/kml/linux/changecolor.png b/tutorials/pictures/kml/linux/changecolor.png deleted file mode 100644 index 6395be6909e0b28b420464586d167ed8f3113bcf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1526 zcmVrC@Muxk)a}O2MQy~c#K!;D3w;JpmwZB zJz9~YA_{gyMp_lM;#D2*fEWU~I3nT9{tVkc5)){O)>h#U{rtCYzwf>8oBi#ZT}-dn z!)t~WMNxz8VW6Q6wt+$5&CIU{Z)UdS(lX(wwYLYv%73Fe)7LTS^b4iXs9tp5&0i7b z>&|D|*gJXnhHkCurYH)i!Qk=LJ*mN7Lawb1n;1DIe&3y*euMzyIVu`%Y7>U4s>I~%=dqETDHC9dq3BqE%z=+7((o2);H2o6wBf- zII2N?sZ0}$Nz6={Ds*77aqp;|*E%Xd8p@bvKAVi&CH!j9rZP={af5QMYr4sns@3Bdv-UM1gb;do=GY(g z?Ifu@8i(V_XALu3W3G`0?rU{A<*83a%=nUSgj$PYx$f~tq@6nL{huRT!NxH2{mm0t zlQXWikX_Q;NWt)>=Sg!*vLMP@w7kl~iuS}pfm>{@q)pe^P?7G<^50(5duI88IS!*! z%IZ3F?GKME9Kj7g+-UIc;5cEy{#$BIYrD=+$h5DM0s!=oBqR2*xt}eIO;;*a08Y@GHo06&On+L6As|?4brZl)V?Zfyu*Pw= zsgppjZZs5i^Bl$KQ^~Zq$4!|`mKL7sve=6hjfR<*Bn1>jJ%9S$$m9Og*Wtl zjo9$`HZqyac-3BCuW@k}JnckL4tz(lUe;v1Ew8V)a(3qZ-M<0T*&Y_UBAt`FXkJF? z%{om-TVw6@D-YW(p1A?7S`F^rV>wnp*5(ym>PH0t08?gvB;B7?uGYg7W%;(eTZX8| z$5Eo=n>JJ`bQFbJrI&9jdgtJAs}j0$BIf6uzAJC-&?>8|{*)oWrbk7z?A?%G+tq^_ zD>r6W21bY2o0IHh1)c$k zdv0{}Gl)yGaHPjGX11G~bvW$NQXhfih>(MB?}?3JQA)wyX;;4R zCD4=>FP8^jPY7(zuH7ttzVub{%D=beRb|)Y z9eMz=;l!4oq++q|%^zL=Q8A-BnIW-I+O;fI1mGUHx3vIVCg!07;52 AHUIzs diff --git a/tutorials/pictures/kml/linux/kmloverlay.png b/tutorials/pictures/kml/linux/kmloverlay.png deleted file mode 100644 index 28892fbe5391b9c4073f4958cc4fca7200602cec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1460 zcmV;l1xxygP)V|dw-x4wq*{zay<@Bf~*~cjd9J2&%G5#*IO`?8|3JZ z$aMq)Ijm?}%2qySCkl+wHa1WC_V|=?VwzNJdTHWE!Rh zz<(&7-u$Jar0v0FURN)3C^!Is<>rPrxP{`;cq>1xW|_J6G&&7FJmnp5q0cuazv|B? z6};o?em}Y?=*Q=;G`s%;)A8T=%OOTAF)}^eR%@8i_VBYS;^vPWz>Cb(I)A#&z|^h8 z{L^Q9b|fT*xtJR*BpO?J9K0IiRvB^bjyN}DvE!Ng;j~^Z+i+9-a{y^$_7Trj6yv3) zj=>im^#TCY#X7ltcbglwp1jB+{Jv7}j*>{s`CXG`Wa@icB5rj6JUF(>^gyXh5tWth zr&)$SkdOC2#cILOjMEdrwbv3P-A*p8^*RC8XVq4n0O-} zRa7k4xY-W>lF{+-bo#5Tta%gWQ_ttuBZLqV-?(0Eg9R9)`t#d&SGp$)pVgNhkjHL| zssw}(K=s+ra#qJbY^*xA2EpjJ5se*pp1)nwC@MRs5qCJT7OmYDsLM+)l_G?Ytc;hV zgR$0*_dC`6YrfqVBm74qk&j7~$+b5#-R`CFTLIunTB?-AvXGBIT6Jje=rAxa7(=~f zKq9oax6OBDY`W24N~!lg%`lhIs(c)75QhX&FQHH#c|b>FI-pD}&z1yC#C(JbtkQ zM%M>uYHAuN*lR|mQmItxhZzI?J&L<3(bB?5rS+gFH)5xctBZ@v21cAvE|USkQZ{F0 zF1Ju3E#qe!Ffl7tL6h!3ATXlQh%3HGJh3o7B;vkg^r--ZG~HP~HE9{wQ!)+N7#Sf@ zdJ)4!Pk@cReP2<*UD->fH$P9X#L0<@kcKmZ-gG^yx-hH60rS&G2$4tzIsu7wB9TZW z5;Z)3@%XeoLVy5=o2MiZ5Fmt}#s`O#TSsLV3k0`PLd}K+7%@1uH~B?^oIGPDW~N5a zwBG+XT3A99B`NfHxmbzCJG1CdBlo1325u#duaFJ(ONKEZRpPbiUw*EY6dH1|w6?XU zyQ!uyI`Ckz-Pilq0|3l(Wcat{AH7oU%=9Jz0MNEzCOOfOxBu>!%Lls}Dyy3&*4W&V=yEwN?O z^;OdILrD6=@J(n_3TW7b7w~q?=k0Z~B2#R9PTaGK{y91D9Yqj1FajEi%3Uxo9R*?X zX-a})<$ilI$%wLs5hZvrvA|UypY(e0$x2%%S1f)J-B)*n*Yf5#*FH32GRSJnkXS5M z8<;cb$_z5Q>?+LNsX8Ti{qMh1q5IA^rv#q6Ftz4kZvX!)Ic&|kBC+5kruq^_P0Gjq zPNTlQzL{4x!;nvWH^I-Oj+w_K&J~!m`>$}W>TcYL%)$Aqow+KhVEzU6)ctf)teLR@ O0000ZgXgFbngSdJ^%m!FLXs%bVG7w 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 diff --git a/tutorials/pictures/kml/linux/remove_files.png b/tutorials/pictures/kml/linux/remove_files.png deleted file mode 100644 index 9f333f1c5c78751193e4961b565846a26f97173e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1338 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 diff --git a/tutorials/pictures/kml/linux/unselect_all_files.png b/tutorials/pictures/kml/linux/unselect_all_files.png deleted file mode 100644 index 0ade36eef31e5eed6968b38e19a35d2de1d38235..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1628 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 diff --git a/tutorials/pictures/mscolab/linux/add_user.png b/tutorials/pictures/mscolab/linux/add_user.png deleted file mode 100644 index d1ef341c7f3ce509b805002ac17561344de919ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1006 zcmVHiZ(^GP-$bJL@sJkfl!+P!9T!-i$a8mphXbW6^s^{ zGEs{bMNkke!m{s7qtsyflEOj#J&OV9HI4U;mxrFSyXT&JIQPey4*|z<=b6cF%0wg_;_`7 z^#^i%19=O>FpOHQHX4nKi;Ed@ety2bzMdcmyWL(`SjdynXtb}dPb!smbadR^-JuV& z2Y~hU_0G=D%#xz0lamvU2!{cj+T~|?(OYmCCnRrVPU~!GMP*!m&=t-rvc#j_}FT-y4~(zFqlfEqR}X?v{)?V z<>fawHyGpP<>jpC{DeL(0bpZeBM=Cjo}OM`U)yXpp-^~vd1*GAT`pHB6aoOd-OlSB z4u@K;4u`{PwfbL0e|UJ<-{042wKX+01VLG#ZUksT7Gs!^6WqpN|*U*4CQM zX0cc-6lO=w-F3Iy&9W>-QB_q{2L}fjV}wvF79$9P7Zev4mz0zsgyQk|+uK`)m&s&b z7nBdw(9qy^yXWTSR4SF%>qQ7fA`ymRNRlK;a&T}kkx1}*g+h_fD)$=(V~jCYsZ7^4M&oceBoav=5a9J3$Hn7u01%7CMMXuCNQ5Lwgb>TJnS4A%B2g-p;+a$`#j-3v zv-b9OpU=0uyQ|mh{eHhhA~6^YGcz+EF9`truyaPwQ5e79&$6sery~f0Ac)4s#@5zW zkH@3cY8i&{`~Asea(a3?(*&(n>vTFfjthswOG``WpIs+O^8EZf7K;G@#yF8k06;Jp zyuG~zfV#Rmu~_{6{;t>Syp=S}K*cwY6<+ZSlvyNH2JKdHK4XKNeqoO@BE4;^=vQ c1b#k#1J1e3l#@8+UH||907*qoM6N<$f?)#L$N&HU diff --git a/tutorials/pictures/mscolab/linux/addop_ok.png b/tutorials/pictures/mscolab/linux/addop_ok.png deleted file mode 100644 index 1ad28896f84d0e357681189d4ba8ffe6a1bb3cf5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1080 zcmV-81jqY{P)tYEFeTg zjY&g=XbSpj%=(jBwQWpLBWg$p8-H3$E7VH6$nMmJow+k}@9|-D8Fovvp$|Pd4>M=J z^L_W6drmS~(+#vtCIG;)X|oKp4Z{ew`|q=_K>vdQA?23Zck10GX=hUR^z`%tZfA~5 zum*oR|LYQamQmf5F@(}#kV)=h+KZf>5E2f5*x0xO0T80aVhQV4Rn?bRH^&%S&=rNW z1zWxt`gnHs24jd2EgqAtP6$bY&z(K@L_xvomAM<->pJ%Ak>xov-u+_N;ZVqYj$DpB zxIRB}Ig-rc7(lu!sv2x>DX;V3EXo+0jn3YT-JF*dtLwxt!KdHpJND|4gQ3GekB)yj zFeu04^>wwnVK4wd0Du_4%*@R5+Z%iP&TM>WQ&MGto@<&GXlt(C=ABcbtMeRz_9j3y zf8+fai&mIP&KP_Bwd1{grz@+f5kitAoq4bS(T6t;em+oBTZa%r2oi}~+n;;3v#Z-z zR%Vq;oi$C<{JWYew-wIBuOWoitj%4sHaGpMMn1c6VBc{DFg7+O%d)S$BFV$?ysy0C zyKleQT2l)El(OBeEk#~$Q}fOgt<<_Z+FOe%9=SGiWsw=V@O5Nw=b?hf9tVJUEM}J^ zp69JRj#Z97r>4xv2k)On2svG@^acxz-3~Fv5QnCwCl?XE^y6<`!B@5v7MqE7Nt%;o zP16P8_V!0-qf$-|V*r4>#ifUi9Q73!RaI{-E_u>Q#eu~z#+x^98I4S;3R%!Ee*a6G z|0QpUFPW0ReuG_-hKIgPMl?+u8UCuU$YZ{`-EODTb>ie(9l?Ms%T~G6Ie($qU+Ss3 zGJ2J0#Of+hBNs;lyL)_Pl~z2$xO?yZ{XJbH!$bef&ySD)9`Lt1ov!lAYV&phAqG%a z|4d<#_rSg$YwiMb-qPA;P&#rx>~ycXI(A)Jk!KUL5azl&LmqD_V+><##qZqZw`FG^ z4IP}En6TUJRZrEt@pdo982;NaFeQ$iI9XBR`6T?&)AbEzBA$xItVEPjUDs7b>F+=F z!=+)3=r8xYnY+@#ahT_MQOvSs+ibRMTUM4J-m!A`V0X+KLWmlas3eh48X9&a6m@VQ z41knG0&|=wiiW``V*rTbc#aqDMw+4FKd%3s5&{59DW%lVbwUWG6l2T_yeNo*C<=nW y^L(=H>+&3TqfTc|)t>n4#uATz&$gWZE&l>`0Ko`??j!#I0000Qvll~)r8vE0sBoRwO7ex@X5Rx{+Flw!$7LhH&MeT~fg+%Ra(J#@`s3;dM zT7*c8Ac`_b41!1oqLfOT#y97^ivj7gxr?uR?{^WtXLa6l-t(O2Jsi%Ah~qd=Y!Lr# z0p9_J5c=C1{xyoS(?u!z501yu($eki?fm@wZ)Yy-^yA|rhG9~vw7k6BXf&>`uYa4` zYPAjy4*r@vVPJyZOeVu|TsRyiNz!C8nM|f{P!k04YubJaG5P`k076J2k@WTTb#!!G zUS1-Ea2$_BB3iB1X0suLc6N4jI-OiD9~v5ZeSMvrob-4+eEP@7hf1ZoxVZ57d}Cu{ z2qBK+d_EsR5Vf_nE|)8rOd^Crp-@*>SH5UdQ`7bJHA2Ym_Y(x6R4UD8^Ucl8S9u5_ z0RIV^rU3v9!=zHF?d|P~ii&773Lz{hDe3L)jmP6JFE6oJtg5Q&^z@Wr7>c4i9?$Xd zaYI8xHk*YIhC-pXwl)Z%*Xtb}9fc5XZf+V3hP%7FbUHmeJWP@#gfJKkS}c})UA0;r zjYglIo=QtgX_|%*o}HaB3@3I&E? zxm=ES=5o1YGRfO&wR&Y`C6P$jY&M-vXSdtGD*4XG#csFf+xh(be06mN0MP68m6er? zi;E72qu{>7;qd$YG)=QCJ2y8+QIuFLCI})Pk3T#-006eOwpf-00Aw{x5rm!-cJz3+S;1i?XIq_Zf|ctIy&P06h$2#9+D(kkm7VYT`rf!V$o`~ zEXyt|Eb!~XO55eMEnzr3;+Ln{8uQ- cP8X%(o={e^c)B(Wz2wAb(P)3($!V=g>PRGaq7dQv-Zr*jvJhjEi~sn8XijJu7<hREh~>Cgt52d1VMCm z^@L-I@rid?S(JYz7)c0q4rfE7EBG@u?ft~E$Lbp{D9Lv#qbMpnJ9}h!NDzb~i$$eU z&CSjvlgY-5O^E1fy5#fDg?SzkbUNLYma8O5p0pJ+24;Eb2O^G*j$UtT*Jw1yjix%M z6EIC1Da)t?0P*7W8^3?Cyx4lDr+aHl=0kie8nu`?iX3i=iu_qG{Z0k!{oGA88iKHiXh1J zT%;&UtIZ1YywPL=fLJVw2+9~zMpQybCVf8dmMkC6$uY3(`o_Agq?91Y2Lt`0B#ELp zJ3Et#1c03^fWME^H8mdhT~U$(fxz=;Pj~z&?4H@CBz3g6Sqr#RWj2~-&el3ek{o&b zWc1ZbOTMYZTKI7AJ|Y5cFL!Fqw%@lpy81?+)NBI5g$uh=bR!WeqN2dah@xoI#%@$lZGwWLsD)t=^aphHAE+pc z5TSNvEp(BUqIm<)G)mUYyHI?4t?&D)FX&~S)$cqr{Lb^7^E)%=94HtJ(x?R_5!vl_ z0D#}`|3Nk1g19BS-3~#JQmOn=G2er@B>+HeZ7oTXKda_j5Vz#_`>ElF_IfQ*#uEI~ z0=^%}>jpI@>W{*shNa(yM}8gzLFjb4{QP__mz$TDH!?Evg(P3b*4Ea$Jn}Dg{}W?l zV+RKZtE;O;MMZA6+vRfo?;-vu007g|(;AKDooB&dFc=K(?(Rk+k??0=wOZ+Py2WBi z;yoTuPft%)R#s+a=G4^G&wf_-|~HZeO+B$fk43Fa1<66>U26B$7g3}SuED^@iC6$D=RCdrKLEI8;wQ? zf|i$;0RSeGiOpua-EJJmdwYAkySqc7klAeJ@p!klw*UYXMd#<|{eHj2VhIES*Voqq zf#6+XUtdc;pHCzbz2yxC!{p>7gTcti$QT|T-r3n9Ns>mRDV55yvNDn++uGV}He2lF z)YR00fdLqX3knKya&m5NZU}-f7!2d%<0&aA3WY+UP#hf{U0hs5qtT(EAtsY4l}Z^5 zMl9zyuch?#bdSfAL}C~Q01$~pv8G5QdU$w<#X_Ml7SQSR=jZ2GoSmHw!|`E75F{Fn zVi<;DSbcqcjEqDg006JoD;A3(2>OVyuYbfVD=Q}^CL9h2ilPaU&1Q2rocsHGnM_8b z(VR}FL?Vf0hG94{w2AH@2zuQqNs=56hs9!@o}Nl1k_2xyo84~rTY2va{(9lbNg=#tya&>%!ERrmzNi- z)rw(Qad9z8lI!d1Pft%Sm&@n#@pwD{z}?*)K@dr4|JV?H*44(whDxRC@9#%Zw5qCV ze}A7O$;HJ*1VLmnSxrq%b8~ZNXXk6hNpTVxYc4G~wp?at&Fe;)bdg;Zk6x35tP!#oG7zF(RUC4i+ zpe#a!dNb)Ef~*ux^I`V1l}z{dFz3jJaTtZ~oR3e=?_pT$$E-cSwf0)GXYhDDj%6K4 zgwXHS@avJCSrNZmz`ug*+^g)&ia==~kw{o9mWqlBnM_t*UOqE3lTOsH!r|dzrXhGV z8s$xWZf@@E>}+Rer>d&T@AvzBKBLk2ACw%!FaUtTV0e6d%q%Rz2e;cT5C~i@R|+2p z1V%gw$5Y)40jOeUjg+HSW;B9Y6>%eJ;Qv)QarD3nU& z?(S}Kg}%PNf`S6AR(o=C!jU&OHw_I9B9VxqsO#(NnwlDdASjCJ?(XK5ngj;+8SsV`F1OLqp+k*yHgS30!tL$t+S*zOq1|rBaeQlQ3jpA7 zI3yB@-|vSIj*gBF4-Yd8<8(UZa{2S~GXQ|1sI|4V_xE>~%M}iX@9*yw3I(so6!6bg zC=^nuRNr}<&9=C>C=?0{3k#>Gr;m@15keS-wYIj_)zu+{dV6~x9v--xb8~YiCMF1i zsI06kEiHX|dP*b`Hk)mJem*BBN3YlG_4@Pk^V{3oSS&U*H6;>>v|6oDDCE}6bgoF= z7R}Gk4+H`!B+Ie@0F_F`xhj?F?d^@Tl}aUN2n2$!uP@FnDJda{zs^RIBxA7{%d#xX zwzs!)WHcHD00e_UwOWnicxF@d4g7ER#>U2lg$0ktLs3+clt?5}sr2>rRj1Qo80PhQ zH5v_9CxRf71;+j^GmhgwKL{Ztl}g28@zvFpMx#mcPN&oF_kWkfD>4QA9Z1u3XJ==B zf4{@w004Ns-n+Xy48u$&)B5^4gfJKkTCLWBfq{SOmZY+z%Uw+-)AI5%!!Yr9-0gO= zEL&Y&jS$+~+xz_d^!a?DP)IJ90{~uLUJ{8!`W=|od9}a4Z!{Xm$Hys(YHDgaJv~JT zZES3iB&pNsT3TAVy1E7j2Y(iwVyBRtySceZk|a&jWo2cPlamlak|d9gjt&kE)M~ZS zX!QAfd3kwev$?ssxu>Tm{j6|?VKUm>KLXkL#gv^{{r@NO=O8ZgXgFbngSdJ^%m!FLXs%bVG7w zVRUJ4ZXi@?ZDjycb#7!~c_1-0AVGC!b#rteGB7eRATcpIH846cFd$G(2o`!P000d9 zNkl;xk!jExx!~8<(8y$ z$leWt-aRo zxAyPM2p29~FbNVQ$nPHUw{{>%kRaEGU@->?a*Gfw<{&|C5q`yD-icbT3ATN|p2hMa zeE1k)eU_H`Tp1Zl=!~PkqDNQ5KGY`lP3c|HJ#sv^-mpf)~P~RR1D60`(pd_CMjKuzE4V}NW8>LVIy?bx;$X_;En|XqZ=T4EiZ8f56T0dZ8~x zTh}8?us_fUSB+R)wdxc4R7xp9^dO}q<7inAS8P1aF3BjzefiJwJ~`CCzd=ZgTDYp# z#WDUf#53zrJ2l1?T@z>bH;}UP#g8n){!j~?HLBsdtqG1lFGBJZ*ty2mSCl}uVOEVp z?>vs2kN=Fh?ggwn0{voH)Ru|3b|Q}Ex8aJ7#Z~uCLf$2 zv#XSx%8QGA#*2isX^FGtcw~U{Td@sli!&}3SA#a#XYRN({>r-jh-)U`Xi^(j%|(>J3g)yFY)d!BFFX_S|G6WX9QuDHfHMt*>ln{VsG#|V3L64v1z2yNX0 zXN%&wcqIbnp}6pU=oN#t>$m95cVgY@y@HBtRL#=*1L$)$khSd_a$9W1{>p}Y#RswV8HU_=8d-;rkTo+A zX;d$4yGvOaF3}jzg3v&2gU50<}- zLVREena56$ap)^@A8$yYv==r#OUQF4(B^C*9b}w1LB{rlcw0pT7Iiel``*`NoIFP62VD?fc^unLe_&aTp?n>M zd;K;tcf5wqNv!>QqEt>Md*4wqKAQjw`l7DQEL(T#yVwVRiGTW5GLCG*9kBw-?tC)| zz3^3Dh2`^tRWTQ3=^Bh4-MQQ|v)f#9JFECdttNBtUNUEN0Q?Zwi`)(S$T+c&oF^45 zk3Wy%zHIQBb=W%$L+ZAOoa8$(@gu$34g2;+xIaHg#@-Zs+0zJ_yuEPDrhg$0SV-2E zZ^@cm1xvtOEJ?{?&ixYW;Y7SmA~EA4@b?%D%jRF<S8K3jD9`QPwqj=C@E$t62h^4h^KSFQUQO{xB$hhBhO!eIdn4K zhT$MN@edn=@5~A;#|mUrw5P|QSF}P*546_laQ8^O4P0Q4N9)`OY43qN(cA9Fmsk^1 z5ukEwJWsYk+Pb6s=NVT;6lN6=E?z7{PQjFO5ceNI%+SGA8~ygc{kPGk2U>gth%#p5 z5qKZIh_tP=!=Vtgp-*5~0YQL6i?HmuAJ2De&+2F4!oI z;n-3_IwT`J^$FR-6Y}$98CsKsb92xGWGq zpGD9sU{)x&Q3)feaE&oTLJ-d8lo~EGqR2|Gs+c)Bd4W$$Lx`%5ap@FG!ib4R{O%e~ z52LDZ>2yE|!L;NcLE zGd#QuxmI`PtAGLDHSp;0HiFTISnamljCP!DHdN(+%W)^(c3l1vz0Uq|M+=G501fK zV!sbXzqcvkyibr{Y>8gvm{?rC969n{Jax+(pmGuwg_)L$sDWPrFc43k#;8(`Hz8i* zuW?n9@XSuaW4aMHO(CTFXsm74;4QW(RmO~#@poTE_Ry$nvxgsLQeP|~bI9J(9zzu% zr5|B)ZqQ%he!mLkXjSx9v1N*y%?IO78I0R|4tdrSgbaHNecSlU+SJ|H22Mp7zm}}- zwJ{}-N7ck$>M|rytmTM~#&mQdciRl~60dGI>J@YwQBjDyihjqQM>t>PGV&38t)53& zbqHa}7W6*zN?#l=&zlo-u|IPV-`anYb4NIE09&*Bi*>O6kqhvBH-wOWRml1Har_oQ zjm8X*#{1#7c z_k86b!s5qX-ge|sp%R960`aGEoYI%zXXKUZkM96aDuNR9c0jHK=T~k&IUxeFx;f-5`I&nQ>who2| z;qXS3o#)EbZFK62{^fkE$2Gu2{A3Q6w4%zx(0bm7{MH~W-?hc}aOvHMQK=Hb(PM=- zvN93WYWVAf0|vr|d075)*&wMJzIRsQ33-E%0UzWa_O`@3P{I1_H00B6Ku1X1kFsH3 zIX|0DAunEpe5&M=!ZbYc4z|td=t_~(JlVuLb!z@aF5>4?k-NQ#u3o0a(Hi%fv4CEE|-f}g;XEUf?23zMiCl!79tbSUSEW#X{%oYClaC&7lR)e{(HL@Z$=BydDv#5G2H zVli1m+EC`2V_rmhFB$deR0KiAj7!2Zb9jJpiFp2+h<$Kt993d4Bb%eQtyZoB9RP$H zct2VM6JNm5bSQ$yf*IEg-}o75W$BK2g519y$KIz6N<<{)9qsTnUQ?`S%!hiRjqZu^ z(0H@}t!n}PKI6~_K834>8`{kvW8M(ly#}G)^9RhB2+aCz(e4Pav&ymf=B)im~1gQ8HZ2PU92=cqYkFa(&7H@BIl78QX z47X%42MJOh(y(@Ef^B~szPFd-zXkr~=oW0Vg9Q2iWy!D0>)|p@$zNM+8P@Z54CW*y1E{jaiZyrrmpT><8;0`yQ{zFrKdkXKi_`x6E`uFy1>A| z)Kt}wKGxW25)JRN-Q3*7BoYohym@xExwN#jwwBhiB};1l{+im*$#U@T@9)LM#o~G~ z6~Di|{r~@ed1-&gaq=y;zjPy-JS&;NgaA3u4blAt6mK7Gav2{~b)Ynz*z)coi5cr%`S_~P~J z-KDRuJwDzKwCw-o<^I>#$1lI)lA8MT<>lq#qN1MO-q8^eGIli*zKo9rG^zy*l{GXv p)ZFKNZf{zjDbNS>_8QtLFd! diff --git a/tutorials/pictures/mscolab/linux/johndoe_profile.png b/tutorials/pictures/mscolab/linux/johndoe_profile.png deleted file mode 100644 index 2cfd60ea5d7eca297f203b0cb7f3eb7d68ee8daa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1267 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 LNkvXXu0mjfMs+zd diff --git a/tutorials/pictures/mscolab/linux/manageusers_add.png b/tutorials/pictures/mscolab/linux/manageusers_add.png deleted file mode 100644 index da622257b5915bbd6112853a43f1fe97d7aef0b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 593 zcmV-X0K&;>CETz>-GBTN;aDf zg+j?>@_0PHo;w%}qR}XdqRnR0<#Ji};H~nT%?5_ycs!m;r6!Y!NzybO3*oVA41?!+UDw;~HUQv!KFhM~^ZD-gJBp&FPdFS_RTY9DRaIZ4 z$K&yOy&u?v*=&a6_cIgp-`<>O)HM$jYcDv%S9p)!!QVfu*wWyE|-71<4?i< ff5CnQ_W#b`o^z3juc?7i00000NkvXXu0mjf)E)~) diff --git a/tutorials/pictures/mscolab/linux/manageusers_left_selectall.png b/tutorials/pictures/mscolab/linux/manageusers_left_selectall.png deleted file mode 100644 index bc9260697a01ec781698f833b8a759241a7c312d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 905 zcmV;419tq0P)f&=!kWgOdt%s09Z>t5dN9Uth1UuQLquTmGq{C<_YFM(F@@J$>nog(FEX#IubdV(3+uKXiG{!iW%Y{OrmX;Q`+r7WPU($gon@A*lK3`*F zBf~JFCawq-|zomUteDpMbST2Rn^F5vsnOu#bWv4f^r-uNfJ%d z1VJE#AcXz>{gp>5-P2B|GaL?YZEZy&k-ECNlamvQqI6d8puWD|$O?k+aZi{R;!ic zxZT}d0KnkjU?!6xNpgC6y05QqWMoA5f)ECS!QxmG6B7_ZT=Dj>$g*5{f&M{M<=dkw fL;ru8{waJ1X_zoQy%F@~6;-k-Z*jL}aiRbD$@6YGc>v`VK)1h8oUeNCa4FJE0SHU+$ ze}BKi-e$Ay?d{#) z-vhwq`>2!K{cz9xB0*jGIBzvgU)zvF2 zD`jP6(P(sPYRYD_ZES4dqhK(|^ZYwJLa4B?&}y}YLLmSU1i|TaW-=Lky0x{{+1c69 z&_Gd?%jGI4C^$GckR(Y_l(DffilRC?I#`y)wUQ;kIZj+Imnez=Aj`5ON!{JuZ}{v< zIGs*W6cIwADDpf{bXk^-MkDz&8jXoW;_mLwWHJ%4w6ye#h2S_Y9*>`&pNGTYuCA`4 zqN0zp2LO)aqS0tL95xsX48xEFJ?qnut^?mHJ|4-y4mMjSCA4EiElX z`MH0--|x@Q&u?yS&L;i@{`U5^t*x!3q-1${`2+41$Nsog9l~UK00000NkvXXu0mjf DP)>d>_gF10FE5EZO$ zied*v5kXspss#-xh(W!rZIeLn8v-G(AF&UGzQX%W;r{PE|8wrWoLoGc&0>{|4k1+T z9KOY`awqUzD!%~q6V%ky#N+WF36;a?>1kVAn}&20J@DhlYk$R#x)!QC$8Ql!|k8bwv=w($Z2Yl~M-X zZuj{3csiZFxVSJHjU2~GlBCz`pP!!#s+mltxw&~^VF5yTcXyXaB%;wMNs0MGL#lL^DH&(BZ4-|uiZl;Fy$+=XIk z_+=<_4V~Oo6Tf0&CSgrgghQkRaI4MYina;7DwR^7Z@#M2>9)4E*4EZY zlC<0H9LEs^F*P;S)z#&6Iu$R!-#;=kf~oq<=;-M3^0K=5sBe)1E(pSxRsPFV{9EY% pbGqDAd;t}Ig>*QMmpg}V@f!vNGJDku3qt?^002ovPDHLkV1gf%q^s`00003b3#c}2nYz< z;ZNWI000?uMObuGZ)S9NVRB^vXKrt8Wi4}Ka%E+1b7*gL?*qR+000FjNklaL9jo5 zDEiPyUD*)92n}lPn}3#pidt-}CNp=t9!0l%`f%dD_Z`8D@$$l3zfZsOoZs_2=RD7I z4!*m)i~TVm;QtmgGc(oI)%$WGAcU>0tu#$gwuXF!(Q@4nhdwjT<*QIy!_F zy4o^ z1OjDcWk-%2aX1`1J3A0Uj^lcIdI}2*X_~%$`?j#U6%`c(K?DK;v>X~5vREu4ktmT! zIGs+pTwYpQ>i7H6E=|*K-@a9=)fS6ocX!w2aw(O{f`S68)tXEugTbI)uSd$Uv9a>< za+yqKGMVP)=8#b?mp^{|Sgls)<>k3tE{@|c06=qdv&ZAPd-rZ-W#!h^76724qGED# z5&*!m?8wMSB9T~HS~_^};Oy)y0O0)j^9F+<91ce!5v5Xj{P^+J)z$U&^%Eyf3=R$g z03JSksMTsCkw_#G(P%V2pAQJo*4E~7xe(WAG^3-V0Dw!EE;*e}hGC|srw<)E^y$+l z002c%CX?yIhY#_1d}wIs^y$-yM1o^#j@7cWYsQi()TRaI42S0|Inva+&_M&ruL3WV_4vu9VYUX@5B5{blaxAVP1 zu-olEpO53X`T6;HJZ>_XIF1`09&T@MKXBlHPN&o9bgy5(M&kDN_QJwKilS61)$;Q4 z=;$bhVOp(L81Ti57iP2h)TvWAjys)BilW}Vdxy^1+S*D{lvFA;8jXvKi^N|uO%Q#3 zeQvk=>({RY@x#8dveMMlWHOn~o;@21g*c8wc9~3u2uYHM4+YT z2w`Vu=lJ;e>gp;o#$vIPCr{!y9twpP78cU}ur$uY;c$37p0%|#2w^xJM)NO(&1UoY zeDQev%$YMNh}~}Q>gr+`CY4IPfB&9k*|gwmYir?f7(%F4tHomR_VzYk6sy(x>eZ`Y zFqlfEo;-Pi#^9pclAWF1)6;YL@?|7)yWK9AD<>yMuh-Ys)~4N!_e$$+LqkI} z8a12EiA18Hpupqt&@?UVtyZh0D5|EWhOd2ZZ*NCOM@dNugs`}{`03N9Sy@?pIyA1$ z&CLwMh{fWYH*YEwifA;71WKjy{{8#cu3h``<%>?I^Lo7`NeXYv=fxR@!TuPK@b53` j_r{-fTjHN3zY)Fz2;LzK+|@qq00000NkvXXu0mjf967=; diff --git a/tutorials/pictures/mscolab/linux/openviews.png b/tutorials/pictures/mscolab/linux/openviews.png deleted file mode 100644 index 8d787cb3d1dc4536315e54e15de007a221d47454..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1388 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 Z;$IcAdeX{oGwuKY002ovPDHLkV1g5wZeRca diff --git a/tutorials/pictures/mscolab/linux/refresh_window.png b/tutorials/pictures/mscolab/linux/refresh_window.png deleted file mode 100644 index f8782c122c931643834229c4c27959c7b33e5952..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1730 zcmV;z20i(SP)n00003b3#c}2nYz< z;ZNWI000?uMObuGZ)S9NVRB^vXKrt8Wi4}Ka%E+1b7*gL?*qR+000J6Nkl0Wsb=+~){k=QW3!e&T3c}a6m);y}ZSUUA>({x@bALVeeXesq zPsYm13Rp)PApF+}{BN*csdPa=zYDY3%;9ioG@8j|`VDiXQmNHye>?h{H*Z8DQJTs( zZ{9p|~V_6NX`8u{hDE@$vE8++2gffQx3cStgT_$z+8>@n_?%ja2pl01!eO zH*P$B{CFf1nVp?Q2)%pvP9l-y=jTf#5|_&ri^cZt-TURsm-_m8l}d#WDk>^6o6Y6r z<@@&SLkJBH4sy9%8jaT0))t9G5JConp{AyW!C-JW9Gy;w2XZ(Zd_JE_rOM^JjYi|ilPAZ<#&FSUwaR2No6S~LRfQ|l>Gb>e@0XXC(`d94Cr+%ct|Ekj z!QlS=`}6bjg+k${PoMDjLI{nHj`I2Zyu7@fJ9oZ)`!*JfZQ8WS@Ao5wtX3T`pHiNlANqJA}~fcIV~gSuB=VET++DSS(gF z8f|E3c=+%kgz)0Ui(9sA>F(}^5Z=9e_u#>U$tk6>&uX=j$z%$J^61f{v9U1#z^hlU zqS5HlqelS%9UUF7U%w6pgDF-Bp>yZXvDs`AiKNr%6bi-Gty?h+>*(kh9v%h&@OZoj z4<2~E-s0k727`fHsnzPj!a^dEc;LVRx7(fUaQpV{p-^agdU||(TrQV$I2?qK)9Eyu z%~GlKXKI`|bB07BQK?kCVE}-Uk&*M~&u`eUp{%T|rKJT|8yXtgvu96jZ7qR7Xm4-N z%F41>EK;e|Y&HV`o;`ck+1dHx#R~v{$z+nrWWQt|06-)XMIw>W(a}I4P*6~S5DEkW zb8~Y{CX>lz;$B$vydKYxy2Qpp#^&#so2mm`tL;^Jaq zVIjVmcC7b8{+{%H#2bLLs~*znpyuG&D4@+3Z`lZUF#j zG#Zo1^!|jVN}XlUr^)29Um1*N5> zeSLi?nkh`IsHo6rG^b9T+Prx)pU*cK3<#k+ckZxQtdf!vqtV#a)rHc)wr$&1R#tZJ z-i?c7GMUTeCNlygu`eYVgiudU&&sTA>XDVH+S1Min Y1yLF{@RDullK=n!07*qoM6N<$f@}0yzW@LL diff --git a/tutorials/pictures/mscolab/linux/server_options.png b/tutorials/pictures/mscolab/linux/server_options.png deleted file mode 100644 index a757e1297bcb1555d694c873660e6a66f694269b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1568 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 Q>;M1&07*qoM6N<$g2iY)2mk;8 diff --git a/tutorials/pictures/options.png b/tutorials/pictures/options.png deleted file mode 100644 index aa1a6c9354ff79f8a373395f6ab817327685e6f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 443 zcmeAS@N?(olHy`uVBq!ia0vp^$v~{g!3HGHt`_|Sq!^2X+?^QKos)S9a~60+7Besim4Gngy)^j>poRcX7srqc=eN^t3pOhVxW4Sn%V`VCIH06) zl5D=O!;COAks1h5H6OFp)k)V*T!{i%k&!Y_GJ z863gFQy5ubO#TUpsR2V z@~m<4{bgSdBm}P)b}*{3@M4pGEzrhq{`H!J(|h3@(YiqMGd!1)^#80&`!}^mNyA?* zMec+7-U##aJ*76jtB-Uan6$8WN9o&I*0=2C*G|7$HZxS#Wm)zsE<+nhccUV+;$IEQ z%4}QfT)G)nEil{r=j_?#bP$8bpdl9{BX26+KTYzV>zFCaL`*!ly+aWlM}Q<=|Szu$X#-uHXH@BPmC2#5&( zdl3KW2L2UzsRF=gR8SpS;J3NpIhFgP;?RmIY*$y0Rj2Pi7s5_SBq#RAt`YrLKjTc= zcKz>IhN@7NG>tm>i;Dh#Tfy3dC7h>hauDw4B*r(d%&)F4+bcBdxRP;J`K*lQQ{PJz z`I(oT}Orr(Q8^nDg%7ysbbEuX$mL)=j)sNP&hB9Tlcbx8MY zd3F`Rerl*6<4KcrzaEP}{L3R100*G?bhOj-?H6|P?N?^0drc+rUNq4Uh={T{hv2<= zJ3?3#WAFH@SR+eV?CoGdHlw-BPcB7F54TzVZSE$%lNm{nT#J=QV+0&029?I}n6oXv z9#Ot~-gr96h+@lR&fI!%9hYHDG-oiG%#b}d`>HE;%`l#O@D3tkqd3eQe79ahL_}GP z18e;y#Fi?_hM;i_D@&Vkvo~cc5E1M43Sj;>YD7d$<+0UoxY$tb-1zUOZWK7jCw zD%evQJ`wwiYwGInDvyR+JBIGNT+`4{RebekGh%u;+1i00eY{***U*UAa7iL5QEBjQ z@s>&CfZf%Ii1JU|m~r{NwaOE`Xrk0uXdB)}{nwu5bQEt|6ov1fd;nu!D%7it!w>28& zIxDpn5ixz4+4Su<`|A#v2(46n);nSW9jKuZe&@tzhF)Ozd}4MjakDZvHI|>sW{5T| z=UADT(A`*!UY;J|hlxUGin)m~fQe^7fUAWOklh44rczlqnv?3r;~5ncQ~{L8@?0Z7 zm{O7VlNRpD<+pfzKSHN8L+Z71-4rU3NacwZ(sOf*3{tyf*?E(qKAJ)^GNN#zW9L1Z zVM2jRuG~wsq7>G0R01P_pEk<%%-L$vYmZjw?un820R>l8(X`+rbwhQ54s;b zUWMjNu{>~JLDCEo06=&C27j?c9T7PooJi~=G`FUa@7`?ya2?hSrQ4Tpu30O~dizyV zxVmLx^w7!#0%53;G>tcD)s;-?4ZSeM#gs1aOgNwBP{`n~W&qry+ZYAJmZmK=Gf3#a z;cV#iE_qW2fCT_RYilczhBP%|@kap-8RBx`vPEMZdf^iGrm$Rma-@SyoE{QfcO)sJ zy0hoc4%Lm{+XwkIY7O1PXRImc+lwU9;aDuG)O*(}@AQCPuQ#yr`7T8V(l98<9e^$n z(9fl2wek6E0HDkc3oP6hFRRt+pr^C`T7}ARTTKJ!_!k~dE^XIAxAMfnEcFPD_kv2L z)>Kw#AJ*ai&npXe9$V41Cxq?jJYoKq^0q;4|J4cX z(@VL$SwT^!yut&=3_}nSx0;YL!;QW8fXcwk;`2Lir~`yt008Ge0o+pZ_&xvtz=%ls zF06T5Fq`Sb;x70!SJh+4XSX6b#kU~Rm&Nj1S_gEFU@2vMLit&7H{Dyo00K$v; znkxE~z25rF#dR*D3x9#$5jBO<>*_8Y=(UPtUrCH6PiBrfb(o|2UqxH#{xwmnYTI>W xOILo(k&V33uk8h$h$qAgG!f5$mnr}N`~^5;sV6iZVD$h1002ovPDHLkV1h`|1UCQx diff --git a/tutorials/pictures/performancesettings/linux/maximum_takeoff_weight.png b/tutorials/pictures/performancesettings/linux/maximum_takeoff_weight.png deleted file mode 100644 index d2ce02d46695006751ccd8b91b0a600855d964a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2594 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 diff --git a/tutorials/pictures/performancesettings/linux/selecttoopencontrol.png b/tutorials/pictures/performancesettings/linux/selecttoopencontrol.png deleted file mode 100644 index aa3f705dc9cb87c14ab75fbc32d85b83fd35d76e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2210 zcmV;T2wnGyP)xkkuW%Mvwaf%K{3-wU7E^X(; zt<`axK>{vVIaQ}>#jT@a6;O;&6+#G!k&xv*FpxKYB!qy_j-Jzzp5gn~@4a{5{eA9R z?)&{7Wj32(K>bNhZyI1gJ>^0OdD9F7>Lw@hnmUu_ohVhkZtwqh8NQz8mU_(GYn#KA z)bF1%Z1GwBf9niYo2`yb_Ok?^ z&dD)nd_2+nSJ{l_%ey};s(PuqQI?l~S^cZL@qeq=4a7NO&4#E0S$h?ZmDnoP{wdy* zzG}o6W98Y6Q-=$=Y#(lLT*k2{7-QU2wq^E6K8wi?9>1!n#)L7(#dF=qY=43=#?9q> z7siQLOjbZ#`hj~b7-Nj}RYzC86UwI382%$ypS$*1qL2`M5|H)0;8?jQhJYtZu z3zIJriP8#n7&qP6@rThtTqctfJ~jK~V-xN!gZy~bIDrm&L^HkG~ zW2})*fRsZ9j4?KzU*;FS;m0lzKnNj(OeG5$v76)wA$0%aNWXE7)?6=tj1bbE zO5+92FR0a<)HiZsm=pKaA%u$Ox{ulZ7$M`e)kA!zNh_LB^SzxDSTUbeB82Yb4rj!y zJtNmNH&x%MZbC@CXELN5L_KvSQxv-Xmi1X%vd};9y~FpP8(W^9TOG+v{QO}z+xuHb zvBqrtzCmw%c4totPq^%y&WZuS3ku{qQ?vZo`-54j1**P*sQ}6TO0ChTC|xpyk#^GH z=)P8(xlt7T?e!Py_2~J%ZSQ)=ZmRAXh+3ND&JLRJ*K;*$ zjkd*bW6eg*)8vqUAYF^7Z* z2qA{IXD+l_(;*>*l*?qs*{jlm z-H4lb(Sn2@&zx0}n$st0C#}v-6nMCK1%w88b>+^NbE^I6s;rou{?W|A?v#t~*zC;l zR|-p-91QDD6;(~l{B!IeXNn#=KYKo{PtJo{kO+2#QrIFn%K+DgZEY-t4|&PNL;!q&c~U!i&LgVJbF~yGY~?c z&1d$<*<<;h9yFJGMMVZl*5WWvCnrYaiZwHu3X3akhl$PFH6%pz@Zm#yZx?I&G8pvU z3HeH1!{ZV+W0Nb?`G}Xo)Jr_RL0tGCI;;S0D$&ZTC73emH1RDmG<=9pyN&e z03B`gfM^|PG#aiz==Mrk)u>b&_z8Sl$%F6DRBN6Aa68+TjY?lW-_=&~_vhZfoQ?E4Gi?;s~4^K~{L2p~l?!n2~2a+>?D#%Elvtd~6 zHeU{X_`1uNRtUSiST_j}LVP(K;Zj*e-bjbW8XvZ%T4}aVH~;`;HSd8=QP#x*;KAW~ zD(V%4ga81T8X9!MFo8n{4u`A0t7s!JZU6v^h6X=@(3ubd(5Px8gbx5vS6|QYttCV}B{`sU$JXmjZSAd^`|?H$ zp|Myrc|}DVu~;k)JDkO0DJv_qgjg(OoMhJXzvmY|MjbCSSM&dNB`I~9w}WZm3`zLE zc5eGYYwj?~k9_(MNR>oeG3sSs7Cyxmb3@VQ{hH)yqsh+$+j0wZ&ozvsgbM<7nep0d4XfnkJefUHGcU=f|JvS1SjN|I}C&< zb+x1}Cu(r$qM{ah(q>tvi*#~`FeE~XdU)A#J3RQs3s!93x}Inujrq)(pS=EO0O z?j}^^I@uP{iTA>VfpJSO#z+r;Ack&kI&NOMCW?>m3YOCAt$7xeq(|%?eYJF%Km3;T z?Gnz8xT>?uxW8=LKh=6JBVu#drSDb-bk^AKxai3nH|$HvJwI<(lGhvKdvy^2LM)`~ zQ21@h^6>LbZ1poj5 diff --git a/tutorials/pictures/performancesettings/linux/show_performance.png b/tutorials/pictures/performancesettings/linux/show_performance.png deleted file mode 100644 index e21d64205302b5d426ab9820c147ef0c1c434c49..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1852 zcmV-C2gCS@P)%aeJ zoqf(e7=#emum;$_TEO22o0V3>W~J4zS!p$FR$7gztZXlk@z!7be<_(7W7Frag$TG@ zpNw;Zf3=oL_|3`@b;XWE36H^Gc?HOpd{A@$S!6qtMkZ6JR2qXVki@L{T0N~mtp(OYE zlSwfId)AyaDWP|po3#KiqWX4AoOmY3(>r|C{>FO%0PO`bd11{ruZM9NzS~Y7Ub^zL zHtme6{ju2|tKHGVD zsR2FW@FED#y=l;?mGvc?Bgk$U6?%kzdRIInB- zcm2?*H*{XyAR^5#x{Z*&BE#KTwxi;fM$@6FYt~FG7$Ib-N@7Uv`6CJU_9q4eCjPs) zq0dr3(&g(U&Uhg5UW%F3+SU%xb+)8AYTY^s8IL9LQ#Yg%Po25~{ID=wZLJbOQCY=Z z`9?@n)rI>oP*Za&Od5oFydtk|5;!wF#c^A%c;$VwEeHpo~nLe4iIPVN#;A*lkBhn z0FLq%DO@6E8c#0ckA=Ou%4a$jN0Zg+Hy0sv3~BSTmuEZ7G{&LyZ+I`i}nFJ`6- z9dHmUAS^|pp1+39g9gFlvK z0DvRIg=p@^69^{LFn~s*p)whk4U6f@>}%5xL!30Mv#18$?5=c@9Zzt*lv8ziXh!W- zv2-u-cMo@P?#=Q{`nZOk9<8bTUqZp#005&#BWcN906Kt^kGIQ{djs@nG}9PNr-xTA zOxF>ez8?Sp*xugbH+UM2ZoZ3n9Ny8{*}PC39ye+l9tFVm)gO5GgHu1>F=8+SszJxB zQF9dCh4HYu2n4)o$ONF%>0NzT;|gLz!H-$_&@qZ=**h!0^8HaUk}(?qi~arA5{*W` z)7WhU@iYJcb+?+%V&Q=h8a5}R{&YplSoBApBx6*N(BJ>daMTPyd6Vf!p67DpmRi`r}|iIPpos>cWD0!|>>R z< zJyQ;MkS~@u?8~dx8pnpZ%XS@5B_@ae`E!O*r|W0=iWzn=qB~pi{evln2E@jWoy_0g zc-J&?UwfmicT&L?6@85d-;56yu-SeuZf=wm9?arRHnr#K!$s1%wZUAjDD^01`>{=8 z0ssI{D!tdDlSKyt0C+@2!Yx&pRQxo{;$;+nI#01ad?w4?DDa)Fr43W~YR=#H=lc1d>2gQaR;|gG zcUUw1WJV~tzhmt?+hAjE1_lOk=YposT9n($#K53id;0b3*RNmiFH)3D-|_nO>({R@ zx6;Uy3=9kp?%kI+G7@B9W@l$%xOe})hLJHR11mc_JZ1g6ytID)xvvloo1%lK9zW%_ zGBGePFfp^Ru&}T&GcqtRLYW^AcSbrHs4FX}nFO^RL@SYB@5(nZ$=>yvfq~)gqs=wG z#wrSm8ulr(FMUN-_y5hY35gDRs&WdN4#|^FePCetb$UWb-s(3uCV1)TCC}T?=vTk< z&yjj7J-y;RFij6vq$bR~eWu+}PtT(9D8s!8j><&`7#J8B&eUp$v@b1o)0LNz)AMQB ze`!;hmw}R;qK-%9&KC>}gdFo84h|J7N@nbau-~59wDQ>fSMOh6o#M)?ReSpV`}a>K zIx_?ipFwXuvt->Fyc-@kuOl{TSS)K6#G{Rzz}Hcin`r&(<3VC9Z$}{{8#i zIg*TGepAmrfA{iCn;ko=uv`1Fr*GffoEs>tSa67tV{nv1Ov<)yngXm0EK)8GnvWhm zf{HS}UY{Pn!FKkdL{(M>28M^5*Ijka%+cXtWRVC=@cOiU#|?xLKlZHKZj@ebFV4oq zF6~vBqO@i0z8@r}HZ;KJo*1db!N{iU?x_0TCOSovi-}px-NoSH{rms$JBEP;tAW4o zZ|iQKz5m|l|BQcM-T3G5A0o`Sue$l|e*QlqabA#$*>pEQSb+ivB+I z!i1H32BR2E!NiZ-l;CBr6`t#kJ*W9XxYP?Jg->cMRibJAPP!QGX6E004pC0P$h1)u8|Y002ovPDHLkV1iEPQmOy| diff --git a/tutorials/pictures/remotesensing/linux/azimuth.png b/tutorials/pictures/remotesensing/linux/azimuth.png deleted file mode 100644 index e691ce443c1e93eb1c3bb662456cc3fcf226a818..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 794 zcmV+#1LgdQP)Nsmx$^}B0|Ud}JZaa-&!A?X zsnoLUxy|r!MQXy#+h^Jx_4F(nk1{YYFtC3;+Yw@_DkraI89C|nXDrU*5Z90F+;-vi z-BVpkn@T31Vqjo!YCCu7(xpom)}$&6c&7x4qj>25z6HCCXPka;Z+|B9l<=UXO1;PK z+&|eQwYqZVWgKB3A2GMhm(!$j|D{VO>a7_V7#Ln`*~MJ3^ZKKk+Y^6wSI&Qo=`03m zb2mdVc1A`aXJ@0Aj~@MkS$}V4?sS2Mo={OVOZeOqBb7K9*_7QKRsY*Wr)Y99F^jpo z7(Bdx{~x{xLI#Ys3ArXhEDRjV{vKL)?%somv%o$7{NUuaDO<0<|I5hu;SPgx0MzKm za|$N#R_~3Fz%Wu&RE!Z~1~UtvkPyTqW)|juzyF{+>;M1%*qy~EAu`<|v%E*W-5EvL3m|0l<{{0JK|9$oP zJ%b1~O-xMK^Bhd{)9W`3ih4RiEDXOMtloGABKUG;QQMcoiOI6~?8gBV6_xIsJpTGW z1H<3P%a`o^2^AI;6uf`+%6Hu1hw3buh^n-=&E{r~Zh`HeJ%W`O7#J9CE}XRM!>!2y z`g(eLdU`%nZj$0G`S{lSzx{6Jb}pV}cU&U$p~6P-rKTq{wN-Q~_TkgbI9ex?00ybE Y0AeNcLWUmD^Z)<=07*qoM6N<$f{p5dEC2ui diff --git a/tutorials/pictures/remotesensing/linux/drawtangent.png b/tutorials/pictures/remotesensing/linux/drawtangent.png deleted file mode 100644 index 5c7f67908b849dd657a73f75e2a6bb8def2f9264..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2021 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 Z{{Z5c)0l4N05bpp002ovPDHLkV1o1%4PO8N diff --git a/tutorials/pictures/views/linux/add_waypoint.png b/tutorials/pictures/views/linux/add_waypoint.png deleted file mode 100644 index e64a1119be5f878aa35ba9af5f946301f8ba4aef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 686 zcmV;f0#W^mP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0z*keK~z{r?Up^y zF+miD>3kGI6ck9Pk%&T}ROmIL_a8_UqLU~j5<-hktQ-)&B%TGQ;7J z^7%Z4!(p;mECR&nM_zW_t2~vd)oSGP`N(RuGPE+h4|&wdu73rk%;j>Sa=A=)yZuH$ z1?uYcy7XQ~QOYcrO9}>qVm8ovyT3PR@55}+UXcDqgE@t6XEfVd7PPIiq7LoyzZhkCu9 zv=05qLk7!)rG2|hwOSQ7#WKX>aaym}H#LaSkG!cei^YOmE|=J#XfY7>5(Yu5-QcFm z9FIq~RD3Wl6p2JAolff*jYj6l0K8f8Let9N6R_EAbev8nb7ep-m%EuA^!59F_8Gv; zX~|?VFsIRIki+4iWHKq9 z5X(0h3`C9Tr-`%M?U(=`2p~p3ulbk^(0<7QVr~ATGWc!q`~7V1pyj_4oR7+Y?RHDe zW|Ot|Ih?vwD#e8GD}ngF<`FN;JOZZ<0+^x6WWr)uor<^0{Fbkbim!}{KO}Rde`Quv UHC!o-%K!iX07*qoM6N<$g54Z81ONa4 diff --git a/tutorials/pictures/views/linux/delete_waypoint.png b/tutorials/pictures/views/linux/delete_waypoint.png deleted file mode 100644 index 33c75548201900e9e605da69e81e776dd79136d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 639 zcmV-_0)YLAP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0u)I^K~z{r?Uui; z0YMapS4rbf6f_DRK%-Q81C8iaUVua)I*CG}5L!HficX@@sSpV&3dO#`x%o}9lfk*O zvy0qYZ1H8BnKLt=nc3O3Y}=;)L?GlwAmoN8q4W7n>-C!E^SJ~u>bZPB7z@p2GpbZ7 zl+9+zvMdQ=)T5p2=M^KNPNzeuREk2OkU$l3>d=lnuD=(wLbuzEn$0GK!{G;s-#{Mn z(T^L?1g+3&wW3TWBUc4wGMS`WtwxPTLzIZscI2ZUH=YSvq2X{S|2-y)$Kx~_jp%y4 ziguKUQSV3r^m`u56s=ITT9uPvJG$L2n}>RJThWipGr?FWkx0;ZJZAGykM^$%wcBku z3AQAk&(mhJdB{PGdUeatPtEayR%o$UP&68qlcB_+#Tmh+h*CZDAQp?!a=B#3GeIkK zxm?8Zs}tgPE|;TXvB*-d*XeLLu;ZDa6#}?{ap|c-cwKC_TbBKPPxt$s9m@n)Ay6)t zA65xMm?AB z2Twvk{Q>~*yxBQC3*nO^olc9hgwpvG`4paoz;3sre!nkDuKgW+34N15$c;eAjX+3` ZO+Os*Db3 zYgAO%72Y!pLO_K9h8Y2wVHieb5P4|OU^U7^K&Xg3DuUo+DO9vIk<>QEMWR?tP0%PP ziDgww5d%T32pSPYYa75Q6cn{VV0g{r@|cGXgEMFM$IJ-W)+IKp^;`GHzWbhi_P*!a z=R1d{){Gz`yopes`YG#bvZ^SKG2SaKh*;I};oY1-XxKe#i{-hdEYH7ZFWNrkbX}&$ z#PG7@dDRK`PDEP1Vm<~EAg{3$9hb@mmd4JXI{C;+fB+x@ArqXA!MpY;MT8hW10g`% zR)6#xx-$U+s)o{=oVW;A0D!3rw;pm4SiTVc5-8An!RmXMtzX2Y(hLB4Xthm-YF@X+IINd3>LQ{k1ZJh=_xAIq{2q z1TK8O_v-z%gNTR-Xr_BmknGQOb#;Gt!_g)sB4Ybzf$qPkF(d>-@eR8@{j%1njqvvezwS`htosPCzU-}FYQQ4TEx@eB;41ssp-pU?5B91;fvpvYq!_}SV5g2o% zNr#9CgQ_Q#LQN^u)Zq1WE^Fnn=H8*fmhvq^I)vm)DTVq$!Ae_EMn$Jm)>*a5!#=T8 zMk&brj?!IinDsGm}(siEfcUpM?fsU>a4gQtl;{9a8dq`5)B@Y;UyhatJN zw+#GMTWY4qPbir7UG&cyM3Wq6${A@CZn^@dB zpz8X|9$%Z#ymqy$B++u=zPpr~8tDD*R*P7nrW9)W7^}?}Dn{cnlK4|-=A7lblI8;d zBC5ar=JLGFnE|#mn(gA07}n*=?~I(_3s7FYP~)GT6~?zVe~TmJG7%A-0)l;5<^ar~ zAYVzBSZk1JeoR`FlR20>Meay){`T@;5fN$pz|c813^bb&ywtNx+-0C0d=r1Z(18IK zyq{&nYAY|b8QrF~c+o%@SC-LBZj_hXt=k#JHUlfKP4S{@Ro6#oJR!fmqOhWM2&TJ> zcyA#hA_EB!!77&1TbpAR%&zgw%h$!1DkA0O~Z_5Ct)ARwNVlA|;nw zI5{!LIkCU)i%;_^+6Hwr+VEo#MCp+bM6kDGqCwJFY_{sQOhb^~aq07%FYDTsgr=82 zBufk<5$x^kjT5pQS<(j*f^LTGNDvSK0SS`l1_1y-<1ZLjhA1VHC+$@c?q2}_fKF@S zr&JK(&axt#LkAK9vfS70JFh0_u%$BA%pNh8{ zr)da4Gc?AywXkK{jr^^D2+`K)P@dhjwq*9Xii>aCF`gDD&OC3*)i04CasU8&x_hQO zI!-}x{+7-4kylIdMbqfr$AaE|Y#J(;$q;7(07xVm2TQN_ zuhndI9;+1*?B;JgP@eLB{lVn8^rAbLQXGJ8I7bK~@d{r7Wyj0KTGS8T&MT=0KqNRd zcujEqvAtJ2pXyMj>TPZnj}k;1CbP4(^$8L^F@@vwcOw=z6lB+QYo3h`w?FLGDC88l zd5M^G)OM6zxNXpUM8uwRd8J(=WVG*_+*9%u;Q<5(D9T#k<6%w1C-=UtYBP-lxLbVm zhD@s;={S>LZm}ZF!}R0CG+=@;ooTCTyVs*5gy;=z0Z3p;bWCsICnXP+I-=J|?>9YG zoV$Kcc+(EQC?-f7NAd%MWTjlLF zOXweb2;ciO$r%xm)<+HN|IKwoSrjX5+}w90jQ*caY>P)00003b3#c}2nYz< z;ZNWI000?uMObuGZ)S9NVRB^vXKrt8Wi4}Ka%E+1b7*gL?*qR+000TmNkl-w)kw^=HGDt$+58^-)B%o`#T+R23bMBtb+57HuZ!RdQR0?X; z3>{2YSJ#)0LCseQ`b!6(=4(Y=I%5m#hI=FJar+SDi#JfMekhU+da@!+u*?%e*|6ozO#Ga)Enq!&`I`6G<_Bm25|88=5p|lB~x~k2?FT zLlvzLQuBEaECW~nDdrlVXr#9^am$%Ue-A-p9DQ;-n_U9{0Meq|ds;@E+c~WO03g0! zSnP-(`hato!5pnM{Gf_s*Zt1g@tujP3awXD3w%Ro;pFGFk!|a6dpBpa4rbsJ16GMy zXIt7^1eLk_J<8XI?kyQqzF%roFCB5i&24ktsAK|(Hf32tS)YD(g!!^7_xJeO8R^bB z+xo8E)Sy@)9l>hyCjt z6hM1|zwN5{L-U-?jg8I5hb25II>K_bA`mIA!SSWtZxxy^rxY_o{B)qFIw{iI(aOw} zLLDD)q(lN>7#KDsKOt?f{xw*@AbNasp@%h@NOAPrbc5du007UEmNH$aW@J+v&xi{g zai1M(P{Naw!A=A{uLCuTe$XTIZG0kw#{RFOqTl9rR;8_-KGupzFm>{azQpYT`P8Uf zI<+SkE-sjO;!*v}7uOcI?hM>q@CEAB`dPm0b+$o9A8edkNcFK?3og=YakhpIS)_rRshx2sTWB@R<^jn?sgxko?WeI=U zmfWl?I9)p3si>ep0#Kcsi$bPHFkJ84x+ei>C@5$}5a<5V(!Ap-mT|@Pg6hjNYofQN zw*r9Z$`A9?Om;q~e_3&n@i2oe=b)U;Hb4+F*@$;GQ5b!P+f;SV{n2XgsG23&Re~4U z{tcUVWs3SFUS*_|&15mOpf_i4ROPIjRSo>w$4NV9+iCy+mQHsr$}j9ub?cP|+?lgJ zC|+w*Vg1YJ$w>a<$mG`m0LG)|ZOT2dMt9NiFjv5enXkvP3{RZv{HR4z4&r6WpOVDS!{sGEJW4c9U% zrmbB&-T;Hf(iU!7sFj+2zb`I{U-bUz7P>g?5%LRu5x@MX;jY6W#vf4!bJp4rYi%^z zaLOD+qqk~`YTAPtGRY|w(rJn z->vX4#Gnkk{XEdN0n2;|nkbzy(-9Lcmj?g`i5~U~xEX_~5G%7sWpM*i-#w?ldlz07ZHvo)sWqMMuXuwTidTQ3y z$vRt`d(@Fonm!IYQZC(DVR)TU=FWk!T%R+kYiJ)_Da*)c3tqp(5vQS{?-CUqAV|wB zmkUb1GXvlQj~B;yz1_2{6*idw0FC@cBN9ndHf>5Kw^R$o002OQ6?-;eTQ; zAbnh&NF>T;H8nMR#0s}!NBPB{cb_Y)?~tOp8hO2;y~=z6xX|e>y9z5^?v;6mPej8= z22(dUr*v-feFx-#p1iOLk%)fpvr7!nE);f_DyQ&>T~6I+v?izLw7)l^Z! zZHI25P(*QZCyfM&@LI;*PSwIy&bS~JE`8srvbJ7HYst|A*DON=ZJ~c^R4zyBo15ln(2UVE8jVI{9`0=J>Am33N}@R;U;F%LV`XAKlG}4na43 zR))wm_%V|wH|OW`RIyZ=lG+o@a*>Usg1n8>MpMizsa|`e8xAct1AtC8`}YaRq>*xp zMH^`bGA8c%V=y;Zzql)_HBSZD5sb*T-jNq7yFe#k{SNGDPbUwAv9i=V_6eW8$U*V^gx|HgTG|43JVvSZz$&9) z6$lRCi17fVVn@ZwlzP+&Qi^gYha@2af$T;?$m<^oAv##GGfeCF`Qtb9+qdt%@B8h( z-5+ISWCXn!m?MM$;J=nJ;AjaSO~Zk4LLdcz#MWU+z}d2S@ljun-0u0`JaKuHK8 z3egHiO#XdB2RPGfnP%ae8p>8%nmA!Vh(a-X^_7>_FWDe^N&jwqjtSA$e96m! z{F^lM4qB5qo^)~&z%VX^kw|0x}41FyLc3| zs;--odz!}&9@&Xwl4XKOn(@d$XjC(-HZZImmdgiSy!@P|fmVLH{Gxhnp6^>i&B=68 zGglBX4PbKej55c^wes2@)Vn_6zL+JT{6Ba;|;Ead&j~SSZ+4D;<+& z8UzI-@;Yj+gz{r&0001Ub#8H@Vq5fVXD7GUm#0^Dj6CMlb-e6OoFL4?Bm_FU-jQh_ z{t$^z%bI>D#G4bo^uvZQfv?eZu25)xqdQIzqTh~_l4mHNqEe}JI^B~x=Kn|2_Js+m zADeb9dbUfSN~JzuKZe@Q!mNCYwdedNXvKd90G^*xWkpFrTdv!4GJi_(e0IeEKp7ky he6BX|zl2Jq{tfwu(RXRnj%ok^002ovPDHLkV1l!1p+5is diff --git a/tutorials/pictures/views/linux/move_waypoint.png b/tutorials/pictures/views/linux/move_waypoint.png deleted file mode 100644 index 5ad659a4039d1529574480332dd5ca7f268297ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 941 zcmV;e15*5nP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&142neK~z{r?Uuhw zLQxdQ4Ma;2EgY=DAMMtZQ#nXOiw(MnyNio!f{@izG#CzLNKq6yHlzeCLE_?Iq*#Q5 zP5pqt|KP;;!hKxr>U&QJ8XUgdch5cV-p@Pd-21Mf+wIbS(-=yt#!y-XkP_e(sqfwflpC^OCpa^}~F)n*guQWq#Z*P;+>7NI~ z(HEl^3WezP^;J5b7O6rV9v&)-Vd3fNY0BsG^#1*n%&)9sUPeZHyVw!zP?V6kB@YFdrL1bFH-+u(gig$GegJ6$5KDoF}}34#Eyxr ztt~Q{OcaSkbc3lvWilBB3h7x~T%=m9*5iXd?6b48RH;-Lkkx8s{${h8*4EZ~{MDoi zb$541Hk(bU7qlpR)EXj!*6a1M2;JY`Q!<%U{KSs|SgX~NuF)@5sJFK_mO^n6QLqE( z`udt0jRxD1klXEM;~tNP5{U$*(`ojBvNigp3I*Y$#=$59;wjtQ++>mB?_dYQXJuuD z!E!s!JpMdLnxG&QtQalqGDHYKJY`5H_a6iWJ5o8U_%%b}5am!jWqg-$8`3-|eWD%3 z9}b5Hf@y|=R##Wq;t+P+!NCFBWgH5^Gw<_fQQQw;h_GxOT~OjqEEbD&etym(!2wWr z*qMmN4s{z+sc9v zB7`U)Vn{D`TC>?Czu(Wsu_5pe1Q>3o^?F@8uPEJ6ka%-|a=9F{KR-XSYfB&yAiLep z#!!O*!;ymHqkk@$MuNh11*zk;#ji0CPbDvYF13`28VL%*8I#RsrDx66)s?apgBTZe+C8NR`B`xDIFW8KZP1qV<@c}Luu7NgzC}{1x&t`_`RB- P00000NkvXXu0mjf`&Y1I diff --git a/tutorials/pictures/views/linux/secondary_axis.png b/tutorials/pictures/views/linux/secondary_axis.png deleted file mode 100644 index e929213256dec21f7834aa763f22331302919a7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 772 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 U_dEq1?*IS*07*qoM6N<$g5;6N6#xJL diff --git a/tutorials/pictures/views/linux/topview_point2.png b/tutorials/pictures/views/linux/topview_point2.png deleted file mode 100644 index 5c2c498bbace598b658395179181259c952db963..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 644 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 UHC!o-%K!iX07*qoM6N<$g54Z81ONa4 diff --git a/tutorials/pictures/views/win/delete_waypoint.png b/tutorials/pictures/views/win/delete_waypoint.png deleted file mode 100644 index 33c75548201900e9e605da69e81e776dd79136d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 639 zcmV-_0)YLAP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0u)I^K~z{r?Uui; z0YMapS4rbf6f_DRK%-Q81C8iaUVua)I*CG}5L!HficX@@sSpV&3dO#`x%o}9lfk*O zvy0qYZ1H8BnKLt=nc3O3Y}=;)L?GlwAmoN8q4W7n>-C!E^SJ~u>bZPB7z@p2GpbZ7 zl+9+zvMdQ=)T5p2=M^KNPNzeuREk2OkU$l3>d=lnuD=(wLbuzEn$0GK!{G;s-#{Mn z(T^L?1g+3&wW3TWBUc4wGMS`WtwxPTLzIZscI2ZUH=YSvq2X{S|2-y)$Kx~_jp%y4 ziguKUQSV3r^m`u56s=ITT9uPvJG$L2n}>RJThWipGr?FWkx0;ZJZAGykM^$%wcBku z3AQAk&(mhJdB{PGdUeatPtEayR%o$UP&68qlcB_+#Tmh+h*CZDAQp?!a=B#3GeIkK zxm?8Zs}tgPE|;TXvB*-d*XeLLu;ZDa6#}?{ap|c-cwKC_TbBKPPxt$s9m@n)Ay6)t zA65xMm?AB z2Twvk{Q>~*yxBQC3*nO^olc9hgwpvG`4paoz;3sre!nkDuKgW+34N15$c;eAjX+3` ZO+Os*Db3Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&142neK~z{r?Uuhw zLQxdQ4Ma;2EgY=DAMMtZQ#nXOiw(MnyNio!f{@izG#CzLNKq6yHlzeCLE_?Iq*#Q5 zP5pqt|KP;;!hKxr>U&QJ8XUgdch5cV-p@Pd-21Mf+wIbS(-=yt#!y-XkP_e(sqfwflpC^OCpa^}~F)n*guQWq#Z*P;+>7NI~ z(HEl^3WezP^;J5b7O6rV9v&)-Vd3fNY0BsG^#1*n%&)9sUPeZHyVw!zP?V6kB@YFdrL1bFH-+u(gig$GegJ6$5KDoF}}34#Eyxr ztt~Q{OcaSkbc3lvWilBB3h7x~T%=m9*5iXd?6b48RH;-Lkkx8s{${h8*4EZ~{MDoi zb$541Hk(bU7qlpR)EXj!*6a1M2;JY`Q!<%U{KSs|SgX~NuF)@5sJFK_mO^n6QLqE( z`udt0jRxD1klXEM;~tNP5{U$*(`ojBvNigp3I*Y$#=$59;wjtQ++>mB?_dYQXJuuD z!E!s!JpMdLnxG&QtQalqGDHYKJY`5H_a6iWJ5o8U_%%b}5am!jWqg-$8`3-|eWD%3 z9}b5Hf@y|=R##Wq;t+P+!NCFBWgH5^Gw<_fQQQw;h_GxOT~OjqEEbD&etym(!2wWr z*qMmN4s{z+sc9v zB7`U)Vn{D`TC>?Czu(Wsu_5pe1Q>3o^?F@8uPEJ6ka%-|a=9F{KR-XSYfB&yAiLep z#!!O*!;ymHqkk@$MuNh11*zk;#ji0CPbDvYF13`28VL%*8I#RsrDx66)s?apgBTZe+C8NR`B`xDIFW8KZP1qV<@c}Luu7NgzC}{1x&t`_`RB- P00000NkvXXu0mjf`&Y1I diff --git a/tutorials/pictures/views/win/secondary_axis.png b/tutorials/pictures/views/win/secondary_axis.png deleted file mode 100644 index e929213256dec21f7834aa763f22331302919a7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 772 zcmV+f1N;1mP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0+~rfK~!i%?UwD8 z;vFVx#TDq5x=fAu&V;?g?IFG1!z8Aw&SQMNb7CHfm3?tg%*} zdYtaF>>ST;g>uRuva#9B-|gb-99H>xHZ|^c>@|)kBzVgD=IHB*z(l6aC!k;4JWNQd z0+?dr1@UeX?Xxj%hbbS<%5Ou|!y2P%WN(u8%iDKtdR*mJ>@~MUA%agE#h6YuVQ%gX zQLJtX(`WaieG)LIiXpF_j-}rU<&>%q^QIW{2)AB(&MJS;rpGDV*lX+`P5lN^G26+~ z?>wBGdYxio^z4lJwaxx1#vGCC-wMFgj`PTdo5SXDn_H~rZ&|3NUwy+F!mi_m;J}}G zD{)`__VM!PZT&6lU=?rXMOB8ro z-v7qF8ZRY^J5NXOa?eL_hY&?D5uyktLKK1GQT_lDiz2kZpwS5c0000Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0&ht~K~!i%?O4lo z#4rd{PwvLe_?#}>ic4`Niv9)!5=!>vtk;J3LL4JyJZ0%S78y&{;@elvl(y^M&;uNiqTuXgsdL$DtG~6Jk~)^ z61+%)VJ__pVp_fF+0&iJ`mAgWkNbOInS%Ilui3SNL1?|Q^|FLs_w z`+}HOFS`biG%xe2Yeny++8J}RL?Ot50ihB4vDR|3J7Me!Kfrncqr4oOe!n}=cwV<2 z(~FgIA#WPf>gCoDW6dgFXr0gDSv3)wN45eQ5*`vy{fOPtn^#81#oh z`G1y(Pac?=CBK3Aw>e{$fb>lp{bqPqe7IRA_s*NeU|?WoF(`5SZ%w82)481#tN;K2 M07*qoM6N<$f;bRPO8@`> diff --git a/tutorials/pictures/views/win/vertical_axis.png b/tutorials/pictures/views/win/vertical_axis.png deleted file mode 100644 index d3cb4ad55c6b443bb72ddb03a8fbfb8ef61747d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 684 zcmV;d0#p5oP)4X00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0zpYcK~!i%?N!Nf z#2^qPDgto?WJ55@MX@LY48x(B=8(MWb(JuWE(w^kn?oJP;TE2cCFCSnLQaAuvwLgp|Dh|S)ygTeRr9;mPHF3Qn7;-R|E>_fE#zNr44&(E^7c>UW}H&S zZDM{WfwkS)++94G_)p@~LQ(?d%*N(+>$DIz+NgxnHWjx8`>^)qOgQ1iHpIXNWUP1_ zz??^wqe(!|FK$F}qtJ#8aUdSevlxxrsjh*K)4x6AoChG=rFu%9#BDD(@ei6L4ByDj zupl0Et(GyIP9btA)ccAnc+_-ih{raK>$}Rq;|(DEc-^tiNDPZd_E76fLZS$*+9Ad$ zH?6%OB<+kMMkJsF5ryG86i2{0Hm-{22zH*3f^65y!O`>cNVQwBF|nRDVG>vaAKzUA zyW6=X#zOKWAwhK|aiSt#5|U!^O~GwiR}%gsMt%`=yNH}%1O2s$#khQYcMa`4ZsnG^ z5HdC#2+7~F)+IlnQQWApbV90FRgMb+GKPL0DW4Hf$4}yApwFnj-Wu8ghCH9R6f&Mf z-?YYL{H=*|yMNgG#f3S(ySPn^RlSi!IEzC~Wg!D;#$axWR6%wxduwJ*hEBZG}wk6hBgo@B00005P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0&Yn}K~z{r?Up@j zDnS&6{fbCHu?gDPC_%6hw6jSeLW*=2cB08&Q1Am$>;xOZGL~sUfn=`szklR>1mG61yad(_iwcqdIe{+=(xk`xqv&3?_gvn$Ao6Uy& z{2b1b==FN={QL|KhXd4VHK_S!wr6d?g^8*U6szpQ} z;6*}Pln@jo3pFiiFenAJE*fWR?KmmVS?+vn=XCMT6h&>_x|LAidRHa9l|00cp(udny}{Ro2m$n}>mUpS8AIPTG-N57fKAHezZ z=N%4*R;yjU$(l85oKEMNGiR2NiJ~ZqqR;2sym>REKY#wL(P$!(h#&|zZ{8FH0RX_V ztoS{`;V=Lo9*?IoTU%ROTU#MLH8o{48bwij{``4uZSCdDmvuT_Pfw2^2;JS?TefV` z>-83kg{En!bMWB7rluynUSC{X>~_1MlexJ$i^XCv7>bLFA3S)Fx>htA)oQh~v$LWo z3WCty-cAq%hG8d9o@{ApX>4qSYCO*yjmELDvF`3}yWK8|BFAwAK}<|cq-C_&7c(<6 zX0sX68jU7DKmYyv_XvVCG&KCADVC4X(a~40UcGzwuBoZ1zrVk~zkg(8Bp3{080K&| zpw8gn;I?hsB9X|A8#in=+lLPy003vto?W+YT__Z~ckiCfW}BUzReX+*kDJYAy{&1vY-?+4XlU?wJc&dC05CK(gke}kMMY|B?AfztXlN)c$i-U3<8chb z6s}IEo12?kPLgW1Ix8zHD=W+CbpAw*VVJ8|ui`kKot^D=yHB4!ou8kdot@p$(ed!% zLwFeBIBv7qR4P?HGKZ zH#awvB&pG8ii(QNW^-X-;nSy20RV2dyRNP-EgqA}G&MDq#$22u27@6MizzdOWm&aa zojPJyP78VvLiZYo@nVmgqa^=builX3|4FJG#yt=x&qoX5pg8%@gr>9AhOy~0d z!RPapm6feuzkc`b-P6<4deMM5$4g2|JRT3!j7FnYt5vVpZ`iP5 z=gytMU{G=L@#9Af!=QOQ9&c%B$;rvlXf$@a9V!eA45(D9Kp>zj3PtgFoFE9l-=CHd zqO|nNVtjnuZnwXF{rb18tXAu;UAr0^8<&*2-R|4BZ$EzgIF0}0$&;R*p5fu)H2x~O zkNl6@Kv!3n%jH_~zmsLz&d$!vGD(u;bUJ%`doxW|*L~!7gW0rc)0;PMG7a0>+WPwX zPMkPVTU(nZLJ(whbactJkj#t5UyD_=M^+qP0mIdAU6nX*f&c&j07*qoM6N<$f=Gzx Aod5s; diff --git a/tutorials/pictures/wms/linux/add_waypoint.png b/tutorials/pictures/wms/linux/add_waypoint.png deleted file mode 100644 index d7595ce2e4e180e1813e1ab8fc71164507f8e749..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 726 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

    `Y+&w5YXVlNKOoHZy#R#=PvsoiS`l# zO?!j;2U$yt5kPk7pEAzc+}U`f3t`@dmGKCh`&TMeGrYaQY3mRYiR5y*B_$;p85v_& z8iT=5Sy{>Da({uNTrPJb5?x0Fg!HV0^~L3yQb&1oAKPJ{Yx#lW{~{9aiH05CK# zh+WbLfB_%`2T`peY}5gZJNYlacXKR0%7}{*r#83_F{kh?$@4B{c%w!H2N zK&Fs!nLA}i7C!CU3V=k8%nf8RMPH-*3BU~iptrXNT)-jWFeD;T(cKL&W5jVh?Jyk$ z@2n8>uL_Q_@W_sX(c$-p8^$`5$fQS&3KIYd00uOVezIww6C3+@+c(ut-feewb_NCp zj*T-I4244BkcIKFvbFZiHr|T!wEP{JKmY#1rerU?QL?qXm7ksH3IKlmKl)$%%f6cK zCuZ|tU%TYa*uTIgrzMJyep=r*1W($}6kbpPI3^rsE|+UQ`*GpT-a%7;_nrHXCe~_{ zD(tNAKs*xqZ=Vx1+I0m0pG7HN6$OR2bwj4Un}@%=W_yB-;cm~Te6`fc6;i2GL?Y30 z{q(81zrVj!DxFN-cPt%;BhZ2ttSIV;JXm#UBg=NS#4gHWODbFV*>RSa-+#^7FJmQZ z=g%Nfyu&m1i)>e00HD27SQJ^agvDTlEv*erwmv=%2(6%FKKf?I^wXKMC}iKb4VStm z*3N!!Pnu*^Xhcl>n}wtd-t-CX77?N&OvsG#)~S9T%vdcEa$ zO-&7(&7Q!~f>0kxrJ5DJBp3J3(k#0gTVR4$i?g@sv;h?GjDNF?&{@fl}||Nqb?zNDX- z5JY~-(E8QqU!$Ume_O{BqZJAX33+xqg+jq(GGk+7M8t-@ s)v_F!cF5J;Z$a*<9dXXw9Zfy|1=E8X`xI3(!~g&Q07*qoM6N<$g6N~E1ONa4 diff --git a/tutorials/pictures/wms/linux/clear_cache.png b/tutorials/pictures/wms/linux/clear_cache.png deleted file mode 100644 index d49b7974a21645dd819a9598309b76340f0d21e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1405 zcmV-@1%mpCP)T{=^dajCX->!=9U zcC1Skp=z-x3JN1k-Dp*6QV=Bw2!sHdC_=(J{Skza7Y1r=oq>M8-hJ;Z-}lbB_uczQ zb8|BQ0KlC)cL*V3u^0wAN=ixqfY~+HXf%O=fr$9>DE&w9^YbexDCkzMR;vetYy;G4 zHPd)Jc$5PG<7E?gX<(X+2SeEjAz)H->@PImMzG?J>iTW}FX8lj+o0Wb&*RxY<&>&$ z$HLHY?oM|0E*|4Tmu6NQ`r@B(?g3rpFB{$o6uCOuIq*hKj7ZLJ>aXW>lK^_R)1bYu zEGYJzIA!OV1Acs{D=n(!@?T)*0jRPPq)Cd|U+%jQ=IzvWzvN1bC;Npa=mmY%ykhUF zblcTg-!BRp%eA-XjtQ6(Jc=0Jw!!@2)tz%EkKl7%y{50uuI@lYMESuFLMI5__=1uC z@BX0BAtGL0DU9BKZBLAki=E`Fa-#%Sx2*ixZ}Z9RbNxp-**Um+heQSl5V7gv`Y6d* z4*`FePsF-sP0)OK+dPTT*?}YQivR8|A|fy=GZspOTo?Z6VA=6Xs#i+%n)ji+=|cVu zaa^3x^bvG6+LPQ*V|H7uzND;`AC?v-Bpp^EB07zUT6^k7WsTy{>o-2$n5{Q>6eTac zHakO6ohuvB9jGiXzd9%`#>F(Togg3~t+=dOS-hONYeiZaB5F>3GG~`A@x-6CRljGZ zgu5dGz@-z{Lk`}oslBz=yC7*(uD&-)e_Oe#v61k3G+u}XYpU>QrsMqOGkHV6#`pcV zee+J|KSe}G@l=0TYl1efh59p%_Q0Ocu#v9YZmE_)2LRn`Fu-Sd?J)i7uhp$ zh#)lBOQ}>MQiWM3xe06MdD&Z9aXf|21Q8M3qh-+^R)nwvXGw>Ym(}$~iB7+ddCqol z!1{U>0N3neZC#zVB7541Jpcfpe`+;R^9aD=a$3sQ$FJA75u#B(v0F+RjE9T7hMG&z za<8r(fYsx+?r!!M>r?Y9G!*G*kYg|fpjD|_-KM&l`15$Y&MONG3!Prq3ngbdchfx_ z_WYi6BmtvWWMwL4AG-H&>zwB{PR`c8YYH!Zd3KtQ2PLJq%n)`pq(qd zIZE`j(Dw=_N?a3BksK>KP+V58dE8Q?__g#wS1JT)OjLF1+9QfbdhBSe|Kqp2T5M}* zc;xCck;g)P!>P=Rt=+HFOn4+ymaG!zEsOpviSDFncD*fy?A*$2`A>Qu8JOg4= za%;NsAX-n`lChYXK1U?r4ikkgJ|usF*0Yv>ZoN-BVM<8Yl3PKsuNt+IIq5h&@yDEX z<8N+>myB`q@Ctr!M^O`wOH7_|J!$;pz&GMkIgv9R0T9tPa_fBQ0Md0nKY#vwcz8HPQS`P!z>xvx)J)*Y@^ykw|1XxDQkyqFgR#u~>frEqJRpit$M&00000 LNkvXXu0mjf*i@W= diff --git a/tutorials/pictures/wms/linux/clear_filter.png b/tutorials/pictures/wms/linux/clear_filter.png deleted file mode 100644 index 3fca379c1a4b1feacb8bb4ed3e57ab680eaa858f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 661 zcmV;G0&4ws%R`~&L#mrWdLk!=hF!9x|iYS9vc zVi4U@5)k?W+;&*I(lKq4t|spev00O*GU#D1@8RKjzR!o}dEOsHqtSrDRxp>#4TvD> z_4=R)jB&r1etOzGK5pOKKp-$QJ|0dc(bSZW4*M-ll1j6)3V>%<5(!7C6?tILC^t?e3@uRp#fN6jv3RDC(D&*f8z{5dcVwp5dwf3pQ-VgUWy}j9jaxT#mPw(p|)^>6&WjXbvhZXV;h69ZJqx_L>@wyR&)yUH76&2Wc)g9$7V6p8wFa_W%F)oolul zXH_|fvof+td)K6?Y+AMZ`~Uy{|1%il;|NjdyFfcGYd-{|`T3Y1){|5{Vj7*GB6CuR^|J)*?oVrzq4&=xn zOvYm=GQbzD%AP*5+xokYzx?^<$Lnjip8xv~b=!Y#k5G^EGg|gM`}yz3P<2D{MUCo}4o-}v1$7U?bqvs~&FR*)I+ta3kfxNltfp)3((B)dbsXcT vPoIbh;Q;}Ix^5lRjvLg~*Psr$LG3sIoxMR^JZ_5v00000NkvXXu0mjf)+&b0 diff --git a/tutorials/pictures/wms/linux/cloudcover.png b/tutorials/pictures/wms/linux/cloudcover.png deleted file mode 100644 index b27a7e3bc234c2f0d67071be5df2c3b232155a95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4919 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 diff --git a/tutorials/pictures/wms/linux/delete_layers.png b/tutorials/pictures/wms/linux/delete_layers.png deleted file mode 100644 index 1ebe0a3bf5fc589d28fcb776a5b2dcdab4e095e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 370 zcmV-&0ge8NP)nKYimkZrj#%T>vPG!f~7=Nlx{$EZerN zu4_b`=NSOjb*<|l8)dIBuFI%d+nG!We6s#xzYrNZAEX(q{#KV934a5>&g}P2w QApigX07*qoM6N<$f~8TNLI3~& diff --git a/tutorials/pictures/wms/linux/deleteselected.png b/tutorials/pictures/wms/linux/deleteselected.png deleted file mode 100644 index b0e16cf5537581ea3781483dc14836c14f2b11fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1476 zcmV;#1v~nQP)*e00003b3#c}2nYz< z;ZNWI000?uMObuGZ)S9NVRB^vXKrt8Wi4}Ka%E+1b7*gL?*qR+000G7Nklzor*Tf z_-OGlh^dMNYFm^lQmYUw2&ff;A{0W>Jh&tqAh5STk{GBPrw^TWTEAbr`J4l_#MoI$h!EaZA%9Gx|*|v)$Kv7-MX^b9{ZA z)L-Bq7Pm3)e$V68Fvb}7JuJwKj|vb7gD0<4Ry(?Z)PA=+B_@dL$CHfTP*8I+b?Ik6 zSL~T4l}^}E=0ReAcz$z2q*x#bo4M_ZX}IfF26hS%4wx^ciFxysHAaiMsc>UB9TIbN z1Yx+4DhytJT7#^*8#^cY#vgt}5QI)Cg9W+B16wYaNO!0mrj!9LXb;JluN-daA_#MH zrTRW12+Q@iLwR$RwI;%J`_nn>N$=GW1aUNloJbLcWL&ip zghnYFF?RKt+Z`sormDV`Ac(WenbSXc;9P=A(wIGy6)i7mMP0h8tT`Nz$jeQJ#)vfynTe>!R$eeMJ=(G6n#HAEpr;%i_Q#RyD^D1cwRXg zWr(w3^m@H0LgYLV3WWdwV9{zVnxgoSg8%@~*F8L@3jr{kQPk`JPzUf`FYeU6rH2nF z6bVt=W7D(tZJo*2YPE*K#UbbD008|xjKGOzx0zO}?frUFSV#r{0NLB?v&;;?>h;6K zVveWLKqb-Z^$d~7&k^7y6pn)KCxxzk3YW*zX|))O0RRjJ0{{lhHzn)$V)UsesIQyUrI6;EZQ8_oGaq<#cNfyikAk4S2$IC9-o!G zQ=^QhxyjDE-0t!=6UKgS1-P7D**$>5SNnNdg$G}d#Dfh+dArhYO=T-uqT$yX1CSV8ZahFOf7fK*@nvM zjvf*Z4Oir8M1Z-|V+IbHW z!|l)4y&S|H=^Hpjp1XUtWYbkwMiz_3F=%NB4ITvlW6<&O^F-fZc0000AH diff --git a/tutorials/pictures/wms/linux/divergence_layer.png b/tutorials/pictures/wms/linux/divergence_layer.png deleted file mode 100644 index 0b05f69c7a3fcaf16f6c622258be64144ed71c28..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2490 zcmV;r2}SmaP)PfaK@l;Iw6P(&wy_}Sj4N1X1`ubk&aQ&4j*eYG zEZBB@Ecl2@5er}eL3%<$3xr%GK=wmI5dx#LDm%~c`;_;d`# zZ8Te2S_V6ZAqE-jU?(ud;4{<&L%e4bJc}9U7FY0zQyFY-tzfw9DIM%A{%6V(yhm?H z@5d-T;;Z{rQd_Ulo=xy7+FlnwOixdbV8F6)n!Wz$V~NUQI_tYlGq@i=;XeUwl{a@S z_a0-)q>yMvBOQI$pD+J}!SWJQ^8bKNcsuc~SoN8~Tfc3N^#_R0&+m07ATCaoN}Fnm zGLs|6mBjLR(T`gI0Q9W9c_VRuAiF<1f~0vabI-!8-Iue9%U-1?ZlA}qWPY+}^1BDt zB|ZOeogg_n_O3{Ep>68vV@vul2dJATkygaNU+KDRyc_m69j^odLMSbiWw9Z7E_4Y?uTh#mpltwNyE^?omSUST2P?G2v~A`pVEQ z_GM2;O_xHbWd5-#q=x#OgA2!T$waczxJ5^vNtE*oQofmL%_b8_T*u)3nKcL@gw6#K z{o)P}moeNbth*;+0)c9OtYz`(zEu|H*;q5}hHEH5H%mXPJcEl_8WDWk;Y| z@^{@Y7uVKHQCWf)Vf5OYl~QSSRveE4+|YD{`V(Sw$41_%XhQY5J16NoYI-711eh(oe z%h_s87$5Vny17Y^w#f+xy_z6x6^pPaRb2}-KL|Ic`s~kZKn=Njcx3ySe1y=8I7hPc z_jjwBnrojNMn(_jOUzqQQzEHb|d@? zqQ~9>gpl~)G~gc_C`4K)3q$7Z$P`Isq6ZOn_^F452%+>)u0>?FO4r>4{nVjv6AZX9 zG-Mdc>g!v(u1PZkJnyGmYyg0&l;r2r0;dftxtN>OB%DAEU{fy?BP?$`$49tkFDV z_X<0z9v-KGu3!Akarp)piiQSZ)Yq%|L;wKjPY4s2u5&cd1f2Po-qwYMMF8;Ve2P3U zGSrc#izAs?uyFlrm0-=?r&*Jv)OFlLqdSZNl8-w@DMW{&@o>e?DpjH zf2yJUQgXgmSlC#yCKf+pe#Cs-*`)hQty#cIUye3Lhcjb+Fz4FU2LS2G=ctP#W*g(M zTC@p~Yu)c9UJ~^Rt}X-sfWFVF6*l@B82vA1O<@-n4zQE$rw)D5?B54KMMZ_afq{1S z0BJ_R#I?U(tP7c(l2YIk?xhPk0%7abm1e_30RTE$<@k9L02~0x%97Dp%4#8w$y7b1 zuuP^QNC80DkC4b<=qjgbX=$~|XFbKWYZ(u;Fsi^_5006UPNttuxx5KRuwzLBXgu>d? zfZ^w`008Z8b&c%np{lZq%4Dj%0h`5YC=|D;{7}1dXv`9|G%2bYnQon%3p|TW6pO_G z!+Qo-7Xko)&R}4>)*)}{z0?2wI`n0LCXn4cb4lj$!`Pl<`qKj@uRf9bLVDrl%x|Y? z1JLL+J-@9b30^&aMMVHuY}Y-8MxQ4@eZ06;@SJ!ryv2|J-07zhOd0HZFC>a?Ad z?OV9$KL1wgVH*OrZ0GnfuR9HWJyU7a+ULS{01W_Wt*dS7WwZQgk9UbM>u9nd7u z*#w~Al@>aU##xf`JjS-i>^FvV(UXc+aL@(-sHm(oTv^e;;c);km>vsK7XmWX7U&(_@7AF&>K+Ge&7z`=(;H_lN@PZE_9MPy z!+QsMJxcn=#Ug&7r#1kX@q^4S{kSb%@K&LaOG>lzg?-%Nc|M-^|Ghg$(gANvPsg8E zzRSFauoHV)L^}bs{l-#F&u;EXeZCrVhB*HksBkoYZNHo=XliYHE2$JplymI;1Fl?dwWYo2F$O?|fo`j%42c@6##J!27EsD0P55Z_-5)NqWwWgOj?-e2w>YSZ=j!ixGr)>zjU6EW(Cux*)mxx%eSlU?&l+_Qxf(*+=~!@cIeoAb>7UPXc2Io(TE4iY0~E5NV?QTrxH$jC{(Ss@ zKdv9}?cie}$=Sa=cyUQ%hc?m7ef7!BZl5LU5bw)>0U9xNGVF?lU;qFB07*qoM6N<$ Eg3RN*mjD0& diff --git a/tutorials/pictures/wms/linux/equivalent_layer.png b/tutorials/pictures/wms/linux/equivalent_layer.png deleted file mode 100644 index 7580a8c1f2f7bfec638278c8cf34e9b39e78e259..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3348 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�c44rI5?zozHD=_}7AZJP`duSWVgRB`I}P-`;+y z9-PZBYHUM*D+*ImPdbhErUA5{UtfIUIF687ync&3e%%xbrqya+@%QTuZ|E%q!Td8t z^ZV8Zm!A7#Nz9$*~f5OYhSjJ*) e*Xv7klKmgZKnWMMsAC!c0000d270yw`%86`I*C)C!OsMT2Z+%Q^P~9Q^VfEHhiCmPq ztR^}xryBqUnlr<^H`kfk5ytZqbncZ3qcDm*5rzK+eFX^#gIkkUP$sfz+PpnUi!yem zNyb?x0szwGx4g>({5=~v#s<_SyW^IYoy+S$h-5$uI9`^$)=E9n7y|$Z1mZLd0w9EH z<2ju8T5MgM>}I{K3L#Xp+irFG{gZ2*XhvQ~MMxvBOy+qwuxRvI9-+soUm~QnS@~7= zPH%fUjmcTQ^U9M!t&{G1--o)-vZOOPAMU*-QS08n>+;!xMH@q{5kg2KEk3wrp^Yio z%zA!IUfDiJ+iew?vAQDGdd}W^8W@Us=0}mMAE#$E;>Co}=&DgSi=H^mb<&;~JI`f_ zL@fY>S*s!g7~_`A1VFbBY`?!cUo0!%KBxVB?7H);Z+>ZMZ>>ror?1(X+oiQBP05>g z_79ot;TgBGO);6z0HCjU*V>es`|q^LMVEYqn_{!ub$s5fta9@8H3I;6_S2`lT#Lk8 zk{7M_F6~?99~n-~&8c_|0H78Mu9A779y(!6^YZ!Nu~4YvONY^^QT6p9vavCCMlmsY z`OjVn3pN-|*W#K~|29K~=k}B^M=IIKV4z5F&3)S!{;U~916%IFE$sY@CE7+t4cfAa zV?-ob_{7J0+`3-g3rKh|mln166Dtyd>>9ISK}lixn;!98BpP(&*a843iY}LW#(l+Q zQw)r)=GvQsQ+R}3eoje0fTlu_Z_ektj8HSfku$rgu^Cq>NqZO`QbshYK~GN)LSz5{ zrBZ2RWcX%+A+71Cfrb!3(9gY>LrB9Hv?fDGjRs*Q49L#e8FMIYYcpUm%`h9Lm6cj9 z??eazqS4JUMP^LqOqooM6jEuAxWL~g6#xKUt7dvAx)8F&vfZsPp->P)054>60~>3a zjzrtg2-k%ITPT6Nzz3;G zCDH-Z-&&ZI_H;epPb)`Rn3`7KqtSGf9!{wX;4U`=f*&uW_2mAXMui$QDp`HqW1SZS z2AfT6ZfXMnFk8X(sXmc-Q_`m%>=HLhQ~*F-6X8>ne6Yx#&$An?VX(QW>Yk{ahNXw z$Abm`^rcW)yr;G09RKA0Emw{Q8f$G_*87XMy0RE9+a3jFo(i)>2pO(Cp1)&yS`eE~ zWjOf8W;OKcj1n*JB@M!gE`$)WiO$ISSiH-NMYVKZeWFf<5JHBlBK-b*z~Y724vWB| zR^Js?&GzwO;08qy2n4~w!MG3G)s(c)RrS8*0LTlyUIkq-m)KZsNWTE2|R^2?Jw!qw)fp#_y79 zBaeNCn->`VhSH;|0RX%)_zrBNZu}lKHmQ~S=JT>tP8-rM1d&It5gg-h{|e*(5d?ui uz~yrFEj9W8)`llrhCV`~(P&et!R2qQk&|k5+ zS&LN>!~qJnwTNYCK?VylDS@C!P$7`q^L~WH071op#agm|-JE>)-gEc2&%QS|RC9AP z456P;Rv6uNwYZeml3UR*z}XB!jAJvo2K4Z$mhwTWx7K!EEp8XXSF@c4FtFfHly-WZAhK~>1Zp0bZqxm{~6E0pxRLdoAh zY)E3J`8lZfvtz%22tWb!NKe^piib2P*~*BTrMoKtB6*w^w;?-xmZuQ_0O&jU>I1&! zv8s;>m8;R#g2Z5F4%^&n%7Xa)pIiAKX+=aN{LC#g9oYtEEW3HzvL7N=9a|K%r|j0| z2{u;tQP<_fO?cO0<~Vb7_4UmhBYtj1Z2vPc$i>Fe%#dT{^2PZYL_|ytHCmW>Z1KM> zm}V?n|Cr21iL*Y;+B#=~Eysw(vY8Q+`A||Us3a=gx4^;F#DqP0(ar(^B4Wk2&g{=J zq}7Coh&R_-*+<Z|o9gRMr{%u8y()*pP;7M|`zL=@yE202-pe892s*qB-; zM#PTtA-wv&q zpn<5OXFE6ca$ZHvjc-`zSH}N=ND#68%z^73NB_ECk`+xq9_0HWfw?(k=pTK)%%Zs#8j zVjvIz03`VI%3A;Zs%x(PQ~5*4j7lH4 zPO_@5sqQjnyHA#kXDEZJ+1!X(!qkf;D5Xtt1LYUg6(!lp$=dUm`!JP2&HB?h+-qsS zv{PmltcsMRl%i(PU~|phf0f-p^0Igh4f>;)1PK5DlgXrjAR&AnkK{y;v)TdxfTA{` zTMHr57v9Y?n)rNG0|UBT5bLx47rQ&9-(#Rr5}aI-7FgBW>lU$38Ls zSvprekB6ua08m$3r=h3!9@Zp=hU9o(Ig!X6L95;6@=>MqQGR11qA>sfkJrf5)0NIC zwV>`=gYFH_(xu{qFI^WYuXNBfI$;qBQ@mh6Y`$wWPRCLlUcDn^)?#0ZQd3ZrYz@~>(XmQh&U!-wSDel2Mcaw=Cc6|0{8BAtJpZzoWr&c z+*c?x^j{V7;Ol>l^YHZDE|}(PF4Kkc^l_QN4yL1Bwzda-ztLSCarD9so(0S7r@FZX z9nkUdrc3kUE?sV#v2#yV()54<>&j_2(QKA)ZQr^+u8eSpSj$yshdxRDIW~5 zHRf=vr>{6x-iD+hW7BjS>klm)T({$j*Z{*meNDmrnq&0YUpDxO6&wTwM`l^%hTKo^ zP|WpfAbpupf;ncG^^$w&Gg)@lq(^UETEZ79sq0yLeH-Vm-&?S628v2yP~c@x z9QaF0oGkLfnXTM&fXjw&~J?H@XC3B7lNq5HKL CPbc*N diff --git a/tutorials/pictures/wms/linux/home.png b/tutorials/pictures/wms/linux/home.png deleted file mode 100644 index fe573e634ce585cc909779b1e3f3b9bf2431ac59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 595 zcmV-Z0<8UsP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0q99YK~z{r?Umcg zF;N)DDcchVb{F7?T!UleA_zHh;KV&BQZ_{nI98PGBBW%K+=93OlsovpJ*#Fi(=s#f z_eEb{bMULNHqZapn#UT$^ZCTTgf66pE~JJoq{jaz)bID<@p$0%dNCLb#BrC)1%tr= zqtS@zbSl}S#9s*&3I&) znM?*Yo9#P7*XtFbP>2OttyVOfO|)7qwA*de>vdQx7E$EmLdW9~KA(?;P|wU}Gj=+3 zN<==N$7C{r+wK0mP_Ng6!{J~7!C(-F!vT>OR)QM5QmKf|PWRR2a^YqPsJ^n*YK6sO zfzfEh4b=i&jPv;%>-CzO@rvfA?smJ}FfDxKU-nKYolc|E=`fPXBzMhMg&K{9xTaRC zao6ZV!kVuN(Px70B6*ma5baqmmy%jaXci()rxP}tjRgAG3iI?VpQk;qHPrZ(&^OS9 h)X;_0(1nzF;s?9+1wOTSxy%3n002ovPDHLkV1m2L64(F$ diff --git a/tutorials/pictures/wms/linux/horizontalwind.png b/tutorials/pictures/wms/linux/horizontalwind.png deleted file mode 100644 index d7de751b0d3da0c9afe4da24f1177c06b1b7f379..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5122 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 z%0Aq@yaK^Z#4yC23;Lcu<5LbFcu?QV?I<|6I~;tyEksLKo?p4WHTcvjUCigG6N$v) z=g&I;Ap|uwwNz6JJtaWRnNdliim3pAqti`OW3G*JcH+GwuwpuwL`K#!7-nK!pWSTN zl0Sb+s$`??viPvbAz!yh8yV3G3dH>6CI4bXlee!8@6tIXkF&}<5J;28OfpdJ?Cb&n zs4XbyFftmF8)av(=9Qh?8Lz@(@!qfF=lIOJcli6Hk`69#+RAfs-wj_c5Q)T!7tiZq z(1Tco=}ylK+n(~ilf!N*di}nuxuymTCYzCDpyzGGW#N@p0BC-i5dW~ay0yEjG5^|` z>vdD7nu4LHk5Sy-(2Nh=2(g<>AHJ*E**k z0Dzh9y|6lL_2IOVrp~tN+=w;1Zrd&P&>F0y;t{$Ld-VG=Lvv*x9ClY9Y~CUjaeDfC zx_i0uF5YgBemiituDj!H)Pb|jUJL9*SbCilo>9`&gE+1EmpA?T+|6sIJV4W}-TvQS zs{jDNyRjS|AKa5Rl{KH2CyVby-+5Ku(AnKyk^P^@ES;%SsNxKR0K=v(P(8iPlYTyu z)7;bg^5~%$4X^oQMXQVbJTh1t6VAB5bS#Nv;C7C3Fh0~mL^T2c@=hJP-uU{kgC31W zqtWO;=Z#F=V%d*Y_x-dfI_qwLbn!v7XKORUAt|X1|CN?a@-LA zZunDF@0$?scUz3fTHb;$`*i}tBV6CCHz#9NjoiX=x^)(ZuBrQB+}J5LjyqcBcpHlF zoKu~4=o{P7R6Nm;xu?o2`cHoDK}e{jeK7)j8@L&+uV zdXVhzF1iFPQO9oiUwNGXveOU7?;@U`tBc2JTCGVGb(asuh{0%oRqfW9De9jB>s}*&h}BvCq~df3qqcoXqd^mJyR)?KBnhb*yL_!d;AFeBUJa zs(xDbUrX}0w77+(Cmg=E9}@oiNdDc6cu3oB51V8Y{MX7vf3ZJP6iRo-u7k}XvEGsd z^gmC*@p-IMB?E0uzlfv_Vr|zxLiXNiD`Hv)+9n&5E-uy-E&nz6uUxojBZ=XZf4m zWqNvgJ`=7noT=3^Yq`$Az`*cqYgLG~uDX)4x>fYl^WRa8gem*~d`C@yxu%-3vWA&Y z+oA6a3=HoNcSbrHs4FX}nFO^Re9yq}a7Aju%-d($9rg4q8jm973x+>Wb~S}rX)DPo z>bd1Ey7~>bYoE>!(s1s-LZpWo7{0Baxz}#a>3a{(PSZY8(7pFBLP|S*S(P1wYuBYq zm$nojr!*l2*X+f|uid-0CG<^m^UAka4L&oqbdSNb^SAEaKD(wpOqY{^fq`GjDsk$8 zD|c@lC}f#YHUAESe8k)~Urv+C{g*DCsJBMa{BTZE#xaXYM{Yg1wl?8YTX@~EU$|V$ zBX44Dp&~@IhZ$r;azhn4n3#m@d~I3o+WXrm1F1fiTf-Fp&s!?T8 z9BbE}z~x$gr>eQFzEa5AS@61ETtWiMVr60d^ZPep|9-!=vb}rB@yFl)Gk$;eI%6s7^w4yFdTF-(J?ef*~x}2$ww{4|ZoY zY=dniy`ZrlPK8ReC>SQl1`zUUHr1`CMZwXSb+X!U4X55M@U3rKTq{wN-Q~_91Cj zOj|I+_EfgHl9EBxGU3MMRmQAXEn#3_`1Sbg-V^t}APb?_!^0kspQMK<81?XQ2LLWX Vt0HVUea8R*002ovPDHLkV1gRVJjeh5 diff --git a/tutorials/pictures/wms/linux/insert.png b/tutorials/pictures/wms/linux/insert.png deleted file mode 100644 index 96909a14a02085f277aca788d49a59f329ebdf89..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 758 zcmVXlareU2Q@wX`Q*yO z65(F*7!o)fK@0GCRj9$C+`CikFr~g@1qbt`)XL9%|Ni}8zO-x2yc|0PAwGTy{m`kW zKYjoH{rlUaePM?3;=)3rat5KZuYCXh{rmfKvl7hY1o;JJ&64I{`Uo*Z+GpC4eqR+n zHlxOc<;G%cOgv(8a&o>?AE4UC@C_?CkT0cHeg?u|6m;!5_44!Am&d9Mx!mWx`1b9^ z3>R+C*^fVe`}X1C=`*)Jef#$1Sdof|_q5CJzrDXS!Bx<>q2wLdY{QW-|Nm!zGkCmn(zSV*7z`$hLzHheu-7a@+oZr8EU|?Wi zU{KK1;v#GVj&kBZ+|djSj7*GBMQ~9DW-0sp>GqjVcb5f(7pp#8C?zT?le+qHvp$j$ z|4$+7Wnf@nV1(*M2RNbw$qgtW2;qFaefq-7pa1@|ifZY|GJXH{=f6=;4=p-yrU{q<$R;r`{AXbJ|NqzhHTjNe3QDRv zUMr<~nuEFiGZ|H{ZkOAVWGE{pt!kY#{p=?Uz5jW=^Kuv`*(j>`&v=At7vrZ-p9n`9 ojlrO(I|hX#h{&R}2ZJIc0I%|*O!tK@mH+?%07*qoM6N<$f-X3RVE_OC diff --git a/tutorials/pictures/wms/linux/layer_filter.png b/tutorials/pictures/wms/linux/layer_filter.png deleted file mode 100644 index 5979616170fd85f98874b9f4bb1cda9286de54d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1163 zcmV;61a$j}P)OCj837^d7**bsr10Tn3F?vKg_6A&$KX!!kglK1_d?|E|GbNL9F zOonF^^8YsQ1X;@Vbh4D~>0~L}e}k$biXD9JE&#cZM+>_!uA$zH*_xBw%=VROsQfVc z(l^qkz!S=ebz2kPTW>JEt^IONjr{R;plWVX0|L(65ppO10FpulVZZUU=?%(XzmHEh z$qRDOeGK*a&g4k#*W3LZt*sbVZ2#!|PN4HkA9}d>-u?1#NzUf)mMAcC{rnzZHp85; z&UbHiivj?u<7@=N+HXP~Uo`RhqIJ3|K~kvqAqk);Vuk;iyZ@ALW$xUT)?9telg)NL z^fRF9y>guI!n81d$tC2opJV_a7xL^5mzKo%GFQ-d6)AsD-^}tp(fDZDv`to4XE zao>NNp#?3=uuzYyg+Hio!k|NB@|HC(=;~fKl4!kD^~)sk==3Ip}%Co;|v0m3qNEpEp%kd1~Uxf+pDnLG-u4lA{Fl z0tG$?zZ3WJokZdx?pN*<6y52T7KAdzBmhi|YSZod0RUz!md=c_9tmZqHc$Y7y4(P0 zp3_55cehgVu^Tf9090ecqrL;`0?V2;p_EZDaCR48s{i>v>(<|PPhJr|cd%nNn|06T6lnUR=QFCBwQ zA#16-A)U+iyr9u&?bjNIp;D=y_viUYQsYZ*4J$Dr|D&O?W9)(UKg&Peop7mZ?ra0X z;FXi?$fNUPBl-@zID7hWj}7_9tJ|eAl zP~&X&VF#QlBW;a{z)OP???c1HQmyl6I<9Z@j$zxqo!2SrhdVJ(ClnFUcS#Z z&NQzgYK;aWPYR8`-6Kt@M{-y@sup7~Xz)LIJ=xc4El12sJ`rlNkcJiz4QFyuvzX2S d3(d2f?JupJdDb<$#Yq4F002ovPDHLkV1n9jEQ|mE diff --git a/tutorials/pictures/wms/linux/layers.png b/tutorials/pictures/wms/linux/layers.png deleted file mode 100644 index f6c4df135f26c4d7009ffd536750116306c2b8ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1585 zcmV-12G043P)X0ssI2Ez#~{00003b3#c}2nYz< z;ZNWI000?uMObuGZ)S9NVRB^vXKrt8Wi4}Ka%E+1b7*gL?*qR+000HVNkl)ga8RjE!yg6oeF&nV`(e2Ry#_y4_ZqV ztu11QM+C(3s?ZU%GBUN)f`CXAL68s=B7|%thCTg|5FyBj<1iHYeY)Ab=idLx|D4^s zm`o-^2nmHkxm+%lO3{l8gTcUY+|JGpA%w|fGNDk2VVH}H%Tx=Sbn5Er5JL9$_EdzB zTrT(V@d1FTRyFB3J3CibS0jX|2qCFdIuml%plM{P=EuxQTLWdP1xyW8-DoBlj$xQ) z@VLllLfSgCMGhlSw&o*MSK#jZYI_v@x3YNiS?^aX2%_P0XA^UF#MhTQ#FAcVVJyCU-*ti@2tu40 zYUG*LMzo&@F?Ecu>>3#8D&Mh~5mzW72yr@x#$W~PIWH97m-Z0^K}fP9&4Q2LBM5@H z`<)jG&Fm*ea`#eEEsqO{r80 z1Ok~%Hr8#E^+&6I^lIJf|B4Gg`AqWw0JIee8Ux=+a5P4AHN)N`%0V)Di-a_wcc_~N(QI8Lnjw#y?@d$^~5h&2bW z+nIID^Xhw^3oWhfR`L&C6b&yiYC*SXZb!vsA702@003ZaOg|kh*%#@Ud~a7)Rt5zHkt8`zBm56(-oG+p;QQu-uRYtLfD8Kr(2_JE-Kd8J^OyYrZo4eYCm8*H}eCQNuoE_NA{V2K+r-ZJ?dD2w0T+_xzUz(kY&ia33&3d8OLLUhhMsD52aP9ru4{`X0IGotHn zue*nMa6R0-?y7TUjt&nai$q|8*Qf0}ZXg??7BgjLwjmo~+gONo)|VGAzuw#Cqdtp) zfq{YH&kuH^PoMZhLhMVHYcA z#M(iIvv@>BICRSQ?a7ix7WE8vE?mCt=GRTPe2Sge7#KuEMR|g%Z%lS!L{fB*bZ6m< z#J_)jz`*Z+jCP^HPi8l+y7l=l!@nP|u3xNn{1`ISV`@iy0V%N611u^*pNN{rhH}<#eS_Z?uID zyRJ;0SNziP>nj1v(AQ7qt({|4=u}ryoAQS{k#kShdrpyzq+`+9KX_NN966j(uWEFS zPk6%fCtugzsjxe{QEPekYrXU9U+?zx;JLOxGEhj^X`bDw2?Ax-D+4U!pIw&B_S(1M z-K9U@T6epjSnA~Bbahtxi5u2uXXJDJG>`YtXz6J^eCq8zo1by(>u&yMI4#E`Y5jfT zS091t`z-kCdqRJours{3v*A0(&D$GaeEAY_+jotf%%_x%>K#hS>P-*! z#Pzf@cO)O%nP(R|d(Gq03sU{fxldhw_ubE&-!Mm7Qh@2+V%0TlPIhddXs8q72m~{x rd<^3FVRMC1m}wtBO8jV<{bvua``rD*V^IMxbuf6k`njxgN@xNACn60g diff --git a/tutorials/pictures/wms/linux/move_waypoint.png b/tutorials/pictures/wms/linux/move_waypoint.png deleted file mode 100644 index 9539524e5ce540a2a5409803ea631b1d6cd8e224..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 941 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 DqXOy( diff --git a/tutorials/pictures/wms/linux/options.png b/tutorials/pictures/wms/linux/options.png deleted file mode 100644 index ebb820a40a7edce30901e70d53a18e5663285646..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1001 zcmVP-JhvmKDdwpZBi)4cBv|6`w-;UyM}1ppX^VOiE} zHp9S$0sw|#2qCetvEB~jUMK)yS(c`0MD%tT_d)@H*=!#0J)y5E0Q?VCeY_pOb5P%| z2K-?8siJ3TEL<3#S>s_->c^_?{a0UKDmT-~Glv0ybbe;;`*62A{*1@oRqjzq)~Wl} zzJ>3Yc?r`-c*>f`$p_fhRGGaba;#h;Q_%RVEIN zrE632U);@^rBY4Lsz&BujAHBc-zms7$4a!b$4e!$aj|K-Hyk2b9~7*KnGmd01gfGn z=NN~xI#<`&pNKK~kh_F-$=)1pb$TXL`VVwY}DHk($Yx+~SMLU#)h5n*gn+G3>;e3c2?*C;D1Z};tfx3Qb{uMo5{Jvr2m z5D}d?SJ3eCEe;*05f%< zv;YwS+TXU@C*RyWTq+ra2n5jX1IT;!uRwgrHy=KqKtZP$8tQKD7gr>2Ki}q=;2T(4x1F{)BUm6(X`H;mO`{8MV;F=M+S8Wq>~eJ%CjPXl=i5rTD5o>qM)MzJ$P%BS8+iagy{|fx?0KgajY}BV`tT1*9{f37w zICNt95Jv+Oi^XCv7~aV%HQG7J2?#d>Ki}YNCJ5EaofXKN{&qcVYD!5Y5 zMNogf#bM%#I^x_Q>nt8T?t2>w07N1Y$8m%Z*L|7(MJNF9_xG=_uUD(pgb;7%aU)*= X^xYjt&{wz!00000NkvXXu0mjf_4M0N diff --git a/tutorials/pictures/wms/linux/pan.png b/tutorials/pictures/wms/linux/pan.png deleted file mode 100644 index dd4e33e7710ccd1385bff5990ded5c9162916866..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 545 zcmV++0^a?JP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0k%m*K~z{r?Uq5x zfWz` zxvZE@!P0Vr!5}C0!1sOb7+FfmT`m`@*Xvv{U|AO3Za3~&8A^Y-^Z882tM6laOZp= zl#8N>Y}@9(ybO-x(Di!d&iO#9=CXoVlHG3iM7!N)d8gCK<>}IZ7&dU;Txq1_Kw5CI zSmbh1x6WoWB{3i^2OtQsFbs2oK7*LXX$JbAYcl?|jx~Z$P0 diff --git a/tutorials/pictures/wms/linux/previous.png b/tutorials/pictures/wms/linux/previous.png deleted file mode 100644 index 8e5a93372ecd54c0b48197fa1ad5f83677bb870e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 590 zcmV-U0ZgXgFbngSdJ^%m!Ep$a#bVG7w zVRUJ4ZXi@?ZDjycb#7!~c_1(}AWC&?c_1<{GBF@9GCDIkIy5vOK~0lL)f4~#0jEhs zK~z|U?bb1m!C)N6@$a2$(=KAL5QEtykr+%8BNCB_L^t9+7#X`^kQgwK7{sDx%-)2} zYOqL7GJ@waDZSih~MwGoY?F2 zqU$=5NW?jy+wD@T)hutkr}Ftcy#kw#O8ZN~L}UAW4!KK-YD$ z*{rK@MN!Z+&9?96OL#mUsZ=T$hH*6(kH;B}MyRTKb7EPRk!4vNa6y#IW#;qw)mSo_ zWIP_DD2i=}Rax!>k|fdZ_sL{3S7WQyic+b>;c$2yzz(roF3~j272>X+(FKuCr?18q ziv`2s@UegkVlWt(B78pI;{mr2)oPVOp&$%!d?ErkolZ<96I4|tm&*x<3j=<${_e5- c1^73BFD9#-+A}W_X8-^I07*qoM6N<$f{1JQr~m)} diff --git a/tutorials/pictures/wms/linux/redraw.png b/tutorials/pictures/wms/linux/redraw.png deleted file mode 100644 index a91b8c35f4e2997ca97939336c3023e35619a227..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 589 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 diff --git a/tutorials/pictures/wms/linux/reverse.png b/tutorials/pictures/wms/linux/reverse.png deleted file mode 100644 index 26e6502b290a36bf07350179fc28b99e9fe27a38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 950 zcmV;n14;aeP)vxCFSOCK7?TU_``)xZ#pwibNANv0^kD zMPhIPBcKKw6buNmgn(OA)QadWQ0z7Xtmd?~edevt ze+K~2`||3RP#;%kS2}d-^*2Ts1p*J@_L^~gQCMoxE4EXu!OxG+o;+V#r$V~6d`3uR zR2r%>#BSRv5ylwnN>lif{Y@AvFUB|oq$xTKhK>iDefWvR-57VuBW(Fp4kPZ#u{aP!y6sOhMJw;PBHSTCo-@2Ro4 z>J?kvgi%L|T2uy0q$)$~vPHqIwfQS;LQcL%Bl2hc`AQ^~Aj`6*ducR9Q4~e3zhN>W z4H39H-oCh~ju?1SR3b`XXoBcsS##~7U``d2*I`0h?z+TJ^0SCSWEB^}+H#})gV*Ig z>H>mbSyp{*4R?p$@z}1{pc%RThV?HH1j!>UF@cj{LR?mxJc|5%4g|y4#TM5t=_{j{ zC!PSncM{kv-qu>UcKjd}0)ik(@|U@dFf9FCSZc?^%vA}yXyp~5K;X9VW~(%C(98uAI(E#)t zJ~y{0O~Z}Q+xlje(F|h+egR@$FYbq+ScWW+%A3?CFdKW`HojJm(jUCxfx*6Bsrly@ zHtbmF>Fnw?XLU{$3jhGq;^S=}R=6{ES+}UUa~+Lg5K%oIUV@4&0QvmnP2=PZiM%lFv2jM#+!V>2#xfJywCW&kt+Q&)VnP Y4?JIzjUnj}ga7~l07*qoM6N<$fPx#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0i#JoK~z{r?UucY z!axwm;{zxtD1sK2TG_${iyvervok*yb`!(0Ec_O;M31Ogb3$EZ{)!q8f+#_xz~waU4Ay^dfo2*2OYJ0$l#<|5}A@m7{!zdPuLjE3CGRNZ)>2#Wpt%`EF zjPv;{^oc|wC=?1p{vKB{@1SK=crLTw?~zOOdra( zWm>HkH}!&cyDgnz<0x~zUa{NluwJjH8In Q*#H0l07*qoM6N<$f;r3NRsaA1 diff --git a/tutorials/pictures/wms/linux/selecttoopencontrol.png b/tutorials/pictures/wms/linux/selecttoopencontrol.png deleted file mode 100644 index aa3f705dc9cb87c14ab75fbc32d85b83fd35d76e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2210 zcmV;T2wnGyP)xkkuW%Mvwaf%K{3-wU7E^X(; zt<`axK>{vVIaQ}>#jT@a6;O;&6+#G!k&xv*FpxKYB!qy_j-Jzzp5gn~@4a{5{eA9R z?)&{7Wj32(K>bNhZyI1gJ>^0OdD9F7>Lw@hnmUu_ohVhkZtwqh8NQz8mU_(GYn#KA z)bF1%Z1GwBf9niYo2`yb_Ok?^ z&dD)nd_2+nSJ{l_%ey};s(PuqQI?l~S^cZL@qeq=4a7NO&4#E0S$h?ZmDnoP{wdy* zzG}o6W98Y6Q-=$=Y#(lLT*k2{7-QU2wq^E6K8wi?9>1!n#)L7(#dF=qY=43=#?9q> z7siQLOjbZ#`hj~b7-Nj}RYzC86UwI382%$ypS$*1qL2`M5|H)0;8?jQhJYtZu z3zIJriP8#n7&qP6@rThtTqctfJ~jK~V-xN!gZy~bIDrm&L^HkG~ zW2})*fRsZ9j4?KzU*;FS;m0lzKnNj(OeG5$v76)wA$0%aNWXE7)?6=tj1bbE zO5+92FR0a<)HiZsm=pKaA%u$Ox{ulZ7$M`e)kA!zNh_LB^SzxDSTUbeB82Yb4rj!y zJtNmNH&x%MZbC@CXELN5L_KvSQxv-Xmi1X%vd};9y~FpP8(W^9TOG+v{QO}z+xuHb zvBqrtzCmw%c4totPq^%y&WZuS3ku{qQ?vZo`-54j1**P*sQ}6TO0ChTC|xpyk#^GH z=)P8(xlt7T?e!Py_2~J%ZSQ)=ZmRAXh+3ND&JLRJ*K;*$ zjkd*bW6eg*)8vqUAYF^7Z* z2qA{IXD+l_(;*>*l*?qs*{jlm z-H4lb(Sn2@&zx0}n$st0C#}v-6nMCK1%w88b>+^NbE^I6s;rou{?W|A?v#t~*zC;l zR|-p-91QDD6;(~l{B!IeXNn#=KYKo{PtJo{kO+2#QrIFn%K+DgZEY-t4|&PNL;!q&c~U!i&LgVJbF~yGY~?c z&1d$<*<<;h9yFJGMMVZl*5WWvCnrYaiZwHu3X3akhl$PFH6%pz@Zm#yZx?I&G8pvU z3HeH1!{ZV+W0Nb?`G}Xo)Jr_RL0tGCI;;S0D$&ZTC73emH1RDmG<=9pyN&e z03B`gfM^|PG#aiz==Mrk)u>b&_z8Sl$%F6DRBN6Aa68+TjY?lW-_=&~_vhZfoQ?E4Gi?;s~4^K~{L2p~l?!n2~2a+>?D#%Elvtd~6 zHeU{X_`1uNRtUSiST_j}LVP(K;Zj*e-bjbW8XvZ%T4}aVH~;`;HSd8=QP#x*;KAW~ zD(V%4ga81T8X9!MFo8n{4u`A0t7s!JZU6v^h6X=@(3ubd(5Px8gbx5vS6|QYttCV}B{`sU$JXmjZSAd^`|?H$ zp|Myrc|}DVu~;k)JDkO0DJv_qgjg(OoMhJXzvmY|MjbCSSM&dNB`I~9w}WZm3`zLE zc5eGYYwj?~k9_(MNR>oeG3sSs7Cyxmb3@VQ{hH)yqsh+$+j0wZ&ozvsgbM<7nep0d4XfnkJefUHGcU=f|JvS1SjN|I}C&< zb+x1}Cu(r$qM{ah(q>tvi*#~`FeE~XdU)A#J3RQs3s!93x}Inujrq)(pS=EO0O z?j}^^I@uP{iTA>VfpJSO#z+r;Ack&kI&NOMCW?>m3YOCAt$7xeq(|%?eYJF%Km3;T z?Gnz8xT>?uxW8=LKh=6JBVu#drSDb-bk^AKxai3nH|$HvJwI<(lGhvKdvy^2LM)`~ zQ21@h^6>LbZ1poj5 diff --git a/tutorials/pictures/wms/linux/star_filter.png b/tutorials/pictures/wms/linux/star_filter.png deleted file mode 100644 index 6b47856c700d40c048472745c48e080e073fc8d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 466 zcmV;@0WJQCP)b7cE7~O%rVS$4*;<(Dg>nE|&qI)oOLS zT}hHM(lpIlt)}a`cpi+g)oO)^a=+hs?0UUUr_&@!@<}2J!*ID=@}zCskH>@evsc+}x4mAEpG~{nW{mNE zMjj3aS(ek7J{C*UG}m=`GTYl?@l|ELUWZ|bh|OkG*Y#4Vgoq*%09@DgeZSxD*Xwlv z*zfm?#iG;cyo=^{%NUa+X)qY17yu}WG8_&OG4~JW|IfeZ2`H6_F8D*QV*mgE07*qo IM6N<$g3!9oQ~&?~ diff --git a/tutorials/pictures/wms/linux/transparent.png b/tutorials/pictures/wms/linux/transparent.png deleted file mode 100644 index 66a039fdf28ad1d064d84cf1d05bfc40e0948b01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1282 zcmV+d1^xPoP)L~1P&5Yp={fA4U)OjaoLc`~)LrpA3}|!)gTY|XDS)y*(hA}|R^*=jJW|RZ z6P?+P=Deinm!%S+ND}nc?n)y7G^i9IDVeFtKo5bdc=Cds=Q{vcbom?RPV({)i97=% zww4-!29-jw>Cm>Q2||HT8kTtIa#vr|D~A&!Ci#ekB5y@}R=o)T$~XEf%&0gJ8zA7$ zR9!{Kg|C+cdkNjf%OX?qn|lGEAYQoSt3xY7q;3Lt-&w12G#22}-sr?EL(zwRetr?E z+B+=CKuXuTJ1;G=z8qFEoyJa!KT@U9>aG!@`zSrmh7mz2C4U+Wn$yWs9HdEQ*9jp+eJcB8 zO>-O37A8no^D+#CkfKCq&KUWEy=9Hs=9a&*W4W$xX4dJiYfF=4qeA!8_f18)MD454 zwY6P65hrqp{lP#8(e4gMWcom%huKAQ`0CVffrA~(7GU`(6heDCnBtJB<1`wrH6?iQ zs)+96-IeZKewJGSlb**`kIy*h%&pzNe^F+RVXEd2)nFfMw9(w#-4 zIZl6V8bhsa9Ylcf5Rzg~uUp^3SbHQj?fWxLMhj(Z*7nBq_Jv3BxGZZu8=IaU6X5*r zx5wFEY*`w##VIm*$JQ86761n)N87$MTOOCws&57OH_oST*>SA0wVR?kG=Gmwv{rQW z^kf47Znj+OZ1_1svJU`Y>SD^;+W`Pv9?v?I4a3IN)AN{;G}b`|@~2_K7Yy}q6AE9Ws`*8h}K(lxLUHC;JRyMOc1 zD;;L^cD2@3HFn;&DT~8lUa7g%4U5I{=#n!MK9=Og$fkzPdhdk4GIWqNxKXwNN;)z sEDuh40z5sxGmpX0t?MxIzc|T%0Tcz}n9lNnLjV8(07*qoM6N<$f>p9#r2qf` diff --git a/tutorials/pictures/wms/linux/unselected_divergence_layer.png b/tutorials/pictures/wms/linux/unselected_divergence_layer.png deleted file mode 100644 index 81ae29199497f325de44affc8849226c4e5d9025..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1503 zcmV<51t9u~P)KgWx?AoEL_Y$CNHXHm80D80=_^;q)Hkg(%?L?VmD z8kL4d3$KBp*49>=&Gxz(fkF_Z@3~5)ve|4VlZnk{`}z5eR$C)a|93=FQxliV4GIdX zsi}F@+BMtWirm%ks#Qja=Zr8I40Jl(eoP<`j7Fme73#N!_~OS92!yc|7Jtr?wMYN# zbYub+Z{C zf*{CW-^`q|=^=biXh&I)FBgIVCFnaLxiwRt16}ZiFGLbH9$~)5emh z0^zo@Hv3qH>$ywc=8|x}q)FjBDqU-xpv`78l}a7lECPXmMx(`Iu?NOtXD284T`8|} z1}KXqkyoW>+W`PTb4FUTP_l^7bRg~1>bMj4G!NzLY`fpve%-#RySpc|Xx70R_3cd| zpg)$rqU_B>w;ncL-tB*K|3&*1ZiF}Uenf4!)YPW>W5tu5YY#UAfVFb-lI-TBBekuq zb?5h|^4)6fpc4`jN=iz)ySv@Z3_%c?OePYEJa7R30LC-McRhO4TaTQaI!`Vw>HvUN z>8ZO3l7um;qQX0I8Ck(ZloxZ!^2E-PlDhx^VB{^```Jt?0guBVzZI2+u3SIY4}%#y z{qqbl2><}x2_fc_HK7zQ!13M@zR}pI0Dv23q!vkbRw&IEM-Jq(aPGBsBhYAcQc{vs zDz#WF11DuRn@dYeg+igHE&za9ttL_^-n{{GVrtB_(?#vjB9%5QlEnLhR-^4cpB~8J za5x-J+DVJ~vHb;0o+e;8M7lODj>#lDx@0oP0|2-ck{AqM`=wYcw#Q-t0G(dXnlJ&? z+lD)>9WMaDU@$_Vu&SymEG(?wq~vnBNF+j`P~a(DzZabCirzB2Z;U8Pjmw^6s<-BFwt4j zmG&9U4t+~Y%hah;2jJF<>lq!;o$&KTU%>18m(3z1WO9sv z=3`d;{QW7++@FSZk?Jr)tJV7Z`$t4XI1_X_old7KmCE+^_Tg;6J)JiXto&%X%J|fq z6d09pJSWn{PI>6)zx#Y`aq+9JuC9(qB(mA;QE6zj_!oSI(W1Y@t(*V=002ovPDHLk FV1hCy*oy!F diff --git a/tutorials/pictures/wms/linux/unstar_filter.png b/tutorials/pictures/wms/linux/unstar_filter.png deleted file mode 100644 index e0e1cb2f3bca4d6f564e81d9edf241a5942a937d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 630 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 diff --git a/tutorials/pictures/wms/win/auto_update.png b/tutorials/pictures/wms/win/auto_update.png deleted file mode 100644 index 969aa1a7765f65acfa66187993fd2b7834233a2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 544 zcmV+*0^j|KP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0kug)K~z{r?N-Zm z10f8gD(aydilLXSWji1dUoi-n|}bVOO+t|5MIa@9k+G8+ci-wf6cg@?&7ZWq-}HSNW_l zESR6u#;OeC9mQ!7~L++-3+6>omvyS`DV&1%QG5<&370ARw-`wzg@i z{%uQ*1@ZY+U`-4WHDvyNb9fB+m@Z|^K-VnR4x{8x{uh>iL>>#+zz5(MxQS!nCcezT i=iwN*iDTdvZvO*Soul3~^}CAz00007?!3HE>IB{?SDaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9EWB_ParFHODzs9}w#i(^QJ^V?~s7q%$yxW4Vo%V-PBNa$K1 zV!eS`&i=RnlaW8Gc;uW`rH9Wr9_!rR7MW(__~-fOM7F?=`0GE^y(u? ztwu*?u(yA|ekJeB`{~K++U}Ka-tsHDe!05)T029%(p}H5g??xBl~<+aF(=O9&r!(>xy7=9|pO5^JgTb@N=FgRdW1 zZjJl3LrqS8XVW~km4-J=#2gAIEe_pZ74>1)+`T15fqRtq{{54?d&1HEvx52x<<~8) zypXbDXM8fFXq4rwugQA9%;nbIzUjTo?(wb)|BN21vXF`oGCvZeRQrAhHC66#YtHHm ztK~n%`u}Rm`7d8D9kzWmbAIB-$qA@P;iz%)i6h2~Z@!T);?&r2WGxFbFftiDUHx3v IIVCg!0P4i+G5`Po diff --git a/tutorials/pictures/wms/win/clone.png b/tutorials/pictures/wms/win/clone.png deleted file mode 100644 index 740f63cbbd11967360d5ea67ad44347b38b54f48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 370 zcmeAS@N?(olHy`uVBq!ia0vp^kw7fQ!3HE(vl@s2DaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9EWB_ParFHODzsDa(n#W5tp{q42eye$d>tN~Svq-QM@jan$8 zHFN55g%#OmYUg%*th>W6qi~biIxEPl<&LxhH_Hho#!U_lIsyvZPZCcw&e{0nap2o} z=9ET;NQJHwl2;}?+;L$Z?}>|(-xj<5F+0C|-@RWqvSOZ>w#7VnZWZX%_v9^8K*H@C zm0iL`t*;yuo2|G~>zto_eQK~H?EAYH!M^HNC!V&;Z*66)bb2baxW9d) zu?n~II$Ju)z4*} HQ$iB}5bKvW diff --git a/tutorials/pictures/wms/win/cloudcover.png b/tutorials/pictures/wms/win/cloudcover.png deleted file mode 100644 index 0d2d9710adf8b629e66d1648e576c9a6330c93ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2004 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 JPDHLkV1i1PId1>} diff --git a/tutorials/pictures/wms/win/deleteselected.png b/tutorials/pictures/wms/win/deleteselected.png deleted file mode 100644 index cd37cfe02211d5b726de4317f8ad4c02cf73af7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 490 zcmeAS@N?(olHy`uVBq!ia0vp^(LgND!3HE#))@BzDaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9EWB_ParFHODzsG;7|#W5tq`R$a0jSUJsEH69rGTOeECTdS{ zQk}yxK|;WF=N#+uP!&IAh@#g+8MR1n`hH5 zIxJp#^z!W5huya>cr)a?M^EJ5vTer7J-u#e##?iK_~^5}P@j`&_Vr4+&HGcg8-E7EaB`PbTi5&EWcgMIepkNbBig~Uw#z^3ELpGLGd{B2B7E`FN6SC)?e?B?TI`>Q=4?S!#4BINC~T{$`^Zzopr0Or%!fdBvi diff --git a/tutorials/pictures/wms/win/divergence_layer.png b/tutorials/pictures/wms/win/divergence_layer.png deleted file mode 100644 index 283cde2d072f4834683adeafd2da70b182bda93b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2141 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 Hu0mjfMxEwy diff --git a/tutorials/pictures/wms/win/europe_cyl.png b/tutorials/pictures/wms/win/europe_cyl.png deleted file mode 100644 index 84ae6bb1efc6714915d4e891ff9b65678fcb38cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 835 zcmV-J1HAl+P)X1^@s6$8t#y00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0@z7JK~#8N?U_At zvoH{bxf8W}_wKli>YTzfDc!o~44!fC8~6qaizLvl7Fm3m&jj-p&kV5ogV{$bC)M-$ zv;cthU=m&~7XZMUn3eGHcmM$YGmsE~m4So+tPCUsU}Yd704oCtOKBThRh4a8CH9im zHdSp+*I)l?9Po)Oldv6EsAg_8HmA^H#BQTu*i zk1UX|v!;%AaN5ypV>=&jn;=w)guhAX_tTl$R!ytM%1GatzSrO0c)+_TmvGpiYZP9ZVvfgVT`%&0Ja7RsEHT^JfF|(c}%&4EWrsa zv9xCrFNeU1B>Ttr%Gm0fa%1^eB9@a%t%fv*Jc_Ct5HYu2xfTaWs?G^Ei4?I!Kw@K+ zQ1l}~SY?c5lI^3|An)8pAkZXC8{yT{9C*dJb$wf8{6&*65vjn^dN;c#FA#&Joxg!0J7 zcKU#xY@A#hKF9D^$+4~0&xtiAJCjSaXE8U=Bni#c=xZY3lJ|7|QQBtn}b|C zjWPEfz@AtjArTTxCYjsS+3_w(+(10W1Q#hhGg0ztiQF?(r7%t$m-EQJB0js`Iax&i zs9Fx+f>lpt?W){hk1UjMOUy|74=npXp{tUwj+x_cM{s0#{;Na+xzApd( N002ovPDHLkV1gN^fwcet diff --git a/tutorials/pictures/wms/win/get_capabilities.png b/tutorials/pictures/wms/win/get_capabilities.png deleted file mode 100644 index 403f79b3fd7a57400073a3adf0324d2cb93dc5cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 610 zcmV-o0-gPdP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0ryEnK~!i%?Ul)q z#2^es$%$-;WNxJ5RET2Eoq;o8*e%35t~MO3!>a-$A?brLJI_-S>v6)fpI>4jB>W}{ z;Xj`b@gOApCP?^Akno!z;Wt6TZ{h{QA6hW3rK;H=vwq*r3H(Wh4nw9Slb^LxBp zcu33dnO?@iE2%kSnk8Tnwy4V{4!fqb#Pvdv%^gq_85f5M4W&ZNTu803U$G~MaZNRH zOvk}GXU2Hm(ZG#S+_dnn$k;##j+0LH>|coRWV{22wNgf9*>V&i6>0t-IqyKyzYUObq0H7M=y5XQo{ZhTWb z3oaH>+>}5sUsZt6%-y)A-zYxJyBZ*nZ(n$q1fO6m0=;hrWqx2+vH1z-_}Jo&$4^j} z4+#Jpxf_?+dSloNv~@HvdEmOM5xgD);r#~ba}fe0wfLfv9c-*559_TPfnviUTyb^{ z=0pBj9e3lPh^;a1CASs(Ck@bg7BeG!22Q$?ow>iyQ24{#o^)gF?azL;D|wyp@N;QB w8!LF3@WeBa@S7mvH_;THSOW>aiSs=5AIXy_64X+e6#xJL07*qoM6N<$g3rPcApigX diff --git a/tutorials/pictures/wms/win/horizontalwind.png b/tutorials/pictures/wms/win/horizontalwind.png deleted file mode 100644 index 979bc249a6a98ee4c94d47c09e7d2f201b070e88..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2084 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 diff --git a/tutorials/pictures/wms/win/layers.png b/tutorials/pictures/wms/win/layers.png deleted file mode 100644 index 753ea716825c5403093a738de0738619a54b60e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 606 zcmV-k0-^nhP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0rN>jK~!i%?Uvz@ zgdhxs>B$DR4w|7A8rUKz#SZKsX8l{D68AS*$j~G?+Ji|bSVXh#sI?Mccx>J8)p835>&Q)VW=M7Zr0+yaH|M z*qX#-z_EEZ;S5Yk%hSw7@k)a&JxlP)-VY)sYcU2sd%-JDp2hz(&aQ{IgqpcL?jAc7 z4r?h^gJOr3ui3`SdTKN)ci*u5JP9nBMb=uq-jkMs=7jscI00Nk&5TL^R=Fg$-#^nA zG@Q{>r_pnx!TyvGk^tLupBqlOkStb4nos8T#zAlmOR!~bG~|#2T|=(V=xNX}m*Y=+ z+t`~$MF;94GDA0Wy5p4o5^71~IDP&3yi~2UC7jUH;$w4Ja8`*qWf5B|VCHnk!9&W& zDoGz=XY-<|Cbfjp8cJoK2K5hG{@WGBgx4`tBCQ~2blkWi!oPB>vCN#_I5?Ik-`+2c zd*kRlT!)oOLHmQoUXM~zivGhKnAre3k+l!=8Cbv4_!M++7H0;eE diff --git a/tutorials/pictures/wms/win/level.png b/tutorials/pictures/wms/win/level.png deleted file mode 100644 index 559a28bfb60cb8bbd0d6d2462678f26bcae67916..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 660 zcmeAS@N?(olHy`uVBq!ia0vp^89*$_!3HGnCS@N5QjEnx?oJHr&dIz4a#+$GeH|GX zHuiJ>Nn{1`ISV`@iy0V%Nrjc>9{Qd%;FYoSzACH`teEW^gW!v`q3(VD;er;T`e8K9M8n;*4 zbKkvX;q0i**2KXDb2GyE7gQ=4wQUZqV7-vW4h$m(Pgg&ebxsLQYo1iIg@+x?Zhyp) zkT0|~+xhbOrytinO{t9bD8KVR zrc;{z?Sc0%6LdMJ&bZIE>*CE#QL|?K4qW55?rKX`(dh`qumk@yo4;1rh_!m@*lf~kmv^n7 zC*7mmFym}KbA8I$|DL&~KU#$kD7px*-g))Xb)UI&89TyUHmqt=#W%>})r3C;Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0qsddK~!i%?Umb& z#2^essfv2&hGOW2TG9cFd95+#F(WccBtDLmV1w<80dZZIJM7_zxsfC0Mvj;pIbv?) zh`Es)#WYR{>9pl+m{q^$(-9L26ix``46zvnzv16waBlUy z{{~2Fo7@DQ*idJhTg52NlgaaV#$s9~Fgr%)jlO3#L?|X+HL#I*W8u1qg%hFCoVd0Z z0nKY;d%H5sT)KgSJQEW!QxOmhIw1t5$>0Dz+p1sw=enUYrxE~&BE;^bYtu1mNB>Z7 zwJx_-%~Z^stn!`ECOAmuYrlRsly1aFJQR(^k8BaM+tEC8G5vpd!K?deA(?OdX5Ekq z)a8hCb1*b69i#RSa$#5!6Bg#X1s|z&B@`R>gKy?xpnl;LqvZ>LT06$@&MS zS-bdmVzV}sbCXNUnwTS72ojIh=%tTBXCY|PoS#Se>jlU1gj13qi~_8Y<^b#|!oGa$ z<{#C@k mh`Es?=0=W~8#!Wb<8pr|$3oE{9?%{D0000Nn{1`ISV`@iy0V%N(vvUYPqYA-#a%?t?;n_uv?&$!9}UT-m}IW>M?)e a7tCu~b diff --git a/tutorials/pictures/wms/win/remove.png b/tutorials/pictures/wms/win/remove.png deleted file mode 100644 index 7763a6864e77fa03a17d81935bf542e01daca829..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 399 zcmeAS@N?(olHy`uVBq!ia0vp^kw7fY!3HD~A|G!CQjEnx?oJHr&dIz4a#+$GeH|GX zHuiJ>Nn{1`ISV`@iy0V%N+p3UHZ~N{0g4mO_m)rPwb&5oW7%@!caZqIrILUy-Y*A$|IyQ-a-WtP{p2Y_D zr|)=pXwv(;GMnv!xV&v%9S__-;kj$r;(yO7?^m5>oG{1!SXI~OSe5*lYpfadHf%KA z_sq}pYkx-a!Ht$PU*z`t?|ph;&6BsQw6^f)Z@Tu!`OK1i$E)U^mRMc?MwdO#==QHU z^HuJ@nGlvPX#J^g@6#WXpWGFz)u`M)so18kxwl!@di!6`SH3Z>eOB{pU$ItL2664aSH$5R#-%0M;KjCJ j661;#1`tzB{UY}>-Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0a!^yK~!i%?UmgX zgCGn>-HB}&qYLodEm*)I7{y6aegpzVp|#}HbMkOshzWB2)MZ^)U}*5Nw{2r4!?Fos z*@UocLRdB-ESnIPO~`F!Yq`L(Sfp;&|B*kEZ9s`2P$<)=m$2+hd?x#jOpvWXy2M$e zjtP{Yt6Ice%aot^jhvtw^nYjQ`~{iJ*^yOr!Wkl-M9Pr0qlNO21?+4Y` t5SC2{%O-?n6T-3yVcCQv*$f3Mya2*=^6s8pR_6c!002ovPDHLkV1nT*$MXOH diff --git a/tutorials/pictures/wms/win/reverse.png b/tutorials/pictures/wms/win/reverse.png deleted file mode 100644 index 2851089ffb6e3750b950055bb7f25ecdb73b2f82..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^aX>7_!3HEfZ))cQDaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9EWB_ParFHODzs6oNg#W5tq`R&xxf=3lNT;HC|%Q+U7b2Mm8 z%X4+D!Yga!$`3#CQR6VOR6Mq1ipNCvqs;*y6+&z{MERXyOvR6lb2dI{R?p^cz%Th}B0gcZNpIP=+SJEI3j*1I2YuKwX# zHvduf*=5II(A`)OP|x ae;KlR44h^pnOXq@kipZ{&t;ucLK6Tj7O>L* diff --git a/tutorials/pictures/wms/win/selecttoopencontrol.png b/tutorials/pictures/wms/win/selecttoopencontrol.png deleted file mode 100644 index 217dbbb07f8577bed1ee07bbce4c005866e64f63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 895 zcmV-_1AzRAP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0~AR_K~#8N?U+55 z>mUq;y(ib{)2H)hT)$Ps8%UepYsfk2vxDq_XMrI?2n@C}6DBIYa|R@Xbh-Y?G|%T# z2|^8sS%qp4vkKK9W)-R-Fze-V5rmI`dXqKlMp&GWC zb?8*nG^!hzSQdlonpX8A7ZW}UbXJ?seH|Ojx`)xWuCa8z+zc;v)_Uw!+jMFO^4`a4 zvyOeMnr;Z@%Mi2v9Q$iMbd75J5#_v>m1Z4P-!hHQuLp-SGc@{|f4x4a0I|F<)^z`- z8qhen%b`XluAc~YZsG8vxpUq9t6%@n7W$1|N1;8>dKTBkX))&xcC96S=6tLdn2;fK*-}qJ0} zMX}s|^>vB*V?7tug~u6t7p-krV^+W#_IkZ$85P*qtQaeHOmqzrAe9$9tt`BN(Ri>2 z#3N!SbLqY)mfJ5oM#Oq9uFH-y_AXl6P-@oO?dJXl!yXO?&!{*~S!Y%d(z^pErbfLV z3oggpoj5j5KLA1jQ+Xz~`27|@<7VvkSTt5F7uQW=x&3luOuFZ+y1cQu&ewwOU9vU+ zHOA`SpLJ$si!_bCUVS)DV$SGPfLIXnHn`%Trr z_nfXPKK8YU?47;#oJ${CZ)Bxe=f;;l1^z6NgZ?rq`9CZ0%?BoCHBVsvZT2oUm~}#b z)0BN+{|&y}tiN>b+srBmKZuxBsD^B_3c@Eqy&0HQ5Iz+#t56MMR-qcitU@I`)qhxv VVzH9j4{`ti002ovPDHLkV1hkoooWC8 diff --git a/tutorials/pictures/wms/win/star_filter.png b/tutorials/pictures/wms/win/star_filter.png deleted file mode 100644 index e7b9cebb163cba40af32a94539c5c3a4c24af471..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 561 zcmV-10?z%3P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0mey0K~zXf-IiUe z0Z|l&{XXSyxD{^X2PuSzd?cSmA^ErvaivgFL&|4ft$mgp&g_}JPaPNMsde$Lnb~i9 z?X_3qFbo5qiqDz_gF)zo+wF$O<1u|^7Y=9=jYbiPL|EyeWj2{i(ChV3E|;-huQ{uZ zmRT$o!}WT_@pwc!o#w1M%4YNV9L;8v6;Lb|#i8q0sH-4dv}&*RIcT6 ziQ#aFYPHJQC%Ie>?RFcB#X^|d@nY8R_mNB{MaNNOvsu*ZbJ`&7>vTG( zR4T00$>;N;1d7Y1RO{-A>F$Y9f`AOMeS|J|A2z7yN#| z>GSX5^?KoSIuQs2OrJmEAOBsMCI2cuXlCFJ_&Y&NC!y^<00000NkvXXu0mjf6u|j! diff --git a/tutorials/pictures/wms/win/star_layer.png b/tutorials/pictures/wms/win/star_layer.png deleted file mode 100644 index 538b345e7055b47f1595c80cc121d13d7202f62a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 594 zcmV-Y0Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0q03XK~zXf<(5f{ z0x=MU=l`EQc@b1p6vYK`0|gavK~&U>;DU-PuIRaj7e;Agb$8M*&S3)Iq4Q#%sj5`> z7yZHbXQuc2{i8TCZMWMro6T5F1xKcSzn^?QAFHXLZQASgs9vvAHk+mSe9o@1L)$bE z2+;LgTa8++3=c$QWfO6-|w>;FwJST zS`~MZ)2v7)lOh$I_Q0}`ip8RqaVnJ(QQ|ZsmZkuv<#L(TjO6oqs?}<&CSz+lolccb zi##3<2Ue4@HARS3dTg`Vq)w;9YBIK_=(XIu3_2C=DW}QUn#voYJ7F#si_vnqe9_QM zjL&8$n{GB6QJp9%Tz;GkcNue!_J=NH{H+<5rr10b3W*MdV&V#AyWNWZ3tcr8mZq!K zirj9uI3=eWSu7Uha=An==5!;r;r$JS>8FPy)8TL^a_Mw9GDSt>o$GXHo8mhO?_7K< g?f3hiS)D=j4RisIalb;;{{R3007*qoM6N<$g0P_uPyhe` diff --git a/tutorials/pictures/wms/win/transparent.png b/tutorials/pictures/wms/win/transparent.png deleted file mode 100644 index e8c4390386bfd856440fa7d847ce69f813ef2d72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 466 zcmV;@0WJQCP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0cS}>K~z{r?Un5j zgCGosnTmPXhGE!+S=@kzs1RcK=t-}($B*YuFeERp0oAr`VR4UT;sln76IdorV3|09 ztBKwQtyEAIwDo2DU*m#NI#@5g%XB(%EXn`!Q4^1OcXq7_4(H0QHIBtwlin2_?eaWg zTxZQdtwP&%O<$W2R_FMf=gJKeA+ff>7xM(X7T5;R9QF>MX$^=?CXzL1w4?=)Y8Bdc zL()eW)rRjXH3|1Rv1V@KjN$QQjG}302r(pg*+C<&lKEN_l)e6$w8uZx4{nTSH^*KUbvh)a2-HOI!^ss&u)DN>zAE`fO_;*lY9D7+?y zj3F*}6cF>^ea6;MBi^bNVRs_n;YT9J({X%0JGRc$JJDMWn>mh)F-%NbXg+4W`JM4v zCoEt3@$mR^oa@v&xohG#shROSmWdNsCQjhNCjJh~#M^A)1$W!i#B@7livR!s07*qo IM6N<$f}63*&j0`b diff --git a/tutorials/pictures/wms/win/use_cache.png b/tutorials/pictures/wms/win/use_cache.png deleted file mode 100644 index faf40f865c2b46b6b5fbc8136af64feee8d59b30..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 461 zcmV;;0W$uHP)37Z00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0b)r+K~z{r?Umh; z#UKcUt;%}r#$xQnTI_&D{0So7n0t3-XLo)09uf!~4kj@vB{*ChLj=bV!7)VeM+^zz zj0GRH*fWG7TH)_&HV{2nW56(6Q1Z6;?S=%W7=V4Ohsg7``AI|SFdY|I-p;locx%A= zsKhd3ow=5XQ~#>bU)sj{yV{-_LdTrPF-C&Kkd)^j^csw*#tlQQ(&t(NUb}p%>cH(j zEp2>eOB?crA&SFOlOjUh#Eqar9k8=n2+!yDrRGg-$j7e?6_&3NkM%m0%hPpd$gZ}@kOaYvVxzZU+Seb84})ZZ z6q|>P_<>VR%#cXAB^)Bxk_({Rc3t}D-;jkN54BB(^rroI$!+nN6v1M5zfc;SLa}7o{eW00000NkvXXu0mjf Dib2Sw diff --git a/tutorials/pictures/wms/win/valid.png b/tutorials/pictures/wms/win/valid.png deleted file mode 100644 index 9b205ae49144b0b4d941698a5b405bc863525f2d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 398 zcmeAS@N?(olHy`uVBq!ia0vp^kw7fL!3HEJ@&~2?DaPU;cPEB*=VV?2IV|apzK#qG z8~eHcB(eheoCO|{#S9EWB_ParFHODzs6pA&#W5tq`R%lef`=72+Gd`rym2h?&e6y_ z4N`lScx-slFeZ>s14YN+QK6$D1W#8{)?`uuV?Dr_%n0M;vuPL94?*EPSkQ3b4dB?v0o_k_< zsn4dn62d(#o+lF$7ahCAyK(u}jyW%V+UNh=;*;hkrhk9tcbj8%g7K>55o=19eU3SK na6iXMzLpergdg|KanrwW?4q#$*gTe~DWM4fzaXp# diff --git a/tutorials/pictures/wms/win/view.png b/tutorials/pictures/wms/win/view.png deleted file mode 100644 index 81145b321b53f8c6c83357af8ce39d23b223d412..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 411 zcmeAS@N?(olHy`uVBq!ia0vp^5kM@#!3HGj&RT&ujKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85o30K$!7fntTONgMp`uV@QVc+o`7ok0@|ByzR`(ITn_4G$@Eu z`w{!yGjBRK>^}U^H;GA3uH)e@y^=2l2T$!tNR8QGq!%G6zNUve>=>)|=cP@?#~5au zy1W1WA?pZ9=Y!_4Q9@1~Gw%PpXqE8dh|hg4H4cuIXa2~1FZFCte71Ajfi2CR9U>cA zJPx*>h-?W~c<$g6@vCl&z4&3i+kz95-+D|lb=f4K7{T$>M$rA|h1N^eQ)7ePv|9Q% z@*XpeIH5GrdD@iAT^o)awo}vSdwM=^?Z>sZxBq=(u>Z+1!{yIs&0n5y^Psg(S?9b< z?Jt!7Wu9UE%(0@#Q7&Ea$1|ho8Rd(+!VU=1MAyr&&My6|62nCl)=;0&t;ucLK6UQGOlO< diff --git a/tutorials/pictures/wms/win/wms_url.png b/tutorials/pictures/wms/win/wms_url.png deleted file mode 100644 index ac0c6b65d3958955424be84f91c15d57ea156a63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 549 zcmeAS@N?(olHy`uVBq!ia0vp^u0Slt!3HF!O%E&pQjEnx?oJHr&dIz4a#+$GeH|GX zHuiJ>Nn{1`ISV`@iy0V%Nw^hQ1`~z;PSul7pUwSJ zY@4;kwxdthzu8~Yb?E+Mz3GSU=Dc0gu+r2aJ$232#+X&?BL8L6mb&C~tT;LMGHXVH zc;2_`%W7o)J?A?qTHU=nxZoMjQK?N+-%k~Jt1o`F)A8f|M75A{qv@6q1*EY5>l`f6H>hO_gpFyo7bHef$(rXW=x=v`CbtSRz%7!gh_k5FF z*VyJ{+7@qLD{}tFT#;#t>izSCgO%hwex-iQd(U#~*bIrfysG;V3l=Wvtvem_AT#gt zdPYyl>Q*6h*9#6?Pux&X5kDH~xX*rTZF1|8r<0vFUP`M!J#Wo_^Ms(NLo?dnbgfAF zkj|9IEp{7@)xX diff --git a/tutorials/pictures/wms/win/zoom.png b/tutorials/pictures/wms/win/zoom.png deleted file mode 100644 index e27228dd57e20d178be7a7a8c639e1ece463d505..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 740 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGmbN~PnbOGLGA9w%&0(nV9K~z{r?Up}l zGC>fBjYbi{U=y@SqljQ5m@Z8U5iIO1>_XJ9px_@QA)Sy$NU3ES8x=&wLTrR2SO^MY zC5nO?h<<|!bKs(8Zg2G^FDWdZ%HPa9cf0PMYQ0{=U*jYt%1KI;f0CNd=P(+L;Pdm7 z{QX!CmTI@#5DtgIX0yTB*%_!*D$wb4;BvVjkx0OBI3(*1L0D=wn?WcPVj^-F3iEZ*Ol; zxl}4e_EbVqDx1yj4D$JWFqurq?8nCkSglr;#l42oRF(%<3f~fL=*^Jp>ube-TCdlG zi;D|Zb9Z+~=6GWa)zbh}+v zhrGYPlQ}uKQqgFXHTHNsWJV6$Pn+6OsYGVv;7Y~gan|_e=7!8rESF2rXf&+0TCI{f zIk-~!e4aJNy|Gv<$lN~ldY#oFgTa8z$-$NC_xqq$t66hA9;nXAza#fNyvEDtdT$AagtvrCO~PXti24Un!D1|&eKC(d@91aIWA`ytiVh{`lCE5MV`T05Iayham499<(SUg(nC@E1W zF?=8@yUnjkA$Y}Xi{WKW<+k~CDWqI3OBXtR$BUfq|3&IhPEw+rq(nJMiSh@f*6~} diff --git a/tutorials/tutorial_hexagoncontrol.py b/tutorials/tutorial_hexagoncontrol.py index 3dd5249df..ab71a6077 100644 --- a/tutorials/tutorial_hexagoncontrol.py +++ b/tutorials/tutorial_hexagoncontrol.py @@ -28,8 +28,8 @@ from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils.picture import picture def automate_hexagoncontrol(): @@ -46,17 +46,18 @@ def automate_hexagoncontrol(): # Maximizing the window try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') + pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') except Exception: print("\nException : Enable Shortcuts for your system or try again!") raise pag.sleep(2) pag.hotkey('ctrl', 'h') pag.sleep(3) + create_tutorial_images() # Changing map to Global try: - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png')) pag.click(x, y, interval=2) pag.press('down', presses=2, interval=0.5) pag.press(enter, interval=1) @@ -67,7 +68,7 @@ def automate_hexagoncontrol(): # Zooming into the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) pag.click(x, y, interval=2) pag.move(379, 205, duration=1) pag.dragRel(70, 75, duration=2) @@ -83,9 +84,12 @@ def automate_hexagoncontrol(): pag.hotkey('ctrl', 't') pag.sleep(3) + # update images, because tableview was opened + create_tutorial_images() + # Relocating Tableview by performing operations on table view try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) pag.moveTo(x + 250, y - 462, duration=1) if platform == 'linux' or platform == 'linux2': # the window need to be moved a bit below the topview window @@ -132,9 +136,11 @@ def automate_hexagoncontrol(): pag.press(enter) pag.sleep(2) + create_tutorial_images() + # Entering Centre Latitude and Centre Longitude of Delhi around which hexagon will be drawn try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'center_latitude.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-center-latitude.png')) pag.sleep(1) pag.click(x + 370, y, duration=2) pag.sleep(1) @@ -158,7 +164,7 @@ def automate_hexagoncontrol(): # Clicking on the add hexagon button try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) pag.click(x, y, duration=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): @@ -167,7 +173,7 @@ def automate_hexagoncontrol(): # Changing the Radius of the hexagon try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'radius.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-radius.png')) pag.click(x + 400, y, duration=2) pag.sleep(1) pag.hotkey(ctrl, 'a') @@ -181,11 +187,9 @@ def automate_hexagoncontrol(): # Clicking on the Remove Hexagon Button try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'remove_hexagon.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-remove-hexagon.png')) pag.click(x, y, duration=2) pag.sleep(2) - pag.press('left') - pag.sleep(1) pag.press(enter) pag.sleep(2) except (ImageNotFoundException, OSError, Exception): @@ -194,7 +198,7 @@ def automate_hexagoncontrol(): # Clicking on the add hexagon button try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) pag.click(x, y, duration=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): @@ -203,7 +207,7 @@ def automate_hexagoncontrol(): # Changing the angle of first point of the hexagon try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'radius.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-radius.png')) pag.sleep(1) pag.click(x + 967, y, duration=2) pag.sleep(1) @@ -215,11 +219,9 @@ def automate_hexagoncontrol(): # Clicking on the Remove Hexagon Button try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'remove_hexagon.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-remove-hexagon.png')) pag.click(x, y, duration=2) pag.sleep(2) - pag.press('left') - pag.sleep(1) pag.press(enter) pag.sleep(2) except (ImageNotFoundException, OSError, Exception): @@ -228,7 +230,7 @@ def automate_hexagoncontrol(): # Clicking on the add hexagon button try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) pag.click(x, y, duration=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): @@ -236,7 +238,7 @@ def automate_hexagoncontrol(): raise # Changing to a different angle of first point - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'radius.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-radius.png')) pag.sleep(1) pag.click(x + 967, y, duration=2) pag.sleep(1) @@ -248,11 +250,9 @@ def automate_hexagoncontrol(): # Clicking on the Remove Hexagon Button try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'remove_hexagon.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-remove-hexagon.png')) pag.click(x, y, duration=2) pag.sleep(2) - pag.press('left') - pag.sleep(1) pag.press(enter) pag.sleep(2) except (ImageNotFoundException, OSError, Exception): @@ -261,7 +261,7 @@ def automate_hexagoncontrol(): # Clicking on the add hexagon button try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) pag.click(x, y, duration=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): diff --git a/tutorials/tutorial_kml.py b/tutorials/tutorial_kml.py index ad4e75dfe..a51f88f4b 100644 --- a/tutorials/tutorial_kml.py +++ b/tutorials/tutorial_kml.py @@ -29,8 +29,8 @@ from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils.picture import picture def automate_kml(): @@ -49,16 +49,31 @@ def automate_kml(): # Maximizing the window try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') + pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') except Exception: print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) + pag.hotkey('ctrl', 'h') - pag.sleep(3) + pag.sleep(1) + # lets create our helper images + create_tutorial_images() + + # Changing map to Global + try: + pic = picture('topviewwindow-01-europe-cyl.png') + x, y = pag.locateCenterOnScreen(pic) + + pag.click(x, y, interval=2) + pag.press('down', presses=2, interval=0.5) + pag.press(enter, interval=1) + pag.sleep(5) + except (ImageNotFoundException, OSError, Exception): + print("\n Exception : Map change dropdown could not be located on the screen") + raise # Opening KML overlay dockwidget try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-select-to-open-control.png')) pag.click(x, y, interval=2) pag.sleep(1) pag.press('down', presses=4, interval=1) @@ -68,9 +83,11 @@ def automate_kml(): except (ImageNotFoundException, OSError, Exception): print("\nException :\'select to open control\' button/option not found on the screen.") + create_tutorial_images() + # Adding the KML files and loading them try: - x, y = pag.locateCenterOnScreen(picture('kml', 'add_kml_files.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-add-kml-files.png')) pag.click(x, y, duration=2) pag.sleep(1) pag.typewrite(kml_file_path1, interval=0.1) @@ -90,11 +107,11 @@ def automate_kml(): # Unselecting and Selecting Files to demonstrate visibility on the map. try: - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'unselect_all_files.png')) + x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-unselect-all-files.png')) pag.click(x1, y1, duration=2) pag.sleep(2) try: - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'select_all_files.png')) + x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-select-all-files.png')) pag.click(x1, y1, duration=2) pag.sleep(2) except (ImageNotFoundException, OSError, Exception): @@ -102,113 +119,39 @@ def automate_kml(): except (ImageNotFoundException, OSError, Exception): print("\nException :\'Select All Files(Unselecting & Selecting)\' button not found on the screen.") raise - + create_tutorial_images() # Selecting and Customizing the Folder.kml file - try: - x, y = pag.locateCenterOnScreen(picture('kml', 'colored_line.png')) - pag.click(x + 100, y, duration=2) - pag.sleep(4) - try: - # Changing color of folder.kml file - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'changecolor.png')) - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2, y2 - 30, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(4) - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2 + 20, y2 - 50, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color (folder.kml)\' button not found on the screen.") - raise - try: - # Changing Linewidth of folder.kml file - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'changecolor.png')) - pag.click(x1 + 12, y1 + 50, duration=2) - pag.sleep(2) - pag.hotkey(ctrl, 'a') - for _ in range(8): - pag.press('down') - pag.sleep(3) - pag.hotkey(ctrl, 'a') - pag.typewrite('6.50', interval=1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color(folder.kml again)\' button not found on the screen.") - raise - # Selecting and Customizing the color.kml file - pag.click(x + 100, y + 38, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'KML Overlay\' fixed text not found on the screen.") - raise + pag.move(-200, 0, duration=1) + pag.click(interval=2) - # Changing map to Global try: - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png')) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - pag.press(enter, interval=1) - pag.sleep(5) - except (ImageNotFoundException, OSError, Exception): - print("\n Exception : Map change dropdown could not be located on the screen") - raise - - # select the black line - try: - x, y = pag.locateCenterOnScreen(picture('kml', 'colored_line.png')) - pag.click(x + 100, y, duration=2) + # Changing color of folder.kml file + x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-change-color.png')) + pag.click(x1, y1, duration=2) pag.sleep(4) + pag.move(-220, -300, duration=1) + pag.click(interval=2) + pag.press(enter) + pag.sleep(1) except (ImageNotFoundException, OSError, Exception): - print("\nException :\'KML Overlay\' fixed text not found on the screen.") + print("\nException :\'Change Color \' button not found on the screen.") raise - try: - # Changing color of color.kml file - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'changecolor.png')) - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2 - 20, y2 - 50, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(3) - - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2 - 5, y2 - 120, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(4) - - # Changing Linewidth of color.kml file + # Changing Linewidth of folder.kml file + x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-change-color.png')) pag.click(x1 + 12, y1 + 50, duration=2) - pag.sleep(4) + pag.sleep(2) pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('6.53', interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(3) - + for _ in range(8): + pag.press('down') + pag.sleep(3) pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('3.45', interval=1) - pag.sleep(1) + pag.typewrite('6.50', interval=1) pag.press(enter) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color(Color.kml file)\' button not found on the screen.") + print("\nException :\'Change Color(folder.kml again)\' button not found on the screen.") raise print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") diff --git a/tutorials/tutorial_mscolab.py b/tutorials/tutorial_mscolab.py index f482f1280..baf2c280b 100644 --- a/tutorials/tutorial_mscolab.py +++ b/tutorials/tutorial_mscolab.py @@ -28,8 +28,8 @@ from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils.picture import picture # ToDo fix waypoint movement @@ -62,40 +62,42 @@ def automate_mscolab(): path = os.path.normpath(os.getcwd() + os.sep + os.pardir) example_image_path = os.path.join(path, 'docs/mss-logo.png') modify_x, modify_y = None, None - _, sc_height = pag.size()[0] - 1, pag.size()[1] - 1 + # _, sc_height = pag.size()[0] - 1, pag.size()[1] - 1 # Maximizing the window try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') + pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') except Exception: print("\nException : Enable Shortcuts for your system or try again!") raise pag.sleep(4) + create_tutorial_images() # Connecting to Mscolab (Mscolab localhost server must be activated beforehand for this to work) try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'connect_to_mscolab.png')) + x, y = pag.locateCenterOnScreen(picture('msuimainwindow-connect.png')) pag.sleep(1) pag.click(x, y, duration=2) pag.sleep(2) - + create_tutorial_images() # Entering local host URL try: - x1, y1 = pag.locateCenterOnScreen(picture('mscolab', 'connect.png')) - pag.click(x1 - 100, y1, duration=2) + x1, y1 = pag.locateCenterOnScreen(picture('mscolabconnectdialog-http-localhost-8083.png')) + pag.click(x1, y1, duration=2) pag.sleep(1) pag.hotkey(ctrl, 'a') pag.sleep(1) pag.hotkey(ctrl, 'a') pag.typewrite(localhost_url, interval=0.2) pag.sleep(1) - pag.click(x1, y1, duration=2) + pag.press("enter") pag.sleep(2) except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Connect (to localhost)\' button not found on the screen.") + print("\nException :\'Url not found\' button not found on the screen.") raise - # Adding a new user + create_tutorial_images() + pag.sleep(1) try: - x2, y2 = pag.locateCenterOnScreen(picture('mscolab', 'add_user.png')) + x2, y2 = pag.locateCenterOnScreen(picture('mscolabconnectdialog-add-user.png')) pag.click(x2, y2, duration=2) pag.sleep(4) @@ -111,13 +113,13 @@ def automate_mscolab(): pag.press(enter) pag.sleep(2) - if pag.locateCenterOnScreen(picture('mscolab', 'emailid_taken.png')) is not None: - print("The email id you have provided is already registered!") - pag.sleep(1) - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) + # if pag.locateCenterOnScreen(picture('mscolab', 'emailid_taken.png')) is not None: + # print("The email id you have provided is already registered!") + # pag.sleep(1) + # pag.press('left') + # pag.sleep(1) + # pag.press(enter) + # pag.sleep(2) # Entering details of the new user that's created pag.press('tab', presses=2, interval=1) @@ -132,9 +134,11 @@ def automate_mscolab(): print("\nException :\'Connect to Mscolab\' button not found on the screen.") raise + create_tutorial_images() # Opening a new Mscolab Operation + file_menu = picture("msuimainwindow-menubar.png", boundingbox=(0, 0, 38, 22)) try: - file_x, file_y = pag.locateCenterOnScreen(picture('mscolab', 'file.png')) + file_x, file_y = pag.locateCenterOnScreen(file_menu) pag.moveTo(file_x, file_y, duration=2) pag.click(file_x, file_y, duration=2) for _ in range(2): @@ -149,8 +153,9 @@ def automate_mscolab(): pag.press('tab') pag.sleep(2) + create_tutorial_images() try: - x1, y1 = pag.locateCenterOnScreen(picture('mscolab', 'addop_ok.png')) + x1, y1 = pag.locateCenterOnScreen(picture('addoperationdialog-ok.png')) pag.moveTo(x1, y1, duration=2) pag.click(x1, y1, duration=2) pag.sleep(2) @@ -162,8 +167,12 @@ def automate_mscolab(): except (ImageNotFoundException, OSError, Exception): print("\nException :\'File\' menu button not found on the screen.") raise + + create_tutorial_images() + + pic = picture("msuimainwindow-operations.png", boundingbox=(0, 0, 72, 17)) try: - open_operations_x, open_operations_y = pag.locateCenterOnScreen(picture('mscolab', 'active_operations.png')) + open_operations_x, open_operations_y = pag.locateCenterOnScreen(pic) pag.moveTo(open_operations_x, open_operations_y + 20, duration=2) pag.sleep(1) pag.doubleClick(open_operations_x, open_operations_y + 20, duration=2) @@ -177,17 +186,26 @@ def automate_mscolab(): pag.moveTo(file_x, file_y, duration=2) pag.click(file_x, file_y, duration=2) pag.press('right', presses=2, interval=2) + pag.press('down', presses=3, interval=2) + pag.press('right') pag.press('down', presses=2, interval=2) pag.press(enter) pag.sleep(3) else: print('Image not Found : File menu not found (while managing users)') - # Demonstrating search and select of the users present in the network. + create_tutorial_images() + pic = picture("mscolabadminwindow-all-users-without-permission.png") + ref_x, ref_y = pag.locateCenterOnScreen(pic) + left_side = (ref_x, ref_y, 400, 800) + + pic = picture("mscolabadminwindow-all-users-with-permission.png") + ref_x, ref_y = pag.locateCenterOnScreen(pic) + right_side = (ref_x, ref_y, 800, 1000) + try: - selectall_left_x, selectall_left_y = pag.locateCenterOnScreen(picture('mscolab', - 'manageusers_left_selectall.png'), - region=(0, 0, 600, sc_height)) + selectall_left_x, selectall_left_y = pag.locateCenterOnScreen(picture('mscolabadminwindow-select-all.png'), + region=left_side) pag.moveTo(selectall_left_x, selectall_left_y, duration=2) pag.click(selectall_left_x, selectall_left_y, duration=1) pag.sleep(2) @@ -222,7 +240,7 @@ def automate_mscolab(): pag.click(selectall_left_x, selectall_left_y + 57 * count, duration=1) try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'manageusers_add.png')) + x, y = pag.locateCenterOnScreen(picture('mscolabadminwindow-add.png'), region=left_side) pag.moveTo(x, y, duration=2) pag.click(x, y, duration=2) pag.sleep(1) @@ -234,9 +252,8 @@ def automate_mscolab(): # Searching and changing user permissions and deleting users try: - selectall_right_x, selectall_right_y = pag.locateCenterOnScreen(picture('mscolab', - 'manageusers_right_selectall.png'), - region=(600, 0, 1200, sc_height)) + selectall_right_x, selectall_right_y = pag.locateCenterOnScreen(picture('mscolabadminwindow-select-all.png'), + region=right_side) pag.moveTo(selectall_right_x - 170, selectall_right_y, duration=2) pag.click(selectall_right_x - 170, selectall_right_y, duration=2) pag.typewrite('t', interval=0.3) @@ -256,7 +273,7 @@ def automate_mscolab(): pag.click(duration=1) pag.sleep(2) try: - modify_x, modify_y = pag.locateCenterOnScreen(picture('mscolab', 'manageusers_modify.png')) + modify_x, modify_y = pag.locateCenterOnScreen(picture('mscolabadminwindow-modify.png')) pag.click(modify_x - 141, modify_y, duration=2) if i == 0: pag.press('up', presses=2) @@ -296,7 +313,7 @@ def automate_mscolab(): print('Image Not Found: Select All button has previously not found on the screen') # Closing user permission window - pag.hotkey('command', 'w') if platform == 'dawrin' else pag.hotkey(alt, 'f4') + pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') pag.sleep(2) # Demonstrating Chat feature of mscolab to the user @@ -309,11 +326,12 @@ def automate_mscolab(): else: print('Image not Found : File menu not found (while opening Chat window)') + create_tutorial_images() # Sending messages to collaboraters or other users pag.typewrite(chat_message1, interval=0.05) pag.sleep(2) try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'chat_send.png')) + x, y = pag.locateCenterOnScreen(picture('mscolaboperation-send.png')) pag.moveTo(x, y, duration=2) pag.click(x, y, duration=2) pag.sleep(2) @@ -339,7 +357,7 @@ def automate_mscolab(): raise # Searching messages in the chatbox using the search bar try: - previous_x, previous_y = pag.locateCenterOnScreen(picture('mscolab', 'chat_previous.png')) + previous_x, previous_y = pag.locateCenterOnScreen(picture('mscolaboperation-previous.png')) pag.moveTo(previous_x - 70, previous_y, duration=2) pag.click(previous_x - 70, previous_y, duration=2) pag.sleep(1) @@ -368,9 +386,10 @@ def automate_mscolab(): pag.press(enter) pag.sleep(4) + create_tutorial_images() # Adding some waypoints to topview try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) pag.moveTo(x, y, duration=2) pag.click(x, y, duration=2) pag.move(-50, 150, duration=1) @@ -410,9 +429,10 @@ def automate_mscolab(): pag.press(enter) pag.sleep(1) + create_tutorial_images() # Operations performed in version history window. try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'refresh_window.png')) + x, y = pag.locateCenterOnScreen(picture('mscolabversionhistory-refresh-window.png')) pag.moveTo(x, y, duration=2) pag.click(x, y, duration=2) pag.sleep(2) @@ -429,7 +449,7 @@ def automate_mscolab(): # Changing this change to a named version try: # Giving name to a change version. - x1, y1 = pag.locateCenterOnScreen(picture('mscolab', 'name_version.png')) + x1, y1 = pag.locateCenterOnScreen(picture('mscolabversionhistory-name-version.png')) pag.sleep(1) pag.moveTo(x1, y1, duration=2) pag.click(x1, y1, duration=2) @@ -447,14 +467,12 @@ def automate_mscolab(): pag.click(x, y + 125, duration=1) pag.sleep(1) - # Checking out to a particular version - pag.moveTo(x1 + 95, y1, duration=2) - pag.click(x1 + 95, y1, duration=1) + x2, y2 = pag.locateCenterOnScreen(picture('mscolabversionhistory-checkout.png')) + pag.sleep(1) + pag.moveTo(x2, y2, duration=2) + pag.click(x2, y2, duration=2) pag.sleep(1) - pag.press('left') - pag.sleep(2) pag.press(enter) - pag.sleep(2) # Filtering changes to display only named changes. pag.moveTo(x1 + 29, y1, duration=1) @@ -474,9 +492,13 @@ def automate_mscolab(): pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') pag.sleep(4) + create_tutorial_images() # Activate Work Asynchronously with the mscolab server. + # ToDo this needs to be extracted to a different tutorial + """ try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'work_asynchronously.png')) + x, y = pag.locateCenterOnScreen(picture('msuimainwindow-work-asynchronously.png', + boundingbox=(0, 0, 149, 23))) pag.sleep(1) pag.moveTo(x, y, duration=2) pag.click(x, y, duration=2) @@ -492,19 +514,22 @@ def automate_mscolab(): pag.sleep(4) # Moving waypoints. + create_tutorial_images() + point2 = picture("topviewwindow-top-view.png", boundingbox=(322,112, 346, 135)) try: if wp1_x is not None and wp2_x is not None: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-mv-wp.png')) pag.click(x, y, interval=2) try: - wp2_x, wp2_y = pag.locateCenterOnScreen(picture('mscolab', 'topview_point2.png')) + wp2_x, wp2_y = pag.locateCenterOnScreen(point2) except (ImageNotFoundException, OSError, Exception): print("\nException : Topview's \'Point 2\' not found on the screen.") raise pag.click(wp2_x, wp2_y, interval=2) pag.moveTo(wp2_x, wp2_y, duration=1) - pag.dragTo(wp1_x, wp1_y, duration=1, button='left') + pag.dragTo(wp1_x, wp1_y + 20, duration=1, button='left') pag.click(interval=2) + pag.sleep(4) except (ImageNotFoundException, OSError, Exception): print("\n Exception : Move Waypoint button could not be located on the screen") @@ -524,12 +549,14 @@ def automate_mscolab(): pag.press(enter) pag.sleep(3) + create_tutorial_images() # Overwriting Server waypoints with Local Waypoints. try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'overwrite_waypoints.png')) + x, y = pag.locateCenterOnScreen(picture('msuimainwindow-server-options.png')) pag.sleep(1) pag.moveTo(x, y, duration=2) pag.click(x, y, duration=2) + pag.press('down', presses=2, interval=1) pag.sleep(2) pag.press(enter) pag.sleep(3) @@ -537,6 +564,8 @@ def automate_mscolab(): print("\nException : Overwrite with local waypoints (during saving to server) button" " not found on the screen.") raise + create_tutorial_images() + # Unchecking work asynchronously pag.moveTo(work_async_x, work_async_y, duration=2) @@ -545,7 +574,7 @@ def automate_mscolab(): except (ImageNotFoundException, OSError, Exception): print("\nException : Work Asynchronously (in mscolab) checkbox not found on the screen.") raise - + """ # Activating a local flight track if open_operations_x is not None and open_operations_y is not None: pag.moveTo(open_operations_x - 900, open_operations_y, duration=2) @@ -566,7 +595,7 @@ def automate_mscolab(): pag.sleep(4) # Adding waypoints in a different fashion than the pevious one (for local flighttrack) try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) pag.moveTo(x, y, duration=2) pag.click(x, y, duration=2) pag.move(-50, 150, duration=1) @@ -598,7 +627,7 @@ def automate_mscolab(): # Opening the topview again by double clicking on open views try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'openviews.png')) + x, y = pag.locateCenterOnScreen(picture('msuimainwindow-open-views.png')) pag.moveTo(x, y + 22, duration=2) pag.doubleClick(x, y + 22, duration=2) pag.sleep(3) @@ -628,11 +657,12 @@ def automate_mscolab(): pag.press(enter, presses=2, interval=2) pag.sleep(3) + create_tutorial_images() # Opening user profile try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'johndoe_profile.png')) - pag.moveTo(x + 32, y, duration=2) - pag.click(x + 32, y, duration=2) + x, y = pag.locateCenterOnScreen(picture('msuimainwindow-john-doe.png')) + pag.moveTo(x + 40, y, duration=2) + pag.click(x + 40, y, duration=2) pag.sleep(1) pag.press('down') pag.sleep(1) diff --git a/tutorials/tutorial_performancesettings.py b/tutorials/tutorial_performancesettings.py index 6000b8ead..c485c12e1 100644 --- a/tutorials/tutorial_performancesettings.py +++ b/tutorials/tutorial_performancesettings.py @@ -30,8 +30,8 @@ from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils.picture import picture def automate_performance(): @@ -53,16 +53,18 @@ def automate_performance(): # Maximizing the window try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') + pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') except Exception: print("\nException : Enable Shortcuts for your system or try again!") pag.sleep(2) pag.hotkey('ctrl', 't') pag.sleep(3) + create_tutorial_images() + # Opening Performance Settings dockwidget try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'selecttoopencontrol.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) pag.moveTo(x + 250, y - 462, duration=1) if platform == 'linux' or platform == 'linux2': # the window need to be moved a bit below the topview window @@ -75,7 +77,7 @@ def automate_performance(): raise tv_x, tv_y = pag.position() - # Opening Hexagon Control dockwidget + # Opening Performance Control dockwidget if tv_x is not None and tv_y is not None: pag.moveTo(tv_x - 250, tv_y + 462, duration=2) pag.click(duration=2) @@ -87,9 +89,11 @@ def automate_performance(): pag.press(enter) pag.sleep(2) + create_tutorial_images() + # Exploring through the file system and loading the performance settings json file for a dummy aircraft. try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'select.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) pag.click(x, y, duration=2) pag.sleep(1) pag.typewrite(sample, interval=0.1) @@ -101,7 +105,8 @@ def automate_performance(): raise # Checking the Show Performance checkbox to display the settings file in the table view try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'show_performance.png')) + pic = picture('tableviewwindow-show-performance.png', boundingbox=(0, 0, 140, 23)) + x, y = pag.locateCenterOnScreen(pic) pag.click(x, y, duration=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): @@ -110,7 +115,7 @@ def automate_performance(): # Changing the maximum take off weight try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'maximum_takeoff_weight.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-maximum-take-off-weight-lb.png')) pag.click(x + 318, y, duration=2) pag.sleep(4) pag.hotkey(ctrl, 'a') @@ -124,7 +129,7 @@ def automate_performance(): raise # Changing the aircraft weight of the dummy aircraft try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'aircraft_weight.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-aircraft-weight-no-fuel-lb.png')) pag.click(x + 300, y, duration=2) pag.sleep(4) pag.hotkey(ctrl, 'a') @@ -139,7 +144,7 @@ def automate_performance(): # Changing the take off time of the dummy aircraft try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'take_off_time.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-take-off-time.png')) pag.click(x + 410, y, duration=2) pag.sleep(4) pag.hotkey(ctrl, 'a') @@ -154,9 +159,12 @@ def automate_performance(): print("\nException :\'Take off time\' fill box not found on the screen.") raise + create_tutorial_images() + # Showing and hiding the performance settings try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'show_performance.png')) + pic = picture('tableviewwindow-show-performance.png', boundingbox=(0, 0, 140, 23)) + x, y = pag.locateCenterOnScreen(pic) pag.click(x, y, duration=2) pag.sleep(3) diff --git a/tutorials/tutorial_remotesensing.py b/tutorials/tutorial_remotesensing.py index 4faae905d..bc20dc9da 100644 --- a/tutorials/tutorial_remotesensing.py +++ b/tutorials/tutorial_remotesensing.py @@ -21,13 +21,13 @@ See the License for the specific language governing permissions and limitations under the License. """ - import pyautogui as pag from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images + +from tutorials.utils.picture import picture def automate_rs(): @@ -49,9 +49,12 @@ def automate_rs(): pag.hotkey('ctrl', 'h') pag.sleep(3) + # lets create our helper images + create_tutorial_images() + # Opening Remote Sensing dockwidget try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-select-to-open-control.png')) pag.click(x, y, interval=2) pag.sleep(1) pag.press('down', presses=3, interval=1) @@ -61,10 +64,12 @@ def automate_rs(): except (ImageNotFoundException, OSError, Exception): print("\nException :\'select to open control\' button/option not found on the screen.") raise + # update our helper images + create_tutorial_images() # Adding waypoints for demonstrating remote sensing try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) pag.click(x, y, interval=2) pag.move(-50, 150, duration=1) pag.click(interval=2) @@ -85,7 +90,7 @@ def automate_rs(): # Showing Solar Angle Colors try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'showangle.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-show-angle-degree.png')) pag.sleep(1) pag.click(x, y, duration=2) pag.sleep(1) @@ -115,8 +120,9 @@ def automate_rs(): # Changing azimuth angles try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'azimuth.png')) - pag.click(x + 70, y, duration=1) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-viewing-direction-azimuth.png')) + pag.click(x + 90, y, duration=1) + pag.move(100, 100) azimuth_x, azimuth_y = pag.position() pag.sleep(2) pag.hotkey(ctrl, 'a') @@ -135,7 +141,7 @@ def automate_rs(): # Changing elevation angles try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'elevation.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-elevation.png')) pag.click(x + 70, y, duration=1) pag.sleep(2) pag.hotkey(ctrl, 'a') @@ -154,7 +160,7 @@ def automate_rs(): # Drawing tangents to the waypoints and path try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'drawtangent.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-draw-tangent-points.png')) pag.click(x, y, duration=1) pag.sleep(2) # Changing color of tangents @@ -174,7 +180,7 @@ def automate_rs(): # Zooming into the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) pag.click(x, y, interval=2) pag.move(0, 150, duration=1) pag.dragRel(230, 150, duration=2) diff --git a/tutorials/tutorial_satellitetrack.py b/tutorials/tutorial_satellitetrack.py index 4b6b72991..07f6eeab2 100644 --- a/tutorials/tutorial_satellitetrack.py +++ b/tutorials/tutorial_satellitetrack.py @@ -27,8 +27,8 @@ from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils.picture import picture def automate_rs(): @@ -47,16 +47,17 @@ def automate_rs(): # Maximizing the window try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') + pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') except Exception: print("\nException : Enable Shortcuts for your system or try again!") pag.sleep(2) pag.hotkey('ctrl', 'h') pag.sleep(3) + create_tutorial_images() # Opening Satellite Track dockwidget try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-select-to-open-control.png')) pag.click(x, y, interval=2) pag.sleep(1) pag.press('down', presses=2, interval=1) @@ -66,10 +67,12 @@ def automate_rs(): except (ImageNotFoundException, OSError, Exception): print("\nException :\'select to open control\' button/option not found on the screen.") raise + # update images + create_tutorial_images() # Loading the file: try: - x, y = pag.locateCenterOnScreen(picture('satellitetrack', 'load.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-load.png')) pag.sleep(1) pag.click(x - 150, y, duration=2) pag.sleep(1) @@ -85,7 +88,7 @@ def automate_rs(): # Switching between different date and time of satellite overpass. try: - x, y = pag.locateCenterOnScreen(picture('satellitetrack', 'predicted_satellite_overpasses.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-predicted-satellite-overpasses.png')) pag.click(x + 200, y, duration=1) for _ in range(10): pag.click(x + 200, y, duration=1) @@ -105,7 +108,7 @@ def automate_rs(): # Changing map to global try: if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png')) pag.click(x, y, interval=2) pag.press('down', presses=2, interval=0.5) pag.press(enter, interval=1) @@ -116,7 +119,7 @@ def automate_rs(): # Adding waypoints for demonstrating remote sensing try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) pag.click(x, y, interval=2) pag.move(111, 153, duration=2) pag.click(duration=2) @@ -129,7 +132,7 @@ def automate_rs(): # Zooming into the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) pag.click(x, y, interval=2) pag.move(260, 130, duration=1) pag.dragRel(184, 135, duration=2) diff --git a/tutorials/tutorial_views.py b/tutorials/tutorial_views.py index 39fafc831..9034b83a4 100644 --- a/tutorials/tutorial_views.py +++ b/tutorials/tutorial_views.py @@ -23,13 +23,12 @@ See the License for the specific language governing permissions and limitations under the License. """ - import pyautogui as pag from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images, get_region +from tutorials.utils.picture import picture # ToDo in sideview and topview waypoint movement needs adjustment @@ -50,7 +49,7 @@ def automate_views(): # Maximizing the window try: if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'up') + pag.hotkey('winleft', 'pageup') elif platform == 'darwin': pag.hotkey('ctrl', 'command', 'f') elif platform == 'win32': @@ -61,10 +60,11 @@ def automate_views(): pag.sleep(2) pag.hotkey('ctrl', 'h') pag.sleep(2) + create_tutorial_images() # Shifting topview window to upper right corner try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) pag.click(x, y - 56, interval=2) if platform == 'win32' or platform == 'darwin': pag.dragRel(525, -110, duration=2) @@ -79,9 +79,11 @@ def automate_views(): elif platform == 'darwin': pag.hotkey('command', 'v') pag.sleep(4) + create_tutorial_images() + # Shifting Sideview window to upper left corner. try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) + x1, y1 = pag.locateCenterOnScreen(picture('sideviewwindow-ins-wp.png')) if platform == 'win32' or platform == 'darwin': pag.moveTo(x1, y1 - 56, duration=1) pag.dragRel(-494, -177, duration=2) @@ -143,12 +145,13 @@ def automate_views(): # Locating Server Layer try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layers.png'), + x, y = pag.locateCenterOnScreen(picture('topviewwindow-server-layer.png'), region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) pag.click(x, y, interval=2) + create_tutorial_images() # Entering wms URL try: - x, y = pag.locateCenterOnScreen(picture('wms', 'wms_url.png'), + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png'), region=(int(sc_width / 2), 0, sc_width, sc_height)) pag.click(x + 220, y, interval=2) pag.hotkey('ctrl', 'a', interval=1) @@ -156,9 +159,10 @@ def automate_views(): except (ImageNotFoundException, OSError, Exception): print("\nException : Topviews' \'WMS URL\' editbox button/option not found on the screen.") raise + + create_tutorial_images() try: - x, y = pag.locateCenterOnScreen(picture('wms', 'get_capabilities.png'), - region=(int(sc_width / 2), 0, sc_width, sc_height)) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) pag.click(x, y, interval=2) pag.sleep(4) except (ImageNotFoundException, OSError, Exception): @@ -177,71 +181,33 @@ def automate_views(): ll_tov_x, ll_tov_y = pag.position() except (ImageNotFoundException, OSError, Exception): print("\nException : Topviews WMS' \'Server\\Layers\' button/option not found on the screen.") - raise + pag.press('enter', interval=1) + # raise # Selecting some layers in topview layerlist - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 16 - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'equivalent_layer.png'), - region=(int(sc_width / 2), 0, sc_width, sc_height)) - temp1, temp2 = x, y - pag.click(x, y, interval=2) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap * 2, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, -gap * 4, duration=1) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Divergence Layer\' option not found on the screen.") - raise - # Setting different levels and valid time - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 3), interval=2) - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'level.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x + 200, y, interval=2) - pag.move(0, 140, duration=1) - pag.click(interval=1) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Pressure level\' button/option not found on the screen.") - raise + # lookup layer entry from the multilayering checkbox try: - x, y = pag.locateCenterOnScreen(picture('wms', 'valid.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x + 200, y, interval=1) - pag.move(0, 80, duration=1) - pag.click(interval=1) - pag.sleep(4) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) + # Divergence and Geopotential + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + # Relative Huminidity + pag.click(x + 50, y + 110, interval=2) + pag.sleep(1) + except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Valid till\' button/option not found on the screen.") + print("\nException : \'Multilayering \' checkbox not found on the screen.") raise + # Moving waypoints in Topview try: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png'), + x, y = pag.locateCenterOnScreen(picture('topviewwindow-mv-wp.png'), region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) pag.click(x, y, interval=2) - try: - px, py = pag.locateCenterOnScreen(picture('views', 'topview_point2.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Point 2\' not found on the screen.") - raise - pag.click(px, py, interval=2) - pag.moveTo(px, py, duration=1) - pag.dragTo(px + 46, py - 67, duration=1, button='left') + # move point x1,y1 + pag.click(x1, y1, interval=2) + pag.moveTo(x1, y1, duration=1) + pag.dragTo(x1 + 46, y1 - 67, duration=1, button='left') pag.click(interval=2) x3, y3 = pag.position() pag.sleep(1) @@ -250,7 +216,7 @@ def automate_views(): raise # Deleting waypoints try: - x, y = pag.locateCenterOnScreen(picture('wms', 'remove_waypoint.png'), + x, y = pag.locateCenterOnScreen(picture('topviewwindow-del-wp.png'), region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) pag.click(x, y, interval=2) pag.moveTo(x3, y3, duration=1) @@ -259,7 +225,6 @@ def automate_views(): pag.press('left') pag.sleep(2) if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('right') pag.press('enter', interval=1) elif platform == 'darwin': pag.press('return', interval=1) @@ -271,7 +236,7 @@ def automate_views(): # Changing map to Global try: if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png'), + x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png'), region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) pag.click(x, y, interval=2) pag.press('down', presses=2, interval=0.5) @@ -286,7 +251,7 @@ def automate_views(): # Zooming into the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png'), + x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png'), region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) pag.click(x, y, interval=2) pag.move(155, 121, duration=1) @@ -298,9 +263,9 @@ def automate_views(): raise # SideView Operations # Opening web map service + try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('sideviewwindow-select-to-open-control.png')) pag.click(x, y, interval=2) pag.press('down', interval=1) if platform == 'linux' or platform == 'linux2' or platform == 'win32': @@ -312,12 +277,12 @@ def automate_views(): raise # Locating Server Layer try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layers.png'), region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('sideviewwindow-server-layer.png'), + region=(0, 0, int(sc_width / 2) - 100, sc_height)) pag.click(x, y, interval=2) # Entering wms URL try: - x, y = pag.locateCenterOnScreen(picture('wms', 'wms_url.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png')) pag.click(x + 220, y, interval=2) pag.hotkey('ctrl', 'a', interval=1) pag.write('http://open-mss.org/', interval=0.25) @@ -325,13 +290,14 @@ def automate_views(): print("\nException : Sideviews' \'WMS URL\' editbox button/option not found on the screen.") raise try: - x, y = pag.locateCenterOnScreen(picture('wms', 'get_capabilities.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) pag.click(x, y, interval=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): print("\nException : SideView's \'Get capabilities\' button/option not found on the screen.") - raise + pag.press('enter', interval=1) + # raise + if platform == 'win32': pag.move(-171, -390, duration=1) pag.dragRel(10, 570, duration=2) @@ -349,9 +315,12 @@ def automate_views(): gap = 22 elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': gap = 16 + try: - x, y = pag.locateCenterOnScreen(picture('wms', 'cloudcover.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) + # Cloudcover + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) temp1, temp2 = x, y pag.click(x, y, interval=2) pag.sleep(3) @@ -375,7 +344,7 @@ def automate_views(): pag.click(temp1, temp2 + (gap * 4), interval=2) try: - x, y = pag.locateCenterOnScreen(picture('wms', 'valid.png'), region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('sideviewwindow-valid.png')) pag.click(x + 200, y, interval=1) pag.move(0, 80, duration=1) pag.click(interval=1) @@ -384,31 +353,39 @@ def automate_views(): print("\nException : Sideview's \'Valid till\' button/option not found on the screen.") raise - # Move waypoints in SideView + create_tutorial_images() + # smaller region, seems the widget covers a bit the content + pic = picture('sideviewwindow-cloud-cover-0-1-vertical-section-valid-' + '2012-10-18t06-00-00z-initialisation-2012-10-17t12-00-00z.png', boundingbox=(20, 20, 800, 200)) + loc = get_region(pic) + sideview_region = (0, 0, loc.left + loc.width, loc.top) try: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('sideviewwindow-mv-wp.png'), region=sideview_region) pag.click(x, y, interval=2) try: - px, py = pag.locateCenterOnScreen(picture('views', 'sideview_point1.png')) + pic = picture('sideviewwindow-cloud-cover-0-1-vertical-section-valid-2012-10-18t06-00-00z-' + 'initialisation-2012-10-17t12-00-00z.png', boundingbox=(103, 300, 118, 312)) + px, py = pag.locateCenterOnScreen(pic) # point1: 127, 394 except (ImageNotFoundException, OSError, Exception): - print(f"\nException : Sideview's \'Point 1\' not found on the screen.") + print("\nException : Sideview's \'Point 1\' not found on the screen.") raise offsets = [0, 114, 161, 200, ] for offset in offsets: pag.click(px + offset, py, interval=2) pag.moveTo(px + offset, py, duration=1) - pag.dragTo(px + offset, py - offset, duration=5, button='left') + pag.dragTo(px + offset, py - offset - 50, duration=5, button='left') pag.click(interval=2) except ImageNotFoundException: print("\n Exception :Sideview's Move Waypoint button could not be located on the screen") raise + + create_tutorial_images() + # Adding waypoints in SideView try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) + x, y = pag.locateCenterOnScreen(picture('sideviewwindow-ins-wp.png'), region=sideview_region) pag.click(x, y, duration=1) pag.click(x + 239, y + 186, duration=1) pag.sleep(3) @@ -430,13 +407,17 @@ def automate_views(): elif platform == 'darwin': pag.hotkey('command', 'w') pag.sleep(1) - pag.click(ll_tov_x, ll_tov_y, duration=2) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - elif platform == 'darwin': - pag.hotkey('command', 'w') + try: + pag.click(ll_tov_x, ll_tov_y, duration=2) + if platform == 'linux' or platform == 'linux2': + pag.hotkey('altleft', 'f4') + elif platform == 'win32': + pag.hotkey('alt', 'f4') + elif platform == 'darwin': + pag.hotkey('command', 'w') + except UnboundLocalError: + # ToDo improve this + pass # Table View # Opening Table View @@ -451,9 +432,11 @@ def automate_views(): pag.hotkey('ctrl', 't') pag.sleep(2) + create_tutorial_images() # Relocating Tableview and performing operations on table view + # ToDo refactor to a module improve where it enters data try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) pag.moveTo(x, y - 462, duration=1) if platform == 'linux' or platform == 'linux2': pag.dragRel(250, 887, duration=3) @@ -500,24 +483,24 @@ def automate_views(): # Locating the selecttoopencontrol for tableview to perform operations try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png'), - region=(0, int(sc_height * 0.75), sc_width, int(sc_height * 0.25))) + x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) + xoffset = -50 # Changing names of certain waypoints to predefined names - pag.click(x, y - 190, duration=1) if platform == 'win32' else pag.click(x, y - 325, duration=1) + pag.click(x + xoffset, y - 360, duration=1) pag.sleep(1) pag.doubleClick(duration=1) pag.sleep(2) - pag.move(88, 0, duration=1) if platform == 'win32' else pag.move(78, 0, duration=1) + pag.move(78, 0, duration=1) pag.sleep(1) pag.click(duration=1) pag.press('down', presses=5, interval=0.2) pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') + pag.press('enter') pag.sleep(1) # Giving user defined names to waypoints - pag.click(x, y - 160, duration=1) if platform == 'win32' else pag.click(x, y - 294, duration=1) + pag.click(x + xoffset, y - 294, duration=1) pag.sleep(1) pag.doubleClick(duration=1) pag.sleep(1.5) @@ -531,7 +514,7 @@ def automate_views(): pag.press('return') if platform == 'darwin' else pag.press('enter') pag.sleep(2) - pag.click(x, y - 127, duration=1) if platform == 'win32' else pag.click(x, y - 263, duration=1) + pag.click(x + xoffset, y - 263, duration=1) pag.sleep(1) pag.doubleClick(duration=1) pag.sleep(2) @@ -545,8 +528,9 @@ def automate_views(): pag.press('return') if platform == 'darwin' else pag.press('enter') pag.sleep(2) + # offset is wrong # Changing Length of Flight Level - pag.click(x + 266, y - 95, duration=1) if platform == 'win32' else pag.click(x + 236, y - 263, duration=1) + pag.click(x + 236, y - 263, duration=1) pag.sleep(1) pag.doubleClick(duration=1) pag.sleep(1) @@ -556,17 +540,18 @@ def automate_views(): pag.sleep(2) # Changing hPa level of waypoints - pag.click(x + 344, y - 65, duration=1) if platform == 'win32' else pag.click(x + 367, y - 232, duration=1) + pag.click(x + 367, y - 232, duration=1) pag.sleep(1) pag.doubleClick(duration=1) pag.sleep(1) pag.write('250', interval=0.2) pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') + pag.press('enter') pag.sleep(2) + # xoffset # Changing longitude of 'Location A' waypoint - pag.click(x + 194, y - 160, duration=1) if platform == 'win32' else pag.click(x + 165, y - 294, duration=1) + pag.click(x + 165, y - 294, duration=1) pag.sleep(1) pag.doubleClick(duration=1) pag.sleep(1) @@ -577,17 +562,14 @@ def automate_views(): # Cloning the row of waypoint try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'clone.png')) - pag.click(x + 15, y - 130, duration=1) if platform == 'win32' else pag.click(x + 15, y - 263, - duration=1) + x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-clone.png')) + pag.click(x + xoffset + 15, y - 263, duration=1) pag.sleep(1) pag.click(x1, y1, duration=1) pag.sleep(2) - pag.click(x + 15, y - 100, duration=1) if platform == 'win32' else pag.click(x + 15, y - 232, - duration=1) + pag.click(x + xoffset + 15, y - 232, duration=1) pag.sleep(1) - pag.doubleClick(x + 130, y - 100, duration=1) if platform == 'win32' else pag.click(x + 117, y - 232, - duration=1) + pag.click(x + xoffset + 117, y - 232, duration=1) pag.sleep(1) pag.write('65.26', interval=0.2) pag.sleep(1) @@ -605,14 +587,12 @@ def automate_views(): raise # Inserting a new row of waypoints try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'insert.png')) - pag.click(x + 130, y - 160, duration=1) if platform == 'win32' else pag.click(x + 117, y - 294, - duration=1) + x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-insert.png')) + pag.click(x + 117, y - 294, duration=1) pag.sleep(2) pag.click(x1, y1, duration=1) pag.sleep(2) - pag.click(x + 130, y - 125, duration=1) if platform == 'win32' else pag.click(x + 117, y - 263, - duration=1) + pag.click(x + 117, y - 263, duration=1) pag.sleep(1) pag.doubleClick(duration=1) pag.sleep(1) @@ -620,7 +600,7 @@ def automate_views(): pag.sleep(0.5) pag.press('return') if platform == 'darwin' else pag.press('enter') pag.sleep(2) - pag.move(63, 0, duration=1) if platform == 'win32' else pag.move(48, 0, duration=1) + pag.move(48, 0, duration=1) pag.doubleClick(duration=1) pag.sleep(1) pag.write('-1.64', interval=0.2) @@ -639,7 +619,7 @@ def automate_views(): raise # Delete Selected waypoints row try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'deleteselected.png')) + x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-delete-selected.png')) pag.click(x + 150, y - 70, duration=1) if platform == 'win32' else pag.click(x + 150, y - 201, duration=1) pag.sleep(2) @@ -653,7 +633,7 @@ def automate_views(): raise # Reverse waypoints' order try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'reverse.png')) + x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-reverse.png')) for _ in range(3): pag.click(x1, y1, duration=1) pag.sleep(1.5) @@ -696,9 +676,10 @@ def automate_views(): pag.dragRel(853, 360, duration=3) pag.sleep(2) + create_tutorial_images() # Relocating Linear View try: - pag.locateCenterOnScreen(picture('views', 'selecttoopencontrol.png')) + pag.locateCenterOnScreen(picture('linearwindow-select-to-open-control.png')) if platform == 'linux' or platform == 'linux2': pag.keyDown('altleft') @@ -740,14 +721,12 @@ def automate_views(): # Locating Server Layer try: - x, y = pag.locateCenterOnScreen(picture('views', 'layers.png'), - region=(900, 830, sc_width, sc_height)) + x, y = pag.locateCenterOnScreen(picture('linearwindow-server-layer.png')) pag.click(x, y, interval=2) # Entering wms URL try: - x, y = pag.locateCenterOnScreen(picture('views', 'wms_url.png'), - region=(0, int(sc_height * 0.65), sc_width, int(sc_height * 0.35))) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png')) pag.click(x + 220, y, interval=2) pag.hotkey('ctrl', 'a', interval=1) pag.write('http://open-mss.org/', interval=0.25) @@ -755,8 +734,7 @@ def automate_views(): print("\nException : Linearviews' \'WMS URL\' editbox button/option not found on the screen.") raise try: - x, y = pag.locateCenterOnScreen(picture('views', 'get_capabilities.png'), - region=(0, int(sc_height * 0.65), sc_width, int(sc_height * 0.35))) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) pag.click(x, y, interval=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): @@ -773,39 +751,43 @@ def automate_views(): pag.sleep(1) except (ImageNotFoundException, OSError, Exception): print("\nException : Linearview's WMS \'Server\\Layers\' button/option not found on the screen.") - raise + # raise + pag.press('enter') + + create_tutorial_images() # Selecting Some Layers in Linear wms section if platform == 'win32': gap = 22 elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': gap = 16 + try: - x, y = pag.locateCenterOnScreen(picture('views', 'vertical_velocity.png'), - region=(0, int(sc_height / 2), sc_width, int(sc_height / 2))) - pag.click(x, y, interval=2) - x, y = pag.locateCenterOnScreen(picture('views', 'horizontal_wind.png'), - region=(0, int(sc_height / 2), sc_width, int(sc_height / 2))) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) + # Cloudcover + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) temp1, temp2 = x, y pag.click(x, y, interval=2) - pag.sleep(1) + pag.sleep(3) pag.move(0, gap, duration=1) pag.click(interval=1) - pag.sleep(1) + pag.sleep(3) pag.move(0, gap * 2, duration=1) pag.click(interval=1) - pag.sleep(1) + pag.sleep(3) pag.move(0, gap, duration=1) pag.click(interval=1) - pag.sleep(1) + pag.sleep(3) pag.move(0, -gap * 4, duration=1) pag.click(interval=1) - pag.sleep(1) + pag.sleep(3) except (ImageNotFoundException, OSError, Exception): - print("\nException : Linearview's \'Horizontal Wind Layer\' option not found on the screen.") + print("\nException : Linearview's Lazer' option not found on the screen.") raise + # Add waypoints after anaylzing the linear section wms try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png'), region=(0, 0, int(sc_width / 2), sc_height)) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) pag.click(x, y, interval=2) pag.sleep(1) pag.click(x + 30, y + 50, duration=1) diff --git a/tutorials/tutorial_waypoints.py b/tutorials/tutorial_waypoints.py index 7ca6186b1..4841ee7f4 100644 --- a/tutorials/tutorial_waypoints.py +++ b/tutorials/tutorial_waypoints.py @@ -29,8 +29,8 @@ from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils.picture import picture def automate_waypoints(): @@ -45,7 +45,7 @@ def automate_waypoints(): # Maximizing the window try: if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'up') + pag.hotkey('winleft', 'pageup') elif platform == 'darwin': pag.hotkey('ctrl', 'command', 'f') elif platform == 'win32': @@ -56,9 +56,12 @@ def automate_waypoints(): pag.hotkey('ctrl', 'h') pag.sleep(5) + # lets create our helper images + create_tutorial_images() + # Adding waypoints try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\nException : Clickable button/option not found on the screen.") @@ -81,7 +84,7 @@ def automate_waypoints(): # Moving waypoints try: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-mv-wp.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Move Waypoint button could not be located on the screen") @@ -96,7 +99,7 @@ def automate_waypoints(): # Deleting waypoints try: - x, y = pag.locateCenterOnScreen(picture('wms', 'remove_waypoint.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-del-wp.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Remove Waypoint button could not be located on the screen") @@ -106,6 +109,7 @@ def automate_waypoints(): pag.press('left') pag.sleep(3) if platform == 'linux' or platform == 'linux2' or platform == 'win32': + pag.press('left', interval=1) pag.press('enter', interval=1) elif platform == 'darwin': pag.press('return', interval=1) @@ -115,7 +119,7 @@ def automate_waypoints(): try: if platform == 'linux' or platform == 'linux2' or platform == 'darwin': print(pag.position()) - x, y = pag.locateCenterOnScreen(picture('waypoints', 'europe_cyl.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Map change dropdown could not be located on the screen") @@ -129,7 +133,7 @@ def automate_waypoints(): # Zooming into the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Zoom button could not be located on the screen") @@ -140,7 +144,7 @@ def automate_waypoints(): # Panning into the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'pan.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-pan.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Pan button could not be located on the screen") @@ -155,7 +159,7 @@ def automate_waypoints(): # Switching to the previous appearance of the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'previous.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-back.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Previous button could not be located on the screen") @@ -164,7 +168,7 @@ def automate_waypoints(): # Switching to the next appearance of the map try: - x, y = pag.locateCenterOnScreen(picture('wms', 'next.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-forward.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Next button could not be located on the screen") @@ -173,7 +177,7 @@ def automate_waypoints(): # Resetting the map to the original size try: - x, y = pag.locateCenterOnScreen(picture('wms', 'home.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-home.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Home button could not be located on the screen") @@ -182,7 +186,7 @@ def automate_waypoints(): # Saving the figure try: - x, y = pag.locateCenterOnScreen(picture('wms', 'save.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-save.png')) pag.click(x, y, interval=2) except ImageNotFoundException: print("\n Exception : Save button could not be located on the screen") diff --git a/tutorials/tutorial_wms.py b/tutorials/tutorial_wms.py index 4b8794195..543d5a527 100644 --- a/tutorials/tutorial_wms.py +++ b/tutorials/tutorial_wms.py @@ -23,13 +23,12 @@ See the License for the specific language governing permissions and limitations under the License. """ - import pyautogui as pag from sys import platform from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import platform_keys, start, create_tutorial_images +from tutorials.utils.picture import picture def automate_waypoints(): @@ -44,7 +43,7 @@ def automate_waypoints(): # Maximizing the window try: if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'up') + pag.hotkey('winleft', 'pageup') elif platform == 'darwin': pag.hotkey('ctrl', 'command', 'f') elif platform == 'win32': @@ -55,26 +54,30 @@ def automate_waypoints(): pag.sleep(2) pag.hotkey('ctrl', 'h') pag.sleep(1) + # lets create our helper images + create_tutorial_images() # Locating Server Layer try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layers.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-server-layer.png')) pag.click(x, y, interval=2) if platform == 'win32': pag.move(35, -485, duration=1) pag.dragRel(-800, -60, duration=2) elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': pag.move(35, -522, duration=1) - pag.dragRel(950, -30, duration=2) + pag.dragRel(650, -30, duration=2) pag.sleep(1) except (ImageNotFoundException, OSError, Exception): print("\nException : \'Server\\Layers\' button/option not found on the screen.") raise + # lets create our helper images + create_tutorial_images() # Entering wms URL try: - x, y = pag.locateCenterOnScreen(picture('wms', 'wms_url.png')) - pag.click(x + 220, y, interval=2) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png')) + pag.click(x - 220, y + 10) pag.hotkey('ctrl', 'a', interval=1) pag.write('http://open-mss.org/', interval=0.25) except (ImageNotFoundException, OSError, Exception): @@ -82,33 +85,32 @@ def automate_waypoints(): raise try: - x, y = pag.locateCenterOnScreen(picture('wms', 'get_capabilities.png')) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) pag.click(x, y, interval=2) pag.sleep(3) except (ImageNotFoundException, OSError, Exception): print("\nException : \'Get capabilities\' button/option not found on the screen.") raise + # lets create our helper images + create_tutorial_images() - # Selecting some layers - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 18 - + # lookup layer entry from the multilayering checkbox try: - x, y = pag.locateCenterOnScreen(picture('wms', 'equivalent_layer.png')) - pag.click(x, y, interval=2) - x, y = pag.locateCenterOnScreen(picture('wms', 'divergence_layer.png')) - temp1, temp2 = x, y - pag.click(x, y, interval=2) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) + # Divergence and Geopotential + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + # Relative Huminidity + pag.click(x + 50, y + 110, interval=2) pag.sleep(1) + except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Divergence Layer\' option not found on the screen.") + print("\nException : \'Multilayering \' checkbox not found on the screen.") raise # Filter layer try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layer_filter.png')) + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-layer-filter.png')) pag.click(x + 150, y, interval=2) except (ImageNotFoundException, OSError, Exception): print("\nException : \'Layer filter editbox\' button/option not found on the screen.") @@ -116,98 +118,51 @@ def automate_waypoints(): if x is not None and y is not None: pag.write('temperature', interval=0.25) - pag.moveTo(temp1, temp2, duration=1) pag.click(interval=2) pag.sleep(1) - # Clearing filter - pag.moveTo(x + 150, y, duration=1) - pag.click(interval=1) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('backspace', presses=11, interval=0.25) - elif platform == 'darwin': - pag.press('delete', presses=11, interval=0.25) - pag.sleep(1) - - # Multilayering - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'multilayering.png')) - pag.moveTo(x, y, duration=2) - # pag.move(-48, None) - pag.click() - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering Checkbox\' button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'checkbox_unselected_divergence.png')) - if platform == 'win32': - pag.moveTo(x - 268, y, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.moveTo(x - 55, y, duration=2) - pag.click(interval=1) - pag.moveTo(x - 55, y + 30, duration=2) - pag.click(interval=1) - pag.sleep(2) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Divergence layer multilayering checkbox\' option not found on the screen.") - raise + # lets create our helper images + create_tutorial_images() + # clear by clicking on the red X + try: + pic = picture('multilayersdialog-temperature.png', boundingbox=(627, 0, 657, 20)) + x, y = pag.locateCenterOnScreen(pic) + pag.click(x, y, interval=2) + except (ImageNotFoundException, OSError, Exception): + print("\nException : \'Layer filter editbox\' button/option not found on the screen.") + raise + # star two layers try: - x, y = pag.locateCenterOnScreen(picture('wms', 'multilayering.png')) - pag.moveTo(x, y, duration=2) - # pag.move(-48, None) - pag.click() + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) + # Divergence and Geopotential + pag.click(x, y + 70, interval=2) pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering Checkbox\' button/option not found on the screen.") - raise - - # Starring the layers - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'equivalent_layer.png')) - pag.moveTo(x, y, duration=2) - pag.click(interval=1) - x, y = pag.locateCenterOnScreen(picture('wms', 'divergence_layer.png')) - if platform == 'win32': - pag.moveTo(x - 255, y, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.moveTo(x - 100, y, duration=2) - pag.click(interval=1) + # Relative Huminidity + pag.click(x, y + 110, interval=2) pag.sleep(1) except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Divergence layer star\' button/option not found on the screen.") + print("\nException : \'Multilayering \' checkbox not found on the screen.") raise # Filtering starred layers. try: - x, y = pag.locateCenterOnScreen(picture('wms', 'star_filter.png')) + pic = picture('multilayersdialog-temperature.png', boundingbox=(658, 2, 677, 18)) + x, y = pag.locateCenterOnScreen(pic) pag.click(x, y, interval=2) - pag.click(temp1, temp2, duration=1) - pag.sleep(1) + pag.sleep(2) + # removing starred selection showing full list + pag.click(x, y, interval=2) + pag.sleep(1) except (ImageNotFoundException, OSError, Exception): print("\nException : \'Starred filter\' button/option not found on the screen.") raise - # removind Filtering starred layers - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'unstar_filter.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, interval=1) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Unstarred filter\' button/option not found on the screen.") - raise - # Setting different levels and valid time - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 4), interval=2) try: - x, y = pag.locateCenterOnScreen(picture('wms', 'level.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-level.png')) pag.click(x + 200, y, interval=2) pag.click(interval=1) pag.sleep(3) @@ -216,7 +171,7 @@ def automate_waypoints(): raise try: - x, y = pag.locateCenterOnScreen(picture('wms', 'initialization.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-initialisation.png')) initx, inity = x, y pag.click(x + 200, y, interval=1) pag.sleep(1) @@ -224,9 +179,8 @@ def automate_waypoints(): except (ImageNotFoundException, OSError, Exception): print("\nException : \'Initialization\' button/option not found on the screen.") raise - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'valid.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-valid.png')) validx, validy = x, y pag.click(x + 200, y, interval=2) pag.click(interval=1) @@ -263,38 +217,35 @@ def automate_waypoints(): # Auto-update feature of wms try: - x, y = pag.locateCenterOnScreen(picture('wms', 'auto_update.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-auto-update.png')) pag.click(x - 53, y, interval=2) except (ImageNotFoundException, OSError, Exception): print("\nException :\' auto update checkbox\' button/option not found on the screen.") raise - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2, interval=1) - try: - retx, rety = pag.locateCenterOnScreen(picture('wms', 'retrieve.png')) - pag.click(retx, rety, interval=2) - pag.sleep(3) - pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.click(retx, rety, interval=2) - pag.sleep(3) - pag.click(x - 53, y, interval=2) - pag.click(temp1, temp2, interval=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\' retrieve\' button/option not found on the screen.") - raise + try: + retx, rety = pag.locateCenterOnScreen(picture('topviewwindow-retrieve.png')) + pag.click(retx, rety, interval=2) + # pag.click(temp1, temp2 + (gap * 4), interval=2) + pag.click(retx, rety, interval=2) + pag.sleep(3) + pag.click(x - 53, y, interval=2) + # pag.click(temp1, temp2, interval=2) + pag.sleep(2) + except (ImageNotFoundException, OSError, Exception): + print("\nException :\' retrieve\' button/option not found on the screen.") + raise # Using and not using Cache try: - x, y = pag.locateCenterOnScreen(picture('wms', 'use_cache.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-use-cache.png')) pag.click(x - 46, y, interval=2) - pag.click(temp1, temp2, interval=2) + # pag.click(temp1, temp2, interval=2) pag.sleep(4) - pag.click(temp1, temp2 + (gap * 4), interval=2) + # pag.click(temp1, temp2 + (gap * 4), interval=2) pag.sleep(4) pag.click(x - 46, y, interval=2) - pag.click(temp1, temp2 + (gap * 2), interval=2) + # pag.click(temp1, temp2 + (gap * 2), interval=2) pag.sleep(2) except (ImageNotFoundException, OSError, Exception): print("\nException :\'Use Cache checkbox\' button/option not found on the screen.") @@ -302,34 +253,31 @@ def automate_waypoints(): # Clearing cache. The layers load slower try: - x, y = pag.locateCenterOnScreen(picture('wms', 'clear_cache.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-clear-cache.png')) pag.click(x, y, interval=2) if platform == 'linux' or platform == 'linux2' or platform == 'win32': pag.press('enter', interval=1) elif platform == 'darwin': pag.press('return', interval=1) - pag.click(temp1, temp2, interval=2) + # pag.click(temp1, temp2, interval=2) pag.sleep(4) - pag.click(temp1, temp2 + (gap * 4), interval=2) + # pag.click(temp1, temp2 + (gap * 4), interval=2) pag.sleep(4) - pag.click(temp1, temp2 + (gap * 2), interval=2) + # pag.click(temp1, temp2 + (gap * 2), interval=2) pag.sleep(4) except (ImageNotFoundException, OSError, Exception): print("\nException :\'Clear cache\' button/option not found on the screen.") raise - # rent layer - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2, interval=2) - pag.sleep(1) + # transparent layer try: - x, y = pag.locateCenterOnScreen(picture('wms', 'transparent.png')) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-transparent.png')) pag.click(x - 53, y, interval=2) if retx is not None and rety is not None: pag.click(retx, rety, interval=2) pag.sleep(1) pag.click(x - 53, y, interval=2) - pag.click(temp1, temp2, interval=2) + # pag.click(temp1, temp2, interval=2) pag.click(retx, rety, interval=2) pag.sleep(1) except (ImageNotFoundException, OSError, Exception): @@ -337,31 +285,33 @@ def automate_waypoints(): raise # Removing a Layer from the map - if temp1 is not None and temp2 is not None: - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'remove.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.click(x, y, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Transparent Checkbox\' button/option not found on the screen.") - raise - # Deleting All layers try: - x, y = pag.locateCenterOnScreen(picture('wms', 'delete_layers.png')) - if platform == 'win32': - pag.click(x - 74, y, interval=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.click(x - 70, y, interval=2) + x, y = pag.locateCenterOnScreen(picture('topviewwindow-remove.png')) + pag.click(x, y, interval=2) + pag.sleep(1) + # pag.click(temp1, temp2 + (gap * 4), interval=2) + pag.click(x, y, interval=2) pag.sleep(1) except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Deleting all layers bin\' button/option not found on the screen.") + print("\nException :\'Transparent Checkbox\' button/option not found on the screen.") raise - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + # Deleting All layers + try: + x, y = pag.locateCenterOnScreen(picture('topviewwindow-server-layer.png')) + pag.click(x, y, interval=2) + except (ImageNotFoundException, OSError, Exception): + print("\nException : \'Server\\Layers\' button/option not found on the screen.") + raise + + try: + x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) + # Divergence and Geopotential + pag.click(x - 16, y + 50, interval=2) + pag.sleep(1) + except (ImageNotFoundException, OSError, Exception): + print("\nException : \'Multilayering \' checkbox not found on the screen.") + raise if __name__ == '__main__': diff --git a/tutorials/tutorials.batch b/tutorials/tutorials.batch index 4042269fd..e5660b102 100755 --- a/tutorials/tutorials.batch +++ b/tutorials/tutorials.batch @@ -10,7 +10,7 @@ export MSUI_CONFIG_PATH=/tmp/msui_tutorials ########################################################################## # wms tutorial -$HOME/miniconda3/envs/mssdev/bin/python tutorial_wms.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_wms.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -34,7 +34,7 @@ cd .. # kml tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_kml.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_kml.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -58,7 +58,7 @@ cd .. # hexagoncontrol tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_hexagoncontrol.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_hexagoncontrol.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -82,7 +82,7 @@ cd .. # performancesettings tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_performancesettings.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_performancesettings.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -108,7 +108,7 @@ cd .. ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_remotesensing.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_remotesensing.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -132,7 +132,7 @@ cd .. # satellitetrack tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_satellitetrack.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_satellitetrack.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -156,7 +156,7 @@ cd .. ################################################## # tutorial waypoints ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_waypoints.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_waypoints.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -180,7 +180,7 @@ cd .. ################################################################ # views tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_views.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_views.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -206,12 +206,12 @@ cd .. # start a mscolab server on standard port after you have it seeded # we should have a seed for tutorials -$HOME/miniconda3/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py db --seed +$HOME/mambaforge/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py db --seed -$HOME/miniconda3/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py start & +$HOME/mambaforge/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py start & ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_mscolab.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_mscolab.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* diff --git a/tutorials/utils/__init__.py b/tutorials/utils/__init__.py index 5fe0ba087..c7f66e63d 100644 --- a/tutorials/utils/__init__.py +++ b/tutorials/utils/__init__.py @@ -64,7 +64,7 @@ def call_msui(): """ Calls the main MSS GUI window since operations are to be performed on it only. """ - msui.main() + msui.main(tutorial_mode=True) def platform_keys(): @@ -126,7 +126,7 @@ def finish(): raise -def start(target=None, duration=120): +def start(target=None, duration=120, dry_run=False): """ This function runs the above functions as different processes at the same time and can be controlled from here. (This is the main process.) @@ -135,10 +135,12 @@ def start(target=None, duration=120): return p1 = multiprocessing.Process(target=call_msui) p2 = multiprocessing.Process(target=target) - p3 = multiprocessing.Process(target=call_recorder, kwargs={"duration": duration}) + if not dry_run: + p3 = multiprocessing.Process(target=call_recorder, kwargs={"duration": duration}) + p3.start() print("\nINFO : Starting Automation.....\n") - p3.start() + pag.sleep(5) initial_ops() p1.start() @@ -146,7 +148,18 @@ def start(target=None, duration=120): p2.join() p1.join() - p3.join() + if not dry_run: + p3.join() print("\n\nINFO : Automation Completes Successfully!") # pag.press('q') # In some cases, recording windows does not closes. So it needs to ne there. sys.exit() + + +def create_tutorial_images(): + pag.hotkey('ctrl', 'f') + pag.sleep(1) + + +def get_region(image): + region = pag.locateOnScreen(image) + return region From fc2f3c6481051fc6916c1d781e94d99779592648 Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Fri, 24 Nov 2023 14:52:49 +0530 Subject: [PATCH 082/176] refactor wait until process signals instead pf sleep (#2099) * refactor wait until process signals * Using 'with' statement to manage subprocess * refactor retry with curl * fix flake --- mslib/mscolab/mscolab.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index 70ff78c2a..316bd7444 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -33,7 +33,6 @@ import sys import secrets import subprocess -import time import git from mslib import __version__ @@ -275,11 +274,8 @@ def handle_mscolab_metadata_init(repo_exists): command = ["python", os.path.join("mslib", "mscolab", "mscolab.py"), "start"] if repo_exists else ["mscolab", "start"] process = subprocess.Popen(command) - - # Add a small delay to allow the server to start up - time.sleep(10) - - cmd_curl = ["curl", "http://localhost:8083/metadata/localhost_test_idp", + cmd_curl = ["curl", "--retry", "5", "--retry-connrefused", "--retry-delay", "3", + "http://localhost:8083/metadata/localhost_test_idp", "-o", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "metadata_sp.xml")] subprocess.run(cmd_curl, check=True) process.terminate() From eaee252c6c157500212d27e6f4c2e6c8ea54f59d Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Thu, 30 Nov 2023 13:53:52 +0530 Subject: [PATCH 083/176] move server.py db parts to file_manager.py (#2106) * move db sessions to file manager * Refactor function create_or_update_idp_user --- mslib/mscolab/file_manager.py | 7 ++++++ mslib/mscolab/server.py | 40 +++++++++++++++++------------------ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index b632e9287..363d9de05 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -216,6 +216,13 @@ def modify_user(self, user, attribute=None, value=None, action=None): # on delete we return succesfull deleted if user_query is None: return True + elif action == "update_idp_user": + user_query = User.query.filter_by(emailid=str(user.emailid)).first() + if user_query is not None: + db.session.add(user) + db.session.commit() + else: + return False user_query = User.query.filter_by(id=user.id).first() if user_query is None: return False diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 2cb0e7c01..0f7a92feb 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -44,7 +44,7 @@ from flask.wrappers import Response from mslib.mscolab.conf import mscolab_settings, setup_saml2_backend -from mslib.mscolab.models import Change, MessageType, User, db +from mslib.mscolab.models import Change, MessageType, User from mslib.mscolab.sockets_manager import setup_managers from mslib.mscolab.utils import create_files, get_message_dict from mslib.utils import conditional_decorator @@ -209,23 +209,24 @@ def get_idp_entity_id(selected_idp): def create_or_update_idp_user(email, username, token, authentication_backend): - try: - user = User.query.filter_by(emailid=email).first() - - if not user: - user = User(email, username, password=token, confirmed=False, confirmed_on=None, - authentication_backend=authentication_backend) - db.session.add(user) - db.session.commit() - - else: - user.authentication_backend = authentication_backend - user.hash_password(token) - db.session.add(user) - db.session.commit() - return True - except (sqlalchemy.exc.OperationalError): - return False + """ + Creates or updates an idp user in the system based on the provided email, username, token, and authentication backend. + :param email: idp users email + :param username: idp users username + :param token: authentication token + :param authentication_backend: authenticated identity providers name + :return: bool : query success or not + """ + user = User.query.filter_by(emailid=email).first() + if not user: + user = User(email, username, password=token, confirmed=False, confirmed_on=None, + authentication_backend=authentication_backend) + result = fm.modify_user(user, action="create") + else: + user.authentication_backend = authentication_backend + user.hash_password(token) + result = fm.modify_user(user, action="update_idp_user") + return result @APP.route('/') @@ -842,8 +843,7 @@ def idp_login_auth(): if user: random_token = secrets.token_hex(16) user.hash_password(random_token) - db.session.add(user) - db.session.commit() + fm.modify_user(user, action="update_idp_user") return json.dumps({ "success": True, 'token': random_token, From 5927cbac6b04482a20c14f547383d58827598ca2 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 6 Dec 2023 14:28:25 +0100 Subject: [PATCH 084/176] Review by pycharm and small refactoring (#2110) * user in this case is None * remove shadow of builtin dir * double quotes for doc string * use parameter accesslevel * verify that the user object is not a boolean, in this case not False * define waypoints a floats * QMessageBox needs a parent * fix shadow of kml and geometry * fix shadow of filter * QmessageBox needs parent not None * double quote for doc strings * fix shadow of type * QMessageBox needs parent and not None * removed mutable keyword * fix expected type * triple double quotes for doc string * use warning instead of warn * flake8 --- mslib/mscolab/mscolab.py | 4 ++-- mslib/mscolab/seed.py | 1 - mslib/mscolab/server.py | 5 +++-- mslib/mscolab/sockets_manager.py | 2 +- mslib/mscolab/utils.py | 12 ++++++------ mslib/msui/flighttrack.py | 2 +- mslib/msui/hexagon_dockwidget.py | 2 +- mslib/msui/kmloverlay_dockwidget.py | 12 ++++++------ mslib/msui/mpl_qtwidget.py | 10 +++++----- mslib/msui/mscolab.py | 2 +- mslib/msui/msui_web_browser.py | 12 ++++++------ mslib/msui/multiple_flightpath_dockwidget.py | 4 ++-- mslib/msui/tableview.py | 8 ++++---- mslib/mswms/dataaccess.py | 4 +++- mslib/support/qt_json_view/datatypes.py | 2 +- mslib/utils/__init__.py | 6 +++--- mslib/utils/auth.py | 2 +- 17 files changed, 46 insertions(+), 44 deletions(-) diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index 316bd7444..cf1e03c2a 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -260,14 +260,14 @@ def handle_mscolab_backend_yaml_init(): def handle_mscolab_metadata_init(repo_exists): - ''' + """ This will generate necessary metada data file for sso in mscolab through localhost idp Before running this function: - Ensure that USE_SAML2 is set to True. - Generate the necessary keys and certificates and configure them in the .yaml file for the local IDP. - ''' + """ print('generating metadata file for the mscolab server') try: diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index c5255d798..dbef1d7ff 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -46,7 +46,6 @@ def add_all_users_to_all_operations(access_level='collaborator'): all_path = [operation.path for operation in all_operations] db.session.close() for path in all_path: - access_level = 'collaborator' if path == "TEMPLATE": access_level = 'admin' add_all_users_default_operation(path=path, access_level=access_level) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 0f7a92feb..0899553dc 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -210,7 +210,8 @@ def get_idp_entity_id(selected_idp): def create_or_update_idp_user(email, username, token, authentication_backend): """ - Creates or updates an idp user in the system based on the provided email, username, token, and authentication backend. + Creates or updates an idp user in the system based on the provided email, + username, token, and authentication backend. :param email: idp users email :param username: idp users username :param token: authentication token @@ -260,7 +261,7 @@ def get_auth_token(): emailid = request.form['email'] password = request.form['password'] user = check_login(emailid, password) - if user: + if user is not False: if mscolab_settings.MAIL_ENABLED: if user.confirmed: token = user.generate_auth_token() diff --git a/mslib/mscolab/sockets_manager.py b/mslib/mscolab/sockets_manager.py index 46c3730e1..4bf5ecf9a 100644 --- a/mslib/mscolab/sockets_manager.py +++ b/mslib/mscolab/sockets_manager.py @@ -227,7 +227,7 @@ def handle_file_save(self, json_req): # emit file-changed event to trigger reload of flight track socketio.emit('file-changed', json.dumps({"op_id": op_id, "u_id": user.id})) else: - logging.debug("login expired for %s, state unauthorized!", user.username) + logging.debug("Auth Token expired!") def emit_file_change(self, op_id): socketio.emit('file-changed', json.dumps({"op_id": op_id})) diff --git a/mslib/mscolab/utils.py b/mslib/mscolab/utils.py index 74e429127..dd2e5b18f 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -59,16 +59,16 @@ def get_message_dict(message): } -def os_fs_create_dir(dir): - if '://' in dir: +def os_fs_create_dir(directory_path): + if '://' in directory_path: try: - _ = fs.open_fs(dir) + _ = fs.open_fs(directory_path) except fs.errors.CreateFailed: - logging.error('Make sure that the FS url "%s" exists', dir) + logging.error('Make sure that the FS url "%s" exists', directory_path) except fs.opener.errors.UnsupportedProtocol: - logging.error('FS url "%s" not supported', dir) + logging.error('FS url "%s" not supported', directory_path) else: - _dir = os.path.expanduser(dir) + _dir = os.path.expanduser(directory_path) if not os.path.exists(_dir): os.makedirs(_dir) diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index 85501cefd..2699d1741 100755 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -128,7 +128,7 @@ class Waypoint: properties. Used internally by WaypointsTableModel. """ - def __init__(self, lat=0, lon=0, flightlevel=0, location="", comments=""): + def __init__(self, lat=0., lon=0., flightlevel=0., location="", comments=""): self.location = location locations = config_loader(dataset='locations') if location in locations: diff --git a/mslib/msui/hexagon_dockwidget.py b/mslib/msui/hexagon_dockwidget.py index ee7c7ff2f..4ca103808 100644 --- a/mslib/msui/hexagon_dockwidget.py +++ b/mslib/msui/hexagon_dockwidget.py @@ -151,7 +151,7 @@ def _remove_hexagon(self): f"points (min, max = {row_min:d}, {row_max:d})") else: sel = QtWidgets.QMessageBox.question( - None, "Remove hexagon", + table_view, "Remove hexagon", f"This will remove waypoints {row_min:d}-{row_max:d}. Continue?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) diff --git a/mslib/msui/kmloverlay_dockwidget.py b/mslib/msui/kmloverlay_dockwidget.py index b0f487b72..42815243d 100644 --- a/mslib/msui/kmloverlay_dockwidget.py +++ b/mslib/msui/kmloverlay_dockwidget.py @@ -44,16 +44,16 @@ class KMLPatch: Represents a KML overlay. """ - def __init__(self, mapcanvas, kml, color="red", linewidth=1): + def __init__(self, mapcanvas, kml_data, color="red", linewidth=1): self.map = mapcanvas - self.kml = kml + self.kml = kml_data self.patches = [] self.color = color self.linewidth = linewidth self.draw() - def compute_xy(self, geometry): - unzipped = list(zip(*geometry.coords)) + def compute_xy(self, geometry_data): + unzipped = list(zip(*geometry_data.coords)) x, y = self.map.gcpoints_path(unzipped[0], unzipped[1]) if self.map.projection == "cyl": # hack for wraparound x = normalize_longitude(x, self.map.llcrnrlon, self.map.urcrnrlon) @@ -567,8 +567,8 @@ def load_file(self): self.dict_files[self.listWidget.item(index).text()]["linewidth"]) self.dict_files[self.listWidget.item(index).text()]["patch"] = patch - except (AttributeError, IOError, TypeError, et.XMLSyntaxError, et.XMLSchemaError, et.XMLSchemaParseError, - et.XMLSchemaValidateError) as ex: # catches KML Syntax Errors + except (AttributeError, IOError, TypeError, et.XMLSyntaxError, et.XMLSchemaError, + et.XMLSchemaParseError, et.XMLSchemaValidateError) as ex: # catches KML Syntax Errors logging.error("KML Overlay - %s: %s", type(ex), ex) self.labelStatusBar.setText(str(self.listWidget.item(index).text()) + " is either an invalid KML File or has an error.") diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index a1244c00a..5932ae5a4 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -830,12 +830,12 @@ def save_figure(self, *args): filters = [] for name, exts in sorted_filetypes: exts_list = " ".join(['*.%s' % ext for ext in exts]) - filter = '%s (%s)' % (name, exts_list) - filters.append(filter) + filter_value = '%s (%s)' % (name, exts_list) + filters.append(filter_value) - fname, filter = _getSaveFileName(self.parent, - title="Choose a filename to save to", - filename=start, filters=filters) + fname, filter_value = _getSaveFileName(self.parent, + title="Choose a filename to save to", + filename=start, filters=filters) if fname is not None: if not fname.endswith(filter[1:]): fname = filter.replace('*', fname) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index bc60da8e2..14839d229 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -585,7 +585,7 @@ def view_description(self): _json = json.loads(r.text) creator_name = _json["username"] QtWidgets.QMessageBox.information( - None, "Operation Description", + self.ui, "Operation Description", f"Creator: {creator_name}

    " f"Category: {self.active_operation_category}

    " "

    " diff --git a/mslib/msui/msui_web_browser.py b/mslib/msui/msui_web_browser.py index 11fb3b018..6347ad155 100644 --- a/mslib/msui/msui_web_browser.py +++ b/mslib/msui/msui_web_browser.py @@ -67,9 +67,9 @@ def __init__(self, url: str): self.show() def closeEvent(self, event): - ''' + """ Delete all cookies when closing the web browser - ''' + """ self.profile.cookieStore().deleteAllCookies() @@ -83,15 +83,15 @@ def closeEvent(self, event): CONNECTION = False def close_qtwebengine(): - ''' + """ Close the main window - ''' + """ main.close() def check_connection(): - ''' + """ Schedule the close_qtwebengine function to be called asynchronously - ''' + """ if CONNECTION: QTimer.singleShot(0, close_qtwebengine) diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index b935a15cf..c34c85e60 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -40,10 +40,10 @@ class QMscolabOperationsListWidgetItem(QtWidgets.QListWidgetItem): """ """ - def __init__(self, flighttrack_model, op_id: int, parent=None, type=QtWidgets.QListWidgetItem.UserType): + def __init__(self, flighttrack_model, op_id: int, parent=None, user_type=QtWidgets.QListWidgetItem.UserType): view_name = flighttrack_model.name super().__init__( - view_name, parent, type + view_name, parent, user_type ) self.parent = parent self.flighttrack_model = flighttrack_model diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index c3d5ae999..a3230c263 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -34,7 +34,7 @@ import types -from mslib.msui import hexagon_dockwidget as hex +from mslib.msui import hexagon_dockwidget as hex_dock from mslib.msui import performance_settings as perfset from PyQt5 import QtWidgets, QtGui from mslib.msui.qt5 import ui_tableview_window as ui @@ -115,7 +115,7 @@ def openTool(self, index): if index >= 0: if index == 0: title = "Hexagon Control" - widget = hex.HexagonControlWidget(view=self) + widget = hex_dock.HexagonControlWidget(view=self) elif index == 1: title = "Performance Settings" widget = perfset.MSUI_PerformanceSettingsWidget( @@ -202,7 +202,7 @@ def confirm_delete_waypoint(self, rows): wps = self.waypoints_model.all_waypoint_data() if len(wps) - len(rows) < 2: QtWidgets.QMessageBox.warning( - None, "Remove waypoint", + self.tableWayPoints, "Remove waypoint", "Cannot remove waypoint, the flight track needs to consist of at least two points.") return False else: @@ -212,7 +212,7 @@ def confirm_delete_waypoint(self, rows): for waypoint in waypoints]) return QtWidgets.QMessageBox.question( - None, "Remove waypoint", text, + self.tableWayPoints, "Remove waypoint", text, QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes diff --git a/mslib/mswms/dataaccess.py b/mslib/mswms/dataaccess.py index c848987c7..3450f77c7 100644 --- a/mslib/mswms/dataaccess.py +++ b/mslib/mswms/dataaccess.py @@ -202,11 +202,13 @@ class DefaultDataAccess(NWPDataAccess): # Workaround for the numerical issue concering the lon dimension in # NetCDF files produced by netcdf-java 4.3.. - def __init__(self, rootpath, domain_id, skip_dim_check=[], **kwargs): + def __init__(self, rootpath, domain_id, skip_dim_check=None, **kwargs): """ Constructor takes the path of the data directory and determines whether this class employs different init_times or valid_times. """ + if skip_dim_check is None: + skip_dim_check = [] NWPDataAccess.__init__(self, rootpath, **kwargs) self._domain_id = domain_id self._available_files = None diff --git a/mslib/support/qt_json_view/datatypes.py b/mslib/support/qt_json_view/datatypes.py index 2c381a8cf..1f85abdf3 100644 --- a/mslib/support/qt_json_view/datatypes.py +++ b/mslib/support/qt_json_view/datatypes.py @@ -259,7 +259,7 @@ def paint(self, painter, option, index): metrics = painter.fontMetrics() spinbox_option = QtWidgets.QStyleOptionSpinBox() start_rect = QtCore.QRect(option.rect) - start_rect.setWidth(start_rect.width() / 3.0) + start_rect.setWidth(int(start_rect.width() / 3.0)) spinbox_option.rect = start_rect spinbox_option.frame = True spinbox_option.state = option.state diff --git a/mslib/utils/__init__.py b/mslib/utils/__init__.py index f73ec58af..294c527e4 100644 --- a/mslib/utils/__init__.py +++ b/mslib/utils/__init__.py @@ -128,13 +128,13 @@ def decorator(func): def prefix_route(route_function, prefix='', mask='{0}{1}'): - ''' + """ https://stackoverflow.com/questions/18967441/add-a-prefix-to-all-flask-routes/18969161#18969161 Defines a new route function with a prefix. The mask argument is a `format string` formatted with, in that order: prefix, route - ''' + """ def newroute(route, *args, **kwargs): - ''' prefix route ''' + """ prefix route """ return route_function(mask.format(prefix, route), *args, **kwargs) return newroute diff --git a/mslib/utils/auth.py b/mslib/utils/auth.py index ac0dace81..a466ca624 100644 --- a/mslib/utils/auth.py +++ b/mslib/utils/auth.py @@ -76,7 +76,7 @@ def get_password_from_keyring(service_name=NAME, username=""): else: return cred.password except (keyring.errors.KeyringLocked, keyring.errors.InitError, DBusErrorResponse) as ex: - logging.warn(ex) + logging.warning(ex) return None From 9084444962be64f3569015756e401dbe9420f1c5 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 6 Dec 2023 14:54:46 +0100 Subject: [PATCH 085/176] Refactor tutorials (#2105) * missing function added * refactored with the help of pycharm * moved routines * simplified tutorial_hexagoncontrol and more helpers * improved readability * further refactoring * refactored * refactored * refactored * refactored * refactored * refactored * refactored * flake8 * refactored based on finding the windows by its os screen region * cleanup * another step of refactoring * not needed import removed * refactored * refactored * platform module introduced * typo fixed * type changed of region default * fix * failure image removed * list - tuple * refactored * refactored * undo catching of TypeError maybe solved by newer QT version * improved * refactored * refactored * refactoring * refactoring * refactored * updates on refactoring * docstrings and name * refactored * simplified * siimplified * refactored * duplicated line removed * dry_run removed --- docs/gentutorials.rst | 5 +- mslib/msui/linearview.py | 2 + mslib/msui/msui_mainwindow.py | 8 +- mslib/msui/sideview.py | 2 +- mslib/msui/tableview.py | 2 + mslib/msui/topview.py | 2 + mslib/msui/viewwindows.py | 21 +- mslib/utils/config.py | 12 +- requirements.d/tutorials.txt | 2 +- tutorials/start_tutorial.sh | 1 + tutorials/tutorial_hexagoncontrol.py | 296 ++--- tutorials/tutorial_kml.py | 166 +-- tutorials/tutorial_mscolab.py | 986 ++++++++--------- tutorials/tutorial_performancesettings.py | 171 +-- tutorials/tutorial_remotesensing.py | 292 +++-- tutorials/tutorial_satellitetrack.py | 156 +-- tutorials/tutorial_views.py | 1219 +++++++++------------ tutorials/tutorial_waypoints.py | 154 +-- tutorials/tutorial_wms.py | 421 +++---- tutorials/utils/__init__.py | 391 ++++++- tutorials/utils/constants.py | 2 +- tutorials/utils/picture.py | 44 + tutorials/utils/platform_keys.py | 58 + 23 files changed, 2019 insertions(+), 2394 deletions(-) create mode 100644 tutorials/utils/picture.py create mode 100644 tutorials/utils/platform_keys.py diff --git a/docs/gentutorials.rst b/docs/gentutorials.rst index ace49c7f2..c370b649c 100644 --- a/docs/gentutorials.rst +++ b/docs/gentutorials.rst @@ -57,11 +57,12 @@ Keep the following things in mind before running a script Getting Started --------------- -On the Anaconda terminal, type the following :: +On the terminal, type the following :: cd ..../MSS/$ $ export PYTHONPATH=.../MSS # Path of MSS - $ conda activate mssdev + $ export MSUI_CONFIG_PATH=/tmp/msui_tutorials # Path where msui_settings.json gets created and all heler images + $ mamba activate mssdev (mssdev)$ mamba install --file requirements.d/tutorials.txt This will install all the dependencies required for running of the tutorials. diff --git a/mslib/msui/linearview.py b/mslib/msui/linearview.py index 46593b7e8..72b3fefd9 100644 --- a/mslib/msui/linearview.py +++ b/mslib/msui/linearview.py @@ -84,6 +84,8 @@ def __init__(self, parent=None, model=None, _id=None): Set up user interface, connect signal/slots. """ super().__init__(parent, model, _id) + self.settings_tag = "linearview" + self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index d1c7a3cd2..c54b0676e 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -274,10 +274,10 @@ def get_shortcuts(self): actions.extend([(obj.window(), obj.toolTip(), obj.currentText(), obj.objectName(), "", obj) for obj in qobject.findChildren(QtWidgets.QComboBox) if self.cbNoShortcut.checkState()]) - # QAbstractSpinBox, QLineEdit + # QAbstractSpinBox, QLineEdit, QDoubleSpinBox actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) for obj in qobject.findChildren(QtWidgets.QAbstractSpinBox) + - qobject.findChildren(QtWidgets.QLineEdit) + qobject.findChildren(QtWidgets.QLineEdit) + qobject.findChildren(QtWidgets.QDoubleSpinBox) if self.cbNoShortcut.checkState()]) # QPlainTextEdit, QTextEdit actions.extend([(obj.window(), obj.toolTip(), obj.toPlainText(), obj.objectName(), "", obj) @@ -955,6 +955,10 @@ def create_view(self, _type, model): except AttributeError: view_window.enable_navbar_action_buttons() self.viewsChanged.emit() + # this triggers the changeEvent to get the screen position. + # On X11, a window does not have a frame until the window manager decorates it. + view_window.showMaximized() + view_window.showNormal() def get_active_views(self): active_view_windows = [] diff --git a/mslib/msui/sideview.py b/mslib/msui/sideview.py index 793ac98da..75c9ab7d2 100644 --- a/mslib/msui/sideview.py +++ b/mslib/msui/sideview.py @@ -259,7 +259,7 @@ def __init__(self, parent=None, model=None, _id=None): super().__init__(parent, model, _id) self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) - + self.settings_tag = "sideview" # Dock windows [WMS]: self.cbTools.clear() self.cbTools.addItems(["(select to open control)", "Vertical Section WMS"]) diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index a3230c263..8fe687d3b 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -61,8 +61,10 @@ def __init__(self, parent=None, model=None, _id=None): """ """ super().__init__(parent, model, _id) + self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) + self.settings_tag = "tableview" self.setFlightTrackModel(model) self.tableWayPoints.setItemDelegate(ft.WaypointDelegate(self)) diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index 4cbf240d1..24a19a5f5 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -30,6 +30,7 @@ import functools import logging + from mslib.utils.config import config_loader from mslib.utils.coordinate import get_projection_params from PyQt5 import QtGui, QtWidgets, QtCore @@ -192,6 +193,7 @@ def __init__(self, parent=None, mainwindow=None, model=None, _id=None, """ super().__init__(parent, model, _id) logging.debug(_id) + self.settings_tag = "topview" self.mainwindow_signal_login_mscolab = mainwindow.signal_login_mscolab self.mainwindow_signal_logout_mscolab = mainwindow.signal_logout_mscolab self.mainwindow_signal_listFlighttrack_doubleClicked = mainwindow.signal_listFlighttrack_doubleClicked diff --git a/mslib/msui/viewwindows.py b/mslib/msui/viewwindows.py index 6a0b19503..a72c62c6d 100644 --- a/mslib/msui/viewwindows.py +++ b/mslib/msui/viewwindows.py @@ -26,11 +26,12 @@ See the License for the specific language governing permissions and limitations under the License. """ +import logging from abc import abstractmethod from PyQt5 import QtCore, QtWidgets -import logging +from mslib.utils.config import save_settings_qsettings class MSUIViewWindow(QtWidgets.QMainWindow): @@ -212,6 +213,24 @@ def disable_navbar_action_buttons(self): self.cbTools.setEnabled(False) self.tableWayPoints.setEnabled(False) + def changeEvent(self, event): + top_left = self.mapToGlobal(QtCore.QPoint(0, 0)) + if top_left.x() != 0: + os_screen_region = (top_left.x(), top_left.y(), self.width(), self.height()) + settings = {'os_screen_region': os_screen_region} + # we have to save this to reuse it by the tutorials + save_settings_qsettings(self.settings_tag, settings) + QtWidgets.QWidget.changeEvent(self, event) + + def moveEvent(self, event): + top_left = self.mapToGlobal(QtCore.QPoint(0, 0)) + if top_left.x() != 0: + os_screen_region = (top_left.x(), top_left.y(), self.width(), self.height()) + settings = {'os_screen_region': os_screen_region} + # we have to save this to reuse it by the tutorials + save_settings_qsettings(self.settings_tag, settings) + QtWidgets.QWidget.moveEvent(self, event) + class MSUIMplViewWindow(MSUIViewWindow): """ diff --git a/mslib/utils/config.py b/mslib/utils/config.py index af9a918e5..a7849a7d3 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -467,10 +467,12 @@ def save_settings_qsettings(tag, settings, ignore_test=False): """ assert isinstance(tag, str) assert isinstance(settings, dict) - if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + if not ignore_test and ("pytest" in sys.modules): return settings + # ToDo we have to verify if we can all switch to this definition, not having 3 different + q_settings = QtCore.QSettings(os.path.join(constants.MSUI_CONFIG_PATH, "msui-core.conf"), + QtCore.QSettings.IniFormat) - q_settings = QtCore.QSettings("msui", "msui-core") file_path = q_settings.fileName() logging.debug("storing settings for %s to %s", tag, file_path) try: @@ -494,11 +496,13 @@ def load_settings_qsettings(tag, default_settings=None, ignore_test=False): if default_settings is None: default_settings = {} assert isinstance(default_settings, dict) - if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + if not ignore_test and "pytest" in sys.modules: return default_settings settings = {} - q_settings = QtCore.QSettings("msui", "msui-core") + + q_settings = QtCore.QSettings(os.path.join(constants.MSUI_CONFIG_PATH, "msui-core.conf"), + QtCore.QSettings.IniFormat) file_path = q_settings.fileName() logging.debug("loading settings for %s from %s", tag, file_path) try: diff --git a/requirements.d/tutorials.txt b/requirements.d/tutorials.txt index 6ea8aefab..e264bdcb6 100644 --- a/requirements.d/tutorials.txt +++ b/requirements.d/tutorials.txt @@ -3,7 +3,7 @@ # platform: linux-64 mouseinfo=0.1.3 opencv=4.5.5 -playsound=1.3.0=pypi_0 +# playsound=1.3.0=pypi_0 # not yet on conda-forge pyautogui=0.9.54 pyscreeze=0.1.29 python-mss=9.0.1 diff --git a/tutorials/start_tutorial.sh b/tutorials/start_tutorial.sh index bbd3f5dbd..33b681392 100755 --- a/tutorials/start_tutorial.sh +++ b/tutorials/start_tutorial.sh @@ -9,6 +9,7 @@ ## xvfb-run --server-args="-screen 0 1920x1080x24" ./start_tutorial.sh python ./tutorial_commands.py ## export LC_ALL=C +export MSUI_CONFIG_PATH=/tmp/msui_tutorials # fluxbox & set -e diff --git a/tutorials/tutorial_hexagoncontrol.py b/tutorials/tutorial_hexagoncontrol.py index ab71a6077..181343134 100644 --- a/tutorials/tutorial_hexagoncontrol.py +++ b/tutorials/tutorial_hexagoncontrol.py @@ -26,10 +26,12 @@ import pyautogui as pag -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images -from tutorials.utils.picture import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, move_window, + select_listelement, find_and_click_picture, zoom_in, type_and_key) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings + +CTRL, ENTER, WIN, ALT = platform_keys() def automate_hexagoncontrol(): @@ -37,45 +39,16 @@ def automate_hexagoncontrol(): This is the main automating script of the MSS hexagon control of table view which will be recorded and saved to a file having dateframe nomenclature with a .mp4 extension(codec). """ - # Giving time for loading of the MSS GUI. pag.sleep(5) - tv_x = None - tv_y = None - - ctrl, enter, win, alt = platform_keys() - - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(3) - create_tutorial_images() + msui_full_screen_and_open_first_view() # Changing map to Global - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png')) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - pag.press(enter, interval=1) - pag.sleep(5) - except (ImageNotFoundException, OSError, Exception): - print("\n Exception : Map change dropdown could not be located on the screen") - raise - + find_and_click_picture('topviewwindow-01-europe-cyl.png', + "Map change dropdown could not be located on the screen") + select_listelement(2) # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) - pag.click(x, y, interval=2) - pag.move(379, 205, duration=1) - pag.dragRel(70, 75, duration=2) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise + zoom_in('topviewwindow-zoom.png', 'Zoom button could not be located on the screen', + move=(379, 205), dragRel=(70, 75)) # Opening TableView pag.move(500, 0, duration=1) @@ -83,199 +56,96 @@ def automate_hexagoncontrol(): pag.sleep(1) pag.hotkey('ctrl', 't') pag.sleep(3) - # update images, because tableview was opened create_tutorial_images() - # Relocating Tableview by performing operations on table view - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) - pag.moveTo(x + 250, y - 462, duration=1) - if platform == 'linux' or platform == 'linux2': - # the window need to be moved a bit below the topview window - pag.dragRel(400, 387, duration=2) - elif platform == 'win32' or platform == 'darwin': - pag.dragRel(200, 487, duration=2) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2': - # this is to select over the window manager the topview and brings it on top - # ToDo the help search function should be used for this (ctrl f) - pag.keyDown('altleft') - pag.press('tab') - pag.keyUp('tab') - pag.press('tab') - pag.keyUp('tab') - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - elif platform == 'darwin': - pag.keyDown('command') - pag.press('tab') - pag.press('right') - pag.keyUp('command') - pag.sleep(1) - if platform == 'win32' or platform == 'darwin': - pag.dragRel(None, -700, duration=2) - tv_x, tv_y = pag.position() - elif platform == 'linux' or platform == 'linux2': - tv_x, tv_y = pag.position() - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : TableView's Select to open Control option not found on the screen.") - raise - - # Opening Hexagon Control dockwidget - if tv_x is not None and tv_y is not None: - pag.moveTo(tv_x - 250, tv_y + 462, duration=2) - pag.click(duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - + # show both open windows arranged on screen and open hexagon control widget + _arrange_open_app_windows() + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) create_tutorial_images() # Entering Centre Latitude and Centre Longitude of Delhi around which hexagon will be drawn - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-center-latitude.png')) - pag.sleep(1) - pag.click(x + 370, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('28.57', interval=0.3) - pag.sleep(1) - pag.press(enter) + find_and_click_picture('tableviewwindow-0-00-degn.png', + '0.00 degN not found', + region=tableview["os_screen_region"]) + type_and_key('28.57') + find_and_click_picture('tableviewwindow-0-00-dege.png', + '0.00 degE not found', + region=tableview["os_screen_region"]) + type_and_key('77.10') + find_and_click_picture('tableviewwindow-add-hexagon.png', + "'Add Hexagon' button not found on the screen.", + region=tableview["os_screen_region"]) - pag.sleep(1) - pag.click(x + 943, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('77.10', interval=0.3) - pag.sleep(1) - pag.press(enter) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Center Latitude\' button not found on the screen.") - raise + # Changing the Radius of the hexagon + find_and_click_picture('tableviewwindow-200-00-km.png', '200 km not found', + region=tableview["os_screen_region"]) + type_and_key('500.00') + find_and_click_picture('tableviewwindow-remove-hexagon.png', + "'Remove Hexagon' button not found on the screen.", + region=tableview["os_screen_region"]) + pag.press(ENTER) + find_and_click_picture('tableviewwindow-add-hexagon.png', + "'Add Hexagon' button not found on the screen.", + region=tableview["os_screen_region"]) - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise + # Changing the angle of first point of the hexagon + find_and_click_picture('tableviewwindow-0-00-deg.png', '0.00 deg not found', + region=tableview["os_screen_region"]) + type_and_key('90.00') - # Changing the Radius of the hexagon - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-radius.png')) - pag.click(x + 400, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('500.00', interval=0.3) - pag.sleep(1) - pag.press(enter) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Radius\' button not found on the screen.") - raise + _remove_hexagon() + _add_hexagon() - # Clicking on the Remove Hexagon Button - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-remove-hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Remove Hexagon\' button not found on the screen.") - raise + create_tutorial_images() + # Changing to a different angle of first point + find_and_click_picture('tableviewwindow-90-00-deg.png', '90.00 deg not found', + region=tableview["os_screen_region"]) + type_and_key('120.00') - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise + _remove_hexagon() + create_tutorial_images() + _add_hexagon() - # Changing the angle of first point of the hexagon - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-radius.png')) - pag.sleep(1) - pag.click(x + 967, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('90.00', interval=0.3) - pag.sleep(1) - pag.press(enter) + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") + finish() - # Clicking on the Remove Hexagon Button - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-remove-hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Remove Hexagon\' button not found on the screen.") - raise - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise +def _add_hexagon(): + # Clicking on the add hexagon button + find_and_click_picture('tableviewwindow-add-hexagon.png', + "'Add Hexagon' button not found on the screen.") - # Changing to a different angle of first point - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-radius.png')) - pag.sleep(1) - pag.click(x + 967, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('120.00', interval=0.3) - pag.sleep(1) - pag.press(enter) - # Clicking on the Remove Hexagon Button - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-remove-hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Remove Hexagon\' button not found on the screen.") - raise +def _remove_hexagon(): + # Clicking on the Remove Hexagon Button + find_and_click_picture('tableviewwindow-remove-hexagon.png', + "'Remove Hexagon' button not found on the screen.") + pag.press(ENTER) - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-add-hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Radius (for Angle of first point)\' button not found on the screen.") - raise - pag.moveTo(tv_x, tv_y, duration=2) - pag.click(duration=2) +def _arrange_open_app_windows(): + # Relocating Tableview by performing operations on table view + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + x_drag_rel = 250 + y_drag_rel = 687 + move_window(tableview["os_screen_region"], x_drag_rel, y_drag_rel) + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + find_and_click_picture('tableviewwindow-select-to-open-control.png', + 'select to open control not found', + region=tableview["os_screen_region"]) + select_listelement(1) - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + pag.sleep(1) + create_tutorial_images() + + pag.keyDown('altleft') + pag.press('tab') + pag.keyUp('tab') + pag.press('tab') + pag.keyUp('tab') + pag.keyUp('altleft') + pag.sleep(1) if __name__ == '__main__': diff --git a/tutorials/tutorial_kml.py b/tutorials/tutorial_kml.py index a51f88f4b..0a3d9103c 100644 --- a/tutorials/tutorial_kml.py +++ b/tutorials/tutorial_kml.py @@ -24,138 +24,82 @@ limitations under the License. """ +import os import pyautogui as pag -import os.path +from tutorials.utils import (start, finish, + change_color, create_tutorial_images, find_and_click_picture, + load_kml_file, select_listelement, type_and_key, msui_full_screen_and_open_first_view + ) +from tutorials.utils.platform_keys import platform_keys -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images -from tutorials.utils.picture import picture +CTRL, ENTER, WIN, ALT = platform_keys() def automate_kml(): - """ - This is the main automating script of the MSS remote sensing tutorial which will be recorded and saved - to a file having dateframe nomenclature with a .mp4 extension(codec). - """ - # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() + msui_full_screen_and_open_first_view() + _switch_to_europe_map() + _create_and_load_kml_files() + _change_color_and_linewidth() + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") + finish() + - # Satellite Predictor file path - path = os.path.normpath(os.getcwd() + os.sep + os.pardir) - kml_file_path1 = os.path.join(path, 'docs/samples/kml/folder.kml') - kml_file_path2 = os.path.join(path, 'docs/samples/kml/color.kml') +def _switch_to_europe_map(): + find_and_click_picture('topviewwindow-01-europe-cyl.png', "Map change dropdown could not be located on the screen.") + select_listelement(2) + pag.sleep(1) + create_tutorial_images() - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.hotkey('ctrl', 'h') +def _create_and_load_kml_files(): + parent_path = os.path.normpath(os.path.join(os.getcwd(), os.pardir)) + kml_folder_path = os.path.join(parent_path, 'docs/samples/kml') + _load_kml_files(kml_folder_path) pag.sleep(1) - # lets create our helper images create_tutorial_images() - # Changing map to Global - try: - pic = picture('topviewwindow-01-europe-cyl.png') - x, y = pag.locateCenterOnScreen(pic) - - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - pag.press(enter, interval=1) - pag.sleep(5) - except (ImageNotFoundException, OSError, Exception): - print("\n Exception : Map change dropdown could not be located on the screen") - raise - - # Opening KML overlay dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-select-to-open-control.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.press('down', presses=4, interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") +def _load_kml_files(kml_folder_path): create_tutorial_images() + find_and_click_picture('topviewwindow-select-to-open-control.png', + "'select to open control' button/option not found on the screen.") + select_listelement(4) + create_tutorial_images() + _load_individual_kml_file('folder.kml', kml_folder_path) + _load_individual_kml_file('color.kml', kml_folder_path) + pag.sleep(1) + create_tutorial_images() + - # Adding the KML files and loading them - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-add-kml-files.png')) - pag.click(x, y, duration=2) - pag.sleep(1) - pag.typewrite(kml_file_path1, interval=0.1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - pag.click(x, y, duration=2) - pag.sleep(1) - pag.typewrite(kml_file_path2, interval=0.1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add KML Files\' button not found on the screen.") - raise - - # Unselecting and Selecting Files to demonstrate visibility on the map. - try: - x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-unselect-all-files.png')) - pag.click(x1, y1, duration=2) - pag.sleep(2) - try: - x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-select-all-files.png')) - pag.click(x1, y1, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All Files(Unselecting & Selecting)\' button not found on the screen.") - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All Files(Unselecting & Selecting)\' button not found on the screen.") - raise +def _load_individual_kml_file(kml_filename, kml_folder_path): + kml_file_path = os.path.join(kml_folder_path, f'{kml_filename}') + load_kml_file('topviewwindow-add-kml-files.png', kml_file_path, "'Add KML Files' button not found on the screen.") + pag.sleep(1) create_tutorial_images() - # Selecting and Customizing the Folder.kml file + +def _change_color_and_linewidth(): + find_and_click_picture('topviewwindow-select-all-files.png', + "'Select All Files(Unselecting & Selecting)' button not found on the screen.") + create_tutorial_images() pag.move(-200, 0, duration=1) pag.click(interval=2) + _change_color('topviewwindow-change-color.png', + lambda: (pag.move(-220, -300, duration=1), pag.click(interval=2), pag.press(ENTER))) + create_tutorial_images() + _change_linewidth('topviewwindow-2-00.png', lambda: (pag.hotkey(CTRL, 'a'), + [pag.press('down') for _ in range(8)], + type_and_key('2.50'), pag.sleep(1), + type_and_key('5.50'))) - try: - # Changing color of folder.kml file - x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-change-color.png')) - pag.click(x1, y1, duration=2) - pag.sleep(4) - pag.move(-220, -300, duration=1) - pag.click(interval=2) - pag.press(enter) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color \' button not found on the screen.") - raise - try: - # Changing Linewidth of folder.kml file - x1, y1 = pag.locateCenterOnScreen(picture('topviewwindow-change-color.png')) - pag.click(x1 + 12, y1 + 50, duration=2) - pag.sleep(2) - pag.hotkey(ctrl, 'a') - for _ in range(8): - pag.press('down') - pag.sleep(3) - pag.hotkey(ctrl, 'a') - pag.typewrite('6.50', interval=1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color(folder.kml again)\' button not found on the screen.") - raise - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() +def _change_color(img_name, actions): + change_color(img_name, "'Change Color' button not found on the screen.", actions, interval=2) + + +def _change_linewidth(img_name, actions): + change_color(img_name, "'Change Linewidth' button not found on the screen.", actions, interval=2) if __name__ == '__main__': diff --git a/tutorials/tutorial_mscolab.py b/tutorials/tutorial_mscolab.py index baf2c280b..402138b10 100644 --- a/tutorials/tutorial_mscolab.py +++ b/tutorials/tutorial_mscolab.py @@ -1,6 +1,6 @@ """ msui.tutorials.tutorial_mscolab - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This python script generates an automatic demonstration of how to use Mission Support System Collaboration for users to collaborate in flight planning and thereby explain how to use it's various functionalities. @@ -22,18 +22,30 @@ See the License for the specific language governing permissions and limitations under the License. """ - +import os import pyautogui as pag -import os.path -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, + select_listelement, find_and_click_picture, type_and_key) + +from tutorials.utils.platform_keys import platform_keys from tutorials.utils.picture import picture +CTRL, ENTER, WIN, ALT = platform_keys() -# ToDo fix waypoint movement +USERNAME = 'John Doe' +EMAIL = 'johndoe@gmail.com' +PASSWORD = 'johndoe' +OPERATION_NAME = 'operation_of_john_doe' +OPERATION_DESCRIPTION = """This is John Doe's operation. He wants his collegues and friends \ + to collaborate on this operation with him in the network. Mscolab, here, \ + will be very helpful for Joe with various features to use!""" +PATH = os.path.normpath(os.getcwd() + os.sep + os.pardir) +EXMPLE_IMAGE_PATH = os.path.join(os.path.join(PATH, 'docs', 'mss-logo.png')) +MSCOLAB_URL = 'http://localhost:8083/' + +# ToDo fix waypoint movement def automate_mscolab(): """ This is the main automating script of the Mission Support System Collaboration or Mscolab tutorial which will be @@ -41,230 +53,243 @@ def automate_mscolab(): """ # Giving time for loading of the MSS GUI. pag.sleep(5) - - ctrl, enter, win, alt = platform_keys() - - # Different inputs required in mscolab - username = 'John Doe' - email = 'johndoe@gmail.com' - password = 'johndoe' - p_name = 'operation_of_john_doe' - p_description = """This is John Doe's operation. He wants his collegues and friends to collaborate on this operation - with him in the network. Mscolab, here, will be very helpful for Joe with various features to use!""" - chat_message1 = 'Hi buddy! What\'s the next plan? I have marked the points in topview for the dummy operation.' \ - 'Just have a look, please!' - chat_message2 = 'Hey there user! This is the chat feature of MSCOLAB. You can have a conversation with your ' \ - 'fellow mates about the operation and discuss ideas and plans.' - search_message = 'chat feature of MSCOLAB' - localhost_url = 'http://localhost:8083' - - # Example upload of msui logo during Chat Window demonstration. - path = os.path.normpath(os.getcwd() + os.sep + os.pardir) - example_image_path = os.path.join(path, 'docs/mss-logo.png') - modify_x, modify_y = None, None - # _, sc_height = pag.size()[0] - 1, pag.size()[1] - 1 - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(4) - + msui_full_screen_and_open_first_view(view_cmd=None) + # create initial images, needs to become updated when elements on the widget change create_tutorial_images() - # Connecting to Mscolab (Mscolab localhost server must be activated beforehand for this to work) - try: - x, y = pag.locateCenterOnScreen(picture('msuimainwindow-connect.png')) - pag.sleep(1) - pag.click(x, y, duration=2) - pag.sleep(2) - create_tutorial_images() - # Entering local host URL - try: - x1, y1 = pag.locateCenterOnScreen(picture('mscolabconnectdialog-http-localhost-8083.png')) - pag.click(x1, y1, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.typewrite(localhost_url, interval=0.2) - pag.sleep(1) - pag.press("enter") - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Url not found\' button not found on the screen.") - raise - create_tutorial_images() - pag.sleep(1) - try: - x2, y2 = pag.locateCenterOnScreen(picture('mscolabconnectdialog-add-user.png')) - pag.click(x2, y2, duration=2) - pag.sleep(4) - - # Entering details of new user - new_user_input = [username, email, password, password] - for input in new_user_input: - pag.typewrite(input, interval=0.2) - pag.sleep(1) - pag.press('tab') - pag.sleep(2) - pag.press('tab') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) + _connect_to_mscolab_url() + create_tutorial_images() + _create_user() + _login_user_after_creation() + create_tutorial_images() + _create_operation() + create_tutorial_images() + open_operations_x, open_operations_y = _activate_operation() + _adminwindow() + _chatting() + wp1_x, wp1_y = _topview_wp() + _versionhistory() + create_tutorial_images() + _work_asynchronously(wp1_x, wp1_y) + _toggle_between_local_and_mscolab(open_operations_x, open_operations_y) + _delete_operation() + create_tutorial_images() + _delete_account() + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") + finish() - # if pag.locateCenterOnScreen(picture('mscolab', 'emailid_taken.png')) is not None: - # print("The email id you have provided is already registered!") - # pag.sleep(1) - # pag.press('left') - # pag.sleep(1) - # pag.press(enter) - # pag.sleep(2) - - # Entering details of the new user that's created - pag.press('tab', presses=2, interval=1) - pag.typewrite(email, interval=0.2) - pag.press('tab') - pag.typewrite(password, interval=0.2) - - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add user\' button not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Connect to Mscolab\' button not found on the screen.") - raise +def _delete_account(): + find_and_click_picture('msuimainwindow-john-doe.png', + 'John Doe (in mscolab window) Profile/Logo button not found.', + xoffset=40) + select_listelement(1) create_tutorial_images() - # Opening a new Mscolab Operation - file_menu = picture("msuimainwindow-menubar.png", boundingbox=(0, 0, 38, 22)) - try: - file_x, file_y = pag.locateCenterOnScreen(file_menu) - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - for _ in range(2): - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - pag.press('tab') + find_and_click_picture('profilewindow-delete-account.png', + 'Delete account not found.') + pag.press(ENTER) + + +def _delete_operation(): + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22)) + select_listelement(4, key=None, sleep=1) + pag.press('right') + select_listelement(4, key=None, sleep=1) + pag.press(ENTER) + # Deleting the operation + pag.sleep(2) + type_and_key(OPERATION_NAME, interval=0.3) + pag.press(ENTER) - for input in [p_name, p_description]: - pag.typewrite(input, interval=0.05) - pag.press('tab') - pag.sleep(2) - - create_tutorial_images() - try: - x1, y1 = pag.locateCenterOnScreen(picture('addoperationdialog-ok.png')) - pag.moveTo(x1, y1, duration=2) - pag.click(x1, y1, duration=2) - pag.sleep(2) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Ok\' button when adding a new operation not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'File\' menu button not found on the screen.") - raise +def _toggle_between_local_and_mscolab(open_operations_x, open_operations_y): + # Activating a local flight track + if open_operations_x is not None and open_operations_y is not None: + pag.moveTo(open_operations_x - 900, open_operations_y, duration=2) + pag.sleep(1) + pag.doubleClick(open_operations_x - 900, open_operations_y, duration=2) + pag.sleep(2) + else: + print("Image Not Found : Open Operations label (for activating local flighttrack) not found, previously!") + # Opening Topview again and making some changes in it + find_and_click_picture("msuimainwindow-menubar.png", + 'Views menu not found', + bounding_box=(40, 0, 80, 22)) + select_listelement(1, sleep=1) create_tutorial_images() + pag.sleep(4) + # Adding waypoints in a different fashion than the pevious one (for local flighttrack) + find_and_click_picture('topviewwindow-ins-wp.png', + 'Add waypoint (in topview again) button not found.') + pag.move(-50, 150, duration=1) + pag.click(interval=2) - pic = picture("msuimainwindow-operations.png", boundingbox=(0, 0, 72, 17)) - try: - open_operations_x, open_operations_y = pag.locateCenterOnScreen(pic) + pag.sleep(1) + pag.move(65, 10, duration=1) + pag.click(duration=2) + pag.sleep(1) + pag.move(-100, 10, duration=1) + pag.click(duration=2) + pag.sleep(1) + pag.move(90, 10, duration=1) + pag.click(duration=2) + pag.sleep(3) + # Sending topview to the background + pag.hotkey('CTRL', 'up') + # Activating the opened mscolab operation + if open_operations_x is not None and open_operations_y is not None: pag.moveTo(open_operations_x, open_operations_y + 20, duration=2) pag.sleep(1) pag.doubleClick(open_operations_x, open_operations_y + 20, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Operations\' label not found on the screen.") - raise - - # Managing Users for the operation that you are working on - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right', presses=2, interval=2) - pag.press('down', presses=3, interval=2) - pag.press('right') - pag.press('down', presses=2, interval=2) - pag.press(enter) pag.sleep(3) - else: - print('Image not Found : File menu not found (while managing users)') - create_tutorial_images() - pic = picture("mscolabadminwindow-all-users-without-permission.png") - ref_x, ref_y = pag.locateCenterOnScreen(pic) - left_side = (ref_x, ref_y, 400, 800) + # Opening the topview again by double-clicking on open views + x, y = find_and_click_picture('msuimainwindow-open-views.png', 'open views not found') - pic = picture("mscolabadminwindow-all-users-with-permission.png") - ref_x, ref_y = pag.locateCenterOnScreen(pic) - right_side = (ref_x, ref_y, 800, 1000) - - try: - selectall_left_x, selectall_left_y = pag.locateCenterOnScreen(picture('mscolabadminwindow-select-all.png'), - region=left_side) - pag.moveTo(selectall_left_x, selectall_left_y, duration=2) - pag.click(selectall_left_x, selectall_left_y, duration=1) - pag.sleep(2) - pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) - pag.click(selectall_left_x + 90, selectall_left_y, duration=1) - pag.sleep(2) + pag.moveTo(x, y + 22, duration=2) + pag.doubleClick(x, y + 22, duration=2) + pag.sleep(3) - pag.click(selectall_left_x - 61, selectall_left_y, duration=1) - pag.typewrite('test', interval=1) - pag.moveTo(selectall_left_x, selectall_left_y, duration=2) - pag.click(duration=2) + # Closing the topview + pag.hotkey(ALT, 'f4') + pag.press('left') pag.sleep(1) - pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) - pag.click(duration=2) + pag.press(ENTER) pag.sleep(2) + else: + print("Image Not Found : Open Operations label (for activating mscolab operation) not found, previously!") - # Deleting search item from the search box - pag.click(selectall_left_x - 61, selectall_left_y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.press('backspace') - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All\' leftside button not found on the screen.") - raise +def _work_asynchronously(wp1_x, wp1_y): + find_and_click_picture('msuimainwindow-work-asynchronously.png', + 'Work Asynchronously (in mscolab) ' + 'checkbox not found ', bounding_box=(0, 0, 149, 23)) + work_async_x, work_async_y = pag.position() + pag.sleep(3) + # Opening Topview again to move waypoints during working locally! + find_and_click_picture("msuimainwindow-menubar.png", + 'Views menu not found', + bounding_box=(40, 0, 80, 22)) + select_listelement(1, sleep=1) + find_and_click_picture('msuimainwindow-server-options.png', + 'Server options button not found.') + select_listelement(1) + create_tutorial_images() + find_and_click_picture('mergewaypointsdialog-keep-server-waypoints.png', + 'Merge waypoints keepe server waypoints not found') + pag.press(ENTER) + pag.keyDown('altleft') + # this selects the next window in the window manager on budgie and kde + pag.press('tab') + pag.keyUp('tab') + pag.keyUp('altleft') + # Moving waypoints. + create_tutorial_images() + find_and_click_picture('topviewwindow-mv-wp.png', + 'Move waypoints not found') + wp2_x, wp2_y = find_and_click_picture('topviewwindow-top-view.png', + 'Topviews Point 2 not found on the screen.', + bounding_box=(322, 112, 346, 135)) + pag.click(wp2_x, wp2_y, interval=2) + pag.moveTo(wp2_x, wp2_y, duration=1) + pag.dragTo(wp1_x, wp1_y + 20, duration=1, button='left') + pag.click(interval=2) + find_and_click_picture('topviewwindow-ins-wp.png', + 'Topview Window not found') + pag.move(-50, 150, duration=1) + pag.click(interval=2) + # Closing topview after displacing waypoints + pag.hotkey(ALT, 'f4') + pag.press('left') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) + create_tutorial_images() + find_and_click_picture('msuimainwindow-server-options.png', + 'Overwrite with local waypoints (during saving to server) button not found.') + select_listelement(2) + create_tutorial_images() + find_and_click_picture('mergewaypointsdialog-overwrite-with-local-waypoints.png', + 'Merge waypoints overwrite with local waypoints not found.') + pag.press(ENTER) + create_tutorial_images() + # Unchecking work asynchronously + pag.moveTo(work_async_x, work_async_y, duration=2) + pag.click(work_async_x, work_async_y, duration=2) + + +def _adminwindow(): + # open admin window + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22), duration=1) + select_listelement(4, key=None, sleep=1) + pag.press('right') + select_listelement(2, sleep=1) + pag.sleep(1) + create_tutorial_images() + # positions of buttons in the view mscolab admin windo + pic = picture("mscolabadminwindow-all-users-without-permission.png") + pos = pag.locateOnScreen(pic) + left_side = (pos.left, pos.top, 500, 800) + pic = picture("mscolabadminwindow-all-users-with-permission.png") + pos = pag.locateOnScreen(pic) + right_side = (pos.left, pos.top, 500, 1000) + selectall_left_x, selectall_left_y = find_and_click_picture('mscolabadminwindow-select-all.png', + 'Select All leftside button not found', + region=left_side) + pag.moveTo(selectall_left_x, selectall_left_y, duration=2) + pag.click(selectall_left_x, selectall_left_y, duration=1) + pag.sleep(2) + pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) + pag.click(selectall_left_x + 90, selectall_left_y, duration=1) + pag.sleep(2) + pag.click(selectall_left_x - 61, selectall_left_y, duration=1) + pag.typewrite('test', interval=1) + pag.moveTo(selectall_left_x, selectall_left_y, duration=2) + pag.click(duration=2) + pag.sleep(1) + pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) + pag.click(duration=2) + pag.sleep(2) + # Deleting search item from the search box + pag.click(selectall_left_x - 61, selectall_left_y, duration=2) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.press('backspace') + pag.sleep(2) # Selecting and adding users for collaborating in the operation. if selectall_left_x is not None and selectall_left_y is not None: for count in range(4): pag.moveTo(selectall_left_x, selectall_left_y + 57 * count, duration=1) pag.click(selectall_left_x, selectall_left_y + 57 * count, duration=1) + x, y = find_and_click_picture('mscolabadminwindow-add.png', + 'Add (all the users) button not found on the screen.', + region=left_side) - try: - x, y = pag.locateCenterOnScreen(picture('mscolabadminwindow-add.png'), region=left_side) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add (all the users)\' button not found on the screen.") - raise + pag.moveTo(x, y, duration=2) + pag.click(x, y, duration=2) + pag.sleep(1) else: print('Not able to select users for adding') - # Searching and changing user permissions and deleting users - try: - selectall_right_x, selectall_right_y = pag.locateCenterOnScreen(picture('mscolabadminwindow-select-all.png'), - region=right_side) - pag.moveTo(selectall_right_x - 170, selectall_right_y, duration=2) - pag.click(selectall_right_x - 170, selectall_right_y, duration=2) - pag.typewrite('t', interval=0.3) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.press('backspace') - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All (modifying permissions)\' button not found on the screen.") - raise - + selectall_right_x, selectall_right_y = find_and_click_picture('mscolabadminwindow-select-all.png', + 'Select All (modifying permissions) ' + 'button not found on the screen.', + region=right_side) + find_and_click_picture('mscolabadminwindow-deselect-all.png', + 'Select All (modifying permissions) ' + 'button not found on the screen.', + region=right_side) + pag.moveTo(selectall_right_x - 170, selectall_right_y, duration=2) + pag.click(selectall_right_x - 170, selectall_right_y, duration=2) + pag.typewrite('t', interval=0.3) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.press('backspace') + pag.sleep(1) # Selecting and modifying user roles if selectall_right_x is not None and selectall_right_y is not None: for i in range(3): @@ -272,21 +297,20 @@ def automate_mscolab(): # pag.move(selectall_right_x, row_gap * (i + 1), duration=1) pag.click(duration=1) pag.sleep(2) - try: - modify_x, modify_y = pag.locateCenterOnScreen(picture('mscolabadminwindow-modify.png')) - pag.click(modify_x - 141, modify_y, duration=2) - if i == 0: - pag.press('up', presses=2) - else: - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(1) - pag.click(modify_x, modify_y, duration=2) + modify_x, modify_y = find_and_click_picture('mscolabadminwindow-modify.png', + 'Modify (access permissions) ' + 'button not found on the screen.)', + region=right_side) + pag.click(modify_x - 141, modify_y, duration=2) + if i == 0: + pag.press('up', presses=2) + else: + pag.press('down') pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Modify (access permissions)\' button not found on the screen.") - raise + pag.press(ENTER) + pag.sleep(1) + pag.click(modify_x, modify_y, duration=2) + pag.sleep(1) # Deleting the first user in the list pag.moveTo(selectall_right_x, selectall_right_y + 56, duration=1) @@ -306,380 +330,224 @@ def automate_mscolab(): pag.click(selectall_right_x - 82, selectall_right_y, duration=2) pag.press('down') pag.sleep(1) - pag.press(enter) + pag.press(ENTER) pag.sleep(1) pag.sleep(1) else: print('Image Not Found: Select All button has previously not found on the screen') - # Closing user permission window - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') + pag.hotkey(ALT, 'f4') pag.sleep(2) - # Demonstrating Chat feature of mscolab to the user - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right', presses=2, interval=2) - pag.press(enter) - pag.sleep(3) - else: - print('Image not Found : File menu not found (while opening Chat window)') +def _versionhistory(): + # Opening version history window. + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22)) + select_listelement(2, sleep=1) create_tutorial_images() - # Sending messages to collaboraters or other users - pag.typewrite(chat_message1, interval=0.05) + # Operations performed in version history window. + x, y = find_and_click_picture('mscolabversionhistory-refresh-window.png', + 'Refresh Window (in version history window) button not found.') + pag.moveTo(x, y, duration=2) + pag.click(x, y, duration=2) pag.sleep(2) - try: - x, y = pag.locateCenterOnScreen(picture('mscolaboperation-send.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(2) - - pag.typewrite(chat_message2, interval=0.05) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - # Uploading an example image of msui logo. - pag.moveTo(x, y + 40, duration=2) - pag.click(x, y + 40, duration=2) - pag.sleep(1) - pag.typewrite(example_image_path, interval=0.2) - pag.sleep(1) - pag.press(enter) - pag.sleep(1) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Send (while in chat window)\' button not found on the screen.") - raise - # Searching messages in the chatbox using the search bar - try: - previous_x, previous_y = pag.locateCenterOnScreen(picture('mscolaboperation-previous.png')) - pag.moveTo(previous_x - 70, previous_y, duration=2) - pag.click(previous_x - 70, previous_y, duration=2) - pag.sleep(1) - pag.typewrite(search_message, interval=0.3) - pag.sleep(1) - pag.moveTo(previous_x + 82, previous_y, duration=2) - pag.click(previous_x + 82, previous_y, duration=2) - pag.sleep(2) - pag.moveTo(previous_x, previous_y, duration=2) - pag.click(previous_x, previous_y, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Previous (while in chat window searching operation)\' button not found on the screen.") - raise - # Closing the Chat Window - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') + pag.click(x, y + 32, duration=2) + pag.sleep(1) + pag.press('down') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) + pag.moveTo(x, y + 164, duration=1) + pag.click(x, y + 164, duration=1) + pag.sleep(4) + # Changing this change to a named version + # Giving name to a change version. + x1, y1 = pag.locateCenterOnScreen(picture('mscolabversionhistory-name-version.png')) + pag.sleep(1) + pag.moveTo(x1, y1, duration=2) + pag.click(x1, y1, duration=2) + pag.sleep(1) + pag.typewrite('Initial waypoint', interval=0.3) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + pag.moveTo(x, y + 93, duration=1) + pag.click(x, y + 93, duration=1) pag.sleep(2) + pag.moveTo(x, y + 125, duration=1) + pag.click(x, y + 125, duration=1) + pag.sleep(1) + x2, y2 = pag.locateCenterOnScreen(picture('mscolabversionhistory-checkout.png')) + pag.sleep(1) + pag.moveTo(x2, y2, duration=2) + pag.click(x2, y2, duration=2) + pag.sleep(1) + pag.press(ENTER) + # Filtering changes to display only named changes. + pag.moveTo(x1 + 29, y1, duration=1) + pag.click(x1 + 29, y1, duration=1) + pag.sleep(1) + pag.press('up') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(3) + # Closing the Version History Window + pag.hotkey(ALT, 'f4') + pag.sleep(4) - # Opening Topview - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.sleep(1) - pag.press('right') - pag.sleep(1) - pag.press(enter) - pag.sleep(4) +def _topview_wp(): + # Opening Topview + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(40, 0, 80, 22)) + select_listelement(1, sleep=1) create_tutorial_images() # Adding some waypoints to topview - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - wp1_x, wp1_y = pag.position() - pag.sleep(1) - pag.move(65, 65, duration=1) - pag.click(duration=2) - wp2_x, wp2_y = pag.position() - pag.sleep(1) - - pag.move(-150, 30, duration=1) - pag.click(duration=2) - pag.sleep(1) - pag.move(180, 100, duration=1) - pag.click(duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint (in topview) button not found on the screen.") - raise - + find_and_click_picture('topviewwindow-ins-wp.png', + 'Topview insert wp button not found') + pag.move(-50, 150, duration=1) + pag.click(interval=2) + wp1_x, wp1_y = pag.position() + pag.sleep(1) + pag.move(65, 65, duration=1) + pag.click(duration=2) + # wp2_x, wp2_y = pag.position() + pag.sleep(1) + pag.move(-150, 30, duration=1) + pag.click(duration=2) + pag.sleep(1) + pag.move(180, 100, duration=1) + pag.click(duration=2) + pag.sleep(3) # Closing the topview - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') + pag.hotkey(ALT, 'f4') pag.press('left') pag.sleep(1) - pag.press(enter) + pag.press(ENTER) pag.sleep(1) + return wp1_x, wp1_y - # Opening version history window. - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right', presses=2, interval=1) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(1) +def _chatting(): + # Demonstrating Chat feature of mscolab to the user + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22)) + select_listelement(1, sleep=1) + pag.sleep(3) create_tutorial_images() - # Operations performed in version history window. - try: - x, y = pag.locateCenterOnScreen(picture('mscolabversionhistory-refresh-window.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.click(x, y + 32, duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - pag.moveTo(x, y + 164, duration=1) - pag.click(x, y + 164, duration=1) - pag.sleep(4) - # Changing this change to a named version - try: - # Giving name to a change version. - x1, y1 = pag.locateCenterOnScreen(picture('mscolabversionhistory-name-version.png')) - pag.sleep(1) - pag.moveTo(x1, y1, duration=2) - pag.click(x1, y1, duration=2) - pag.sleep(1) - pag.typewrite('Initial waypoint', interval=0.3) - pag.sleep(1) - pag.press(enter) - pag.sleep(1) - - pag.moveTo(x, y + 93, duration=1) - pag.click(x, y + 93, duration=1) - pag.sleep(2) + chat_message1 = 'Hi buddy! What\'s the next plan? I have marked the points in topview for the dummy operation.' + chat_message2 = 'Hey there user! This is the chat feature of MSCOLAB. You can have a conversation with your ' + # Sending messages to collaboraters or other users + pag.typewrite(chat_message1, interval=0.05) + pag.sleep(2) + x, y = find_and_click_picture('mscolaboperation-send.png', + 'Send (while in chat window) button not found on the screen.') + pag.typewrite(chat_message2, interval=0.05) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) + # Uploading an example image of msui logo. + pag.moveTo(x, y + 40, duration=2) + pag.click(x, y + 40, duration=2) + pag.sleep(1) + pag.typewrite(EXMPLE_IMAGE_PATH, interval=0.2) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + pag.moveTo(x, y, duration=2) + pag.click(x, y, duration=2) + pag.sleep(2) + # Searching messages in the chatbox using the search bar + previous_x, previous_y = find_and_click_picture('mscolaboperation-previous.png', + 'Previous (while in chat window searching' + ' operation) button not found.') + pag.moveTo(previous_x - 70, previous_y, duration=2) + pag.click(previous_x - 70, previous_y, duration=2) + pag.sleep(1) + search_message = 'chat feature of MSCOLAB' + pag.typewrite(search_message, interval=0.3) + pag.sleep(1) + pag.moveTo(previous_x + 82, previous_y, duration=2) + pag.click(previous_x + 82, previous_y, duration=2) + pag.sleep(2) + pag.moveTo(previous_x, previous_y, duration=2) + pag.click(previous_x, previous_y, duration=2) + pag.sleep(2) + # Closing the Chat Window + pag.hotkey(ALT, 'f4') + pag.sleep(2) - pag.moveTo(x, y + 125, duration=1) - pag.click(x, y + 125, duration=1) - pag.sleep(1) - x2, y2 = pag.locateCenterOnScreen(picture('mscolabversionhistory-checkout.png')) - pag.sleep(1) - pag.moveTo(x2, y2, duration=2) - pag.click(x2, y2, duration=2) - pag.sleep(1) - pag.press(enter) +def _activate_operation(): + find_and_click_picture('msuimainwindow-operations.png', + 'Operations label not found on screen', + bounding_box=(0, 0, 72, 17)) + open_operations_x, open_operations_y = pag.position() + pag.moveTo(open_operations_x, open_operations_y + 20, duration=2) + pag.sleep(1) + pag.doubleClick(open_operations_x, open_operations_y + 20, duration=2) + pag.sleep(2) + return open_operations_x, open_operations_y - # Filtering changes to display only named changes. - pag.moveTo(x1 + 29, y1, duration=1) - pag.click(x1 + 29, y1, duration=1) - pag.sleep(1) - pag.press('up') - pag.sleep(1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Name Version (in topview) button not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException : Refresh Window (in version history window) button not found on the screen.") - raise - # Closing the Version History Window - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') - pag.sleep(4) +def _create_operation(): + find_and_click_picture("msuimainwindow-menubar.png", + 'File menu not found', + bounding_box=(0, 0, 38, 22)) + select_listelement(1, key=None, sleep=1) + pag.press('right') + select_listelement(1, sleep=1) + pag.sleep(1) + pag.press('tab') + for value in [OPERATION_NAME, OPERATION_DESCRIPTION]: + type_and_key(value, key='tab', interval=0.05) + pag.sleep(1) create_tutorial_images() - # Activate Work Asynchronously with the mscolab server. - # ToDo this needs to be extracted to a different tutorial - """ - try: - x, y = pag.locateCenterOnScreen(picture('msuimainwindow-work-asynchronously.png', - boundingbox=(0, 0, 149, 23))) - pag.sleep(1) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - work_async_x, work_async_y = pag.position() - pag.sleep(3) - # Opening Topview again to move waypoints during working locally! - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right') - pag.sleep(1) - pag.press(enter) - pag.sleep(4) - - # Moving waypoints. - create_tutorial_images() - point2 = picture("topviewwindow-top-view.png", boundingbox=(322,112, 346, 135)) - try: - if wp1_x is not None and wp2_x is not None: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-mv-wp.png')) - pag.click(x, y, interval=2) - try: - wp2_x, wp2_y = pag.locateCenterOnScreen(point2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Point 2\' not found on the screen.") - raise - pag.click(wp2_x, wp2_y, interval=2) - pag.moveTo(wp2_x, wp2_y, duration=1) - pag.dragTo(wp1_x, wp1_y + 20, duration=1, button='left') - pag.click(interval=2) - pag.sleep(4) - - except (ImageNotFoundException, OSError, Exception): - print("\n Exception : Move Waypoint button could not be located on the screen") - raise - # Closing topview after displacing waypoints - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - # Saving to Server the Work that has been done asynchronously. - if work_async_x is not None and work_async_y is not None: - pag.moveTo(work_async_x + 600, work_async_y, duration=2) - pag.click(work_async_x + 600, work_async_y, duration=2) - pag.press('down', presses=2, interval=1) - pag.press(enter) - pag.sleep(3) - - create_tutorial_images() - # Overwriting Server waypoints with Local Waypoints. - try: - x, y = pag.locateCenterOnScreen(picture('msuimainwindow-server-options.png')) - pag.sleep(1) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.press('down', presses=2, interval=1) - pag.sleep(2) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Overwrite with local waypoints (during saving to server) button" - " not found on the screen.") - raise - create_tutorial_images() - - - # Unchecking work asynchronously - pag.moveTo(work_async_x, work_async_y, duration=2) - pag.click(work_async_x, work_async_y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Work Asynchronously (in mscolab) checkbox not found on the screen.") - raise - """ - # Activating a local flight track - if open_operations_x is not None and open_operations_y is not None: - pag.moveTo(open_operations_x - 900, open_operations_y, duration=2) - pag.sleep(1) - pag.doubleClick(open_operations_x - 900, open_operations_y, duration=2) - pag.sleep(2) - else: - print("Image Not Found : Open Operations label (for activating local flighttrack) not found, previously!") - - # Opening Topview again and making some changes in it - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.sleep(1) - pag.press('right') - pag.sleep(1) - pag.press(enter) - pag.sleep(4) - # Adding waypoints in a different fashion than the pevious one (for local flighttrack) - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(65, 10, duration=1) - pag.click(duration=2) - pag.sleep(1) - - pag.move(-100, 10, duration=1) - pag.click(duration=2) - pag.sleep(1) - pag.move(90, 10, duration=1) - pag.click(duration=2) - pag.sleep(3) - - # Sending topview to the background - pag.hotkey('ctrl', 'up') - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint (in topview again) button not found on the screen.") - raise - - # Activating the opened mscolab operation - if open_operations_x is not None and open_operations_y is not None: - pag.moveTo(open_operations_x, open_operations_y + 20, duration=2) - pag.sleep(1) - pag.doubleClick(open_operations_x, open_operations_y + 20, duration=2) - pag.sleep(3) + find_and_click_picture('addoperationdialog-ok.png', + 'OK button when adding a new operation not found on the screen.') + pag.press(ENTER) + + +def _login_user_after_creation(): + # Login new user + pag.press('tab', presses=2) + type_and_key(ENTER, key='tab') + type_and_key(PASSWORD, key='tab') + pag.press(ENTER) + # store userdata + pag.press('left') + pag.press(ENTER) - # Opening the topview again by double clicking on open views - try: - x, y = pag.locateCenterOnScreen(picture('msuimainwindow-open-views.png')) - pag.moveTo(x, y + 22, duration=2) - pag.doubleClick(x, y + 22, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Open Views label not found on the screen.") - # Closing the topview - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - else: - print("Image Not Found : Open Operations label (for activating mscolab operation) not found, previously!") +def _create_user(): + find_and_click_picture('mscolabconnectdialog-add-user.png', 'Add User Button not found') + pag.sleep(4) + # Entering details of new user + new_user_input = [USERNAME, EMAIL, PASSWORD, PASSWORD] + for value in new_user_input: + type_and_key(value, key='tab') + pag.press('tab') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) - # Deleting the operation - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=1) - pag.sleep(1) - pag.press('right', presses=2, interval=1) - pag.sleep(1) - pag.press('down', presses=3, interval=1) - pag.press(enter, presses=2, interval=2) - pag.sleep(2) - pag.typewrite(p_name, interval=0.3) - pag.press(enter, presses=2, interval=2) - pag.sleep(3) +def _connect_to_mscolab_url(): + # connect + find_and_click_picture('msuimainwindow-connect.png', + "Connect to Mscolab button not found on the screen.") create_tutorial_images() - # Opening user profile - try: - x, y = pag.locateCenterOnScreen(picture('msuimainwindow-john-doe.png')) - pag.moveTo(x + 40, y, duration=2) - pag.click(x + 40, y, duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter, presses=2, interval=2) - pag.sleep(2) - - pag.click(x + 32, y, duration=2) - pag.sleep(1) - pag.press('down', presses=2, interval=2) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : John Doe (in mscolab window) Profile/Logo button not found on the screen.") - raise - - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + # create user on server + find_and_click_picture('mscolabconnectdialog-http-localhost-8083.png', 'Url not found') + type_and_key(MSCOLAB_URL) + pag.press(ENTER) + # update server data + pag.press('left') + pag.press(ENTER) if __name__ == '__main__': diff --git a/tutorials/tutorial_performancesettings.py b/tutorials/tutorial_performancesettings.py index c485c12e1..ec87619a6 100644 --- a/tutorials/tutorial_performancesettings.py +++ b/tutorials/tutorial_performancesettings.py @@ -22,16 +22,16 @@ See the License for the specific language governing permissions and limitations under the License. """ - -import pyautogui as pag import os.path -import tempfile +import pyautogui as pag import shutil +import tempfile -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images -from tutorials.utils.picture import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, + create_tutorial_images, select_listelement, find_and_click_picture, type_and_key) +from tutorials.utils.platform_keys import platform_keys + +CTRL, ENTER, WIN, ALT = platform_keys() def automate_performance(): @@ -42,140 +42,73 @@ def automate_performance(): # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() - - # Satellite Predictor file path + # Performance file path path = os.path.normpath(os.getcwd() + os.sep + os.pardir) ps_file_path = os.path.join(path, 'docs/samples/config/msui/performance_simple.json.sample') dirpath = tempfile.mkdtemp() sample = os.path.join(dirpath, 'example.json') shutil.copy(ps_file_path, sample) - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) - pag.hotkey('ctrl', 't') - pag.sleep(3) - - create_tutorial_images() - + msui_full_screen_and_open_first_view(view_cmd='t') # Opening Performance Settings dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) - pag.moveTo(x + 250, y - 462, duration=1) - if platform == 'linux' or platform == 'linux2': - # the window need to be moved a bit below the topview window - pag.dragRel(400, 387, duration=2) - elif platform == 'win32' or platform == 'darwin': - pag.dragRel(200, 487, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") - raise - - tv_x, tv_y = pag.position() - # Opening Performance Control dockwidget - if tv_x is not None and tv_y is not None: - pag.moveTo(tv_x - 250, tv_y + 462, duration=2) - pag.click(duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) + find_and_click_picture('tableviewwindow-select-to-open-control.png', + 'Select to open control not found') + select_listelement(2) + pag.press(ENTER) + x, y = pag.position() + pag.moveTo(x + 250, y - 462, duration=1) + pag.dragRel(400, 387, duration=2) + pag.sleep(1) + + # updating tutorial images create_tutorial_images() # Exploring through the file system and loading the performance settings json file for a dummy aircraft. - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) - pag.click(x, y, duration=2) - pag.sleep(1) - pag.typewrite(sample, interval=0.1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select\' button (for loading performance_settings.json file) not found on the screen.") - raise + find_and_click_picture('tableviewwindow-select.png', 'Select button not found') + type_and_key(sample) + # Checking the Show Performance checkbox to display the settings file in the table view - try: - pic = picture('tableviewwindow-show-performance.png', boundingbox=(0, 0, 140, 23)) - x, y = pag.locateCenterOnScreen(pic) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Show Performance\' checkbox not found on the screen.") - raise + find_and_click_picture('tableviewwindow-show-performance.png', + 'Show performance button not found', + bounding_box=(0, 0, 140, 23)) # Changing the maximum take off weight - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-maximum-take-off-weight-lb.png')) - pag.click(x + 318, y, duration=2) - pag.sleep(4) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('87000', interval=0.3) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Maximum Takeoff Weight\' fill box not found on the screen.") - raise + find_and_click_picture('tableviewwindow-maximum-take-off-weight-lb.png', + 'Max take off weight lb not found') + x, y = pag.position() + pag.click(x + 318, y, duration=2) + type_and_key('87000') + pag.sleep(2) + # Changing the aircraft weight of the dummy aircraft - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-aircraft-weight-no-fuel-lb.png')) - pag.click(x + 300, y, duration=2) - pag.sleep(4) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('48000', interval=0.3) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Aircraft weight\' fill box not found on the screen.") - raise + find_and_click_picture('tableviewwindow-aircraft-weight-no-fuel-lb.png', + 'Aircraft weight no fuel not found') + x, y = pag.position() + pag.click(x + 300, y, duration=2) + type_and_key('48000') # Changing the take off time of the dummy aircraft - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-take-off-time.png')) - pag.click(x + 410, y, duration=2) - pag.sleep(4) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - for _ in range(5): - pag.press('up') - pag.sleep(2) - pag.typewrite('04', interval=0.5) - pag.press(enter) + find_and_click_picture('tableviewwindow-take-off-time.png', + 'take off time not found') + x, y = pag.position() + pag.click(x + 410, y, duration=2) + type_and_key('') + for _ in range(5): + pag.press('up') pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Take off time\' fill box not found on the screen.") - raise + type_and_key('04', interval=0.5) + # update tutorial images create_tutorial_images() # Showing and hiding the performance settings - try: - pic = picture('tableviewwindow-show-performance.png', boundingbox=(0, 0, 140, 23)) - x, y = pag.locateCenterOnScreen(pic) - pag.click(x, y, duration=2) - pag.sleep(3) - - pag.click(x, y, duration=2) - pag.sleep(3) - - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Show Performance\' checkbox not found on the screen.") - raise + for _ in range(3): + find_and_click_picture('tableviewwindow-show-performance.png', + 'show performance button not found', + bounding_box=(0, 0, 140, 23)) + # update tutorial images + create_tutorial_images() print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish() diff --git a/tutorials/tutorial_remotesensing.py b/tutorials/tutorial_remotesensing.py index bc20dc9da..e964bb528 100644 --- a/tutorials/tutorial_remotesensing.py +++ b/tutorials/tutorial_remotesensing.py @@ -23,11 +23,13 @@ """ import pyautogui as pag -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, + create_tutorial_images, select_listelement, find_and_click_picture, zoom_in, + add_waypoints_to_topview) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings -from tutorials.utils.picture import picture +CTRL, ENTER, WIN, ALT = platform_keys() def automate_rs(): @@ -36,182 +38,148 @@ def automate_rs(): to a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. - pag.sleep(10) + pag.sleep(5) - ctrl, enter, win, alt = platform_keys() + msui_full_screen_and_open_first_view() - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(3) + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + os_screen_region = topview["os_screen_region"] - # lets create our helper images - create_tutorial_images() + _open_remote_sensing_widget(os_screen_region) + add_waypoints_to_topview(os_screen_region) + _show_solar_angle(os_screen_region) - # Opening Remote Sensing dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-select-to-open-control.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.press('down', presses=3, interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") - raise - # update our helper images - create_tutorial_images() + azimuth_x, azimuth_y = _change_azimuth_angle(os_screen_region) + _change_elevation_angle(os_screen_region) - # Adding waypoints for demonstrating remote sensing - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) - pag.click(x, y, interval=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(65, 65, duration=1) - pag.click(interval=2) - pag.sleep(1) + x, y = _draw_tangents_to_the_waypoints(os_screen_region) - pag.move(-150, 30, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(200, 150, duration=1) - pag.click(interval=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint button in topview not found on the screen.") - raise + _change_tangent_distance(x, y) + _rotate_the_tangent_by_different_angels(azimuth_x, azimuth_y, y, os_screen_region) - # Showing Solar Angle Colors - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-show-angle-degree.png')) - pag.sleep(1) - pag.click(x, y, duration=2) - pag.sleep(1) + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - for _ in range(2): - pag.click(x + 100, y, duration=1) - pag.press('down', interval=1) - pag.sleep(1) - pag.press(enter, interval=1) - pag.sleep(2) + finish() - for _ in range(3): - pag.click(x + 200, y, duration=1) - pag.press('down', interval=1) - pag.sleep(1) - pag.press(enter, interval=1) - pag.sleep(2) - pag.click(x + 200, y, duration=1) - pag.press('up', presses=3, interval=1) - pag.sleep(1) - pag.press(enter, interval=1) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Show angle\' checkbox not found on the screen.") - raise +def _open_remote_sensing_widget(os_screen_region): + # Opening Remote Sensing dockwidget + find_and_click_picture('topviewwindow-select-to-open-control.png', + 'topview window selection of docking widgets not found', + region=os_screen_region) + select_listelement(3) + pag.press(ENTER) + create_tutorial_images() - # Changing azimuth angles - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-viewing-direction-azimuth.png')) - pag.click(x + 90, y, duration=1) - pag.move(100, 100) - azimuth_x, azimuth_y = pag.position() - pag.sleep(2) - pag.hotkey(ctrl, 'a') - pag.sleep(2) - pag.typewrite('45', interval=1) - pag.press(enter) - pag.sleep(3) - pag.click(duration=1) - pag.hotkey(ctrl, 'a') - pag.typewrite('90', interval=1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Azimuth\' spinbox not found on the screen.") - raise - # Changing elevation angles - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-elevation.png')) - pag.click(x + 70, y, duration=1) - pag.sleep(2) - pag.hotkey(ctrl, 'a') +def _rotate_the_tangent_by_different_angels(azimuth_x, azimuth_y, y, os_screen_region): + zoom_in('topviewwindow-zoom.png', "Zoom Button not found", + move=(0, 150), dragRel=(230, 150), region=os_screen_region) + # Rotating the tangent through various angles + pag.click(azimuth_x, azimuth_y, duration=1) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.typewrite('120', interval=0.5) + pag.sleep(2) + for _ in range(10): + pag.press('down') pag.sleep(2) - pag.typewrite('-1', interval=1) - pag.press(enter) - pag.sleep(3) - pag.click(duration=1) - pag.hotkey(ctrl, 'a') - pag.typewrite('-3', interval=1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Elevation\' spinbox not found on the screen.") - raise + pag.sleep(1) + pag.click(azimuth_x + 500, y, duration=1) + pag.sleep(1) + + +def _change_tangent_distance(x, y): + # Changing Kilometers of the tangent distance + pag.click(x + 250, y, duration=1) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.typewrite('20', interval=1) + pag.press(ENTER) + pag.sleep(1) + +def _draw_tangents_to_the_waypoints(os_screen_region): # Drawing tangents to the waypoints and path - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-draw-tangent-points.png')) - pag.click(x, y, duration=1) - pag.sleep(2) - # Changing color of tangents - pag.click(x + 160, y, duration=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(1) + find_and_click_picture('topviewwindow-draw-tangent-points.png', + 'Draw tangent points not found', + region=os_screen_region) + x, y = pag.position() + # Changing color of tangents + pag.click(x + 160, y, duration=1) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + return x, y + + +def _change_elevation_angle(os_screen_region): + # Changing elevation angles + find_and_click_picture('topviewwindow-elevation.png', + 'elevation not found', + region=os_screen_region) + x, y = pag.position() + pag.click(x + 70, y, duration=1) + pag.sleep(2) + pag.hotkey(CTRL, 'a') + pag.sleep(2) + pag.typewrite('-1', interval=1) + pag.press(ENTER) + pag.sleep(1) + pag.click(duration=1) + pag.hotkey(CTRL, 'a') + pag.typewrite('-3', interval=1) + pag.press(ENTER) + pag.sleep(1) - # Changing Kilometers of the tangent distance - pag.click(x + 250, y, duration=1) + +def _change_azimuth_angle(os_screen_region): + # Changing azimuth angles + find_and_click_picture('topviewwindow-viewing-direction-azimuth.png', + 'Viewing direction azimuth not found', + region=os_screen_region) + x, y = pag.position() + pag.click(x + 90, y, duration=1) + pag.move(100, 100) + azimuth_x, azimuth_y = pag.position() + pag.sleep(2) + pag.hotkey(CTRL, 'a') + pag.sleep(2) + pag.typewrite('45', interval=1) + pag.press(ENTER) + pag.sleep(1) + pag.click(duration=1) + pag.hotkey(CTRL, 'a') + pag.typewrite('90', interval=1) + pag.press(ENTER) + pag.sleep(1) + return azimuth_x, azimuth_y + + +def _show_solar_angle(os_screen_region): + # Showing Solar Angle Colors + x, y = find_and_click_picture('topviewwindow-show-angle-degree.png', + 'Show angle in degrees not found', + region=os_screen_region) + for _ in range(2): + pag.click(x + 100, y, duration=1) + pag.press('down', interval=1) pag.sleep(1) - pag.hotkey(ctrl, 'a') + pag.press(ENTER, interval=1) + pag.sleep(2) + for _ in range(3): + pag.click(x + 200, y, duration=1) + pag.press('down', interval=1) pag.sleep(1) - pag.typewrite('20', interval=1) - pag.press(enter) - pag.sleep(3) - - # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) - pag.click(x, y, interval=2) - pag.move(0, 150, duration=1) - pag.dragRel(230, 150, duration=2) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise - - # Rotating the tangent through various angles - try: - pag.click(azimuth_x, azimuth_y, duration=1) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('120', interval=0.5) - pag.sleep(2) - for _ in range(10): - pag.press('down') - pag.sleep(2) - pag.sleep(1) - pag.click(azimuth_x + 500, y, duration=1) - pag.sleep(1) - except UnboundLocalError: - print('Azimuth spinbox coordinates are not stored. Hence cannot change values.') - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Tangent\' checkbox not found on the screen.") - raise - - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + pag.press(ENTER, interval=1) + pag.sleep(2) + pag.click(x + 200, y, duration=1) + pag.press('up', presses=3, interval=1) + pag.sleep(1) + pag.press(ENTER, interval=1) + pag.sleep(2) if __name__ == '__main__': diff --git a/tutorials/tutorial_satellitetrack.py b/tutorials/tutorial_satellitetrack.py index 07f6eeab2..4aa965e2d 100644 --- a/tutorials/tutorial_satellitetrack.py +++ b/tutorials/tutorial_satellitetrack.py @@ -21,14 +21,16 @@ See the License for the specific language governing permissions and limitations under the License. """ - -import pyautogui as pag import os.path +import pyautogui as pag +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, + create_tutorial_images, select_listelement, find_and_click_picture, type_and_key, zoom_in) +from tutorials.utils.platform_keys import platform_keys + -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images -from tutorials.utils.picture import picture +CTRL, ENTER, WIN, ALT = platform_keys() +PATH = os.path.normpath(os.getcwd() + os.sep + os.pardir) +SATELLITE_PATH = os.path.join(PATH, 'docs/samples/satellite_tracks/satellite_predictor.txt') def automate_rs(): @@ -38,110 +40,72 @@ def automate_rs(): """ # Giving time for loading of the MSS GUI. pag.sleep(5) - - ctrl, enter, win, alt = platform_keys() - # Satellite Predictor file path - path = os.path.normpath(os.getcwd() + os.sep + os.pardir) - satellite_path = os.path.join(path, 'docs/samples/satellite_tracks/satellite_predictor.txt') - - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'pageup') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") + msui_full_screen_and_open_first_view() pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(3) create_tutorial_images() - # Opening Satellite Track dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-select-to-open-control.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.press('down', presses=2, interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") - raise + find_and_click_picture('topviewwindow-select-to-open-control.png', + 'topview window selection of docking widgets not found') + select_listelement(2) + pag.press(ENTER) + pag.sleep(2) + + # Changing map to Global + find_and_click_picture('topviewwindow-01-europe-cyl.png', + "Map change dropdown could not be located on the screen") + select_listelement(2) + # update images create_tutorial_images() - # Loading the file: - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-load.png')) - pag.sleep(1) - pag.click(x - 150, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite(satellite_path, interval=0.1) - pag.sleep(1) - pag.click(x, y, duration=1) - pag.press(enter) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Load\' button not found on the screen.") - raise + # Todo find and use QLineEdit leFile instead of Load button + # Loading the file + find_and_click_picture('topviewwindow-load.png', 'Load button not found', xoffset=-150) + type_and_key(SATELLITE_PATH, interval=0.1) + find_and_click_picture('topviewwindow-load.png', 'Load button not found') # Switching between different date and time of satellite overpass. - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-predicted-satellite-overpasses.png')) - pag.click(x + 200, y, duration=1) - for _ in range(10): - pag.click(x + 200, y, duration=1) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) + find_and_click_picture('topviewwindow-predicted-satellite-overpasses.png', + 'Predicted satellite button not found', xoffset=200) + x, y = pag.position() + + pag.click(x + 200, y, duration=1) + for _ in range(10): pag.click(x + 200, y, duration=1) - pag.press('up', presses=3, interval=1) - pag.press(enter) pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Predicted Satellite Overpass\' dropdown menu not found on the screen.") - raise - - # Changing map to global - try: - if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png')) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - pag.press(enter, interval=1) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Map change dropdown could not be located on the screen") - raise - - # Adding waypoints for demonstrating remote sensing - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) - pag.click(x, y, interval=2) - pag.move(111, 153, duration=2) - pag.click(duration=2) - pag.move(36, 82, duration=2) - pag.click(duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint button in topview not found on the screen.") - raise + pag.press('down') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + pag.click(x + 200, y, duration=1) + pag.press('up', presses=3, interval=1) + pag.press(ENTER) + pag.sleep(1) - # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) - pag.click(x, y, interval=2) - pag.move(260, 130, duration=1) - pag.dragRel(184, 135, duration=2) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise + # update images + create_tutorial_images() + + # enable adding waypoints + find_and_click_picture('topviewwindow-ins-wp.png', + 'Clickable button/option not found.') + + # set waypoints + pag.move(111, 153, duration=2) + pag.click(interval=2) + pag.sleep(1) + pag.move(36, 82, duration=2) + pag.click(interval=2) + pag.sleep(1) + + # update images + create_tutorial_images() pag.sleep(1) + # Zooming into the map + zoom_in('topviewwindow-zoom.png', 'Zoom button could not be located.', + move=(260, 130), dragRel=(184, 135)) + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish() diff --git a/tutorials/tutorial_views.py b/tutorials/tutorial_views.py index 9034b83a4..9c2723685 100644 --- a/tutorials/tutorial_views.py +++ b/tutorials/tutorial_views.py @@ -2,7 +2,7 @@ msui.tutorials.tutorial_views ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - This python script generates an automatic demonstration of how to use the top view, side view, table view and + This python script generates an automatic demonstration of how to use the top view, side view, table view andq linear view section of Mission Support System in creating a operation and planning the flightrack. This file is part of MSS. @@ -25,407 +25,179 @@ """ import pyautogui as pag -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images, get_region -from tutorials.utils.picture import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, + select_listelement, find_and_click_picture, zoom_in, type_and_key, move_window, + move_and_setup_layerchooser, show_other_widgets, add_waypoints_to_topview) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings +CTRL, ENTER, WIN, ALT = platform_keys() -# ToDo in sideview and topview waypoint movement needs adjustment def automate_views(): """ This is the main automating script of the MSS views tutorial which will cover all the views(topview, sideview, - tableview, linear view) in demonstrating how to create a operation. This will be recorded and savedto a file having + tableview, linear view) in demonstrating how to create an operation. This will be recorded and savedto a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() - - # Screen Resolutions - sc_width, sc_height = pag.size()[0] - 1, pag.size()[1] - 1 - - # Maximizing the window - try: - if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'pageup') - elif platform == 'darwin': - pag.hotkey('ctrl', 'command', 'f') - elif platform == 'win32': - pag.hotkey('win', 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(2) - create_tutorial_images() - # Shifting topview window to upper right corner - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) - pag.click(x, y - 56, interval=2) - if platform == 'win32' or platform == 'darwin': - pag.dragRel(525, -110, duration=2) - elif platform == 'linux' or platform == 'linux2': - pag.dragRel(910, -25, duration=2) - pag.move(0, 56) - add_tv_x, add_tv_y = pag.position() - pag.move(-486, -56, duration=1) - pag.click(interval=1) - if platform == 'win32' or platform == 'linux' or platform == 'linux2': - pag.hotkey('ctrl', 'v') - elif platform == 'darwin': - pag.hotkey('command', 'v') - pag.sleep(4) - create_tutorial_images() - - # Shifting Sideview window to upper left corner. - try: - x1, y1 = pag.locateCenterOnScreen(picture('sideviewwindow-ins-wp.png')) - if platform == 'win32' or platform == 'darwin': - pag.moveTo(x1, y1 - 56, duration=1) - pag.dragRel(-494, -177, duration=2) - elif platform == 'linux' or platform == 'linux2': - pag.moveTo(x1, y1 - 56, duration=1) - pag.dragRel(-50, -30, duration=2) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2': - pag.keyDown('altleft') - # ToDo selection of views have to be done with ctrl f - # this selects the next window in the window manager on budgie - pag.press('tab') - pag.keyUp('tab') - pag.press('tab') - pag.keyUp('tab') - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - elif platform == 'darwin': - pag.press('command', 'tab', 'right') - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("Exception: \'Side View Window Header\' was not found on the screen") - raise - except (ImageNotFoundException, OSError, Exception): - print("Exception: \'Topview Window Header\' was not found on the screen") - raise + msui_full_screen_and_open_first_view() - # Adding waypoints - if add_tv_x is not None and add_tv_y is not None: - pag.sleep(1) - pag.click(add_tv_x, add_tv_y, interval=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(65, 65, duration=1) - pag.click(interval=2) - pag.sleep(1) + pag.sleep(1) + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + # move topview on screen + x_drag_rel = 910 + y_drag_rel = -10 + move_window(topview["os_screen_region"], x_drag_rel, y_drag_rel) + create_tutorial_images() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + add_waypoints_to_topview(topview['os_screen_region']) + # memorize last added point + x1, y1 = pag.position() + + # click on msui main + pag.move(150, -150, duration=1) + pag.click(interval=2) + pag.sleep(1) - pag.move(-150, 30, duration=1) - x1, y1 = pag.position() - pag.click(interval=2) - pag.sleep(1) - pag.move(200, 150, duration=1) - pag.click(interval=2) - x2, y2 = pag.position() - pag.sleep(1) - pag.move(100, -80, duration=1) - pag.click(interval=2) - pag.move(56, -63, duration=1) - pag.click(interval=2) - pag.sleep(3) - else: - print("Screen coordinates not available for add waypoints for topview") - raise + hotkey = CTRL, 'up' + pag.hotkey(*hotkey) + + # open sideview + pag.hotkey(CTRL, 'v') + pag.sleep(1) + create_tutorial_images() + sideview = load_settings_qsettings('sideview', {"os_screen_region": (0, 0, 0, 0)}) + + # move sideview on screen + x_drag_rel = -50 + y_drag_rel = -30 + move_window(sideview["os_screen_region"], x_drag_rel, y_drag_rel) + + pag.keyDown('altleft') + # this selects the next window in the window manager on budgie and kde + pag.press('tab') + pag.keyUp('tab') + pag.press('tab') + pag.keyUp('tab') + pag.keyUp('altleft') + pag.sleep(1) + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-server-layer.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - create_tutorial_images() - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png'), - region=(int(sc_width / 2), 0, sc_width, sc_height)) - pag.click(x + 220, y, interval=2) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topviews' \'WMS URL\' editbox button/option not found on the screen.") - raise - - create_tutorial_images() - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) - pag.click(x, y, interval=2) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topviews' \'Get capabilities\' button/option not found on the screen.") - raise - - # Relocating Layerlist of topview - if platform == 'win32': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 627, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 675, duration=2) # To be decided - pag.sleep(1) - # Storing screen coordinates for List layer of top view - ll_tov_x, ll_tov_y = pag.position() - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topviews WMS' \'Server\\Layers\' button/option not found on the screen.") - pag.press('enter', interval=1) - # raise + find_and_click_picture('topviewwindow-server-layer.png', + 'Topview Server Layer not found', + region=topview["os_screen_region"]) + create_tutorial_images() + move_and_setup_layerchooser(topview["os_screen_region"], -171, -390, 10, 675) + + tvll_region = list(topview["os_screen_region"]) + tvll_region[3] = tvll_region[3] + 675 # Selecting some layers in topview layerlist # lookup layer entry from the multilayering checkbox - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) - # Divergence and Geopotential - pag.click(x + 50, y + 70, interval=2) - pag.sleep(1) - # Relative Huminidity - pag.click(x + 50, y + 110, interval=2) - pag.sleep(1) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering \' checkbox not found on the screen.") - raise + find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering selection not found', + region=tuple(tvll_region)) + + x, y = pag.position() + # disable multilayer + pag.click(x, y) + # Divergence and Geopotential + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + # Relative Huminidity + pag.click(x + 50, y + 110, interval=2) + pag.sleep(1) + + create_tutorial_images() + ll_tov_x, ll_tov_y = pag.position() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) # Moving waypoints in Topview - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-mv-wp.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - # move point x1,y1 - pag.click(x1, y1, interval=2) - pag.moveTo(x1, y1, duration=1) - pag.dragTo(x1 + 46, y1 - 67, duration=1, button='left') - pag.click(interval=2) - x3, y3 = pag.position() - pag.sleep(1) - except ImageNotFoundException: - print("\n Exception : Move Waypoint button could not be located on the screen") - raise + _tv_move_waypoints(topview["os_screen_region"], x1, y1) + x3, y3 = pag.position() + pag.sleep(1) + # Deleting waypoints - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-del-wp.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - pag.moveTo(x3, y3, duration=1) - pag.click(duration=1) - if platform == 'win32': - pag.press('left') - pag.sleep(2) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - pag.sleep(2) - except ImageNotFoundException: - print("\n Exception : Remove Waypoint button could not be located on the screen") - raise - - # Changing map to Global - try: - if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - pag.sleep(6) - except (ImageNotFoundException, TypeError, OSError, Exception): - print("\n Exception : Topview's Map change dropdown could not be located on the screen") - raise + find_and_click_picture('topviewwindow-del-wp.png', + 'Delete waypoints not found', + region=topview["os_screen_region"]) + pag.moveTo(x3, y3, duration=1) + pag.click(duration=1) + # Yes is default + pag.sleep(3) + pag.press(ENTER) + pag.sleep(2) + create_tutorial_images() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + + find_and_click_picture('topviewwindow-01-europe-cyl.png', + 'Projection 01-europe-cyl not found', + region=topview["os_screen_region"]) + select_listelement(2) # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - pag.move(155, 121, duration=1) - pag.click(duration=1) - pag.dragRel(260, 110, duration=2) - pag.sleep(4) - except ImageNotFoundException: - print("\n Exception : Topview's Zoom button could not be located on the screen") - raise + zoom_in('topviewwindow-zoom.png', 'Zoom button not found', + move=(155, 121), dragRel=(260, 110), + region=topview["os_screen_region"]) + pag.sleep(2) + create_tutorial_images() + sideview = load_settings_qsettings('sideview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + # SideView Operations - # Opening web map service - - try: - x, y = pag.locateCenterOnScreen(picture('sideviewwindow-select-to-open-control.png')) - pag.click(x, y, interval=2) - pag.press('down', interval=1) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'SideView's select to open control\' button/option not found on the screen.") - raise # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('sideviewwindow-server-layer.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x, y, interval=2) - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png')) - pag.click(x + 220, y, interval=2) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideviews' \'WMS URL\' editbox button/option not found on the screen.") - raise - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) - pag.click(x, y, interval=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : SideView's \'Get capabilities\' button/option not found on the screen.") - pag.press('enter', interval=1) - # raise - - if platform == 'win32': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 570, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 600, duration=2) - # Storing screen coordinates for List layer of side view - ll_sv_x, ll_sv_y = pag.position() - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideviews WMS' \'Server\\Layers\' button/option not found on the screen.") - raise - # Selecting some layers in Sideview WMS - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 16 - - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) - # Cloudcover - pag.click(x + 50, y + 70, interval=2) - pag.sleep(1) - temp1, temp2 = x, y - pag.click(x, y, interval=2) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap * 2, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, -gap * 4, duration=1) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideview's \'Cloud Cover Layer\' option not found on the screen.") - raise - # Setting different levels and valid time - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 4), interval=2) - - try: - x, y = pag.locateCenterOnScreen(picture('sideviewwindow-valid.png')) - pag.click(x + 200, y, interval=1) - pag.move(0, 80, duration=1) - pag.click(interval=1) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideview's \'Valid till\' button/option not found on the screen.") - raise + find_and_click_picture('sideviewwindow-server-layer.png', + 'Sideview server layer not found', + region=sideview["os_screen_region"]) create_tutorial_images() - # smaller region, seems the widget covers a bit the content - pic = picture('sideviewwindow-cloud-cover-0-1-vertical-section-valid-' - '2012-10-18t06-00-00z-initialisation-2012-10-17t12-00-00z.png', boundingbox=(20, 20, 800, 200)) - loc = get_region(pic) - sideview_region = (0, 0, loc.left + loc.width, loc.top) - try: - x, y = pag.locateCenterOnScreen(picture('sideviewwindow-mv-wp.png'), region=sideview_region) - pag.click(x, y, interval=2) - try: - pic = picture('sideviewwindow-cloud-cover-0-1-vertical-section-valid-2012-10-18t06-00-00z-' - 'initialisation-2012-10-17t12-00-00z.png', boundingbox=(103, 300, 118, 312)) - px, py = pag.locateCenterOnScreen(pic) - # point1: 127, 394 - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideview's \'Point 1\' not found on the screen.") - raise - offsets = [0, 114, 161, 200, ] - for offset in offsets: - pag.click(px + offset, py, interval=2) - pag.moveTo(px + offset, py, duration=1) - pag.dragTo(px + offset, py - offset - 50, duration=5, button='left') - pag.click(interval=2) - - except ImageNotFoundException: - print("\n Exception :Sideview's Move Waypoint button could not be located on the screen") - raise + move_and_setup_layerchooser(sideview["os_screen_region"], -171, -390, 10, 600) + + ll_sv_x, ll_sv_y = pag.position() + + _sv_layers(sideview["os_screen_region"], tvll_region) + + find_and_click_picture('sideviewwindow-valid.png', + 'Sideview Window not found', + region=sideview["os_screen_region"]) + x, y = pag.position() + pag.click(x + 200, y, interval=1) + pag.move(0, 80, duration=1) + pag.press(ENTER) + + create_tutorial_images() + sideview = load_settings_qsettings('sideview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + + pag.sleep(2) + _sv_adjust_altitude(sideview["os_screen_region"]) create_tutorial_images() + _sv_add_waypoints(sideview["os_screen_region"]) - # Adding waypoints in SideView - try: - x, y = pag.locateCenterOnScreen(picture('sideviewwindow-ins-wp.png'), region=sideview_region) - pag.click(x, y, duration=1) - pag.click(x + 239, y + 186, duration=1) - pag.sleep(3) - pag.click(x + 383, y + 93, duration=1) - pag.sleep(3) - pag.click(x + 450, y + 140, duration=1) - pag.sleep(4) - pag.click(x, y, duration=1) - pag.sleep(1) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Sideview's add waypoint button not found on the screen.") - raise # Closing list layer of sideview and topview to make screen a little less congested. pag.click(ll_sv_x, ll_sv_y, duration=2) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - elif platform == 'darwin': - pag.hotkey('command', 'w') - pag.sleep(1) - try: - pag.click(ll_tov_x, ll_tov_y, duration=2) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - elif platform == 'darwin': - pag.hotkey('command', 'w') - except UnboundLocalError: - # ToDo improve this - pass + pag.hotkey('altleft', 'f4') + pag.sleep(1) + pag.press('left') + pag.press(ENTER) + + pag.click(ll_tov_x, ll_tov_y, duration=2) + pag.hotkey('altleft', 'f4') + pag.sleep(1) + pag.press('left') + pag.press(ENTER) # Table View # Opening Table View pag.move(-80, 120, duration=1) - # pag.moveTo(1800, 1000, duration=1) - pag.click(duration=1) - # ANY now selected - # ToDo ANY should be inactive whithout an OP pag.click(duration=1) pag.sleep(1) @@ -433,384 +205,105 @@ def automate_views(): pag.sleep(2) create_tutorial_images() - # Relocating Tableview and performing operations on table view - # ToDo refactor to a module improve where it enters data - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) - pag.moveTo(x, y - 462, duration=1) - if platform == 'linux' or platform == 'linux2': - pag.dragRel(250, 887, duration=3) - elif platform == 'win32' or platform == 'darwin': - pag.dragRel(None, 487, duration=2) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2': - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab') - pag.keyUp('altleft') - pag.sleep(1) - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab', presses=2) # This needs to be checked in Linux - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - pag.sleep(1) - pag.keyDown('alt') - pag.press('tab') - pag.press('right', presses=2) - pag.keyUp('alt') - elif platform == 'darwin': - pag.keyDown('command') - pag.press('tab') - pag.press('right') - pag.keyUp('command') - pag.sleep(1) - pag.keyDown('command') - pag.press('tab') - pag.press('right', presses=2) - pag.keyUp('command') - pag.sleep(1) - if platform == 'win32' or platform == 'darwin': - pag.dragRel(None, -300, duration=2) - tv_x, tv_y = pag.position() - elif platform == 'linux' or platform == 'linux2': - pag.dragRel(None, -450, duration=2) - tv_x, tv_y = pag.position() - - # Locating the selecttoopencontrol for tableview to perform operations - try: - x, y = pag.locateCenterOnScreen(picture('tableviewwindow-select-to-open-control.png')) - xoffset = -50 - - # Changing names of certain waypoints to predefined names - pag.click(x + xoffset, y - 360, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(2) - pag.move(78, 0, duration=1) - pag.sleep(1) - pag.click(duration=1) - pag.press('down', presses=5, interval=0.2) - pag.sleep(1) - pag.press('enter') - pag.sleep(1) - - # Giving user defined names to waypoints - pag.click(x + xoffset, y - 294, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1.5) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.hotkey('ctrl', 'a') - elif platform == 'darwin': - pag.hotkey('command', 'a') - pag.sleep(1) - pag.write('Location A', interval=0.1) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - pag.click(x + xoffset, y - 263, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.hotkey('ctrl', 'a') - elif platform == 'darwin': - pag.hotkey('command', 'a') - pag.sleep(1) - pag.write('Stop Point', interval=0.1) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - # offset is wrong - # Changing Length of Flight Level - pag.click(x + 236, y - 263, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('319', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - # Changing hPa level of waypoints - pag.click(x + 367, y - 232, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('250', interval=0.2) - pag.sleep(1) - pag.press('enter') - pag.sleep(2) - - # xoffset - # Changing longitude of 'Location A' waypoint - pag.click(x + 165, y - 294, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('12.36', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - # Cloning the row of waypoint - try: - x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-clone.png')) - pag.click(x + xoffset + 15, y - 263, duration=1) - pag.sleep(1) - pag.click(x1, y1, duration=1) - pag.sleep(2) - pag.click(x + xoffset + 15, y - 232, duration=1) - pag.sleep(1) - pag.click(x + xoffset + 117, y - 232, duration=1) - pag.sleep(1) - pag.write('65.26', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - pag.move(580, 0, duration=1) if platform == 'win32' else pag.move(459, 0, duration=1) - pag.doubleClick(duration=1) - pag.sleep(2) - pag.write('This is a reference comment', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's CLONE button not found on the screen.") - raise - # Inserting a new row of waypoints - try: - x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-insert.png')) - pag.click(x + 117, y - 294, duration=1) - pag.sleep(2) - pag.click(x1, y1, duration=1) - pag.sleep(2) - pag.click(x + 117, y - 263, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('58', interval=0.2) - pag.sleep(0.5) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - pag.move(48, 0, duration=1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('-1.64', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - pag.move(108, 0, duration=1) if platform == 'win32' else pag.move(71, 0, duration=1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('360', interval=0.2) - pag.sleep(0.5) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's INSERT button not found on the screen.") - raise - # Delete Selected waypoints row - try: - x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-delete-selected.png')) - pag.click(x + 150, y - 70, duration=1) if platform == 'win32' else pag.click(x + 150, y - 201, - duration=1) - pag.sleep(2) - pag.click(x1, y1, duration=1) - pag.press('left') - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's DELETE SELECTED button not found on the screen.") - raise - # Reverse waypoints' order - try: - x1, y1 = pag.locateCenterOnScreen(picture('tableviewwindow-reverse.png')) - for _ in range(3): - pag.click(x1, y1, duration=1) - pag.sleep(1.5) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's REVERSE button not found on the screen.") - raise - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's selecttoopencontrol button (bottom part) not found on the screen.") - raise - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : TableView's Select to open Control option (at the top) not found on the screen.") - raise + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + # move tableview on screen + x_drag_rel = 250 + y_drag_rel = 687 + move_window(tableview["os_screen_region"], x_drag_rel, y_drag_rel) + + show_other_widgets() + + # pag.dragRel(None, -450, duration=2) + tv_x, tv_y = pag.position() + pag.click(tv_x, tv_y) + pag.sleep(1) + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + create_tutorial_images() + # Locating the selecttoopencontrol for tableview to perform operations + find_and_click_picture('tableviewwindow-select-to-open-control.png', + 'Tableview select to open control not found', + region=tableview["os_screen_region"]) + # explaining the tableview + x, xoffset, y = _tab_add_data() + _tab_clone(tableview["os_screen_region"], x, y, xoffset) + _tab_insert(tableview["os_screen_region"], x, y, xoffset) + _tab_delete(tableview["os_screen_region"], x, y) + _tab_reverse(tableview["os_screen_region"]) + # Closing Table View to make space on screen - if tv_x is not None and tv_y is not None: - pag.click(tv_x, tv_y, duration=1) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - pag.press('left') - pag.sleep(1) - pag.press('enter') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - pag.press('left') - pag.sleep(1) - pag.press('enter') - elif platform == 'darwin': - pag.hotkey('command', 'w') - pag.press('left') - pag.sleep(1) - pag.press('return') + pag.click(tv_x, tv_y, duration=1) + pag.hotkey('altleft', 'f4') + pag.sleep(1) + pag.press('left') + pag.sleep(1) + pag.press('enter') # Opening Linear View pag.sleep(1) pag.move(0, 400, duration=1) pag.click(interval=1) - pag.hotkey('ctrl', 'l') - pag.sleep(4) - pag.hotkey(win, 'up') - pag.click(10, 10, interval=2) - pag.dragRel(853, 360, duration=3) - pag.sleep(2) + pag.hotkey(CTRL, 'l') + pag.sleep(1) create_tutorial_images() - # Relocating Linear View - try: - pag.locateCenterOnScreen(picture('linearwindow-select-to-open-control.png')) - - if platform == 'linux' or platform == 'linux2': - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab') - pag.keyUp('altleft') - pag.sleep(1) - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab') - pag.press('tab') - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - pag.sleep(1) - pag.keyDown('alt') - pag.press('tab') - pag.press('right', presses=2, interval=1) - pag.keyUp('alt') - elif platform == 'darwin': - pag.keyDown('command') - pag.press('tab') - pag.press('right') - pag.keyUp('command') - pag.sleep(1) - pag.keyDown('command') - pag.press('tab') - pag.press('right', presses=2, interval=1) - pag.keyUp('command') - pag.sleep(1) - pag.dragRel(-102, -470, duration=2) if platform == 'win32' else pag.dragRel(-90, -500, duration=2) - lv_x, lv_y = pag.position() - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Linearview's window header not found on the screen.") - raise + linearview = load_settings_qsettings('linearview', {"os_screen_region": (0, 0, 0, 0)}) + + # move linearview on screen + x_drag_rel = 0 + y_drag_rel = 630 + + move_window(linearview["os_screen_region"], x_drag_rel, y_drag_rel) + + show_other_widgets() + + lv_x, lv_y = pag.position() + create_tutorial_images() + linearview = load_settings_qsettings('linearview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('linearwindow-server-layer.png')) - pag.click(x, y, interval=2) - - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png')) - pag.click(x + 220, y, interval=2) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Linearviews' \'WMS URL\' editbox button/option not found on the screen.") - raise - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) - pag.click(x, y, interval=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : LinearView's \'Get capabilities\' button/option not found on the screen.") - raise - if platform == 'win32': - pag.move(-171, -390, duration=1) - pag.dragRel(-867, 135, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(-171, -390, duration=1) - pag.dragRel(-900, 245, duration=2) - # Storing screen coordinates for List layer of side view - pag.position() - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Linearview's WMS \'Server\\Layers\' button/option not found on the screen.") - # raise - pag.press('enter') + find_and_click_picture('linearwindow-server-layer.png', + "Server layer button not found", + region=linearview["os_screen_region"]) + + create_tutorial_images() + move_and_setup_layerchooser(linearview["os_screen_region"], -171, -390, 900, 100) create_tutorial_images() + linearview = load_settings_qsettings('linearview', {"os_screen_region": (0, 0, 0, 0)}) # Selecting Some Layers in Linear wms section - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 16 - - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) - # Cloudcover - pag.click(x + 50, y + 70, interval=2) - pag.sleep(1) - temp1, temp2 = x, y - pag.click(x, y, interval=2) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap * 2, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, -gap * 4, duration=1) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Linearview's Lazer' option not found on the screen.") - raise - - # Add waypoints after anaylzing the linear section wms - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.click(x + 30, y + 50, duration=1) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\n Exception :Sideview's Add Waypoint button could not be located on the screen") - raise + gap = 32 + lvll_region = list(linearview["os_screen_region"]) + lvll_region[0] = lvll_region[0] - 900 - 171 + find_and_click_picture('multilayersdialog-multilayering.png', + ' Multilayer not found', + region=tuple(lvll_region)) + x, y = pag.position() + # unselect multilayer + pag.click(x, y) + pag.sleep(1) + + # Cloudcover + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap * 2, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) # CLosing Linear View Layer List - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 4), duration=2) - pag.sleep(1) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - elif platform == 'darwin': - pag.hotkey('command', 'w') - pag.sleep(1) + pag.click(x, y, duration=2) + pag.sleep(1) + pag.hotkey('altleft', 'f4') # Clicking on Linear View Window Head - if lv_x is not None and lv_y is not None: - pag.click(lv_x, lv_y, duration=1) + pag.click(lv_x, lv_y, duration=1) print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") @@ -818,5 +311,277 @@ def automate_views(): finish() +def _sv_layers(os_screen_region, tvll_region): + """ + + Selects in the sideview layer chooser some layers + + :param os_screen_region: a list representing the region of the screen where the actions will be performed. + :param tvll_region: a list representing the region of the screen that will be used for calculations. + + Return type: + None + + Example usage: + os_screen_region = [0, 0, 1920, 1080] + tvll_region = [100, 100, 500, 500] + _sv_layers(os_screen_region, tvll_region) + + """ + gap = 16 + svll_region = list(os_screen_region) + svll_region[3] = tvll_region[3] + 600 + find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering not found', + region=tuple(svll_region)) + x, y = pag.position() + # Cloudcover + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + temp1, temp2 = x, y + pag.click(x, y, interval=2) + pag.sleep(3) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap * 2, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, -gap * 4, duration=1) + pag.click(interval=1) + pag.sleep(3) + # Setting different levels and valid time + pag.click(temp1, temp2 + (gap * 4), interval=2) + + +def _tab_reverse(os_screen_region): + """ + Reverses the order of a table view displayed on the screen. + + :param os_screen_region (tuple): The region of the screen where the table view is located. + + Returns: + None + """ + find_and_click_picture('tableviewwindow-reverse.png', 'Reverse Button not found', + region=os_screen_region) + x1, y1 = pag.position() + for _ in range(3): + pag.click(x1, y1, duration=1) + pag.sleep(1.5) + + +def _tab_delete(os_screen_region, x, y): + """ + Delete a selected tab in a table view. + + :param os_screen_region (tuple): The region of the screen where the table view is located. + :param x (int): The x-coordinate of the tab to delete relative to the table view. + :param y (int): The y-coordinate of the tab to delete relative to the table view. + + Returns: + None + """ + find_and_click_picture('tableviewwindow-delete-selected.png', 'Delete button not', + region=os_screen_region) + x1, y1 = pag.position() + pag.click(x + 150, y - 201, duration=1) + pag.sleep(2) + pag.click(x1, y1, duration=1) + pag.press(ENTER) + pag.sleep(2) + + +def _tab_insert(os_screen_region, x, y, xoffset): + """ + Inserts multiple new row of waypoints into the table view. + + :param os_screen_region (tuple): The region of the screen where the table view is located. + :param x (int): The x-coordinate of the starting position. + :param y (int): The y-coordinate of the starting position. + :param xoffset (int): The x-offset for clicking on the table view. + + Returns: + None + """ + # Inserting a new row of waypoints + find_and_click_picture('tableviewwindow-insert.png', 'Insert button not found', + region=os_screen_region) + x1, y1 = pag.position() + pag.click(x + 117, y - 294, duration=1) + pag.sleep(2) + pag.click(x1, y1, duration=1) + pag.sleep(2) + pag.click(x + xoffset + 85, y - 263, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('58') + pag.sleep(1) + pag.click(x + xoffset + 170, y - 232, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('360') + + +def _tab_clone(os_screen_region, x, y, xoffset): + """ + Clone a table line in the specified screen region. + + :param os_screen_region: The region of the screen where the table view window is located. + :param x: The x-coordinate of a line in the table view window. + :param y: The y-coordinate of a line in the table view window. + :param xoffset: The offset to be added to the x-coordinate when performing clicks. + + :return: None + + :raises: Exception - If the clone button is not found. + + Example usage: + _tab_clone(os_screen_region, x, xoffset, y) + """ + find_and_click_picture('tableviewwindow-clone.png', 'Clone button not found', + region=os_screen_region) + x1, y1 = pag.position() + pag.click(x + xoffset, y - 263, duration=1) + pag.sleep(1) + pag.click(x1, y1, duration=1) + pag.sleep(2) + pag.click(x + xoffset, y - 232, duration=1) + pag.sleep(1) + pag.click(x + xoffset + 85, y - 232, duration=1) + pag.sleep(1) + type_and_key('65.26') + pag.click(x + xoffset + 550, y - 232, duration=1) + pag.doubleClick(duration=1) + type_and_key('Comment1') + + +def _tab_add_data(): + x, y = pag.position() + xoffset = -100 + # Changing names of certain waypoints to predefined names + pag.click(x + xoffset, y - 360, duration=1) + pag.sleep(1) + pag.doubleClick(duration=1) + pag.sleep(2) + pag.move(78, 0, duration=1) + pag.sleep(1) + pag.click(duration=1) + pag.press('down', presses=5, interval=0.2) + pag.sleep(1) + pag.press('enter') + pag.sleep(1) + # Giving user defined names to waypoints + pag.click(x + xoffset, y - 294, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + # marks word + pag.doubleClick() + type_and_key('Location') + # annother waypoint name + pag.click(x + xoffset, y - 263, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + # no blank in values + type_and_key('StopPoint', interval=0.1) + # Changing hPa level of waypoints + pag.click(x + xoffset + 170, y - 232, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('250') + # xoffset + # Changing longitude of 'Location A' waypoint + pag.click(x + xoffset + 125, y - 294, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('12.36') + return x, xoffset, y + + +def _tv_move_waypoints(os_screen_region, x, y): + find_and_click_picture('topviewwindow-mv-wp.png', + 'Move waypoints not found', + region=os_screen_region) + pag.click(x, y, interval=2) + pag.moveTo(x, y, duration=1) + pag.dragTo(x + 46, y - 67, duration=1, button='left') + pag.click(interval=2) + + +def _sv_add_waypoints(os_screen_region): + # Adding waypoints in SideView + find_and_click_picture('sideviewwindow-ins-wp.png', + 'sideview ins waypoint not found', + region=os_screen_region) + x, y = pag.position() + pag.click(x + 239, y + 186, duration=1) + pag.sleep(3) + pag.click(x + 383, y + 93, duration=1) + pag.sleep(3) + pag.click(x + 450, y + 140, duration=1) + pag.sleep(4) + pag.click(x, y, duration=1) + pag.sleep(1) + + +def _sv_adjust_altitude(os_screen_region): + """ + Adjusts the altitude of sideview waypoints. + + Parameters: + - os_screen_region: The screen region where sideview is located + + Returns: None + """ + # smaller region, seems the widget covers a bit the content + pic_name = ('sideviewwindow-cloud-cover-0-1-vertical-section-valid-' + '2012-10-17t12-00-00z-initialisation-2012-10-17t12-00-00z.png') + # pic = picture(pic_name, bounding_box=(20, 20, 60, 300)) + find_and_click_picture('sideviewwindow-mv-wp.png', + 'Sideview move wp not found', + region=os_screen_region) + find_and_click_picture(pic_name, bounding_box=(187, 300, 206, 312)) + # adjust altitude of sideview waypoints + px, py = pag.position() + offsets = [0, 60, 93] + + for offset in offsets: + pag.click(px + offset, py, interval=2) + pag.moveTo(px + offset, py, duration=1) + pag.dragTo(px + offset, py - offset - 50, duration=5, button='left') + pag.click(interval=2) + + +def _tv_add_waypoints(os_screen_region): + + find_and_click_picture('topviewwindow-ins-wp.png', + 'Topview Window not found', + region=os_screen_region) + # Adding waypoints + pag.sleep(1) + pag.move(-50, 150, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(65, 65, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(-150, 30, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(200, 150, duration=1) + pag.click(interval=2) + + if __name__ == '__main__': start(target=automate_views, duration=567) diff --git a/tutorials/tutorial_waypoints.py b/tutorials/tutorial_waypoints.py index 4841ee7f4..705add3eb 100644 --- a/tutorials/tutorial_waypoints.py +++ b/tutorials/tutorial_waypoints.py @@ -27,10 +27,11 @@ import pyautogui as pag import datetime -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish, create_tutorial_images -from tutorials.utils.picture import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, select_listelement, + find_and_click_picture, zoom_in, panning) +from tutorials.utils.platform_keys import platform_keys + +CTRL, ENTER, WIN, ALT = platform_keys() def automate_waypoints(): @@ -39,33 +40,15 @@ def automate_waypoints(): to a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. - pag.sleep(15) - ctrl, enter, win, alt = platform_keys() - - # Maximizing the window - try: - if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'pageup') - elif platform == 'darwin': - pag.hotkey('ctrl', 'command', 'f') - elif platform == 'win32': - pag.hotkey('win', 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) - pag.hotkey('ctrl', 'h') pag.sleep(5) - # lets create our helper images - create_tutorial_images() + msui_full_screen_and_open_first_view() - # Adding waypoints - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-ins-wp.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\nException : Clickable button/option not found on the screen.") - raise + # enable adding waypoints + find_and_click_picture('topviewwindow-ins-wp.png', + 'Clickable button/option not found.') + + # set waypoints pag.move(-50, 150, duration=1) pag.click(interval=2) pag.sleep(1) @@ -82,14 +65,11 @@ def automate_waypoints(): x2, y2 = pag.position() pag.sleep(3) - # Moving waypoints - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-mv-wp.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Move Waypoint button could not be located on the screen") - raise + # enable moving waypoints + find_and_click_picture('topviewwindow-mv-wp.png', + ' Move Waypoint button could not be located.') + # moving waypoints pag.moveTo(x2, y2, duration=1) pag.click(interval=2) pag.dragRel(100, 150, duration=1) @@ -97,114 +77,56 @@ def automate_waypoints(): pag.dragRel(35, -50, duration=1) x1, y1 = pag.position() - # Deleting waypoints - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-del-wp.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Remove Waypoint button could not be located on the screen") - raise + # enable deleting waypoints + find_and_click_picture('topviewwindow-del-wp.png', + 'Remove Waypoint button could not be located.') + + # delete waypoints pag.moveTo(x1, y1, duration=1) pag.click(duration=1) - pag.press('left') + # Yes is default pag.sleep(3) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('left', interval=1) - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) + pag.press(ENTER) pag.sleep(2) # Changing map to Global - try: - if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - print(pag.position()) - x, y = pag.locateCenterOnScreen(picture('topviewwindow-01-europe-cyl.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Map change dropdown could not be located on the screen") - raise - pag.press('down', presses=2, interval=0.5) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) + find_and_click_picture('topviewwindow-01-europe-cyl.png', + "Map change dropdown could not be located.") + select_listelement(2) pag.sleep(5) # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-zoom.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise - pag.move(150, 200, duration=1) - pag.dragRel(400, 250, duration=2) - pag.sleep(5) + zoom_in('topviewwindow-zoom.png', 'Zoom button could not be located.', + move=(150, 200), dragRel=(400, 250)) # Panning into the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-pan.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Pan button could not be located on the screen") - raise - pag.moveRel(400, 400, duration=1) - pag.dragRel(-100, -50, duration=2) - pag.sleep(5) + panning('topviewwindow-pan.png', 'Pan button could not be located.', + moveRel=(400, 400), dragRel=(-100, -50)) + # another panning, button is still active pag.move(-20, -25, duration=1) pag.dragRel(90, 50, duration=2) pag.sleep(5) # Switching to the previous appearance of the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-back.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Previous button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-back.png', 'back button could not be located.') pag.sleep(5) # Switching to the next appearance of the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-forward.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Next button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-forward.png', 'forward button could not be located.') pag.sleep(5) # Resetting the map to the original size - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-home.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Home button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-home.png', 'home button could not be located.') pag.sleep(5) # Saving the figure - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-save.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Save button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-save.png', 'save button could not be located.') current_time = datetime.datetime.now().strftime('%d-%m-%Y %H-%M-%S') - fig_filename = f'Fig_{current_time}.png' - pag.sleep(3) - if platform == 'win32': - pag.write(fig_filename, interval=0.25) - pag.press('enter', interval=1) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'tab') # if the save file system window is not in the forefront, use this statement. - # This can happen sometimes. At that time, you just need to uncomment it. - pag.write(fig_filename, interval=0.25) - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.write(fig_filename, interval=0.25) - pag.press('return', interval=1) + pag.hotkey('altleft', 'tab') # if the save file system window is not in the forefront, use this statement. + # This can happen sometimes. At that time, you just need to uncomment it. + pag.write(f'Fig_{current_time}.png', interval=0.25) + pag.press(ENTER) print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish() diff --git a/tutorials/tutorial_wms.py b/tutorials/tutorial_wms.py index 543d5a527..a21720b30 100644 --- a/tutorials/tutorial_wms.py +++ b/tutorials/tutorial_wms.py @@ -24,295 +24,206 @@ limitations under the License. """ import pyautogui as pag +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, + find_and_click_picture, move_and_setup_layerchooser, get_region, + select_listelement) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, create_tutorial_images -from tutorials.utils.picture import picture +CTRL, ENTER, WIN, ALT = platform_keys() -def automate_waypoints(): +def automate_wms(): """ This is the main automating script of the MSS web map service tutorial which will be recorded and saved to a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() - - # Maximizing the window - try: - if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'pageup') - elif platform == 'darwin': - pag.hotkey('ctrl', 'command', 'f') - elif platform == 'win32': - pag.hotkey('win', 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(1) - # lets create our helper images - create_tutorial_images() + msui_full_screen_and_open_first_view() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-server-layer.png')) - pag.click(x, y, interval=2) - if platform == 'win32': - pag.move(35, -485, duration=1) - pag.dragRel(-800, -60, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(35, -522, duration=1) - pag.dragRel(650, -30, duration=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Server\\Layers\' button/option not found on the screen.") - raise - # lets create our helper images - create_tutorial_images() - - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-http-localhost-8081.png')) - pag.click(x - 220, y + 10) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'WMS URL\' editbox button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-get-capabilities.png')) - pag.click(x, y, interval=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Get capabilities\' button/option not found on the screen.") - raise - # lets create our helper images + find_and_click_picture('topviewwindow-server-layer.png', + 'Topview Server Layer not found', + region=topview["os_screen_region"]) create_tutorial_images() + move_and_setup_layerchooser(topview["os_screen_region"], -171, -390, 10, 675) + tvll_region = list(topview["os_screen_region"]) + tvll_region[3] = tvll_region[3] + 675 + # Selecting some layers in topview layerlist # lookup layer entry from the multilayering checkbox - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) - # Divergence and Geopotential - pag.click(x + 50, y + 70, interval=2) - pag.sleep(1) - # Relative Huminidity - pag.click(x + 50, y + 110, interval=2) - pag.sleep(1) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering \' checkbox not found on the screen.") - raise + x, y = find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering selection not found', + region=tuple(tvll_region)) + pag.click() + # Divergence and Geopotential + pag.click(x, y + 70, interval=2) + pag.sleep(1) + # Relative Huminidity + pag.click(x, y + 110, interval=2) + pag.sleep(1) + + # let's create our helper images + create_tutorial_images() # Filter layer - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-layer-filter.png')) - pag.click(x + 150, y, interval=2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Layer filter editbox\' button/option not found on the screen.") - raise - - if x is not None and y is not None: - pag.write('temperature', interval=0.25) - pag.click(interval=2) - pag.sleep(1) - - # lets create our helper images - create_tutorial_images() - # clear by clicking on the red X - try: - pic = picture('multilayersdialog-temperature.png', boundingbox=(627, 0, 657, 20)) - x, y = pag.locateCenterOnScreen(pic) - pag.click(x, y, interval=2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Layer filter editbox\' button/option not found on the screen.") - raise + find_and_click_picture('multilayersdialog-layer-filter.png', + 'multilayers layer filter not found', + region=tuple(tvll_region), xoffset=150) + pag.write('temperature', interval=0.25) + pag.click(interval=2) + pag.sleep(1) + # let's create our helper images + create_tutorial_images() + # clear by clicking on the red X + find_and_click_picture('multilayersdialog-temperature.png', + 'multilayersdialog temperature not found', + bounding_box=(627, 0, 657, 20), region=tuple(tvll_region)) + + # let's create our helper images + create_tutorial_images() # star two layers - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) - # Divergence and Geopotential - pag.click(x, y + 70, interval=2) - pag.sleep(1) - # Relative Huminidity - pag.click(x, y + 110, interval=2) - pag.sleep(1) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering \' checkbox not found on the screen.") - raise + xm, ym = find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering selection not found', + region=tuple(tvll_region)) + + pag.click() + + # unstar Relative Huminidity + pag.click(xm, ym + 110, interval=2) + pag.sleep(1) # Filtering starred layers. - try: - pic = picture('multilayersdialog-temperature.png', boundingbox=(658, 2, 677, 18)) - x, y = pag.locateCenterOnScreen(pic) - pag.click(x, y, interval=2) - pag.sleep(2) - - # removing starred selection showing full list - pag.click(x, y, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Starred filter\' button/option not found on the screen.") - raise + x, y = find_and_click_picture('multilayersdialog-temperature.png', + 'multilayersdialog temperature not found', + bounding_box=(658, 2, 677, 18), region=tuple(tvll_region)) + pag.sleep(2) + # removing starred selection showing full list + pag.click(x, y, interval=2) + pag.sleep(1) + + # Load some data + pag.click(xm + 200, ym + 70, interval=2) + create_tutorial_images() + pag.sleep(2) # Setting different levels and valid time - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-level.png')) - pag.click(x + 200, y, interval=2) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Pressure level\' button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-initialisation.png')) - initx, inity = x, y - pag.click(x + 200, y, interval=1) - pag.sleep(1) - pag.click(x + 200, y, interval=1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Initialization\' button/option not found on the screen.") - raise - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-valid.png')) - validx, validy = x, y - pag.click(x + 200, y, interval=2) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Valid till\' button/option not found on the screen.") - raise - - # Time gap for initialization and valid - if initx is not None and inity is not None and validx is not None and validy is not None: - pag.click(initx + 818, inity, interval=2) - pag.press('up', presses=5, interval=0.25) - pag.press('down', presses=3, interval=0.25) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter') - elif platform == 'darwin': - pag.press('return') - - pag.click(validx + 833, validy, interval=2) - pag.press('up', presses=5, interval=0.20) - pag.press('down', presses=6, interval=0.20) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter') - elif platform == 'darwin': - pag.press('return') - - # Previous and Next of Initial(Initialization) values - pag.click(initx + 733, inity, clicks=2, interval=2) - pag.click(initx + 892, inity, clicks=2, interval=2) - - # Previous and Next of Valid values - pag.click(validx + 743, validy, clicks=4, interval=4) - pag.click(validx + 902, validy, clicks=4, interval=4) + region = get_region('topviewwindow-30-0-hpa.png', region=topview["os_screen_region"]) + find_and_click_picture('topviewwindow-30-0-hpa.png', + '30 hPa not found', + region=topview["os_screen_region"]) + for _ in range(5): + select_listelement(1) + pag.click() + + # changing level using the > and < right side + a = region.left + region.width + 45 + b = region.top + region.height / 2 + + for _ in range(3): + pag.click(a, b) + + a = region.left + region.width + 20 + b = region.top + region.height / 2 + + for _ in range(5): + pag.click(a, b) + + region = get_region('topviewwindow-2012-10-17t12-00-00z.png', + region=topview["os_screen_region"]) + + find_and_click_picture('topviewwindow-2012-10-17t12-00-00z.png', + '2012-10-17t12-00-00z not found', + region=topview["os_screen_region"], yoffset=30) + + for _ in range(2): + select_listelement(1) + pag.click() + + # changing valid time using the > and < right side + a = region.left + region.width + 45 + b = region.top + region.height / 2 + + for _ in range(3): + pag.click(a, b) + + a = region.left + region.width + 20 + b = region.top + region.height / 2 + + for _ in range(5): + pag.click(a, b) # Auto-update feature of wms - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-auto-update.png')) - pag.click(x - 53, y, interval=2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\' auto update checkbox\' button/option not found on the screen.") - raise - - try: - retx, rety = pag.locateCenterOnScreen(picture('topviewwindow-retrieve.png')) - pag.click(retx, rety, interval=2) - # pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.click(retx, rety, interval=2) - pag.sleep(3) - pag.click(x - 53, y, interval=2) - # pag.click(temp1, temp2, interval=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\' retrieve\' button/option not found on the screen.") - raise + x, y = find_and_click_picture('topviewwindow-auto-update.png', + 'autoupdate not found', + region=topview["os_screen_region"] + ) + + retx, rety = find_and_click_picture('topviewwindow-retrieve.png', + 'retrieve not found', + region=topview["os_screen_region"]) + pag.click(retx, rety, interval=2) + pag.sleep(3) + pag.click(x, y, interval=2) + pag.sleep(2) # Using and not using Cache - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-use-cache.png')) - pag.click(x - 46, y, interval=2) - # pag.click(temp1, temp2, interval=2) - pag.sleep(4) - # pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.sleep(4) - pag.click(x - 46, y, interval=2) - # pag.click(temp1, temp2 + (gap * 2), interval=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Use Cache checkbox\' button/option not found on the screen.") - raise + find_and_click_picture('topviewwindow-use-cache.png', + 'use cache not found', + region=topview["os_screen_region"]) + + # select a layer + pag.click(xm + 200, ym + 140, interval=2) + pag.sleep(1) + pag.click() # Clearing cache. The layers load slower - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-clear-cache.png')) - pag.click(x, y, interval=2) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - # pag.click(temp1, temp2, interval=2) - pag.sleep(4) - # pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.sleep(4) - # pag.click(temp1, temp2 + (gap * 2), interval=2) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Clear cache\' button/option not found on the screen.") - raise + find_and_click_picture('topviewwindow-clear-cache.png', + 'Clear cache not found', + region=topview["os_screen_region"]) + pag.press(ENTER) + # select a layer + pag.click(xm + 200, ym + 110, interval=2) + pag.sleep(1) + pag.click() # transparent layer - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-transparent.png')) - pag.click(x - 53, y, interval=2) - if retx is not None and rety is not None: - pag.click(retx, rety, interval=2) - pag.sleep(1) - pag.click(x - 53, y, interval=2) - # pag.click(temp1, temp2, interval=2) - pag.click(retx, rety, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Transparent Checkbox\' button/option not found on the screen.") - raise + x, y = find_and_click_picture('topviewwindow-transparent.png', + 'Transparent not found', + region=topview["os_screen_region"], + ) + pag.click(retx, rety, interval=2) + pag.sleep(1) + pag.click(x, y, interval=2) + pag.click(retx, rety, interval=2) + pag.sleep(1) # Removing a Layer from the map - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-remove.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - # pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.click(x, y, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Transparent Checkbox\' button/option not found on the screen.") - raise + x, y = find_and_click_picture('topviewwindow-remove.png', + 'remove not found', + region=topview["os_screen_region"]) + + pag.sleep(1) + pag.click(x, y, interval=2) # Deleting All layers - try: - x, y = pag.locateCenterOnScreen(picture('topviewwindow-server-layer.png')) - pag.click(x, y, interval=2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Server\\Layers\' button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('multilayersdialog-multilayering.png')) - # Divergence and Geopotential - pag.click(x - 16, y + 50, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering \' checkbox not found on the screen.") - raise + find_and_click_picture('topviewwindow-server-layer.png', + 'Server layer not found', + region=topview["os_screen_region"]) + + find_and_click_picture('multilayersdialog-multilayering.png', + 'multilayering not found', + xoffset=-16, yoffset=50) + + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") + + # Close Everything! + finish() if __name__ == '__main__': - start(target=automate_waypoints, duration=280) + start(target=automate_wms, duration=280) diff --git a/tutorials/utils/__init__.py b/tutorials/utils/__init__.py index c7f66e63d..e1e436312 100644 --- a/tutorials/utils/__init__.py +++ b/tutorials/utils/__init__.py @@ -10,7 +10,7 @@ :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft und Raumfahrt e.V. :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) - :copyright: Copyright 2016-2022 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); @@ -25,11 +25,20 @@ See the License for the specific language governing permissions and limitations under the License. """ +import os +import platform import sys import multiprocessing import pyautogui as pag +from pyscreeze import ImageNotFoundException + from mslib.msui import msui from tutorials.utils import screenrecorder as sr +from tutorials.utils.picture import picture +from tutorials.utils.platform_keys import platform_keys +from mslib.msui.constants import MSUI_CONFIG_PATH + +CTRL, ENTER, WIN, ALT = platform_keys() def initial_ops(): @@ -52,7 +61,13 @@ def initial_ops(): def call_recorder(x_start=0, y_start=0, x_width=int(pag.size()[0]), y_width=int(pag.size()[1]), duration=120): """ - Calls the screen recorder class to start the recording of the automation. + Starts a call recording of the specified area on the screen. + + :param x_start: (optional) The x-coordinate of the starting point for the recording area. Defaults to 0. + :param y_start: (optional) The y-coordinate of the starting point for the recording area. Defaults to 0. + :param x_width: (optional) The width of the recording area. Defaults to the width of the screen. + :param y_width: (optional) The height of the recording area. Defaults to the height of the screen. + :param duration: (optional) The duration of the recording in seconds. Defaults to 120 seconds. """ sr.ScreenRecorder() rec = sr.ScreenRecorder(x_start, y_start, x_width, y_width) @@ -67,25 +82,13 @@ def call_msui(): msui.main(tutorial_mode=True) -def platform_keys(): - # sys.platform specific keyse - if sys.platform == 'linux' or sys.platform == 'linux2': - enter = 'enter' - win = 'winleft' - ctrl = 'ctrl' - alt = 'altleft' - elif sys.platform == 'win32': - enter = 'enter' - win = 'win' - ctrl = 'ctrl' - alt = 'alt' - elif sys.platform == 'darwin': - enter = 'return' - ctrl = 'command' - return ctrl, enter, win, alt +def finish(): + """ + Closes all open windows and exits the application. + This method is used to automate the process of closing all open windows and exiting the application. -def finish(): + """ # clean up and close all try: if sys.platform == 'linux' or sys.platform == 'linux2': @@ -128,9 +131,18 @@ def finish(): def start(target=None, duration=120, dry_run=False): """ - This function runs the above functions as different processes at the same time and can be - controlled from here. (This is the main process.) + Starts the automation process. + + :param target: A function representing the target task to be automated. Default is None. + :param duration: An integer representing the duration of the recording in seconds. Default is 120. + :param dry_run: A boolean indicating whether to run in dry-run mode or not. Default is False. + :return: None + + Note: Uncomment the line pag.press('q') if recording windows do not close in some cases. """ + if platform.system() == 'Linux': + # makes shure the keyboard is set to US + os.system("setxkbmap -layout us") if target is None: return p1 = multiprocessing.Process(target=call_msui) @@ -156,10 +168,341 @@ def start(target=None, duration=120, dry_run=False): def create_tutorial_images(): + """ + + This method `create_tutorial_images` is used to simulate the keyboard key + combination 'Ctrl + F' and then puts the program to sleep for 1 second. + + """ pag.hotkey('ctrl', 'f') pag.sleep(1) -def get_region(image): - region = pag.locateOnScreen(image) - return region +def get_region(image, region=None): + """ + Find the region of the given image on the screen. + + :param image: The image to locate on the screen. + :return: The region of the image found on the screen. + :rtype: tuple(int, int, int, int) + """ + if region is not None: + image_region = pag.locateOnScreen(picture(image), region=region) + else: + image_region = pag.locateOnScreen(picture(image)) + return image_region + + +def click_center_on_screen(pic, duration=2, xoffset=0, yoffset=0, region=None, click=True): + """ + Clicks the center of an image on the screen. + + :param pic: The image file or partial image file to locate on the screen. + :param duration: The duration (in seconds) for the click action. Default is 2 seconds. + :param xoffset: The horizontal offset from the center of the image. Default is 0. + :param yoffset: The vertical offset from the center of the image. Default is 0. + :param region: The region on the screen to search for the image. Default is None, which searches the entire screen. + :param click: Indicates whether to perform the click action. Default is True. + + :return: None + """ + if region is None: + x, y = pag.locateCenterOnScreen(pic) + else: + x, y = pag.locateCenterOnScreen(pic, region=region) + if click: + pag.click(x + xoffset, y + yoffset, duration=duration) + + +def select_listelement(steps, sleep=5, key=ENTER): + """ + Selects an element from a list by moving the cursor downward and pressing a key. + + :param steps: Number of times to move the cursor downward. + :param sleep: Time to sleep after pressing the key (default is 5 seconds). + :param key: Key to press after moving the cursor (default is 'ENTER'). + :return: None + """ + pag.press('down', presses=steps, interval=0.5) + if key is not None: + pag.press(key, interval=1) + pag.sleep(sleep) + + +def find_and_click_picture(pic_name, exception_message=None, duration=2, xoffset=0, yoffset=0, + bounding_box=None, region=None, click=True): + """ + + Finds a specified picture and clicks on it. + When the image can't be found, an exception is raised and a failure.png image is created + + :param pic_name: The name of the picture to find. This can be a file name or a string pattern. + :param exception_message: Optional. Custom exception message to be displayed if the picture is not found. + Defaults to None. + :param duration: Optional. The duration of the click in seconds. Defaults to 2. + :param xoffset: Optional. The x-axis offset for the click position. Defaults to 0. + :param yoffset: Optional. The y-axis offset for the click position. Defaults to 0. + :param bounding_box: Optional. The bounding box for the search area. Defaults to None. + :param region: Optional. The region in which to search for the picture. Defaults to None. + :param click: Optional. Indicates whether to perform the click action. Defaults to True. + + :raises ImageNotFoundException: If the picture is not found. + :raises OSError: If there is an error while processing the picture. + :raises Exception: If any other exception occurs. + + :returns: A tuple containing the x and y coordinates of the clicked position. + """ + x, y = (0, 0) + message = exception_message if exception_message is not None else f"{pic_name} not found" + try: + click_center_on_screen(picture(pic_name, bounding_box=bounding_box), + duration, xoffset=xoffset, yoffset=yoffset, region=region, click=click) + x, y = pag.position() + # ToDo verify + # pag.moveTo(x, y, duration=duration) + pag.sleep(1) + except (ImageNotFoundException, OSError, Exception): + filename = os.path.join(MSUI_CONFIG_PATH, "failure.png") + print(f"\nException: {message} see {filename} for details") + im = pag.screenshot(region=region) + im.save(filename) + raise + + return (x, y) + + +def load_kml_file(pic_name, file_path, exception_message): + """ + Loads a KML file using the given picture name and file path. + + :param pic_name: The name of the picture to be found and clicked. + :param file_path: The path to the KML file. + :param exception_message: The exception message to be printed and raised if an error occurs. + :raises ImageNotFoundException: If the specified picture cannot be found. + :raises OSError: If an error occurs while typing the file path or pressing the ENTER key. + :raises Exception: If an unknown error occurs. + + """ + try: + find_and_click_picture(pic_name, exception_message) + pag.typewrite(file_path, interval=0.1) + pag.sleep(1) + pag.press(ENTER) + except (ImageNotFoundException, OSError, Exception): + print(exception_message) + raise + + +def change_color(pic_name, exception_message, actions, interval=2, sleep_time=2): + """ + Changes the color of the specified picture and performs the given actions. + """ + try: + click_center_on_screen(picture(pic_name), interval) + pag.sleep(sleep_time) + actions() + except (ImageNotFoundException, OSError, Exception): + print(f"\nException: {exception_message}") + raise + + +def zoom_in(pic_name, exception_message, move=(379, 205), dragRel=(70, 75), region=None): + """ + This method locates a given picture on the screen, clicks on it, moves the mouse cursor, + performs a drag motion, waits for 5 seconds, and raises an exception if the picture is not found + + :param pic_name: The name of the picture to locate on the screen. + :param exception_message: The message to be displayed in case the picture is not found. + :param move: The amount to move the mouse cursor horizontally and vertically after clicking on the picture. + Defaults to (379, 205). + :param dragRel: The amount to drag the mouse cursor horizontally and vertically after moving. + Defaults to (70, 75). + :param region: The specific region of the screen to search for the picture. + Defaults to None, which means the entire screen will be searched. + """ + try: + x, y = pag.locateCenterOnScreen(picture(pic_name), region=region) + pag.click(x, y, interval=2) + pag.move(move[0], move[1], duration=1) + pag.dragRel(dragRel[0], dragRel[1], duration=2) + pag.sleep(5) + except ImageNotFoundException: + print(f"\nException: {exception_message}") + raise + + +def panning(pic_name, exception_message, moveRel=(400, 400), dragRel=(-100, -50), region=None): + """ + Executes panning action on the screen. + + :param pic_name: The name of the picture file to locate on the screen. + :param exception_message: The message to display in case of exceptions. + :param moveRel: The relative movements to be made after clicking on the picture. Defaults to (400, 400). + :param dragRel: The relative movements to be made during the dragging action. Defaults to (-100, -50). + :param region: The region of the screen to search for the picture. Defaults to None. + """ + try: + x, y = pag.locateCenterOnScreen(picture(pic_name), region=region) + pag.click(x, y, interval=2) + pag.moveRel(moveRel[0], moveRel[1], duration=1) + pag.dragRel(dragRel[0], dragRel[1], duration=2) + except (ImageNotFoundException, OSError, Exception): + print(f"\nException: {exception_message}") + raise + + +def type_and_key(value, interval=0.1, key=ENTER): + """ + Type and Enter method + + This method types the given value and then presses the Enter key on the keyboard. + + :param value (str): The value to be typed. + :param interval (float, optional): The interval between typing each character. Defaults to 0.3 seconds. + """ + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.typewrite(value, interval=interval) + pag.sleep(1) + pag.press(key) + + +def move_window(os_screen_region, x_drag_rel, y_drag_rel, x_mouse_down_offset=100): + """ + + Move the window to a new position. + + :param os_screen_region: A tuple containing the screen region of the window to be moved. + It should have the format (x, y, w, h), where x and y are the coordinates of the top-left + corner of the window, and w and h are the width and height of the window, respectively. + :param x_drag_rel: The amount to drag the window horizontally relative to its current position. + Positive values will move the window to the right, while negative values will move it + to the left. + :param y_drag_rel: The amount to drag the window vertically relative to its current position. + Positive values will move the window down, while negative values will move it up. + :param x_mouse_down_offset: The offset from the left corner of the window where the mouse button + will be pressed. + This is useful to avoid clicking on any buttons or icons within the window. The default value is 100. + + Example usage: + os_screen_region = (100, 200, 800, 600) + x_drag_rel = 100 + y_drag_rel = 50 + move_window(os_screen_region, x_drag_rel, y_drag_rel) + + This will move the window located at (100, 200) to a new position that is 100 pixels to the right and 50 pixels + down from its current position. + + """ + x, y = os_screen_region[0:2] + # x, y is left corner where the msui logo is + pag.mouseDown(x + x_mouse_down_offset, y - 10, duration=10) + pag.sleep(1) + pag.dragRel(x_drag_rel, y_drag_rel, duration=2) + pag.mouseUp() + + +def move_and_setup_layerchooser(os_screen_region, x_move, y_move, x_drag_rel, y_drag_rel, x_mouse_down_offset=220): + """ + + Move and set up the layer chooser in a given screen region. + + :param os_screen_region: The screen region where the actions will be performed. + :param x_move: The horizontal distance to move the mouse cursor. + :param y_move: The vertical distance to move the mouse cursor. + :param x_drag_rel: The horizontal distance to drag the mouse cursor relative to its current position. + :param y_drag_rel: The vertical distance to drag the mouse cursor relative to its current position. + :param x_mouse_down_offset (optional): The offset from the left corner of the window where the mouse button + will be pressed. This is useful to avoid clicking on any buttons or icons within the window. Defaults to 220. + + + Example Usage: + move_and_setup_layerchooser((0, 0, 1920, 1080), 100, -50, 200, 100, x_mouse_down_offset=300) + move_and_setup_layerchooser((0, 0, 1920, 1080), -50, 0, 100, 200) + + """ + find_and_click_picture('multilayersdialog-http-localhost-8081.png', + 'Url not found', region=os_screen_region) + x, y = pag.position() + pag.click(x + x_mouse_down_offset, y, interval=2) + type_and_key('http://open-mss.org/', interval=0.1) + try: + find_and_click_picture('multilayersdialog-get-capabilities.png', + 'Get capabilities not found', region=os_screen_region) + except TypeError: + pag.press(ENTER) + pag.move(x_move, y_move, duration=1) + pag.dragRel(x_drag_rel, y_drag_rel, duration=2) + + +def show_other_widgets(): + """ + Displays other widgets in the application. + + This method shows the sideview, linearview, and topview of the application. + It uses the `pag` module from the PyAutoGUI library to simulate key presses. + + Note: + - The 'altleft' key is pressed and released in the following sections to navigate through the application. + - The 'tab' key is pressed multiple times to switch between different views. + + Example usage: + show_other_widgets() + + """ + # show sideview + pag.keyDown('altleft') + pag.press('tab') + pag.press('tab') + pag.keyUp('altleft') + pag.sleep(1) + # show linearview also + pag.keyDown('altleft') + pag.press('tab') + pag.keyUp('altleft') + # show topview also + pag.keyDown('altleft') + pag.press('tab') + pag.press('tab') + pag.press('tab') + pag.keyUp('altleft') + pag.sleep(1) + + +def msui_full_screen_and_open_first_view(view_cmd='h'): + """ + Open the first view and go full screen in MSUI. + + :param view_cmd: The command to open the view (default is 'h' for Home). + :type view_cmd: str + + :return: None + """ + hotkey = WIN, 'pageup' + pag.hotkey(*hotkey) + pag.sleep(1) + if view_cmd is not None: + pag.hotkey(CTRL, view_cmd) + pag.sleep(1) + create_tutorial_images() + pag.sleep(2) + + +def add_waypoints_to_topview(os_screen_region): + # enable adding waypoints + find_and_click_picture('topviewwindow-ins-wp.png', + 'Clickable button/option not found.', + region=os_screen_region) + # Adding waypoints for demonstrating remote sensing + pag.move(-50, 150, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(65, 65, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(-150, 30, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(200, 150, duration=1) + pag.click(interval=2) + pag.sleep(2) diff --git a/tutorials/utils/constants.py b/tutorials/utils/constants.py index 1e375604e..d6402d85b 100644 --- a/tutorials/utils/constants.py +++ b/tutorials/utils/constants.py @@ -10,7 +10,7 @@ :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) - :copyright: Copyright 2016-2022 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tutorials/utils/picture.py b/tutorials/utils/picture.py new file mode 100644 index 000000000..34e1891d8 --- /dev/null +++ b/tutorials/utils/picture.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" + + mslib.tutorials.utils.picture + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module provides functions to read images for the different tutorials for comparison + + This file is part of MSS. + + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import os +import time +from pathlib import Path +from slugify import slugify +from PIL import Image +from mslib.msui.constants import MSUI_CONFIG_PATH + + +def picture(name, bounding_box=None): + filename = os.path.join(MSUI_CONFIG_PATH, "tutorial_images", name) + if bounding_box is not None: + with Image.open(filename) as img: + cropped_img = img.crop(bounding_box) + part = '-'.join([str(val) for val in bounding_box]) + new_name = slugify(f'{Path(name).stem}-{part}') + filename = os.path.join(MSUI_CONFIG_PATH, "tutorial_images", f'{new_name}.png') + cropped_img.save(filename) + time.sleep(1) + return filename diff --git a/tutorials/utils/platform_keys.py b/tutorials/utils/platform_keys.py new file mode 100644 index 000000000..e8e956038 --- /dev/null +++ b/tutorials/utils/platform_keys.py @@ -0,0 +1,58 @@ +""" + msui.tutorials.utils.platform_keys + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Includes platform-specific modules + + This file is part of MSS. + + :copyright: Copyright 2021 Hrithik Kumar Verma + :copyright: Copyright 2021-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import sys + + +def platform_keys(): + """ + Returns platform specific key mappings. + + Returns: + A tuple containing the key mappings for the current platform. + + Note: + The key mappings returned depend on the value of `sys.platform`. + For Linux, the return values are ('ctrl', 'enter', 'winleft', 'altleft'). + For Windows, the return values are ('ctrl', 'enter', 'win', 'alt'). + For macOS, the return values are ('command', 'return'). + + Example: + ctrl, enter, win, alt = platform_keys() + """ + # sys.platform specific keyse + if sys.platform == 'linux' or sys.platform == 'linux2': + enter = 'enter' + win = 'winleft' + ctrl = 'ctrl' + alt = 'altleft' + elif sys.platform == 'win32': + enter = 'enter' + win = 'win' + ctrl = 'ctrl' + alt = 'alt' + elif sys.platform == 'darwin': + enter = 'return' + ctrl = 'command' + return ctrl, enter, win, alt From 80012e7f45a2180513e32da865931960d55db585 Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Thu, 7 Dec 2023 02:46:53 +0530 Subject: [PATCH 086/176] remove unnecssary lines from gitignore (#2095) * remove unnecssary files from gitignore * update gitignore --- .gitignore | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.gitignore b/.gitignore index 6a11f5109..9671caf94 100644 --- a/.gitignore +++ b/.gitignore @@ -6,15 +6,9 @@ *.swp *.patch *~ -mslib/mss_config.py mslib/performance/data/ -mslib/msui/mss.sideview.cfg -mslib/msui/mss.topview.cfg -mslib/msui/msui_settings.py -mslib/msui/wms_cache/ mslib/mswms/mswms_settings.py mslib/mswms/mswms_auth.py -mslib/mscolab/colabdata/ docs/_build docs/gallery/plots docs/gallery/code @@ -24,5 +18,3 @@ build/ mss.egg-info/ tutorials/recordings tutorials/cursor_image.png -__pycache__/ -instance/ From 159185ed6cf742fbfb79a255fbdf3493d9644130 Mon Sep 17 00:00:00 2001 From: Ataf Fazledin Ahamed Date: Fri, 8 Dec 2023 19:42:41 +0600 Subject: [PATCH 087/176] Fixed Inappropriate Logical Expression (#2113) Signed-off-by: fazledyn-or --- mslib/msidp/idp_uwsgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/msidp/idp_uwsgi.py b/mslib/msidp/idp_uwsgi.py index 9c2ab0ba0..8e1bb3b43 100644 --- a/mslib/msidp/idp_uwsgi.py +++ b/mslib/msidp/idp_uwsgi.py @@ -528,7 +528,7 @@ def do_authentication(environ, start_response, authn_context, key, redirect_uri) logger.debug("Do authentication") auth_info = AUTHN_BROKER.pick(authn_context) - if len(auth_info) >= 0: + if len(auth_info) > 0: method, reference = auth_info[0] logger.debug("Authn chosen: %s (ref=%s)", method, reference) return method(environ, start_response, reference, key, redirect_uri) From 788d10730adc45ff791a90b042347a800912bc42 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 12 Dec 2023 13:31:30 +0100 Subject: [PATCH 088/176] Mambaforge (Discouraged as of September 2023) (#2115) --- CHANGES.rst | 4 ++-- README.md | 4 ++-- docs/development.rst | 14 +++++++------- docs/installation.rst | 18 ++++++++++-------- docs/mswms.rst | 8 ++++---- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index e4bcda2ba..662e9b076 100755 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -85,8 +85,8 @@ Mscolab Operations in use for more than 30 days, move to an inactive list. The initial idea for multiple flightpaths on topview stems from bkirbus. GSoC mentors were Reimar Bauer, Jörn Ungermann, Sonja Gisinger -With MSS 8.0.0 we base our installation on mambaforge. This has -mamba in the base environment. +With MSS 9.0.0 we base our installation on miniforge. This has +mamba in the base environment. Mambaforge is discouraged of September 2023. All changes: https://github.com/Open-MSS/MSS/milestone/81?closed=1 diff --git a/README.md b/README.md index bd552a069..b014afc9d 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ Automatically Manually -------- -As **Beginner** start with an installation of Mambaforge -Get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) for your Operation System +As **Beginner** start with an installation of Miniforge +Get [miniforge](https://github.com/conda-forge/miniforge#download) for your Operation System You must install mss into a new environment to ensure the most recent diff --git a/docs/development.rst b/docs/development.rst index dcee78896..b4a59acdd 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -132,7 +132,7 @@ Requirements 2. Software requirement | Python - | `Mambaforge `_ + | `Miniforge `_ | `Additional Requirements `_ @@ -145,7 +145,7 @@ Requirements Using predefined docker images instead of installing all requirements ..................................................................... -You can easily use our testing docker images which have all libraries pre installed. These are based on mambaforgen. +You can easily use our testing docker images which have all libraries pre installed. These are based on miniforge. We provide two images. In openmss/testing-stable we have mss-stable-env and in openmss/testing-develop we have mss-develop-env defined. In the further course of the documentation we speak of the environment mssdev, this corresponds to one of these evironments. @@ -171,12 +171,12 @@ Use the docker env on your computer, initial setup This example shows by using mss-stable-env how to set it up for testing and development of stable branch. The images gets updates when we have to add new dependencies or have do pinning of existing modules. On an updated image you need to redo these steps :: - rm -rf $HOME/mambaforge/envs/mss-stable-env # cleanup the existing env - mkdir $HOME/mambaforge/envs/mss-stable-env # create the dir to bind to + rm -rf $HOME/miniforge/envs/mss-stable-env # cleanup the existing env + mkdir $HOME/miniforge/envs/mss-stable-env # create the dir to bind to xhost +local:docker # may be needed - docker run -it --rm --mount type=volume,dst=/opt/conda/envs/mss-stable-env,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$HOME/mambaforge/envs/mss-stable-env --network host openmss/testing-stable # do the volume bind + docker run -it --rm --mount type=volume,dst=/opt/conda/envs/mss-stable-env,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$HOME/miniforge/envs/mss-stable-env --network host openmss/testing-stable # do the volume bind exit # we are in the container, escape :) - sudo ln -s $HOME/mambaforge/envs/mss-stable-env /opt/conda/envs/mss-stable-env # we need the origin location linked because hashbangs interpreters are with that path. (only once needed) + sudo ln -s $HOME/miniforge/envs/mss-stable-env /opt/conda/envs/mss-stable-env # we need the origin location linked because hashbangs interpreters are with that path. (only once needed) conda activate mss-stable-env # activate env cd workspace/MSS # go to your workspace MSS dir export PYTHONPATH=`pwd` # add it to the PYTHONPATH @@ -197,7 +197,7 @@ After the image was configured you can use it like a self installed env :: Manual Installing dependencies .............................. -MSS is based on the software of the conda-forge channel located. The channel is predefined in Mambaforge. +MSS is based on the software of the conda-forge channel located. The channel is predefined in Miniforge. Create an environment and install the dependencies needed for the mss package:: diff --git a/docs/installation.rst b/docs/installation.rst index 52e656a30..aff9a2b8d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -41,11 +41,15 @@ Manual Mamba based installation ........................ -We strongly recommend to start from `Mambaforge `_, + + +We strongly recommend to start from `Miniforge `_, a community project of the conda-forge community. -As **Beginner** start with an installation of Mambaforge -- Get `mambaforge `__ for your Operation System +As **Beginner** start with an installation of Miniforge +- Get `miniforge `__ for your Operation System + +If you use already Mambaforge please read the `FAQ `__ Install MSS ~~~~~~~~~~~ @@ -70,7 +74,7 @@ We suggest to create a mss user. * login as mss user * create a *src* directory in /home/mss * cd src -* get `mambaforge `__ +* get `miniforge `__ * set execute bit on install script * execute script, enable environment in .bashrc * login again @@ -99,22 +103,20 @@ Conda based installation `Anaconda `_ provides an enterprise-ready data analytics platform that empowers companies to adopt a modern open data science analytics architecture. -.. warning:: - Installing Mamba in Anaconda setup is not recommended. We strongly recommend to use the Mambaforge method (see above). - Please add the channel conda-forge to your defaults:: $ conda config --add channels conda-forge The conda-forge channel must be on top of the list before the anaconda default channel. +From September 2023 libmamba is the `default installer in anaconda `__. + Install MSS ~~~~~~~~~~~ You must install mss into a new environment to ensure the most recent versions for dependencies. :: - $ conda install -n base conda-libmamba-solver $ conda create -n mssenv $ conda activate mssenv (mssenv) $ conda install mss=$mss_version python --solver=libmamba diff --git a/docs/mswms.rst b/docs/mswms.rst index 582c74510..a0dfd5893 100644 --- a/docs/mswms.rst +++ b/docs/mswms.rst @@ -507,7 +507,7 @@ At current state we have to use pip to install mod_wsgi into the INSTANCE enviro Setup a /etc/apache2/mods-available/wsgi_express.conf:: - WSGIPythonHome "/home/mss-demo/mambaforge/envs/demo/" + WSGIPythonHome "/home/mss-demo/miniforge/envs/demo/" Setup a /etc/apache2/mods-available/wsgi_express.load:: @@ -522,11 +522,11 @@ Configuration of apache mod_wsgi.conf One posibility to setup the PYTHONPATH environment variable is by adding it to your mod_wsgi.conf. Alternativly you could add it also to wms.wsgi. - WSGIPythonPath /home/mss/INSTANCE/config:/home/mss/mambaforge/envs/instance/lib/python3.X/site-packages + WSGIPythonPath /home/mss/INSTANCE/config:/home/mss/miniforge/envs/instance/lib/python3.X/site-packages By this setting you override the PYTHONPATH environment variable. So you have also to add -the site-packes directory of your mambaforge installation besides the config file path. +the site-packes directory of your miniforge installation besides the config file path. If your server hosts different instances by different users you want to setup this path in mswms_setting.py. @@ -548,7 +548,7 @@ INSTANCE is a placeholder for your service name:: | └── wsgi | ├── auth.wsgi | └── wms.wsgi - ├── mambaforge + ├── miniforge │   ├── bin │   ├── conda-bld │   ├── conda-meta From c1f6e86ce3cd45605825486d1f5a1192d6897503 Mon Sep 17 00:00:00 2001 From: Pranith_1604 <116240849+Beeram12@users.noreply.github.com> Date: Wed, 10 Jan 2024 12:43:43 +0530 Subject: [PATCH 089/176] Fixed typo changed gpdr to gdpr (#2139) --- mslib/index.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/index.py b/mslib/index.py index 9ba7ddcb3..c792e28d8 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -210,7 +210,7 @@ def imprint(): else: return "" - @APP.route("/mss/gpdr") + @APP.route("/mss/gdpr") def gdpr(): if file_exists(gdpr_file): content = get_content(gdpr_file) From 1e98de5187977fe0e140cf1649776f918b2bf693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 10 Jan 2024 08:18:04 +0100 Subject: [PATCH 090/176] Reset config directory before each test (#2136) * Reset the config path after every test This eliminates many of the order-dependent test failures. * Create missing parent directories These were previously created in some other test, but resetting the config directory broke that behaviour. The tests should create these directories themselves to avoid unexpected dependencies between them. * Remove unnecessary config resets * Skip test that uses QTimer This test has been skipped unconditionally anyway. * Fix message box error in test_import_permissions This was caused by the QTimer's running rather late and breaking the teardown of this otherwise unrelated test. --- conftest.py | 103 ++++++++++++------ tests/_test_msui/test_mscolab.py | 13 --- .../test_mscolab_merge_waypoints.py | 43 +++----- .../test_mscolab_save_merge_points.py | 3 +- tests/_test_utils/test_airdata.py | 4 + tests/_test_utils/test_config.py | 3 - 6 files changed, 90 insertions(+), 79 deletions(-) diff --git a/conftest.py b/conftest.py index 2b0f2103b..b00271b80 100644 --- a/conftest.py +++ b/conftest.py @@ -40,9 +40,8 @@ from mslib.mswms.demodata import DataFiles import tests.constants as constants -# make a copy for mscolab test, so that we read different pathes during parallel tests. -sample_path = os.path.join(os.path.dirname(__file__), "tests", "data") -shutil.copy(os.path.join(sample_path, "example.ftml"), constants.ROOT_DIR) +# This import must come after importing tests.constants due to MSUI_CONFIG_PATH being set there +from mslib.utils.config import read_config_file class TestKeyring(keyring.backend.KeyringBackend): @@ -97,16 +96,27 @@ def pytest_generate_tests(metafunc): except ImportError: Display = None -if not constants.SERVER_CONFIG_FS.exists(constants.SERVER_CONFIG_FILE): - print('\n configure testdata') - # ToDo check pytest tmpdir_factory - examples = DataFiles(data_fs=constants.DATA_FS, - server_config_fs=constants.SERVER_CONFIG_FS) - examples.create_server_config(detailed_information=True) - examples.create_data() -if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_CONFIG_FILE): - config_string = f''' +def generate_initial_config(): + """Generate an initial state for the configuration directory in tests.constants.ROOT_FS + """ + if not constants.ROOT_FS.exists("msui/testdata"): + constants.ROOT_FS.makedirs("msui/testdata") + + # make a copy for mscolab test, so that we read different pathes during parallel tests. + sample_path = os.path.join(os.path.dirname(__file__), "tests", "data") + shutil.copy(os.path.join(sample_path, "example.ftml"), constants.ROOT_DIR) + + if not constants.SERVER_CONFIG_FS.exists(constants.SERVER_CONFIG_FILE): + print('\n configure testdata') + # ToDo check pytest tmpdir_factory + examples = DataFiles(data_fs=constants.DATA_FS, + server_config_fs=constants.SERVER_CONFIG_FS) + examples.create_server_config(detailed_information=True) + examples.create_data() + + if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_CONFIG_FILE): + config_string = f''' # SQLALCHEMY_DB_URI = 'mysql://user:pass@127.0.0.1/mscolab' import os import logging @@ -185,40 +195,61 @@ def pytest_generate_tests(metafunc): # enable login by identity provider USE_SAML2 = False ''' - ROOT_FS = fs.open_fs(constants.ROOT_DIR) - if not ROOT_FS.exists('mscolab'): - ROOT_FS.makedir('mscolab') - with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: - # windows needs \\ or / but mixed is terrible. *nix needs / - mscolab_fs.writetext(constants.MSCOLAB_CONFIG_FILE, config_string.replace('\\', '/')) - path = fs.path.join(constants.ROOT_DIR, 'mscolab', constants.MSCOLAB_CONFIG_FILE) - parent_path = fs.path.join(constants.ROOT_DIR, 'mscolab') - -if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_AUTH_FILE): - config_string = ''' + ROOT_FS = fs.open_fs(constants.ROOT_DIR) + if not ROOT_FS.exists('mscolab'): + ROOT_FS.makedir('mscolab') + with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: + # windows needs \\ or / but mixed is terrible. *nix needs / + mscolab_fs.writetext(constants.MSCOLAB_CONFIG_FILE, config_string.replace('\\', '/')) + path = fs.path.join(constants.ROOT_DIR, 'mscolab', constants.MSCOLAB_CONFIG_FILE) + parent_path = fs.path.join(constants.ROOT_DIR, 'mscolab') + + if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_AUTH_FILE): + config_string = ''' import hashlib class mscolab_auth(object): password = "testvaluepassword" allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] ''' - ROOT_FS = fs.open_fs(constants.ROOT_DIR) - if not ROOT_FS.exists('mscolab'): - ROOT_FS.makedir('mscolab') - with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: - # windows needs \\ or / but mixed is terrible. *nix needs / - mscolab_fs.writetext(constants.MSCOLAB_AUTH_FILE, config_string.replace('\\', '/')) + ROOT_FS = fs.open_fs(constants.ROOT_DIR) + if not ROOT_FS.exists('mscolab'): + ROOT_FS.makedir('mscolab') + with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: + # windows needs \\ or / but mixed is terrible. *nix needs / + mscolab_fs.writetext(constants.MSCOLAB_AUTH_FILE, config_string.replace('\\', '/')) + + def _load_module(module_name, path): + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + + _load_module("mswms_settings", constants.SERVER_CONFIG_FILE_PATH) + _load_module("mscolab_settings", path) -def _load_module(module_name, path): - spec = importlib.util.spec_from_file_location(module_name, path) - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) +generate_initial_config() -_load_module("mswms_settings", constants.SERVER_CONFIG_FILE_PATH) -_load_module("mscolab_settings", path) +# This import must come after the call to generate_initial_config, otherwise SQLAlchemy will have a wrong database path +from tests.utils import create_msui_settings_file + + +@pytest.fixture(autouse=True) +def reset_config(): + """Reset the configuration directory used in the tests (tests.constants.ROOT_FS) after every test + """ + # Ideally this would just be constants.ROOT_FS.removetree("/"), but SQLAlchemy complains if the SQLite file is deleted. + for e in constants.ROOT_FS.walk.files(exclude=["mscolab.db"]): + constants.ROOT_FS.remove(e) + for e in constants.ROOT_FS.walk.dirs(search="depth"): + constants.ROOT_FS.removedir(e) + + generate_initial_config() + create_msui_settings_file("{}") + read_config_file() @pytest.fixture(autouse=True) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 304a506d2..563c2fcc9 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -43,7 +43,6 @@ from mslib.msui import msui from mslib.msui import mscolab from mslib.mscolab.mscolab import handle_db_reset -from tests.constants import MSUI_CONFIG_PATH from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation PORTS = list(range(25000, 25500)) @@ -52,7 +51,6 @@ class Test_Mscolab_connect_window(): def setup_method(self): handle_db_reset() - self._reset_config_file() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" @@ -261,11 +259,6 @@ def _create_user(self, username, email, password): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - def _reset_config_file(self): - create_msui_settings_file('{ }') - config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") - read_config_file(path=config_file) - @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") @@ -282,7 +275,6 @@ class Test_Mscolab(object): def setup_method(self): handle_db_reset() - self._reset_config_file() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" @@ -793,11 +785,6 @@ def _create_user(self, username, email, password): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - def _reset_config_file(self): - create_msui_settings_file('{ }') - config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") - read_config_file(path=config_file) - def _create_operation(self, path, description, category="example"): self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index e8afee9fc..721ff940a 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -116,7 +116,12 @@ def _select_waypoints(self, table): QtWidgets.QApplication.processEvents() -@pytest.mark.skip("timeout on github") +class AutoClickOverwriteMscolabMergeWaypointsDialog(mslib.msui.mscolab.MscolabMergeWaypointsDialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.overwriteBtn.animateClick() + + class Test_Overwrite_To_Server(Test_Mscolab_Merge_Waypoints): @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_save_overwrite_to_server(self, mockbox): @@ -134,15 +139,9 @@ def test_save_overwrite_to_server(self, mockbox): wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) assert wp_server_before.lat != wp_local_before.lat - # ToDo mock this messagebox - def handle_merge_dialog(): - QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.overwriteBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - - QtCore.QTimer.singleShot(3000, handle_merge_dialog) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(2) + with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickOverwriteMscolabMergeWaypointsDialog): + self.window.serverOptionsCb.setCurrentIndex(2) QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test self.window.waypoints_model not working @@ -157,6 +156,12 @@ def handle_merge_dialog(): assert wp_local_before.lat == new_server_wp.lat +class AutoClickKeepMscolabMergeWaypointsDialog(mslib.msui.mscolab.MscolabMergeWaypointsDialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.keepServerBtn.animateClick() + + class Test_Save_Keep_Server_Points(Test_Mscolab_Merge_Waypoints): @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_save_keep_server_points(self, mockbox): @@ -174,15 +179,9 @@ def test_save_keep_server_points(self, mockbox): wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) assert wp_server_before.lat != wp_local_before.lat - # ToDo mock this messagebox - def handle_merge_dialog(): - QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.keepServerBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - - QtCore.QTimer.singleShot(3000, handle_merge_dialog) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(2) + with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog): + self.window.serverOptionsCb.setCurrentIndex(2) QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test self.window.waypoints_model not working @@ -214,15 +213,9 @@ def test_fetch_from_server(self, mockbox): wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) assert wp_server_before.lat != wp_local_before.lat - # ToDo mock this messagebox - def handle_merge_dialog(): - QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.keepServerBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - - QtCore.QTimer.singleShot(3000, handle_merge_dialog) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(1) + with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog): + self.window.serverOptionsCb.setCurrentIndex(1) QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index b5308778f..32c4cb79e 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -39,6 +39,7 @@ @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): + @pytest.mark.skip("Uses QTimer, which can break other unrelated tests") @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_save_merge_points(self, mockbox): self.emailid = "mergepoints@alpha.org" @@ -58,8 +59,6 @@ def handle_merge_dialog(): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) - if merge_waypoints_model is None: - pytest.skip("merge_waypoints_model undefined") QtCore.QTimer.singleShot(3000, handle_merge_dialog) # QtTest.QTest.mouseClick(self.window.save_ft, QtCore.Qt.LeftButton, delay=1) # trigger save to server action from server options combobox diff --git a/tests/_test_utils/test_airdata.py b/tests/_test_utils/test_airdata.py index 1545b15ab..3d417a2cb 100644 --- a/tests/_test_utils/test_airdata.py +++ b/tests/_test_utils/test_airdata.py @@ -44,6 +44,7 @@ def _download_progress_airports(path, url): 323361,"00AA","small_airport","Aero B Ranch Airport",38.704022,-101.473911,3435,"NA",\ "US","US-KS","Leoti","no","00AA",,"00AA",,,''' file_path = os.path.join(ROOT_DIR, "downloads", "aip", "airports.csv") + os.makedirs(os.path.dirname(file_path)) with open(file_path, "w") as f: f.write(text) @@ -76,6 +77,7 @@ def _download_progress_airspace(path, url): ''' file_path = os.path.join(ROOT_DIR, "downloads", "aip", "bg_asp.xml") + os.makedirs(os.path.dirname(file_path)) with open(file_path, "w") as f: f.write(text) @@ -94,6 +96,7 @@ def _download_incomplete_airspace(path, url): ''' file_path = os.path.join(ROOT_DIR, "downloads", "aip", "bg_asp.xml") + os.makedirs(os.path.dirname(file_path)) with open(file_path, "w") as f: f.write(text) @@ -111,6 +114,7 @@ def _cleanup_test_files(): def test_download_progress(): file_path = os.path.join(ROOT_DIR, "downloads", "aip", "airdata") + os.makedirs(os.path.dirname(file_path)) download_progress(file_path, 'http://speedtest.ftp.otenet.gr/files/test100k.db') assert os.path.exists(file_path) diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index 5c312afe9..9ec480539 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -121,7 +121,6 @@ def test_existing_empty_config_file(self): """ on a user defined empty msui_settings_json this test should return the default value for num_labels """ - create_msui_settings_file('{ }') if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: @@ -210,7 +209,6 @@ def test_modify_config_file_with_empty_parameters(self): """ Test to check if modify_config_file properly stores a key-value pair in an empty config file """ - create_msui_settings_file('{ }') if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { @@ -242,7 +240,6 @@ def test_modify_config_file_with_invalid_parameters(self): """ Test to check if modify_config_file raises a KeyError when a key is empty """ - create_msui_settings_file('{ }') if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { From 1202306fef0f3d38ca83b4a6e5c32f2c30517e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 10 Jan 2024 08:20:12 +0100 Subject: [PATCH 091/176] Remove unnecessary parentheses and inheritance from object (#2138) * Remove unnecessary parentheses * Remove unnecessary inheritance from object --- conftest.py | 2 +- docs/samples/config/mscolab/mscolab_auth.py.sample | 2 +- mslib/support/qt_json_view/datatypes.py | 2 +- mslib/utils/migration/config_before_eight.py | 2 +- mslib/utils/migration/config_before_nine.py | 2 +- tests/_test_mscolab/test_utils.py | 6 +++--- tests/_test_msui/test_aircrafts.py | 4 ++-- tests/_test_msui/test_editor.py | 2 +- tests/_test_msui/test_kmloverlay_dockwidget.py | 2 +- tests/_test_msui/test_linearview.py | 6 +++--- tests/_test_msui/test_mscolab.py | 4 ++-- tests/_test_msui/test_mscolab_admin_window.py | 2 +- tests/_test_msui/test_mscolab_merge_waypoints.py | 2 +- tests/_test_msui/test_mscolab_operation.py | 4 ++-- tests/_test_msui/test_mscolab_version_history.py | 2 +- tests/_test_msui/test_msui.py | 8 ++++---- tests/_test_msui/test_multiple_flightpath_dockwidget.py | 2 +- tests/_test_msui/test_remotesensing.py | 2 +- tests/_test_msui/test_satellite_dockwidget.py | 2 +- tests/_test_msui/test_sideview.py | 6 +++--- tests/_test_msui/test_suffix.py | 2 +- tests/_test_msui/test_tableview.py | 2 +- tests/_test_msui/test_topview.py | 8 ++++---- tests/_test_msui/test_wms_capabilities.py | 2 +- tests/_test_msui/test_wms_control.py | 4 ++-- tests/_test_mswms/test_dataaccess.py | 4 ++-- tests/_test_mswms/test_demodata.py | 2 +- tests/_test_mswms/test_mplhsec.py | 2 +- tests/_test_mswms/test_mss_plot_driver.py | 6 +++--- tests/_test_mswms/test_mswms.py | 2 +- tests/_test_mswms/test_wms.py | 2 +- tests/_test_utils/test_config.py | 4 ++-- tests/_test_utils/test_coordinate.py | 8 ++++---- tests/_test_utils/test_multidict.py | 2 +- tests/_test_utils/test_netCDF4tools.py | 2 +- tests/_test_utils/test_thermolib.py | 2 +- tests/_test_utils/test_time.py | 4 ++-- 37 files changed, 61 insertions(+), 61 deletions(-) diff --git a/conftest.py b/conftest.py index b00271b80..98ef0d556 100644 --- a/conftest.py +++ b/conftest.py @@ -208,7 +208,7 @@ def generate_initial_config(): config_string = ''' import hashlib -class mscolab_auth(object): +class mscolab_auth: password = "testvaluepassword" allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] ''' diff --git a/docs/samples/config/mscolab/mscolab_auth.py.sample b/docs/samples/config/mscolab/mscolab_auth.py.sample index e6c6564cd..5324e0a7e 100644 --- a/docs/samples/config/mscolab/mscolab_auth.py.sample +++ b/docs/samples/config/mscolab/mscolab_auth.py.sample @@ -1,3 +1,3 @@ -class mscolab_auth(object): +class mscolab_auth: password = "please use the methods to save only the encrypted value" allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] diff --git a/mslib/support/qt_json_view/datatypes.py b/mslib/support/qt_json_view/datatypes.py index 1f85abdf3..4b339ead6 100644 --- a/mslib/support/qt_json_view/datatypes.py +++ b/mslib/support/qt_json_view/datatypes.py @@ -9,7 +9,7 @@ TypeRole = QtCore.Qt.UserRole + 1 -class DataType(object): +class DataType: """Base class for data types.""" # (mss) diff --git a/mslib/utils/migration/config_before_eight.py b/mslib/utils/migration/config_before_eight.py index d7ecd0c36..579d0354b 100644 --- a/mslib/utils/migration/config_before_eight.py +++ b/mslib/utils/migration/config_before_eight.py @@ -41,7 +41,7 @@ from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType -class MSUIDefaultConfig(object): +class MSUIDefaultConfig: """Central configuration for the Mission Support System User Interface Application (msui). diff --git a/mslib/utils/migration/config_before_nine.py b/mslib/utils/migration/config_before_nine.py index e38cf8ab8..d82dbb211 100644 --- a/mslib/utils/migration/config_before_nine.py +++ b/mslib/utils/migration/config_before_nine.py @@ -41,7 +41,7 @@ from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType -class MSUIDefaultConfig(object): +class MSUIDefaultConfig: """Central configuration for the Mission Support System User Interface Application (msui). diff --git a/tests/_test_mscolab/test_utils.py b/tests/_test_mscolab/test_utils.py index 3d55acffc..720132a0e 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -38,18 +38,18 @@ from mslib.mscolab.sockets_manager import setup_managers -class Message(): +class Message: id = 1 u_id = 2 - class user(): + class user: username = "name" text = "Moin" message_type = MessageType.TEXT reply_id = 0 replies = [] - class created_at(): + class created_at: def strftime(value): pass diff --git a/tests/_test_msui/test_aircrafts.py b/tests/_test_msui/test_aircrafts.py index 3fc003ffd..9ab17dc12 100644 --- a/tests/_test_msui/test_aircrafts.py +++ b/tests/_test_msui/test_aircrafts.py @@ -42,7 +42,7 @@ } -class Test_SimpleAircraft(object): +class Test_SimpleAircraft: def setup_method(self): self.simple_aircraft = SimpleAircraft(AIRCRAFT_DUMMY) @@ -78,7 +78,7 @@ def test_get_ceiling_alt(self): assert self.simple_aircraft.get_ceiling_altitude(85000) == 410 -class Test_SimpleAircraft2(object): +class Test_SimpleAircraft2: def setup_method(self): self.simple_aircraft = SimpleAircraft(AIRCRAFT_DUMMY2) diff --git a/tests/_test_msui/test_editor.py b/tests/_test_msui/test_editor.py index d69ea4e9b..c933c739f 100644 --- a/tests/_test_msui/test_editor.py +++ b/tests/_test_msui/test_editor.py @@ -35,7 +35,7 @@ @pytest.mark.skip("To be done for new UI") -class Test_Editor(object): +class Test_Editor: sample_file = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data", "msui_settings.json")) sample_file = sample_file.replace('\\', '/') diff --git a/tests/_test_msui/test_kmloverlay_dockwidget.py b/tests/_test_msui/test_kmloverlay_dockwidget.py index d3eaf43a7..b3907c18e 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -39,7 +39,7 @@ # ToDo refactoring, extract helper methods into functions # ToDo review needed helper functions -class Test_KmlOverlayDockWidget(object): +class Test_KmlOverlayDockWidget: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 3d65df523..35c43da89 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -42,7 +42,7 @@ PORTS = list(range(26000, 26500)) -class Test_MSS_LV_Options_Dialog(object): +class Test_MSS_LV_Options_Dialog: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.window = tv.MSUI_LV_Options_Dialog(settings=_DEFAULT_SETTINGS_LINEARVIEW) @@ -67,7 +67,7 @@ def test_get(self, mockcrit): assert mockcrit.critical.call_count == 0 -class Test_MSSLinearViewWindow(object): +class Test_MSSLinearViewWindow: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] @@ -116,7 +116,7 @@ def test_options(self, mockdlg, mockbox): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_LinearViewWMS(object): +class Test_LinearViewWMS: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.port = PORTS.pop() diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 563c2fcc9..666d0ff43 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -48,7 +48,7 @@ PORTS = list(range(25000, 25500)) -class Test_Mscolab_connect_window(): +class Test_Mscolab_connect_window: def setup_method(self): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) @@ -262,7 +262,7 @@ def _create_user(self, username, email, password): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_Mscolab(object): +class Test_Mscolab: sample_path = os.path.join(os.path.dirname(__file__), "..", "data") # import/export plugins import_plugins = { diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index 348c17814..abf8192fc 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -44,7 +44,7 @@ @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_MscolabAdminWindow(object): +class Test_MscolabAdminWindow: def setup_method(self): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 721ff940a..266a6e86f 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -46,7 +46,7 @@ @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_Mscolab_Merge_Waypoints(object): +class Test_Mscolab_Merge_Waypoints: def setup_method(self): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index ec2b769a9..8e00ae7bf 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -41,7 +41,7 @@ PORTS = list(range(22000, 22500)) -class Actions(object): +class Actions: DOWNLOAD = 0 COPY = 1 REPLY = 2 @@ -51,7 +51,7 @@ class Actions(object): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_MscolabOperation(object): +class Test_MscolabOperation: def setup_method(self): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 7d4a33b4c..76b35653b 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -44,7 +44,7 @@ @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_MscolabVersionHistory(object): +class Test_MscolabVersionHistory: def setup_method(self): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index d3f2feb94..5836ccba3 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -66,7 +66,7 @@ def test_main(): assert pytest_wrapped_e.typename == "SystemExit" -class Test_MSS_TutorialMode(): +class Test_MSS_TutorialMode: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.application.setApplicationDisplayName("MSUI") @@ -98,7 +98,7 @@ def test_tutorial_dir(self): assert 'msuimainwindow-connect.png' in common_images -class Test_MSS_AboutDialog(): +class Test_MSS_AboutDialog: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.window = msui_mw.MSUI_AboutDialog() @@ -116,7 +116,7 @@ def teardown_method(self): QtWidgets.QApplication.processEvents() -class Test_MSS_ShortcutDialog(): +class Test_MSS_ShortcutDialog: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.main_window = msui_mw.MSUIMainWindow() @@ -158,7 +158,7 @@ def test_shortcuts_present(self): # ToDo we need a test for reset_highlight when e.g. Transparent was selected and afterwards topview was destroyed -class Test_MSSSideViewWindow(object): +class Test_MSSSideViewWindow: # temporary file paths to test open feature sample_path = os.path.join(os.path.dirname(__file__), "..", "data") open_csv = os.path.join(sample_path, "example.csv") diff --git a/tests/_test_msui/test_multiple_flightpath_dockwidget.py b/tests/_test_msui/test_multiple_flightpath_dockwidget.py index 9d824ced1..3a93fcc15 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -32,7 +32,7 @@ import mslib.msui.topview as tv -class Test_MultipleFlightpathControlWidget(): +class Test_MultipleFlightpathControlWidget: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) diff --git a/tests/_test_msui/test_remotesensing.py b/tests/_test_msui/test_remotesensing.py index a44f3e0dd..b920af39c 100644 --- a/tests/_test_msui/test_remotesensing.py +++ b/tests/_test_msui/test_remotesensing.py @@ -43,7 +43,7 @@ def test_skyfield_data_expiration(recwarn): assert len(recwarn) == 0, [_x.message for _x in recwarn] -class Test_RemoteSensingControlWidget(object): +class Test_RemoteSensingControlWidget: """ Tests about RemoteSensingControlWidget """ diff --git a/tests/_test_msui/test_satellite_dockwidget.py b/tests/_test_msui/test_satellite_dockwidget.py index 8ebbf7c84..c1d54df45 100644 --- a/tests/_test_msui/test_satellite_dockwidget.py +++ b/tests/_test_msui/test_satellite_dockwidget.py @@ -32,7 +32,7 @@ import mslib.msui.satellite_dockwidget as sd -class Test_SatelliteDockWidget(object): +class Test_SatelliteDockWidget: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.view = mock.Mock() diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index c3c6cc7bf..02ba0e53e 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -43,7 +43,7 @@ PORTS = list(range(19000, 19500)) -class Test_MSS_SV_OptionsDialog(object): +class Test_MSS_SV_OptionsDialog: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) @@ -94,7 +94,7 @@ def test_setColour(self, mockdlg): assert mockdlg.call_count == 1 -class Test_MSSSideViewWindow(object): +class Test_MSSSideViewWindow: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] @@ -173,7 +173,7 @@ def test_y_axes(self, mockbox): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_SideViewWMS(object): +class Test_SideViewWMS: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.port = PORTS.pop() diff --git a/tests/_test_msui/test_suffix.py b/tests/_test_msui/test_suffix.py index 0c2f7d1c4..c5cf96392 100644 --- a/tests/_test_msui/test_suffix.py +++ b/tests/_test_msui/test_suffix.py @@ -33,7 +33,7 @@ from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW -class Test_SuffixChange(object): +class Test_SuffixChange: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index a2ebe8b6b..cbd8d54c3 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -36,7 +36,7 @@ import mslib.msui.tableview as tv -class Test_TableView(object): +class Test_TableView: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index e0539e841..0e6d535c3 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -43,7 +43,7 @@ PORTS = list(range(28000, 28500)) -class Test_MSS_TV_MapAppearanceDialog(object): +class Test_MSS_TV_MapAppearanceDialog: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) self.window = tv.MSUI_TV_MapAppearanceDialog(settings=_DEFAULT_SETTINGS_TOPVIEW) @@ -69,7 +69,7 @@ def test_get(self, mockcrit): assert mockcrit.critical.call_count == 0 -class Test_MSSTopViewWindow(object): +class Test_MSSTopViewWindow: def setup_method(self): mainwindow = MSUIMainWindow() self.application = QtWidgets.QApplication(sys.argv) @@ -296,7 +296,7 @@ def test_map_options(self, mockbox): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_TopViewWMS(object): +class Test_TopViewWMS: def setup_method(self): self.port = PORTS.pop() self.application = QtWidgets.QApplication(sys.argv) @@ -359,7 +359,7 @@ def test_server_getmap(self, mockbox): assert mockbox.critical.call_count == 0 -class Test_MSUITopViewWindow(): +class Test_MSUITopViewWindow: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index 4447ad417..8ad01714e 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -32,7 +32,7 @@ import mslib.msui.wms_capabilities as wc -class Test_WMSCapabilities(object): +class Test_WMSCapabilities: def setup_method(self): self.application = QtWidgets.QApplication(sys.argv) diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index ab2f53c48..008f27b0e 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -55,7 +55,7 @@ class VSecViewMockup(mock.Mock): get_plot_size_in_px = mock.Mock(return_value=(200, 100)) -class WMSControlWidgetSetup(object): +class WMSControlWidgetSetup: def _setup(self, widget_type): wc.WMS_SERVICE_CACHE = {} self.port = PORTS.pop() @@ -506,7 +506,7 @@ def test_multilayer_drawing(self, mockbox): assert mockbox.critical.call_count == 0 -class TestWMSControlWidgetSetupSimple(object): +class TestWMSControlWidgetSetupSimple: xml = """ diff --git a/tests/_test_mswms/test_dataaccess.py b/tests/_test_mswms/test_dataaccess.py index 3c5af291a..365d2b299 100644 --- a/tests/_test_mswms/test_dataaccess.py +++ b/tests/_test_mswms/test_dataaccess.py @@ -35,7 +35,7 @@ from tests.constants import DATA_DIR -class Test_DefaultDataAccess(object): +class Test_DefaultDataAccess: def setup_method(self): self.dut = DefaultDataAccess(DATA_DIR, "EUR_LL015") self.dut.setup() @@ -134,7 +134,7 @@ def test_cache_too_large(self): assert "nothere" not in self.dut._file_cache -class Test_DefaultDataAccessNoInit(object): +class Test_DefaultDataAccessNoInit: def setup_method(self): self.dut = DefaultDataAccess(DATA_DIR, "EUR_LL015", uses_init_time=False) self.dut.setup() diff --git a/tests/_test_mswms/test_demodata.py b/tests/_test_mswms/test_demodata.py index 4e5cf951f..46f3be8d5 100644 --- a/tests/_test_mswms/test_demodata.py +++ b/tests/_test_mswms/test_demodata.py @@ -31,7 +31,7 @@ import mslib.mswms.demodata as demodata -class TestDemodata(object): +class TestDemodata: def test_data_creation(self): assert ROOT_FS.exists(u'.') assert DATA_FS.exists(u'.') diff --git a/tests/_test_mswms/test_mplhsec.py b/tests/_test_mswms/test_mplhsec.py index 19f435e8e..aa9279060 100644 --- a/tests/_test_mswms/test_mplhsec.py +++ b/tests/_test_mswms/test_mplhsec.py @@ -32,7 +32,7 @@ from tests.constants import SERVER_CONFIG_FILE -class TestMPLBasemapHorizontalSectionStyle(object): +class TestMPLBasemapHorizontalSectionStyle: def setup_method(self): self.mswms_settings = importlib.import_module("mswms_settings", SERVER_CONFIG_FILE) diff --git a/tests/_test_mswms/test_mss_plot_driver.py b/tests/_test_mswms/test_mss_plot_driver.py index f1cd54026..681226104 100644 --- a/tests/_test_mswms/test_mss_plot_driver.py +++ b/tests/_test_mswms/test_mss_plot_driver.py @@ -56,7 +56,7 @@ def is_image_transparent(img): return False -class Test_VSec(object): +class Test_VSec: def setup_method(self): p1 = [45.00, 8.] p2 = [50.00, 12.] @@ -209,7 +209,7 @@ def test_VS_gallery_template(self): assert img is not None -class Test_LSec(object): +class Test_LSec: def setup_method(self): p1 = [45.00, 8., 25000] p2 = [50.00, 12., 25000] @@ -278,7 +278,7 @@ def test_LS_wrong_mime_type(self): self.plot(mpl_lsec_styles.LS_RelativeHumdityStyle_01(driver=self.lsec), mime_type="image/png") -class Test_HSec(object): +class Test_HSec: def setup_method(self): data = mswms_settings.data["ecmwf_EUR_LL015"] data.setup() diff --git a/tests/_test_mswms/test_mswms.py b/tests/_test_mswms/test_mswms.py index b2e8a4dd1..87f53a3de 100644 --- a/tests/_test_mswms/test_mswms.py +++ b/tests/_test_mswms/test_mswms.py @@ -32,7 +32,7 @@ from mslib.mswms import mswms -class _Application(): +class _Application: """ dummy to skip starting the wms server""" @staticmethod def run(host, port): diff --git a/tests/_test_mswms/test_wms.py b/tests/_test_mswms/test_wms.py index 262fbc84d..cccb00ba3 100644 --- a/tests/_test_mswms/test_wms.py +++ b/tests/_test_mswms/test_wms.py @@ -41,7 +41,7 @@ from tests.constants import DATA_DIR -class Test_WMS(object): +class Test_WMS: def test_get_query_string_missing_parameters(self): environ = { 'wsgi.url_scheme': 'http', diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index 9ec480539..5b3a069fb 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -40,7 +40,7 @@ LOGGER = logging.getLogger(__name__) -class TestSettingsSave(object): +class TestSettingsSave: """ tests save_settings_qsettings and load_settings_qsettings from ./utils.py # TODO make sure do a clean setup, not inside the 'msui' config file. @@ -59,7 +59,7 @@ def test_load_settings(self): assert settings["foo"] == "bar" -class TestConfigLoader(object): +class TestConfigLoader: """ tests config file for client """ diff --git a/tests/_test_utils/test_coordinate.py b/tests/_test_utils/test_coordinate.py index e38582aeb..681e1ffaf 100644 --- a/tests/_test_utils/test_coordinate.py +++ b/tests/_test_utils/test_coordinate.py @@ -35,7 +35,7 @@ LOGGER = logging.getLogger(__name__) -class TestGetDistance(object): +class TestGetDistance: """ tests for distance based calculations """ @@ -52,7 +52,7 @@ def test_find_location(self): assert coordinate.find_location(50.9200002, 6.36) == ([50.92, 6.36], 'Juelich') -class TestProjections(object): +class TestProjections: def test_get_projection_params(self): assert coordinate.get_projection_params("epsg:4839") == {'basemap': {'epsg': '4839'}, 'bbox': 'meter(10.5,51)'} with pytest.raises(ValueError): @@ -63,7 +63,7 @@ def test_get_projection_params(self): coordinate.get_projection_params('crs:83') -class TestAngles(object): +class TestAngles: """ tests about angles """ @@ -84,7 +84,7 @@ def test_rotate_point(self): assert coordinate.rotate_point([100, 90], 90) == (-90, 100) -class TestLatLonPoints(object): +class TestLatLonPoints: def test_linear(self): ref_lats = [0, 10] ref_lons = [0, 0] diff --git a/tests/_test_utils/test_multidict.py b/tests/_test_utils/test_multidict.py index 035278ad5..362f72fb6 100644 --- a/tests/_test_utils/test_multidict.py +++ b/tests/_test_utils/test_multidict.py @@ -32,7 +32,7 @@ LOGGER = logging.getLogger(__name__) -class TestCIMultiDict(object): +class TestCIMultiDict: class CaseInsensitiveMultiDict(werkzeug.datastructures.ImmutableMultiDict): """Extension to werkzeug.datastructures.ImmutableMultiDict diff --git a/tests/_test_utils/test_netCDF4tools.py b/tests/_test_utils/test_netCDF4tools.py index 2ca149a9b..fdc33daee 100644 --- a/tests/_test_utils/test_netCDF4tools.py +++ b/tests/_test_utils/test_netCDF4tools.py @@ -42,7 +42,7 @@ DATA_FILE_AL = os.path.join(DATA_DIR, "20121017_12_ecmwf_forecast.ALTITUDE_LEVELS.EUR_LL015.036.al.nc") -class Test_netCDF4tools(object): +class Test_netCDF4tools: def setup_method(self): self.ncfile_ml = Dataset(DATA_FILE_ML, 'r') self.ncfile_pl = Dataset(DATA_FILE_PL, 'r') diff --git a/tests/_test_utils/test_thermolib.py b/tests/_test_utils/test_thermolib.py index 4a6c00b05..ff67f7983 100644 --- a/tests/_test_utils/test_thermolib.py +++ b/tests/_test_utils/test_thermolib.py @@ -89,7 +89,7 @@ def test_isa_temperature(): assert thermolib.isa_temperature(51000 * units.m).magnitude == pytest.approx(270.65) -class TestConverter(object): +class TestConverter: def test_convert_pressure_to_vertical_axis_measure(self): assert thermolib.convert_pressure_to_vertical_axis_measure('pressure', 10000) == 100 assert thermolib.convert_pressure_to_vertical_axis_measure('flightlevel', 400) == 400 diff --git a/tests/_test_utils/test_time.py b/tests/_test_utils/test_time.py index 16c4d5c52..4077fe9e0 100644 --- a/tests/_test_utils/test_time.py +++ b/tests/_test_utils/test_time.py @@ -31,7 +31,7 @@ LOGGER = logging.getLogger(__name__) -class TestParseTime(object): +class TestParseTime: def test_parse_iso_datetime(self): assert time.parse_iso_datetime("2009-05-28T16:15:00") == datetime.datetime(2009, 5, 28, 16, 15) @@ -39,7 +39,7 @@ def test_parse_iso_duration(self): assert time.parse_iso_duration('P01W') == datetime.timedelta(days=7) -class TestTimes(object): +class TestTimes: """ tests about times """ From 567c2860e93d31bf622fd89845a7bfc3155da7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 10 Jan 2024 17:00:22 +0100 Subject: [PATCH 092/176] Fix collisions with builtins (#2144) * Add flake8-builtins to check for name collisions * Fix or disable warnings from shadowing builtins --- .github/workflows/python-flake8.yml | 2 +- mslib/index.py | 2 +- mslib/mscolab/models.py | 10 +++++----- mslib/msui/mpl_pathinteractor.py | 4 ++-- mslib/msui/mpl_qtwidget.py | 2 +- mslib/msui/mscolab.py | 8 ++++---- mslib/msui/msui_mainwindow.py | 4 ++-- mslib/msui/wms_control.py | 7 +++++-- mslib/mswms/gallery_builder.py | 4 ++-- mslib/support/qt_json_view/datatypes.py | 6 +++--- mslib/utils/qt.py | 6 +++--- requirements.d/development.txt | 2 +- tests/_test_mscolab/test_models.py | 4 ++-- tests/_test_mscolab/test_utils.py | 2 +- tests/_test_utils/test_migration.py | 8 ++++---- tests/_test_utils/test_multidict.py | 6 +++--- 16 files changed, 40 insertions(+), 37 deletions(-) diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index b398d2561..99ec6eb87 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -30,5 +30,5 @@ jobs: - name: Lint with flake8 run: | python -m pip install --upgrade pip - pip install flake8 + pip install flake8 flake8-builtins flake8 --count --max-line-length=127 --statistics mslib tests diff --git a/mslib/index.py b/mslib/index.py index c792e28d8..362965ec8 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -193,7 +193,7 @@ def code(filename): headers={"Content-disposition": f"attachment; filename={filename.split('-')[0]}.py"}) @APP.route("/mss/help") - def help(): + def help(): # noqa: A001 _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'help.md') html_overrides = ('Waypoint Tutorial', diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 63339db4d..1ec9cb2a3 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -36,7 +36,7 @@ class User(db.Model): __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 username = db.Column(db.String(255)) emailid = db.Column(db.String(255), unique=True) password = db.Column(db.String(255), unique=True) @@ -105,7 +105,7 @@ def verify_auth_token(token): class Permission(db.Model): __tablename__ = 'permissions' - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 op_id = db.Column(db.Integer, db.ForeignKey('operations.id')) u_id = db.Column(db.Integer, db.ForeignKey('users.id')) access_level = db.Column(db.Enum("admin", "collaborator", "viewer", "creator", name="access_level")) @@ -127,7 +127,7 @@ def __repr__(self): class Operation(db.Model): __tablename__ = "operations" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 path = db.Column(db.String(255), unique=True) category = db.Column(db.String(255)) description = db.Column(db.String(255)) @@ -158,7 +158,7 @@ def __repr__(self): class Message(db.Model): __tablename__ = "messages" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 op_id = db.Column(db.Integer, db.ForeignKey('operations.id')) u_id = db.Column(db.Integer, db.ForeignKey('users.id')) text = db.Column(db.Text) @@ -182,7 +182,7 @@ def __repr__(self): class Change(db.Model): __tablename__ = "changes" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 op_id = db.Column(db.Integer, db.ForeignKey('operations.id')) u_id = db.Column(db.Integer, db.ForeignKey('users.id')) commit_hash = db.Column(db.String(255), default=None) diff --git a/mslib/msui/mpl_pathinteractor.py b/mslib/msui/mpl_pathinteractor.py index cabe3bd6a..ff7a73c65 100644 --- a/mslib/msui/mpl_pathinteractor.py +++ b/mslib/msui/mpl_pathinteractor.py @@ -1150,8 +1150,8 @@ def appropriate_epsilon_km(self, px=5): # (bounds = left, bottom, width, height) ax_bounds = self.plotter.ax.bbox.bounds diagonal = math.hypot(round(ax_bounds[2]), round(ax_bounds[3])) - map = self.plotter.map - map_delta = get_distance(map.llcrnrlat, map.llcrnrlon, map.urcrnrlat, map.urcrnrlon) + plot_map = self.plotter.map + map_delta = get_distance(plot_map.llcrnrlat, plot_map.llcrnrlon, plot_map.urcrnrlat, plot_map.urcrnrlon) km_per_px = map_delta / diagonal return km_per_px * px diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index 5932ae5a4..320c0dd7e 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -1499,7 +1499,7 @@ def __init__(self, settings=None): self.pdlg.close() @property - def map(self): + def map(self): # noqa: A003 return self.plotter.map def init_map(self, model=None, **kwargs): diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 14839d229..b553828b9 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -843,10 +843,10 @@ def check_and_enable_operation_accept(): self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) def browse(): - type = self.add_proj_dialog.cb_ImportType.currentText() + import_type = self.add_proj_dialog.cb_ImportType.currentText() file_type = ["Flight track (*.ftml)"] - if type != 'FTML': - file_type = [f"Flight track (*.{self.ui.import_plugins[type][1]})"] + if import_type != 'FTML': + file_type = [f"Flight track (*.{self.ui.import_plugins[import_type][1]})"] file_path = get_open_filename( self.ui, "Open Flighttrack file", "", ';;'.join(file_type)) @@ -856,7 +856,7 @@ def browse(): with open_fs(fs.path.dirname(file_path)) as file_dir: file_content = file_dir.readtext(file_name) else: - function = self.ui.import_plugins[type][0] + function = self.ui.import_plugins[import_type][0] ft_name, waypoints = function(file_path) model = ft.WaypointsTableModel(waypoints=waypoints) xml_doc = model.get_xml_doc() diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index c54b0676e..659ed9897 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -109,7 +109,7 @@ class QFlightTrackListWidgetItem(QtWidgets.QListWidgetItem): """ def __init__(self, flighttrack_model, parent=None, - type=QtWidgets.QListWidgetItem.UserType): + user_type=QtWidgets.QListWidgetItem.UserType): """Item class for the list widget that accommodates the open flight tracks. @@ -123,7 +123,7 @@ def __init__(self, flighttrack_model, parent=None, """ view_name = flighttrack_model.name super().__init__( - view_name, parent, type) + view_name, parent, user_type) self.parent = parent self.flighttrack_model = flighttrack_model diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index e793bc813..a1b16a8b5 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -79,7 +79,8 @@ class MSUIWebMapService(ogcwms.WebMapService): """ def getmap(self, layers=None, styles=None, srs=None, bbox=None, - format=None, size=None, time=None, init_time=None, + format=None, # noqa: A002 + size=None, time=None, init_time=None, path_str=None, level=None, transparent=False, bgcolor='#FFFFFF', time_name="time", init_time_name="init_time", exceptions='XML', method='Get', @@ -1241,7 +1242,9 @@ def get_md5_filename(self, layer, kwargs): return os.path.join(self.wms_cache, hashlib.md5(urlstr.encode('utf-8')).hexdigest() + ending) def retrieve_image(self, layers=None, crs="EPSG:4326", bbox=None, path_string=None, - width=800, height=400, transparent=False, format="image/png"): + width=800, height=400, transparent=False, + format="image/png", # noqa: A002 + ): """Retrieve an image of the layer currently selected in the GUI elements from the current WMS provider. If caching is enabled, first check the cache for the requested image. If diff --git a/mslib/mswms/gallery_builder.py b/mslib/mswms/gallery_builder.py index 22d3ef5e3..ccebba6fa 100644 --- a/mslib/mswms/gallery_builder.py +++ b/mslib/mswms/gallery_builder.py @@ -723,8 +723,8 @@ def add_image(path, plot, plot_object, generate_code=False, sphinx=False, url_pr markdown = add_times(itime, [vtime], markdown) plot_htmls[f"{l_type}_{dataset}{plot_object.name}"] = markdown - id = img_path.split("-" + f"{level}".replace(" ", "_").replace(":", "_").replace("-", "_"))[0] - if not any([id in html for html in plots[l_type]]): + img_id = img_path.split("-" + f"{level}".replace(" ", "_").replace(":", "_").replace("-", "_"))[0] + if not any([img_id in html for html in plots[l_type]]): plots[l_type].append(image_md( img_path, plot_object.name, code_path if generate_code else None, f"{plot_object.title}" + (f"
    {plot_object.abstract}" diff --git a/mslib/support/qt_json_view/datatypes.py b/mslib/support/qt_json_view/datatypes.py index 4b339ead6..fbc216281 100644 --- a/mslib/support/qt_json_view/datatypes.py +++ b/mslib/support/qt_json_view/datatypes.py @@ -19,7 +19,7 @@ def matches(self, data): """Logic to define whether the given data matches this type.""" raise NotImplementedError - def next(self, model, data, parent): + def next(self, model, data, parent): # noqa: A003 """Implement if this data type has to add child items to itself.""" pass @@ -162,7 +162,7 @@ class ListType(DataType): def matches(self, data): return isinstance(data, list) - def next(self, model, data, parent): + def next(self, model, data, parent): # noqa: A003 for i, value in enumerate(data): type_ = match_type(value) key_item = self.key_item( @@ -200,7 +200,7 @@ class DictType(DataType): def matches(self, data): return isinstance(data, dict) - def next(self, model, data, parent): + def next(self, model, data, parent): # noqa: A003 for key, value in data.items(): type_ = match_type(value) key_item = self.key_item(key, datatype=type_, model=model) diff --git a/mslib/utils/qt.py b/mslib/utils/qt.py index 0234d4ad5..cbb47d265 100644 --- a/mslib/utils/qt.py +++ b/mslib/utils/qt.py @@ -280,8 +280,8 @@ def resizeEvent(self, event): self.updateText() super().resizeEvent(event) - def eventFilter(self, object, event): - if object == self.lineEdit(): + def eventFilter(self, obj, event): + if obj == self.lineEdit(): if event.type() == QtCore.QEvent.MouseButtonRelease: if self.closeOnLineEditClick: self.hidePopup() @@ -290,7 +290,7 @@ def eventFilter(self, object, event): return True return False - if object == self.view().viewport(): + if obj == self.view().viewport(): if event.type() == QtCore.QEvent.MouseButtonRelease: index = self.view().indexAt(event.pos()) item = self.model().item(index.row()) diff --git a/requirements.d/development.txt b/requirements.d/development.txt index e57232b2b..05561437b 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -2,6 +2,7 @@ # $ conda create --name --file # platform: linux-64 pep8 +flake8-builtins py mock pycodestyle @@ -25,4 +26,3 @@ dnspython>=2.0.0, <2.3.0 gsl==2.7.0 boa xmlschema<2.5.0 - diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index 63b3f3de5..cfec2973b 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -61,8 +61,8 @@ def test_generate_auth_token(self): def test_verify_auth_token(self): user = User(self.userdata[0], self.userdata[1], self.userdata[2]) token = user.generate_auth_token() - id = user.verify_auth_token(token) - assert user.id == id + uid = user.verify_auth_token(token) + assert user.id == uid def test_verify_password(self): user = User(self.userdata[0], self.userdata[1], self.userdata[2]) diff --git a/tests/_test_mscolab/test_utils.py b/tests/_test_mscolab/test_utils.py index 720132a0e..99e34f240 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -39,7 +39,7 @@ class Message: - id = 1 + id = 1 # noqa: A003 u_id = 2 class user: diff --git a/tests/_test_utils/test_migration.py b/tests/_test_utils/test_migration.py index 9041b19fe..ec78674fa 100644 --- a/tests/_test_utils/test_migration.py +++ b/tests/_test_utils/test_migration.py @@ -135,9 +135,9 @@ def test_upgrade_json_file_to_version_nine(self): result = dict() result[default] = mailid assert config_loader_before_nine(dataset="MSS_auth") == {"https://www.your-mscolab-server.de": "youruser"} - all = config_loader_before_nine() + config = config_loader_before_nine() # old version knows MSCOLAB_mailid - assert "MSCOLAB_mailid" in all.keys() + assert "MSCOLAB_mailid" in config.keys() new_version = JsonConversion() # converting and storing new_version.change_parameters() @@ -154,6 +154,6 @@ def test_upgrade_json_file_to_version_nine(self): # added MSCOLAB_mailid to the url based on default_MSCOLAB mss_auth = config_loader(dataset="MSS_auth") assert mss_auth == {"https://www.your-mscolab-server.de": mailid} - all = config_loader() + config = config_loader() # new version forgot about MSCOLAB_mailid - assert "MSCOLAB_mailid" not in all.keys() + assert "MSCOLAB_mailid" not in config.keys() diff --git a/tests/_test_utils/test_multidict.py b/tests/_test_utils/test_multidict.py index 362f72fb6..f9ecff433 100644 --- a/tests/_test_utils/test_multidict.py +++ b/tests/_test_utils/test_multidict.py @@ -55,9 +55,9 @@ def __getitem__(self, key): return v raise KeyError(repr(key)) - def test_multidict(object): - dict = TestCIMultiDict.CaseInsensitiveMultiDict([('title', 'MSS')]) + def test_multidict(self): + test_dict = TestCIMultiDict.CaseInsensitiveMultiDict([('title', 'MSS')]) dict_multidict = multidict.CIMultiDict([('title', 'MSS')]) assert 'title' in dict_multidict assert 'tiTLE' in dict_multidict - assert dict_multidict['Title'] == dict['tITLE'] + assert dict_multidict['Title'] == test_dict['tITLE'] From cff4e63f28922ed28e01f5c6d89e7624f7399782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 11 Jan 2024 11:59:54 +0100 Subject: [PATCH 093/176] Add per-operation locks to FileManager (#2147) Without these locks there is a race condition when writing to the operations "main.ftml" file, which happens in multiple different methods, e.g. save_file. When the mscolab application is running in a multi-threaded wsgi server it can happen that two threads try to write to the same file, corrupting it in the process. --- mslib/mscolab/file_manager.py | 154 +++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 67 deletions(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 363d9de05..0fd506d3f 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -29,6 +29,7 @@ import difflib import logging import git +import threading from sqlalchemy.exc import IntegrityError from mslib.mscolab.models import db, Operation, Permission, User, Change, Message from mslib.mscolab.conf import mscolab_settings @@ -39,6 +40,16 @@ class FileManager: def __init__(self, data_dir): self.data_dir = data_dir + self.operation_dict_lock = threading.Lock() + self.operation_locks = {} + + def _get_operation_lock(self, op_id): + with self.operation_dict_lock: + try: + return self.operation_locks[op_id] + except KeyError: + self.operation_locks[op_id] = threading.Lock() + return self.operation_locks[op_id] def create_operation(self, path, description, user, last_used=None, content=None, category="default", active=True): """ @@ -58,28 +69,31 @@ def create_operation(self, path, description, user, last_used=None, content=None db.session.add(operation) db.session.flush() operation_id = operation.id - # this is the only insertion with "creator" access_level - perm = Permission(user.id, operation_id, "creator") - db.session.add(perm) - db.session.commit() - # here we can import the permissions from Group file - if not path.endswith(mscolab_settings.GROUP_POSTFIX): - import_op = Operation.query.filter_by(path=f"{category}{mscolab_settings.GROUP_POSTFIX}").first() - if import_op is not None: - self.import_permissions(import_op.id, operation_id, user.id) - data = fs.open_fs(self.data_dir) - data.makedir(operation.path) - operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'w') - if content is not None: - operation_file.write(content) - else: - operation_file.write(mscolab_settings.STUB_CODE) - operation_path = fs.path.combine(self.data_dir, operation.path) - r = git.Repo.init(operation_path) - r.git.clear_cache() - r.index.add(['main.ftml']) - r.index.commit("initial commit") - return True + + op_lock = self._get_operation_lock(operation_id) + with op_lock: + # this is the only insertion with "creator" access_level + perm = Permission(user.id, operation_id, "creator") + db.session.add(perm) + db.session.commit() + # here we can import the permissions from Group file + if not path.endswith(mscolab_settings.GROUP_POSTFIX): + import_op = Operation.query.filter_by(path=f"{category}{mscolab_settings.GROUP_POSTFIX}").first() + if import_op is not None: + self.import_permissions(import_op.id, operation_id, user.id) + data = fs.open_fs(self.data_dir) + data.makedir(operation.path) + operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'w') + if content is not None: + operation_file.write(content) + else: + operation_file.write(mscolab_settings.STUB_CODE) + operation_path = fs.path.combine(self.data_dir, operation.path) + r = git.Repo.init(operation_path) + r.git.clear_cache() + r.index.add(['main.ftml']) + r.index.commit("initial commit") + return True def get_operation_details(self, op_id, user): """ @@ -308,31 +322,33 @@ def save_file(self, op_id, content, user, comment=""): if not operation: return False - with fs.open_fs(self.data_dir) as data: - """ - old file is read, the diff between old and new is calculated and stored - as 'Change' in changes table. comment for each change is optional - """ - old_data = data.readtext(fs.path.combine(operation.path, 'main.ftml')) - old_data_lines = old_data.splitlines() - content_lines = content.splitlines() - diff = difflib.unified_diff(old_data_lines, content_lines, lineterm='') - diff_content = '\n'.join(list(diff)) - data.writetext(fs.path.combine(operation.path, 'main.ftml'), content) - # commit changes if comment is not None - if diff_content != "": - # commit to git repository - operation_path = fs.path.combine(self.data_dir, operation.path) - repo = git.Repo(operation_path) - repo.git.clear_cache() - repo.index.add(['main.ftml']) - cm = repo.index.commit("committing changes") - # change db table - change = Change(op_id, user.id, cm.hexsha) - db.session.add(change) - db.session.commit() - return True - return False + op_lock = self._get_operation_lock(operation.id) + with op_lock: + with fs.open_fs(self.data_dir) as data: + """ + old file is read, the diff between old and new is calculated and stored + as 'Change' in changes table. comment for each change is optional + """ + old_data = data.readtext(fs.path.combine(operation.path, 'main.ftml')) + old_data_lines = old_data.splitlines() + content_lines = content.splitlines() + diff = difflib.unified_diff(old_data_lines, content_lines, lineterm='') + diff_content = '\n'.join(list(diff)) + data.writetext(fs.path.combine(operation.path, 'main.ftml'), content) + # commit changes if comment is not None + if diff_content != "": + # commit to git repository + operation_path = fs.path.combine(self.data_dir, operation.path) + repo = git.Repo(operation_path) + repo.git.clear_cache() + repo.index.add(['main.ftml']) + cm = repo.index.commit("committing changes") + # change db table + change = Change(op_id, user.id, cm.hexsha) + db.session.add(change) + db.session.commit() + return True + return False def get_file(self, op_id, user): """ @@ -345,10 +361,12 @@ def get_file(self, op_id, user): operation = Operation.query.filter_by(id=op_id).first() if operation is None: return False - with fs.open_fs(self.data_dir) as data: - operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'r') - operation_data = operation_file.read() - return operation_data + op_lock = self._get_operation_lock(op_id) + with op_lock: + with fs.open_fs(self.data_dir) as data: + operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'r') + operation_data = operation_file.read() + return operation_data def get_all_changes(self, op_id, user, named_version=False): """ @@ -432,22 +450,24 @@ def undo_changes(self, ch_id, user): if not ch or not operation: return False - operation_path = fs.path.join(self.data_dir, operation.path) - repo = git.Repo(operation_path) - repo.git.clear_cache() - try: - file_content = repo.git.show(f'{ch.commit_hash}:main.ftml') - with fs.open_fs(operation_path) as proj_fs: - proj_fs.writetext('main.ftml', file_content) - repo.index.add(['main.ftml']) - cm = repo.index.commit(f"checkout to {ch.commit_hash}") - change = Change(ch.op_id, user.id, cm.hexsha) - db.session.add(change) - db.session.commit() - return True - except Exception as ex: - logging.debug(ex) - return False + op_lock = self._get_operation_lock(operation.id) + with op_lock: + operation_path = fs.path.join(self.data_dir, operation.path) + repo = git.Repo(operation_path) + repo.git.clear_cache() + try: + file_content = repo.git.show(f'{ch.commit_hash}:main.ftml') + with fs.open_fs(operation_path) as proj_fs: + proj_fs.writetext('main.ftml', file_content) + repo.index.add(['main.ftml']) + cm = repo.index.commit(f"checkout to {ch.commit_hash}") + change = Change(ch.op_id, user.id, cm.hexsha) + db.session.add(change) + db.session.commit() + return True + except Exception as ex: + logging.debug(ex) + return False def fetch_users_without_permission(self, op_id, u_id): if not self.is_admin(u_id, op_id) and not self.is_creator(u_id, op_id): From 9fac1b3af41b94858fcd639d418b81b440135c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 11 Jan 2024 12:03:09 +0100 Subject: [PATCH 094/176] Refactor Qt related tests to use pytest-qt (#2146) * Refactor Qt related tests to use pytest-qt Using pytest-qt the QApplication instance can just be taken from its qapp fixture instead of being setup in each test. The fixture to fail if there are message boxes left at the end of a test can then be used by making the qapp fixture request it, which means that only tests that actually use Qt will have this fixture run (as opposed to the previous autouse fixture, which always ran). * Replace pyvirtualdisplay with xvfb-run --- .github/workflows/testing.yml | 9 ++- .github/workflows/testing_gsoc.yml | 9 ++- conftest.py | 48 +------------- docs/development.rst | 8 +-- mslib/msui/msui_mainwindow.py | 2 +- requirements.d/development.txt | 1 + tests/_test_msui/test_editor.py | 27 ++++---- .../_test_msui/test_kmloverlay_dockwidget.py | 11 ++-- tests/_test_msui/test_linearview.py | 28 +++------ tests/_test_msui/test_mpl_map.py | 5 +- tests/_test_msui/test_mscolab.py | 19 ++---- tests/_test_msui/test_mscolab_admin_window.py | 10 +-- .../test_mscolab_merge_waypoints.py | 9 +-- tests/_test_msui/test_mscolab_operation.py | 10 +-- .../test_mscolab_save_merge_points.py | 3 +- .../test_mscolab_version_history.py | 10 +-- tests/_test_msui/test_mss.py | 6 +- tests/_test_msui/test_msui.py | 43 +++++-------- .../test_multiple_flightpath_dockwidget.py | 13 ++-- tests/_test_msui/test_remotesensing.py | 6 +- tests/_test_msui/test_satellite_dockwidget.py | 11 ++-- tests/_test_msui/test_sideview.py | 28 +++------ tests/_test_msui/test_suffix.py | 11 ++-- tests/_test_msui/test_tableview.py | 11 +--- tests/_test_msui/test_topview.py | 33 ++++------ tests/_test_msui/test_updater.py | 10 ++- tests/_test_msui/test_wms_capabilities.py | 13 ++-- tests/_test_msui/test_wms_control.py | 25 ++++---- tests/_test_utils/test_auth.py | 2 +- tests/fixtures.py | 62 +++++++++++++++++++ 30 files changed, 200 insertions(+), 283 deletions(-) create mode 100644 tests/fixtures.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4e6807b59..cd8ac3029 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -62,7 +62,6 @@ jobs: | sed -e "s/menuinst.*//" \ | sed -e "s/.*://" > reqs.txt \ && cat requirements.d/development.txt >> reqs.txt \ - && echo pyvirtualdisplay >> reqs.txt \ && cat reqs.txt \ && mamba env remove -n mss-${{ inputs.branch_name }}-env \ && mamba create -y -n mss-${{ inputs.branch_name }}-env --file reqs.txt @@ -82,9 +81,9 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-${{ inputs.branch_name }}-env \ - && pytest -v --durations=20 --reverse --cov=mslib tests \ + && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests \ || (for i in {1..5} \ - ; do pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ + ; do xvfb-run pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ && break \ ; done) @@ -96,9 +95,9 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-${{ inputs.branch_name }}-env \ - && pytest -vv -n 6 --dist loadfile --max-worker-restart 4 tests \ + && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 4 tests \ || (for i in {1..5} \ - ; do pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ + ; do xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ && break \ ; done) diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml index 17c5cf4a5..b6646731d 100644 --- a/.github/workflows/testing_gsoc.yml +++ b/.github/workflows/testing_gsoc.yml @@ -50,7 +50,6 @@ jobs: | sed -e "s/menuinst.*//" \ | sed -e "s/.*://" > reqs.txt \ && cat requirements.d/development.txt >> reqs.txt \ - && echo pyvirtualdisplay >> reqs.txt \ && cat reqs.txt \ && mamba env remove -n mss-develop-env \ && mamba create -y -n mss-develop-env --file reqs.txt @@ -70,9 +69,9 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-develop-env \ - && pytest -v --durations=20 --reverse --cov=mslib tests \ + && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests \ || (for i in {1..5} \ - ; do pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ + ; do xvfb-run pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ && break \ ; done) @@ -85,9 +84,9 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-develop-env \ - && pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests \ + && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests \ || (for i in {1..5} \ - ; do pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ + ; do xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ && break \ ; done) diff --git a/conftest.py b/conftest.py index 98ef0d556..818483ec3 100644 --- a/conftest.py +++ b/conftest.py @@ -28,8 +28,6 @@ import importlib.util import os import sys -import mock -from PyQt5 import QtWidgets # Disable pyc files sys.dont_write_bytecode = True @@ -88,15 +86,6 @@ def pytest_generate_tests(metafunc): msui_settings_file_fs.close() -if os.getenv("TESTS_VISIBLE") == "TRUE": - Display = None -else: - try: - from pyvirtualdisplay import Display - except ImportError: - Display = None - - def generate_initial_config(): """Generate an initial state for the configuration directory in tests.constants.ROOT_FS """ @@ -252,38 +241,5 @@ def reset_config(): read_config_file() -@pytest.fixture(autouse=True) -def fail_if_open_message_boxes_left(): - """Fail a test if there are any Qt message boxes left open at the end - """ - # Mock every MessageBox widget in the test suite to avoid unwanted freezes on unhandled error popups etc. - with mock.patch("PyQt5.QtWidgets.QMessageBox.question") as q, \ - mock.patch("PyQt5.QtWidgets.QMessageBox.information") as i, \ - mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as c, \ - mock.patch("PyQt5.QtWidgets.QMessageBox.warning") as w: - yield - if any(box.call_count > 0 for box in [q, i, c, w]): - summary = "\n".join([f"PyQt5.QtWidgets.QMessageBox.{box()._extract_mock_name()}: {box.mock_calls[:-1]}" - for box in [q, i, c, w] if box.call_count > 0]) - pytest.fail(f"An unhandled message box popped up during your test!\n{summary}") - # Try to close all remaining widgets after each test - for qobject in set(QtWidgets.QApplication.topLevelWindows() + QtWidgets.QApplication.topLevelWidgets()): - try: - qobject.destroy() - # Some objects deny permission, pass in that case - except RuntimeError: - pass - - -@pytest.fixture(scope="session", autouse=True) -def configure_testsetup(request): - if Display is not None: - # needs for invisible window output xvfb installed, - # default backend for visible output is xephyr - # by visible=0 you get xvfb - VIRT_DISPLAY = Display(visible=0, size=(1280, 1024)) - VIRT_DISPLAY.start() - yield - VIRT_DISPLAY.stop() - else: - yield +# Make fixtures available everywhere +from tests.fixtures import * diff --git a/docs/development.rst b/docs/development.rst index b4a59acdd..374027fd0 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -220,11 +220,10 @@ For developers we provide additional packages for running tests, activate your e $ mamba install --file requirements.d/development.txt -On linux install the `conda-forge package pyvirtualdisplay` and `xvfb` from your linux package manager. -This is used to run tests on a virtual display. -If you don't want tests redirected to the xvfb display just setup an environment variable:: +On linux install `xvfb` from your linux package manager. +This can be used to run tests on an invisible virtual display by prepending the pytest call with `xvfb-run`, e.g.:: - $ export TESTS_VISIBLE=TRUE + $ xvfb-run pytest ... We have implemented demodata as data base for testing. On first call of pytest a set of demodata becomes stored in a /tmp/mss* folder. If you have installed gitpython a postfix of the revision head is added. @@ -540,4 +539,3 @@ GSoC'19 Projects - `Anveshan Lal: Updating Geographical Plotting Routines `_ - `Shivashis Padhi: Collaborative editing of flight path in real-time `_ - diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index 659ed9897..aed56354e 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -1014,7 +1014,7 @@ def show_about_dialog(self): """ dlg = MSUI_AboutDialog(parent=self) dlg.setModal(True) - dlg.exec_() + dlg.show() def show_shortcuts(self, search_mode=False): """Show the shortcuts dialog to the user. diff --git a/requirements.d/development.txt b/requirements.d/development.txt index 05561437b..54c73ca31 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -10,6 +10,7 @@ pytest pytest-cache pytest-pep8 pytest-flake8 +pytest-qt pytest-xdist pytest-cov pytest-timeout diff --git a/tests/_test_msui/test_editor.py b/tests/_test_msui/test_editor.py index c933c739f..2d1bfba5b 100644 --- a/tests/_test_msui/test_editor.py +++ b/tests/_test_msui/test_editor.py @@ -28,7 +28,6 @@ import mock import os import fs -import sys from PyQt5 import QtWidgets from mslib.msui import editor from tests.constants import ROOT_DIR @@ -41,21 +40,17 @@ class Test_Editor: save_file_name = fs.path.join(ROOT_DIR, "testeditor_save.json") - @mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes) - def setup_method(self, mockmessage): - self.application = QtWidgets.QApplication(sys.argv) - - self.window = editor.EditorMainWindow() - self.save_file_name = self.save_file_name - self.window.show() - - def teardown_method(self): - if os.path.exists(self.save_file_name): - os.remove(self.save_file_name) - self.window.hide() - QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() + @pytest.fixture(autouse=True) + def setup(self, qapp): + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window = editor.EditorMainWindow() + self.save_file_name = self.save_file_name + self.window.show() + yield + if os.path.exists(self.save_file_name): + os.remove(self.save_file_name) + self.window.hide() + QtWidgets.QApplication.processEvents() @mock.patch("mslib.msui.editor.get_open_filename", return_value=sample_file) def test_file_open(self, mockfile): diff --git a/tests/_test_msui/test_kmloverlay_dockwidget.py b/tests/_test_msui/test_kmloverlay_dockwidget.py index b3907c18e..d3c42a9ed 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -27,8 +27,8 @@ import os import fs -import sys import mock +import pytest from PyQt5 import QtWidgets, QtCore, QtTest, QtGui from tests.constants import ROOT_DIR import mslib.msui.kmloverlay_dockwidget as kd @@ -41,8 +41,8 @@ # ToDo review needed helper functions class Test_KmlOverlayDockWidget: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.view = mock.Mock() self.view.map = mock.Mock(side_effect=lambda x, y: (x, y)) self.view.map.plot = mock.Mock(return_value=[mock.Mock()]) @@ -56,10 +56,7 @@ def setup_method(self): self.window.select_all() self.window.remove_file() QtWidgets.QApplication.processEvents() - - def teardown_method(self): - QtWidgets.QApplication.processEvents() - self.application.quit() + yield QtWidgets.QApplication.processEvents() self.window.close() if os.path.exists(save_kml): diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 35c43da89..f32bc511b 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -29,7 +29,6 @@ import os import pytest import shutil -import sys import multiprocessing import tempfile from mslib.mswms.mswms import application @@ -43,19 +42,16 @@ class Test_MSS_LV_Options_Dialog: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_LV_Options_Dialog(settings=_DEFAULT_SETTINGS_LINEARVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_show(self, mockcrit): @@ -68,8 +64,8 @@ def test_get(self, mockcrit): class Test_MSSLinearViewWindow: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -81,12 +77,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_open_wms(self, mockbox): @@ -117,8 +110,8 @@ def test_options(self, mockdlg, mockbox): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_LinearViewWMS: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.port = PORTS.pop() self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): @@ -142,12 +135,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) self.thread.terminate() diff --git a/tests/_test_msui/test_mpl_map.py b/tests/_test_msui/test_mpl_map.py index 19d28661a..bd477c666 100644 --- a/tests/_test_msui/test_mpl_map.py +++ b/tests/_test_msui/test_mpl_map.py @@ -24,12 +24,15 @@ See the License for the specific language governing permissions and limitations under the License. """ +import pytest + from matplotlib import pyplot as plt from mslib.msui.mpl_map import MapCanvas class Test_MapCanvas: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): kwargs = {'resolution': 'l', 'area_thresh': 1000.0, 'ax': plt.gca(), 'llcrnrlon': -15.0, 'llcrnrlat': 35.0, 'urcrnrlon': 30.0, 'urcrnrlat': 65.0, 'epsg': '4326'} self.map = MapCanvas(**kwargs) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 666d0ff43..da639d2bd 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys import os import fs import fs.errors @@ -49,7 +48,8 @@ class Test_Mscolab_connect_window: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) self.userdata = 'UV10@uv10', 'UV10', 'uv10' @@ -60,7 +60,6 @@ def setup_method(self): self.user = get_user(self.userdata[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.main_window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.main_window.create_new_flight_track() self.main_window.show() @@ -72,14 +71,11 @@ def setup_method(self): "berta@something.org", "anton@something.org", "other@something.org"]: mslib.utils.auth.del_password_from_keyring(service_name="MSCOLAB", username=email) - - def teardown_method(self): + yield self.main_window.mscolab.logout() self.window.hide() self.main_window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() self.process.terminate() def test_url_combo(self): @@ -273,7 +269,8 @@ class Test_Mscolab: "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"], } - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) self.userdata = 'UV10@uv10', 'UV10', 'uv10' @@ -294,12 +291,10 @@ def setup_method(self): assert add_user_to_operation(path=self.operation_name3, access_level="collaborator", emailid=self.userdata3[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.version_window: self.window.mscolab.version_window.close() @@ -310,8 +305,6 @@ def teardown_method(self): self.window.listViews.item(0).window.handle_force_close() # close all hanging operation option windows self.window.mscolab.close_external_windows() - self.application.quit() - QtWidgets.QApplication.processEvents() self.process.terminate() def test_activate_operation(self): diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index abf8192fc..2eece8076 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -27,7 +27,6 @@ import os import mock import pytest -import sys from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets @@ -45,7 +44,8 @@ @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_MscolabAdminWindow: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) self.userdata = 'UV10@uv10', 'UV10', 'uv10' @@ -68,7 +68,6 @@ def setup_method(self): assert add_user_to_operation(path="tokyo", emailid=self.userdata[0], access_level="creator") QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() @@ -83,8 +82,7 @@ def setup_method(self): self.admin_window = self.window.mscolab.admin_window QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.admin_window: self.window.mscolab.admin_window.close() @@ -93,8 +91,6 @@ def teardown_method(self): with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): self.window.close() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() self.process.terminate() def test_permission_filter(self): diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 266a6e86f..9c4576164 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -25,7 +25,6 @@ limitations under the License. """ import os -import sys import fs import mock import pytest @@ -47,16 +46,15 @@ @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_Mscolab_Merge_Waypoints: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.emailid = 'merge@alpha.org' - - def teardown_method(self): + yield self.window.mscolab.logout() mslib.utils.auth.del_password_from_keyring("merge@alpha.org") with self.app.app_context(): @@ -70,7 +68,6 @@ def teardown_method(self): self.window.mscolab.version_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - self.application.quit() QtWidgets.QApplication.processEvents() self.process.terminate() diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 8e00ae7bf..d1d5b4f8f 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -25,7 +25,6 @@ limitations under the License. """ import os -import sys import pytest from mslib.mscolab.conf import mscolab_settings @@ -52,7 +51,8 @@ class Actions: @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_MscolabOperation: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) self.userdata = 'UV10@uv10', 'UV10', 'uv10' @@ -62,7 +62,6 @@ def setup_method(self): assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() @@ -77,8 +76,7 @@ def setup_method(self): self.chat_window = self.window.mscolab.chat_window QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.chat_window: self.window.mscolab.chat_window.hide() @@ -86,8 +84,6 @@ def teardown_method(self): self.window.mscolab.conn.disconnect() self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() self.process.terminate() def test_send_message(self): diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index 32c4cb79e..ed2e676be 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -35,11 +35,10 @@ PORTS = list(range(21000, 21500)) -# ToDo Understand why this needs to be skipped, it runs when direct called +@pytest.mark.skip("Uses QTimer, which can break other unrelated tests") @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): - @pytest.mark.skip("Uses QTimer, which can break other unrelated tests") @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_save_merge_points(self, mockbox): self.emailid = "mergepoints@alpha.org" diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 76b35653b..0465dd0b3 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -25,7 +25,6 @@ limitations under the License. """ import os -import sys import pytest import mock @@ -45,7 +44,8 @@ @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_MscolabVersionHistory: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): handle_db_reset() self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) self.userdata = 'UV10@uv10', 'UV10', 'uv10' @@ -55,7 +55,6 @@ def setup_method(self): assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() @@ -71,15 +70,12 @@ def setup_method(self): assert self.version_window is not None QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.version_window: self.window.mscolab.version_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - self.application.quit() - QtWidgets.QApplication.processEvents() self.process.terminate() def test_changes(self): diff --git a/tests/_test_msui/test_mss.py b/tests/_test_msui/test_mss.py index 222e4470a..c9ab2379f 100644 --- a/tests/_test_msui/test_mss.py +++ b/tests/_test_msui/test_mss.py @@ -25,17 +25,13 @@ limitations under the License. """ import pytest -import sys from PyQt5 import QtWidgets, QtTest, QtCore from mslib.msui import mss @pytest.mark.skip(reason='needs review, assert missing') -def test_mss_rename_message(): - application = QtWidgets.QApplication(sys.argv) +def test_mss_rename_message(qapp): main_window = mss.MSSMainWindow() main_window.show() QtTest.QTest.mouseClick(main_window.pushButton, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - application.quit() - QtWidgets.QApplication.processEvents() diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 5836ccba3..b99ceecbd 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -26,7 +26,6 @@ """ -import sys import mock import os import fs @@ -67,9 +66,9 @@ def test_main(): class Test_MSS_TutorialMode: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - self.application.setApplicationDisplayName("MSUI") + @pytest.fixture(autouse=True) + def setup(self, qapp): + qapp.setApplicationDisplayName("MSUI") self.main_window = msui_mw.MSUIMainWindow(tutorial_mode=True) self.main_window.create_new_flight_track() self.main_window.show() @@ -77,12 +76,9 @@ def setup_method(self): tutorial_mode=True) self.main_window.show_shortcuts(search_mode=True) self.tutorial_dir = fs.path.combine(MSUI_CONFIG_PATH, 'tutorial_images') - - def teardown_method(self): + yield self.main_window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_tutorial_dir(self): dir_name, name = fs.path.split(self.tutorial_dir) @@ -99,9 +95,12 @@ def test_tutorial_dir(self): class Test_MSS_AboutDialog: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = msui_mw.MSUI_AboutDialog() + yield + self.window.hide() + QtWidgets.QApplication.processEvents() def test_milestone_url(self): with urlopen(self.window.milestone_url) as f: @@ -109,26 +108,17 @@ def test_milestone_url(self): pattern = f'value="is:closed milestone:{__version__[:-1]}"' assert pattern in text.decode('utf-8') - def teardown_method(self): - self.window.hide() - QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - class Test_MSS_ShortcutDialog: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.main_window = msui_mw.MSUIMainWindow() self.main_window.show() self.shortcuts = msui_mw.MSUI_ShortcutsDialog() - - def teardown_method(self): + yield self.shortcuts.hide() self.main_window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_shortcuts_present(self): # Assert list gets filled properly @@ -181,12 +171,12 @@ class Test_MSSSideViewWindow: # "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] } - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self.sample_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), '../', 'data/') - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow() self.window.create_new_flight_track() @@ -194,8 +184,7 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield config_file = os.path.join( self.sample_path, 'empty_msui_settings.json', @@ -205,8 +194,6 @@ def teardown_method(self): self.window.listViews.item(i).window.hide() self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_no_updater(self): assert not hasattr(self.window, "updater") diff --git a/tests/_test_msui/test_multiple_flightpath_dockwidget.py b/tests/_test_msui/test_multiple_flightpath_dockwidget.py index 3a93fcc15..de318a86a 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -24,7 +24,7 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys +import pytest from PyQt5 import QtWidgets, QtTest from mslib.msui import msui from mslib.msui.multiple_flightpath_dockwidget import MultipleFlightpathControlWidget @@ -33,10 +33,8 @@ class Test_MultipleFlightpathControlWidget: - - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = msui.MSUIMainWindow() self.window.create_new_flight_track() @@ -52,12 +50,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_initialization(self): widget = MultipleFlightpathControlWidget(parent=self.widget, diff --git a/tests/_test_msui/test_remotesensing.py b/tests/_test_msui/test_remotesensing.py index b920af39c..fb00f3377 100644 --- a/tests/_test_msui/test_remotesensing.py +++ b/tests/_test_msui/test_remotesensing.py @@ -27,11 +27,9 @@ import datetime -import sys from mock import Mock from matplotlib.collections import LineCollection -from PyQt5 import QtWidgets import pytest import skyfield_data from mslib.msui.remotesensing_dockwidget import RemoteSensingControlWidget @@ -47,8 +45,8 @@ class Test_RemoteSensingControlWidget: """ Tests about RemoteSensingControlWidget """ - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.view = Mock() self.map = qt.TopViewPlotter() self.map.init_map() diff --git a/tests/_test_msui/test_satellite_dockwidget.py b/tests/_test_msui/test_satellite_dockwidget.py index c1d54df45..6ff633e71 100644 --- a/tests/_test_msui/test_satellite_dockwidget.py +++ b/tests/_test_msui/test_satellite_dockwidget.py @@ -26,27 +26,24 @@ """ import os -import sys import mock +import pytest from PyQt5 import QtWidgets, QtCore, QtTest import mslib.msui.satellite_dockwidget as sd class Test_SatelliteDockWidget: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.view = mock.Mock() self.window = sd.SatelliteControlWidget(view=self.view) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_load(self): path = os.path.join(os.path.dirname(__file__), "../", "data", "satellite_predictor.txt") diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 02ba0e53e..8f562ecb6 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -30,7 +30,6 @@ import os import pytest import shutil -import sys import multiprocessing import tempfile from mslib.mswms.mswms import application @@ -44,19 +43,16 @@ class Test_MSS_SV_OptionsDialog: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_show(self, mockcrit): @@ -95,8 +91,8 @@ def test_setColour(self, mockdlg): class Test_MSSSideViewWindow: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -108,12 +104,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_open_wms(self, mockbox): @@ -174,8 +167,8 @@ def test_y_axes(self, mockbox): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_SideViewWMS: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.port = PORTS.pop() self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): @@ -199,12 +192,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) self.thread.terminate() diff --git a/tests/_test_msui/test_suffix.py b/tests/_test_msui/test_suffix.py index c5cf96392..b639322d9 100644 --- a/tests/_test_msui/test_suffix.py +++ b/tests/_test_msui/test_suffix.py @@ -26,27 +26,24 @@ See the License for the specific language governing permissions and limitations under the License. """ +import pytest -import sys from PyQt5 import QtWidgets, QtTest import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW class Test_SuffixChange: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_suffixchange(self): suffix = [' hPa', ' km', ' hft'] diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index cbd8d54c3..d4bc47357 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -28,7 +28,6 @@ import mock import os import pytest -import sys from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft @@ -37,9 +36,8 @@ class Test_TableView: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - + @pytest.fixture(autouse=True) + def setup(self, qapp): # Create an initital flight track. initial_waypoints = [ft.Waypoint(flightlevel=0, location="EDMO", comments="take off OP"), ft.Waypoint(48.10, 10.27, 200), @@ -57,12 +55,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_open_hex(self): """ diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index 0e6d535c3..86ae5fcbe 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -29,7 +29,6 @@ import os import pytest import shutil -import sys import multiprocessing import tempfile import mslib.msui.topview as tv @@ -44,19 +43,16 @@ class Test_MSS_TV_MapAppearanceDialog: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_TV_MapAppearanceDialog(settings=_DEFAULT_SETTINGS_TOPVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_show(self, mockcrit): @@ -70,9 +66,9 @@ def test_get(self, mockcrit): class Test_MSSTopViewWindow: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): mainwindow = MSUIMainWindow() - self.application = QtWidgets.QApplication(sys.argv) initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") waypoints_model.insertRows( @@ -82,12 +78,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_open_wms(self, mockbox): @@ -297,9 +290,9 @@ def test_map_options(self, mockbox): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_TopViewWMS: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self.port = PORTS.pop() - self.application = QtWidgets.QApplication(sys.argv) self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): @@ -325,12 +318,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) self.thread.terminate() @@ -360,8 +350,9 @@ def test_server_getmap(self, mockbox): class Test_MSUITopViewWindow: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): + pass def test_kwargs_update_does_not_harm(self): initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] diff --git a/tests/_test_msui/test_updater.py b/tests/_test_msui/test_updater.py index fc161889c..c2caa3161 100644 --- a/tests/_test_msui/test_updater.py +++ b/tests/_test_msui/test_updater.py @@ -24,8 +24,8 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys import mock +import pytest from PyQt5 import QtWidgets, QtTest from mslib.msui.updater import UpdaterUI, Updater @@ -65,7 +65,8 @@ def create_mock(function, on_success=None, on_failure=None, start=True): class Test_MSS_ShortcutDialog: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self.updater = Updater() self.status = "" self.update_available = False @@ -83,10 +84,7 @@ def status_signal(s): self.updater.on_update_available.connect(update_signal) self.updater.on_status_update.connect(status_signal) self.updater.on_update_finished.connect(update_finished_signal) - self.application = QtWidgets.QApplication(sys.argv) - - def teardown_method(self): - self.application.quit() + yield QtWidgets.QApplication.processEvents() @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index 8ad01714e..d36b9ebf4 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -25,8 +25,8 @@ limitations under the License. """ -import sys import mock +import pytest from PyQt5 import QtWidgets, QtTest, QtCore import mslib.msui.wms_capabilities as wc @@ -34,8 +34,8 @@ class Test_WMSCapabilities: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.capabilities = mock.Mock() self.capabilities.capabilities_document = u"Hölla die Waldfee".encode("utf-8") self.capabilities.provider = mock.Mock() @@ -47,6 +47,8 @@ def setup_method(self): self.capabilities.provider.contact.address = None self.capabilities.provider.contact.postcode = None self.capabilities.provider.contact.city = None + yield + QtWidgets.QApplication.processEvents() def start_window(self): self.window = wc.WMSCapabilitiesBrowser( @@ -56,11 +58,6 @@ def start_window(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) - def teardown_method(self): - QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_window_start(self, mockbox): self.start_window() diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 008f27b0e..f1a701a2f 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -26,7 +26,6 @@ """ import os -import sys import mock import shutil import tempfile @@ -59,7 +58,6 @@ class WMSControlWidgetSetup: def _setup(self, widget_type): wc.WMS_SERVICE_CACHE = {} self.port = PORTS.pop() - self.application = QtWidgets.QApplication(sys.argv) if widget_type == "hsec": self.view = HSecViewMockup() else: @@ -93,11 +91,9 @@ def _setup(self, widget_type): QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - def teardown_method(self): + def _teardown(self): self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) self.thread.terminate() @@ -116,8 +112,11 @@ def query_server(self, url): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_HSecWMSControlWidget(WMSControlWidgetSetup): - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self._setup("hsec") + yield + self._teardown() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_no_server(self, mockbox): @@ -471,8 +470,11 @@ def test_server_no_thread(self, mockbox, mockthread): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_VSecWMSControlWidget(WMSControlWidgetSetup): - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self._setup("vsec") + yield + self._teardown() @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_server_getmap(self, mockbox): @@ -563,8 +565,8 @@ class TestWMSControlWidgetSetupSimple: 500.0,600.0,700.0,900.0 """ - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.view = HSecViewMockup() self.window = wc.HSecWMSControlWidget(view=self.view) self.window.show() @@ -575,12 +577,9 @@ def setup_method(self): self.window.multilayers.delete_server(server) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_xml(self): testxml = self.xml.format("", self.srs_base, self.dimext_time + self.dimext_inittime + self.dimext_elevation) diff --git a/tests/_test_utils/test_auth.py b/tests/_test_utils/test_auth.py index f9418ff98..693886a8b 100644 --- a/tests/_test_utils/test_auth.py +++ b/tests/_test_utils/test_auth.py @@ -49,7 +49,7 @@ def test_keyring(): def test_get_auth_from_url_and_name(): # set start condition to prevent definitions from a test earlier - constants.AUTH_LOGIN_CACHE == {} + constants.AUTH_LOGIN_CACHE = {} # empty http_auth definition server_url = "http://example.com" http_auth = config_loader(dataset="MSS_auth") diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 000000000..9055dc371 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" + + tests.fixtures + ~~~~~~~~~~~~~~ + + This module provides utils for pytest to test mslib modules + + This file is part of MSS. + + :copyright: Copyright 2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import pytest +import mock + +from PyQt5 import QtWidgets + + +@pytest.fixture +def fail_if_open_message_boxes_left(): + # Mock every MessageBox widget in the test suite to avoid unwanted freezes on unhandled error popups etc. + with mock.patch("PyQt5.QtWidgets.QMessageBox.question") as q, \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as i, \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as c, \ + mock.patch("PyQt5.QtWidgets.QMessageBox.warning") as w: + yield + + # Fail a test if there are any Qt message boxes left open at the end + if any(box.call_count > 0 for box in [q, i, c, w]): + summary = "\n".join([f"PyQt5.QtWidgets.QMessageBox.{box()._extract_mock_name()}: {box.mock_calls[:-1]}" + for box in [q, i, c, w] if box.call_count > 0]) + pytest.fail(f"An unhandled message box popped up during your test!\n{summary}") + + +@pytest.fixture +def close_remaining_widgets(): + yield + # Try to close all remaining widgets after each test + for qobject in set(QtWidgets.QApplication.topLevelWindows() + QtWidgets.QApplication.topLevelWidgets()): + try: + qobject.destroy() + # Some objects deny permission, pass in that case + except RuntimeError: + pass + + +@pytest.fixture +def qapp(qapp, fail_if_open_message_boxes_left, close_remaining_widgets): + yield qapp From 889fda8f8d9a1f663f1900e24a893fd8b1932071 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 16 Jan 2024 08:06:16 +0100 Subject: [PATCH 095/176] gives the server control over the clients login options (#2118) --- .../samples/config/mscolab/mscolab_settings.py.sample | 3 +++ mslib/mscolab/conf.py | 3 +++ mslib/mscolab/server.py | 9 ++++++--- mslib/msui/mscolab.py | 11 ++++++++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index 85169f711..e66d4a89e 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -85,6 +85,9 @@ STUB_CODE = """ """ +# accounts on a database on the server +DIRECT_LOGIN = True + # enable login by identity provider USE_SAML2 = False diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index d2b647cc7..30bf80495 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -117,6 +117,9 @@ class default_mscolab_settings: # enable login by identity provider USE_SAML2 = False + # accounts on a database on the server + DIRECT_LOGIN = True + # Enable SSL certificate verification during SSO between MSColab and IdP ENABLE_SSO_SSL_CERT_VERIFICATION = True diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 0899553dc..e4a485020 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -242,16 +242,19 @@ def hello(): auth.login_required() return json.dumps({ 'message': "Mscolab server", - 'USE_SAML2': mscolab_settings.USE_SAML2 + 'USE_SAML2': mscolab_settings.USE_SAML2, + 'direct_login': mscolab_settings.DIRECT_LOGIN }) return json.dumps({ 'message': "Mscolab server", - 'USE_SAML2': mscolab_settings.USE_SAML2 + 'USE_SAML2': mscolab_settings.USE_SAML2, + 'direct_login': mscolab_settings.DIRECT_LOGIN }) else: return json.dumps({ 'message': "Mscolab server", - 'USE_SAML2': mscolab_settings.USE_SAML2 + 'USE_SAML2': mscolab_settings.USE_SAML2, + 'direct_login': mscolab_settings.DIRECT_LOGIN }) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index b553828b9..008a1d2a8 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -239,12 +239,17 @@ def connect_handler(self): except (json.decoder.JSONDecodeError, KeyError): idp_enabled = False - if idp_enabled: - # Hide user creatiion seccion if IDP login enabled + try: + direct_login = json.loads(r.text)["direct_login"] + except (json.decoder.JSONDecodeError, KeyError): + direct_login = True + + if not direct_login: + # Hide user creation when this is disabled on the server self.addUserBtn.setHidden(True) self.clickNewUserLabel.setHidden(True) - else: + if not idp_enabled: # Hide login by identity provider if IDP login disabled self.loginWithIDPBtn.setHidden(True) From 52cea9fbaa86bd4d55db66af4e234d9ef0e67717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Tue, 16 Jan 2024 16:10:39 +0100 Subject: [PATCH 096/176] Refactor servers into fixtures (#2149) * Refactor mscolab and mswms servers into fixtures This consolidates server setup and teardown code into one place, so that the tests requesting the fixtures no longer have to care about e.g. resetting the database before tests or stopping the process afterwards, like they had to previously. This also removes the need for the global PORTS variables in most places and lets the OS pick the port instead, making port collisions impossible. * Refactor Test_Socket_Manager to use fixtures This gets rid of the last usage of a global PORTS variable and removes the TestCase-style from that class. * Refactor _test_mscolab away from a unittest-style This removes the need for the Flask-Testing dependency and gets rid of the last instances of unittest-style TestCase classes. * Add a "Writing Tests" section to the docs --- docs/development.rst | 17 +++ requirements.d/development.txt | 2 - tests/_test_mscolab/test_chat_manager.py | 32 ++--- tests/_test_mscolab/test_file_manager.py | 32 +---- tests/_test_mscolab/test_files.py | 33 ++--- tests/_test_mscolab/test_files_api.py | 34 +---- tests/_test_mscolab/test_models.py | 31 ++--- tests/_test_mscolab/test_mscolab.py | 21 +-- tests/_test_mscolab/test_seed.py | 33 ++--- tests/_test_mscolab/test_server.py | 30 +---- .../test_server_auth_required.py | 28 +--- tests/_test_mscolab/test_sockets_manager.py | 59 ++++---- tests/_test_mscolab/test_utils.py | 31 +---- tests/_test_msui/test_linearview.py | 15 +-- tests/_test_msui/test_mscolab.py | 18 +-- tests/_test_msui/test_mscolab_admin_window.py | 11 +- .../test_mscolab_merge_waypoints.py | 13 +- tests/_test_msui/test_mscolab_operation.py | 11 +- .../test_mscolab_save_merge_points.py | 3 - .../test_mscolab_version_history.py | 11 +- tests/_test_msui/test_sideview.py | 15 +-- tests/_test_msui/test_topview.py | 16 +-- tests/_test_msui/test_wms_control.py | 80 ++++++----- tests/_test_mswms/test_wms.py | 39 +++--- tests/fixtures.py | 126 ++++++++++++++++++ tests/utils.py | 106 +-------------- 26 files changed, 332 insertions(+), 515 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 374027fd0..f920c1b0c 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -393,6 +393,23 @@ example:: +Writing Tests +------------- + +Ideally every new feature or bug fix should be accompanied by tests +that make sure that the feature works as intended or that the bug is indeed fixed +(and won't turn up again in the future). +The best way to find out how to write such tests is by taking a look at the existing tests, +maybe finding one that is similar +and adapting it to the new test case. + +MSS uses pytest as a test runner and therefore their `docs `_ are relevant here. + +Common resources that a test might need, +like e.g. a running MSColab server or a QApplication instance for GUI tests, +are collected in :mod:`tests.fixtures` in the form of pytest fixtures that can be requested as needed in tests. + + Pushing your changes -------------------- diff --git a/requirements.d/development.txt b/requirements.d/development.txt index 54c73ca31..07f90dca1 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -17,10 +17,8 @@ pytest-timeout sphinx sphinx_rtd_theme gitpython -gevent pynco conda-verify -Flask-Testing pytest-reverse eventlet>0.30.2 dnspython>=2.0.0, <2.3.0 diff --git a/tests/_test_mscolab/test_chat_manager.py b/tests/_test_mscolab/test_chat_manager.py index abe1dd2b0..f5999869d 100644 --- a/tests/_test_mscolab/test_chat_manager.py +++ b/tests/_test_mscolab/test_chat_manager.py @@ -26,45 +26,29 @@ """ import os import secrets +import pytest -from flask_testing import TestCase from werkzeug.datastructures import FileStorage from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation, Message, MessageType -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import APP from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation -from mslib.mscolab.sockets_manager import setup_managers -class Test_Chat_Manager(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_Chat_Manager: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, self.cm, _ = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' self.operation_name = "europe" - socketio, self.cm, self.fm = setup_managers(self.app) assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_add_message(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 11852b27b..1cdef6d5e 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -24,40 +24,23 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase import os import datetime import pytest -from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation, User -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user -from mslib.mscolab.mscolab import handle_db_reset @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_FileManager(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_FileManager: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) self.user = get_user(self.userdata[0]) @@ -77,9 +60,8 @@ def setUp(self): assert add_user('UV80@uv80', 'UV80', 'uv80') self.adminuser = get_user('UV80@uv80') self._example_data() - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_modify_user(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index df774e02f..3f3d5a90f 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -25,39 +25,23 @@ limitations under the License. """ # ToDo have to be merged into test_file_manager -from flask_testing import TestCase import os import pytest from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import User, Operation, Permission, Change, Message -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.utils import get_recent_op_id @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_Files(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() - - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) +class Test_Files: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers + self.userdata = 'UV11@uv11', 'UV11', 'uv11' self.userdata2 = 'UV12@uv12', 'UV12', 'uv12' @@ -69,9 +53,8 @@ def setUp(self): assert self.user is not None self.file_message_counter = [0] * 2 self._example_data() - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_create_operation(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 1ac772f6f..760c9915d 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -24,49 +24,29 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase import os import fs import pytest -from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user -from mslib.mscolab.mscolab import handle_db_reset @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_Files(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() - - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) +class Test_Files: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' - assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) self.user = get_user(self.userdata[0]) assert self.user is not None assert add_user('UV20@uv20', 'UV20', 'uv20') self.user_2 = get_user('UV20@uv20') - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_create_operation(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index cfec2973b..5b39025ce 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -24,33 +24,20 @@ See the License for the specific language governing permissions and limitations under the License. """ +import pytest -from flask_testing import TestCase -from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import register_user, APP +from mslib.mscolab.server import register_user from mslib.mscolab.models import User -class Test_User(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_User: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): self.userdata = 'UV10@uv10', 'UV10', 'uv10' - result = register_user(self.userdata[0], self.userdata[1], self.userdata[2]) - assert result["success"] is True + with mscolab_app.app_context(): + result = register_user(self.userdata[0], self.userdata[1], self.userdata[2]) + assert result["success"] is True + yield def test_generate_auth_token(self): user = User(self.userdata[0], self.userdata[1], self.userdata[2]) diff --git a/tests/_test_mscolab/test_mscolab.py b/tests/_test_mscolab/test_mscolab.py index 339d7fb94..5c28e89f9 100644 --- a/tests/_test_mscolab/test_mscolab.py +++ b/tests/_test_mscolab/test_mscolab.py @@ -28,12 +28,10 @@ import pytest import mock import argparse -from flask_testing import TestCase from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation, User, Permission from mslib.mscolab.mscolab import handle_db_reset, handle_db_seed, confirm_action, main -from mslib.mscolab.server import APP from mslib.mscolab.seed import add_operation @@ -62,20 +60,13 @@ def test_main(): # currently only checking precedence of all args -class Test_Mscolab(TestCase): - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app +class Test_Mscolab: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): + with mscolab_app.app_context(): + yield - def setUp(self): - handle_db_reset() + def test_initial_state(self): assert Operation.query.all() == [] assert User.query.all() == [] assert Permission.query.all() == [] diff --git a/tests/_test_mscolab/test_seed.py b/tests/_test_mscolab/test_seed.py index 95090c24f..1de296389 100644 --- a/tests/_test_mscolab/test_seed.py +++ b/tests/_test_mscolab/test_seed.py @@ -24,34 +24,18 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase +import pytest -from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import User, Operation -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import (add_user, get_user, add_operation, add_user_to_operation, delete_user, delete_operation, add_all_users_default_operation) -class Test_Seed(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) +class Test_Seed: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.operation_name = "XYZ" self.description = "Template" self.userdata_0 = 'UV0@uv0', 'UV0', 'uv0' @@ -62,9 +46,8 @@ def setUp(self): assert add_operation(self.operation_name, self.description) assert add_user_to_operation(path=self.operation_name, emailid=self.userdata_0[0]) self.user = User(self.userdata_0[0], self.userdata_0[1], self.userdata_0[2]) - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_add_operation(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 37dfb7140..50e3b2c8b 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -24,8 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ - -from flask_testing import TestCase import os import pytest import json @@ -33,34 +31,20 @@ from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import User, Operation -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import initialize_managers, check_login, register_user, APP +from mslib.mscolab.server import initialize_managers, check_login, register_user from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_Server(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_Server: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): + self.app = mscolab_app self.userdata = 'UV10@uv10', 'UV10', 'uv10' - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_initialize_managers(self): app, sockio, cm, fm = initialize_managers(self.app) diff --git a/tests/_test_mscolab/test_server_auth_required.py b/tests/_test_mscolab/test_server_auth_required.py index 1528c0317..c924634cb 100644 --- a/tests/_test_mscolab/test_server_auth_required.py +++ b/tests/_test_mscolab/test_server_auth_required.py @@ -27,42 +27,24 @@ import os import pytest -from flask_testing import TestCase from mslib.mscolab.conf import mscolab_settings mscolab_settings.enable_basic_http_authentication = True try: - from mslib.mscolab.server import authfunc, verify_pw, initialize_managers, get_auth_token, register_user, APP + from mslib.mscolab.server import authfunc, verify_pw, initialize_managers, get_auth_token, register_user except ImportError: pytest.skip("this test runs only by an explicit call " "e.g. pytest tests/_test_mscolab/test_server_auth_required.py", allow_module_level=True) -from mslib.mscolab.mscolab import handle_db_reset - @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_Server_Auth_Not_Valid(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_Server_Auth_Not_Valid: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): + self.app = mscolab_app self.userdata = 'UV10@uv10', 'UV10', 'uv10' - def tearDown(self): - pass - def test_initialize_managers(self): app, sockio, cm, fm = initialize_managers(self.app) diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index 9735fec0a..682eda8f0 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -26,46 +26,26 @@ """ import os import pytest +import socket import socketio import datetime import requests -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from mslib.msui.icons import icons from mslib.mscolab.conf import mscolab_settings -from tests.utils import mscolab_check_free_port, LiveSocketTestCase -from mslib.mscolab.server import APP, initialize_managers from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation, get_operation -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.sockets_manager import SocketsManager from mslib.mscolab.models import Permission, User, Message, MessageType -PORTS = list(range(27000, 27500)) - - -class Test_Socket_Manager(LiveSocketTestCase): - run_gc_after_test = True - chat_messages_counter = [0, 0, 0] # three sockets connected a, b, and c - chat_messages_counter_a = 0 # only for first test - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 1 - app.config['LIVESERVER_PORT'] = mscolab_check_free_port(PORTS, PORTS.pop()) - return app - - def setUp(self): - handle_db_reset() +class Test_Socket_Manager: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers, mscolab_server): + self.app = mscolab_app + _, self.cm, self.fm = mscolab_managers + self.url = mscolab_server self.sockets = [] - # self.app = APP - self.app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - self.app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - self.app, _, self.cm, self.fm = initialize_managers(self.app) self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' self.operation_name = "europe" @@ -77,12 +57,22 @@ def setUp(self): self.anotheruser = get_user(self.anotheruserdata[0]) self.token = self.user.generate_auth_token() self.operation = get_operation(self.operation_name) - self.url = self.get_server_url() self.sm = SocketsManager(self.cm, self.fm) - - def tearDown(self): - for socket in self.sockets: - socket.disconnect() + yield + for sock in self.sockets: + sock.disconnect() + + def _can_ping_server(self): + parsed_url = urlparse(self.url) + host, port = parsed_url.hostname, parsed_url.port + try: + sock = socket.create_connection((host, port)) + success = True + except socket.error: + success = False + finally: + sock.close() + return success def _connect(self): sio = socketio.Client(reconnection_attempts=5) @@ -109,7 +99,8 @@ def test_handle_connect(self): def test_join_creator_to_operatiom(self): sio = self._connect() operation = self._new_operation('new_operation', "example decription") - assert self.fm.get_file(int(operation.id), self.user) is False + with self.app.app_context(): + assert self.fm.get_file(int(operation.id), self.user) is False json_config = {"token": self.token, "op_id": operation.id} diff --git a/tests/_test_mscolab/test_utils.py b/tests/_test_mscolab/test_utils.py index 99e34f240..fa8d9be6f 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -23,7 +23,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase import os import pytest import json @@ -31,11 +30,8 @@ from fs.tempfs import TempFS from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation, MessageType -from mslib.mscolab.mscolab import handle_db_init, handle_db_reset -from mslib.mscolab.server import APP from mslib.mscolab.seed import add_user, get_user from mslib.mscolab.utils import get_recent_op_id, get_session_id, get_message_dict, create_files, os_fs_create_dir -from mslib.mscolab.sockets_manager import setup_managers class Message: @@ -56,28 +52,15 @@ def strftime(value): @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") -class Test_Utils(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_init() +class Test_Utils: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' - socketio, cm, self.fm = setup_managers(self.app) - - def tearDown(self): - handle_db_reset() + with self.app.app_context(): + yield @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index f32bc511b..070446bdf 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -29,17 +29,13 @@ import os import pytest import shutil -import multiprocessing import tempfile -from mslib.mswms.mswms import application from PyQt5 import QtWidgets, QtTest, QtCore from mslib.msui import flighttrack as ft import mslib.msui.linearview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_LINEARVIEW from tests.utils import wait_until_signal -PORTS = list(range(26000, 26500)) - class Test_MSS_LV_Options_Dialog: @pytest.fixture(autouse=True) @@ -111,15 +107,11 @@ def test_options(self, mockdlg, mockbox): reason="multiprocessing needs currently start_method fork") class Test_LinearViewWMS: @pytest.fixture(autouse=True) - def setup(self, qapp): - self.port = PORTS.pop() + def setup(self, qapp, mswms_server): + self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") @@ -139,7 +131,6 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): QtWidgets.QApplication.processEvents() @@ -154,7 +145,7 @@ def test_server_getmap(self, mockbox): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.image_displayed) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index da639d2bd..5b2eb0c1e 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -38,20 +38,16 @@ from mslib.msui.flighttrack import WaypointsTableModel from PyQt5 import QtCore, QtTest, QtWidgets from mslib.utils.config import read_config_file, config_loader, modify_config_file -from tests.utils import mscolab_start_server, create_msui_settings_file, ExceptionMock +from tests.utils import create_msui_settings_file, ExceptionMock from mslib.msui import msui from mslib.msui import mscolab -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation -PORTS = list(range(25000, 25500)) - class Test_Mscolab_connect_window: @pytest.fixture(autouse=True) - def setup(self, qapp): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) + def setup(self, qapp, mscolab_server): + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -76,7 +72,6 @@ def setup(self, qapp): self.window.hide() self.main_window.hide() QtWidgets.QApplication.processEvents() - self.process.terminate() def test_url_combo(self): assert self.window.urlCb.count() >= 1 @@ -270,9 +265,9 @@ class Test_Mscolab: } @pytest.fixture(autouse=True) - def setup(self, qapp): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) + def setup(self, qapp, mscolab_app, mscolab_server): + self.app = mscolab_app + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -305,7 +300,6 @@ def setup(self, qapp): self.window.listViews.item(0).window.handle_force_close() # close all hanging operation option windows self.window.mscolab.close_external_windows() - self.process.terminate() def test_activate_operation(self): self._connect_to_mscolab() diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index 2eece8076..b1a04170e 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -30,24 +30,18 @@ from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets -from tests.utils import mscolab_start_server from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation from mslib.utils.config import modify_config_file -PORTS = list(range(24000, 24500)) - - @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_MscolabAdminWindow: @pytest.fixture(autouse=True) - def setup(self, qapp): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) + def setup(self, qapp, mscolab_server): + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -91,7 +85,6 @@ def setup(self, qapp): with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): self.window.close() QtWidgets.QApplication.processEvents() - self.process.terminate() def test_permission_filter(self): len_added_users = self.admin_window.modifyUsersTable.rowCount() diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 9c4576164..3e83b1596 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -33,23 +33,19 @@ from mslib.msui import flighttrack as ft from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets -from tests.utils import (mscolab_start_server, mscolab_register_and_login, mscolab_create_operation, +from tests.utils import (mscolab_register_and_login, mscolab_create_operation, mscolab_delete_all_operations, mscolab_delete_user) from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset - - -PORTS = list(range(23000, 23500)) @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_Mscolab_Merge_Waypoints: @pytest.fixture(autouse=True) - def setup(self, qapp): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) + def setup(self, qapp, mscolab_app, mscolab_server): + self.app = mscolab_app + self.url = mscolab_server QtTest.QTest.qWait(500) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() @@ -69,7 +65,6 @@ def setup(self, qapp): if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() QtWidgets.QApplication.processEvents() - self.process.terminate() def _create_user_data(self, emailid='merge@alpha.org'): with self.app.app_context(): diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index d1d5b4f8f..951636a3b 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -30,15 +30,11 @@ from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Message from PyQt5 import QtCore, QtTest, QtWidgets -from tests.utils import mscolab_start_server from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation from mslib.utils.config import modify_config_file -PORTS = list(range(22000, 22500)) - class Actions: DOWNLOAD = 0 @@ -52,9 +48,9 @@ class Actions: reason="multiprocessing needs currently start_method fork") class Test_MscolabOperation: @pytest.fixture(autouse=True) - def setup(self, qapp): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) + def setup(self, qapp, mscolab_app, mscolab_server): + self.app = mscolab_app + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -84,7 +80,6 @@ def setup(self, qapp): self.window.mscolab.conn.disconnect() self.window.hide() QtWidgets.QApplication.processEvents() - self.process.terminate() def test_send_message(self): self._send_message("**test message**") diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index ed2e676be..2ade4a11f 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -32,9 +32,6 @@ from PyQt5 import QtCore, QtTest, QtWidgets -PORTS = list(range(21000, 21500)) - - @pytest.mark.skip("Uses QTimer, which can break other unrelated tests") @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 0465dd0b3..5036c7e51 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -28,26 +28,20 @@ import pytest import mock -from tests.utils import mscolab_start_server from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation from mslib.utils.config import modify_config_file -PORTS = list(range(20000, 20500)) - - @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") class Test_MscolabVersionHistory: @pytest.fixture(autouse=True) - def setup(self, qapp): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) + def setup(self, qapp, mscolab_server): + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -76,7 +70,6 @@ def setup(self, qapp): self.window.mscolab.version_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - self.process.terminate() def test_changes(self): self._change_version_filter(1) diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 8f562ecb6..5aae94cd0 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -30,17 +30,13 @@ import os import pytest import shutil -import multiprocessing import tempfile -from mslib.mswms.mswms import application from PyQt5 import QtWidgets, QtTest, QtCore, QtGui from mslib.msui import flighttrack as ft import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW from tests.utils import wait_until_signal -PORTS = list(range(19000, 19500)) - class Test_MSS_SV_OptionsDialog: @pytest.fixture(autouse=True) @@ -168,15 +164,11 @@ def test_y_axes(self, mockbox): reason="multiprocessing needs currently start_method fork") class Test_SideViewWMS: @pytest.fixture(autouse=True) - def setup(self, qapp): - self.port = PORTS.pop() + def setup(self, qapp, mswms_server): + self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") @@ -196,7 +188,6 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): QtWidgets.QApplication.processEvents() @@ -211,7 +202,7 @@ def test_server_getmap(self, mockbox): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.image_displayed) diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index 86ae5fcbe..fed0a753d 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -29,18 +29,14 @@ import os import pytest import shutil -import multiprocessing import tempfile import mslib.msui.topview as tv -from mslib.mswms.mswms import application from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft from mslib.msui.msui import MSUIMainWindow from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_TOPVIEW from tests.utils import wait_until_signal -PORTS = list(range(28000, 28500)) - class Test_MSS_TV_MapAppearanceDialog: @pytest.fixture(autouse=True) @@ -291,16 +287,11 @@ def test_map_options(self, mockbox): reason="multiprocessing needs currently start_method fork") class Test_TopViewWMS: @pytest.fixture(autouse=True) - def setup(self, qapp): - self.port = PORTS.pop() - + def setup(self, qapp, mswms_server): + self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") @@ -322,7 +313,6 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): QtWidgets.QApplication.processEvents() @@ -337,7 +327,7 @@ def test_server_getmap(self, mockbox): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.image_displayed) diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index f1a701a2f..a97a65bfc 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -31,17 +31,13 @@ import tempfile import pytest import hashlib -import multiprocessing -from mslib.mswms.mswms import application +import urllib from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft import mslib.msui.wms_control as wc from tests.utils import wait_until_signal -PORTS = list(range(18000, 18500)) - - class HSecViewMockup(mock.Mock): get_crs = mock.Mock(return_value="EPSG:4326") getBBOX = mock.Mock(return_value=(0, 0, 10, 10)) @@ -55,9 +51,14 @@ class VSecViewMockup(mock.Mock): class WMSControlWidgetSetup: + @pytest.fixture(autouse=True) + def _with_mswms_server(self, mswms_server): + self.url = mswms_server + parsed_url = urllib.parse.urlparse(self.url) + self.scheme, self.host, self.port = parsed_url.scheme, parsed_url.hostname, parsed_url.port + def _setup(self, widget_type): wc.WMS_SERVICE_CACHE = {} - self.port = PORTS.pop() if widget_type == "hsec": self.view = HSecViewMockup() else: @@ -66,10 +67,6 @@ def _setup(self, widget_type): if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) QtTest.QTest.qWait(3000) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() if widget_type == "hsec": self.window = wc.HSecWMSControlWidget(view=self.view, wms_cache=self.tempdir) else: @@ -95,7 +92,6 @@ def _teardown(self): self.window.hide() QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): while len(self.window.multilayers.cbWMS_URL.currentText()) > 0: @@ -123,7 +119,7 @@ def test_no_server(self, mockbox): """ assert that a message box informs about server troubles """ - self.query_server("http://127.0.0.1:8882") + self.query_server(f"{self.scheme}://{self.host}:{self.port-1}") assert mockbox.critical.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox") @@ -131,7 +127,7 @@ def test_no_schema(self, mockbox): """ assert that a message box informs about server troubles """ - self.query_server(f"127.0.0.1:{self.port}") + self.query_server(f"{self.host}:{self.port}") assert mockbox.critical.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox") @@ -139,7 +135,7 @@ def test_invalid_schema(self, mockbox): """ assert that a message box informs about server troubles """ - self.query_server(f"hppd://127.0.0.1:{self.port}") + self.query_server(f"hppd://{self.host}:{self.port}") assert mockbox.critical.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox") @@ -147,7 +143,7 @@ def test_invalid_url(self, mockbox): """ assert that a message box informs about server troubles """ - self.query_server(f"http://???127.0.0.1:{self.port}") + self.query_server(f"{self.scheme}://???{self.host}:{self.port}") assert mockbox.critical.call_count == 1 @pytest.mark.skip("problem in urllib3") @@ -156,12 +152,12 @@ def test_connection_error(self, mockbox): """ assert that a message box informs about server troubles """ - self.query_server(f"http://.....127.0.0.1:{self.port}") + self.query_server(f"{self.scheme}://.....{self.host}:{self.port}") assert mockbox.critical.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_forward_backward_clicks(self, mockbox): - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) self.window.init_time_back_click() self.window.init_time_fwd_click() self.window.valid_time_fwd_click() @@ -183,7 +179,7 @@ def test_server_abort_getmap(self, mockbox): """ assert that an aborted getmap call does not change the displayed image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(20) @@ -201,7 +197,7 @@ def test_server_getmap(self, mockbox): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() @@ -218,7 +214,7 @@ def test_server_getmap_cached(self, mockbox): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() @@ -249,7 +245,7 @@ def test_server_service_cache(self, mockbox): """ assert that changing between servers still allows image retrieval """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) assert mockbox.critical.call_count == 0 QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) @@ -285,13 +281,13 @@ def test_multilayer_handling(self, mockbox): """ assert that multilayers get created, handled and drawn properly """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) assert server is not None - assert "header" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] - assert "wms" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] + assert "header" in self.window.multilayers.layers[f"{self.url}/"] + assert "wms" in self.window.multilayers.layers[f"{self.url}/"] self.window.multilayers.cbMultilayering.setChecked(True) for i in range(0, server.childCount()): @@ -327,13 +323,13 @@ def test_multilayer_handling(self, mockbox): @pytest.mark.skip("Fails testing reverse order") @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_filter_handling(self, mockbox): - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) assert server is not None - assert "header" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] - assert "wms" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] + assert "header" in self.window.multilayers.layers[f"{self.url}/"] + assert "wms" in self.window.multilayers.layers[f"{self.url}/"] starts_at = 40 * self.window.multilayers.scale icon_start_fav = starts_at + 3 @@ -367,7 +363,7 @@ def test_filter_handling(self, mockbox): QtTest.QTest.mouseMove(self.window.multilayers.listLayers, QtCore.QPoint(icon_start_del + 3, 0), -1) QtWidgets.QApplication.processEvents() self.window.multilayers.check_icon_clicked(server) - assert len(self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + assert len(self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)) == 0 assert mockbox.critical.call_count == 0 @@ -376,13 +372,13 @@ def test_singlelayer_handling(self, mockbox): """ assert that singlelayer mode behaves as expected """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) assert server is not None - assert "header" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] - assert "wms" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] + assert "header" in self.window.multilayers.layers[f"{self.url}/"] + assert "wms" in self.window.multilayers.layers[f"{self.url}/"] self.window.multilayers.cbMultilayering.setChecked(True) self.window.multilayers.cbMultilayering.setChecked(False) @@ -413,8 +409,8 @@ def test_multilayer_syncing(self, mockbox): """ assert that synced layers share their options """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) server.setExpanded(True) @@ -443,8 +439,8 @@ def test_multilayer_syncing(self, mockbox): @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.wms_control.WMSMapFetcher.moveToThread") def test_server_no_thread(self, mockbox, mockthread): - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) server.setExpanded(True) @@ -456,7 +452,7 @@ def test_server_no_thread(self, mockbox, mockthread): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - urlstr = f"http://127.0.0.1:{self.port}/mss/logo.png" + urlstr = f"{self.url}/mss/logo.png" md5_filname = os.path.join(self.window.wms_cache, hashlib.md5(urlstr.encode('utf-8')).hexdigest() + ".png") self.window.fetcher.fetch_legend(urlstr, use_cache=False, md5_filename=md5_filname) self.window.fetcher.fetch_legend(urlstr, use_cache=True, md5_filename=md5_filname) @@ -482,7 +478,7 @@ def test_server_getmap(self, mockbox): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) @@ -499,8 +495,8 @@ def test_multilayer_drawing(self, mockbox): """ assert that drawing a layer through code doesn't fail for vsec """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] server.child(0).draw() wait_until_signal(self.window.image_displayed) diff --git a/tests/_test_mswms/test_wms.py b/tests/_test_mswms/test_wms.py index cccb00ba3..937c900ad 100644 --- a/tests/_test_mswms/test_wms.py +++ b/tests/_test_mswms/test_wms.py @@ -35,20 +35,23 @@ import mslib.mswms.wms import mslib.mswms.gallery_builder -import mslib.mswms.mswms as mswms from importlib import reload from tests.utils import callback_ok_image, callback_ok_xml, callback_ok_html, callback_404_plain from tests.constants import DATA_DIR class Test_WMS: + @pytest.fixture(autouse=True) + def setup(self, mswms_app): + self.app = mswms_app + def test_get_query_string_missing_parameters(self): environ = { 'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=GetCapabilities'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_404_plain(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -60,7 +63,7 @@ def test_get_query_string_wrong_values(self): 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=GetCapabilities&service=WMS&version=1.4.0'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_404_plain(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -102,7 +105,7 @@ def test_get_capabilities(self): ) for tst_case in cases: - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(tst_case["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -112,7 +115,7 @@ def test_get_capabilities_lowercase(self): 'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=getcapabilities&service=wms&version=1.1.1'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -126,7 +129,7 @@ def test_produce_hsec_plot(self): 'request=GetMap&bgcolor=0xFFFFFF&height=376&dim_init_time=2012-10-17T12%3A00%3A00Z&width=479&' 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_image(result.status, result.headers) @@ -151,7 +154,7 @@ def test_produce_hsec_service_exception(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} query_string = environ["QUERY_STRING"] - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(query_string)) callback_ok_image(result.status, result.headers) assert result.data.count(b"ServiceExceptionReport") == 0, result @@ -185,7 +188,7 @@ def test_produce_vsec_plot(self): 'version=1.1.1&bbox=201%2C500.0%2C10%2C100.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C48.08%2C11.28&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_image(result.status, result.headers) @@ -212,7 +215,7 @@ def test_produce_vsec_service_exception(self): 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C48.08%2C11.28&transparent=FALSE'} query_string = environ["QUERY_STRING"] - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(query_string)) callback_ok_image(result.status, result.headers) assert result.data.count(b"ServiceExceptionReport") == 0, result @@ -243,7 +246,7 @@ def test_produce_lsec_plot(self): 'version=1.1.1&bbox=201&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C25000%2C48.08%2C11.28%2C25000'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) @@ -270,7 +273,7 @@ def test_produce_lsec_service_exception(self): 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C25000%2C48.08%2C11.28%2C25000'} query_string = environ["QUERY_STRING"] - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(query_string)) callback_ok_xml(result.status, result.headers) assert result.data.count(b"ServiceExceptionReport") == 0, result @@ -302,7 +305,7 @@ def test_application_request(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) assert isinstance(result.data, bytes), result @@ -316,7 +319,7 @@ def test_application_request_lowercase(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) assert isinstance(result.data, bytes), result @@ -327,7 +330,7 @@ def test_application_norequest(self): 'QUERY_STRING': '', } - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_html(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -339,7 +342,7 @@ def test_application_unkown_request(self): 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=abraham', } - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_404_plain(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -356,7 +359,7 @@ def test_multiple_images(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_image(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -371,7 +374,7 @@ def test_multiple_xml(self): 'version=1.1.1&bbox=201&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C25000%2C48.08%2C11.28%2C25000'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) @@ -397,7 +400,7 @@ def do_test(): 'exceptions=XML&transparent=FALSE'} pl_file = next(file for file in os.listdir(DATA_DIR) if ".pl" in file) - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) # Assert modified file was reloaded and now looks different diff --git a/tests/fixtures.py b/tests/fixtures.py index 9055dc371..24a9255e8 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -25,8 +25,20 @@ """ import pytest import mock +import multiprocessing +import time +import urllib +import mslib.mswms.mswms +import eventlet +import eventlet.wsgi from PyQt5 import QtWidgets +from contextlib import contextmanager +from mslib.mscolab.conf import mscolab_settings +from mslib.mscolab.server import APP, initialize_managers +from mslib.mscolab.mscolab import handle_db_init, handle_db_reset +from mslib.utils.config import modify_config_file +from tests.utils import is_url_response_ok @pytest.fixture @@ -60,3 +72,117 @@ def close_remaining_widgets(): @pytest.fixture def qapp(qapp, fail_if_open_message_boxes_left, close_remaining_widgets): yield qapp + + +@pytest.fixture(scope="session") +def mscolab_session_app(): + """Session-scoped fixture that provides the WSGI app instance for MSColab. + + This fixture should not be used in tests. Instead use :func:`mscolab_app`, which + handles per-test cleanup as well. + """ + _app = APP + _app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI + _app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR + _app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER + handle_db_init() + return _app + + +@pytest.fixture(scope="session") +def mscolab_session_managers(mscolab_session_app): + """Session-scoped fixture that provides the managers for the MSColab app. + + This fixture should not be used in tests. Instead use :func:`mscolab_managers`, + which handles per-test cleanup as well. + """ + return initialize_managers(mscolab_session_app)[1:] + + +@pytest.fixture(scope="session") +def mscolab_session_server(mscolab_session_app, mscolab_session_managers): + """Session-scoped fixture that provides a running MSColab server. + + This fixture should not be used in tests. Instead use :func:`mscolab_server`, which + handles per-test cleanup as well. + """ + with _running_eventlet_server(mscolab_session_app) as url: + yield url + + +@pytest.fixture +def reset_mscolab(mscolab_session_app): + """Cleans up before every test that uses MSColab. + + This fixture is not explicitly needed in tests, it is used in the other fixtures to + do the cleanup actions. + """ + handle_db_reset() + + +@pytest.fixture +def mscolab_app(mscolab_session_app, reset_mscolab): + """Fixture that provides the MSColab WSGI app instance and does cleanup actions. + + :returns: A WSGI app instance. + """ + return mscolab_session_app + + +@pytest.fixture +def mscolab_managers(mscolab_session_managers, reset_mscolab): + """Fixture that provides the MSColab managers and does cleanup actions. + + :returns: A tuple (SocketIO, ChatManager, FileManager) as returned by + initialize_managers. + """ + return mscolab_session_managers + + +@pytest.fixture +def mscolab_server(mscolab_session_server, reset_mscolab): + """Fixture that provides a running MSColab server and does cleanup actions. + + :returns: The URL where the server is running. + """ + # Update mscolab URL to avoid "Update Server List" message boxes + modify_config_file({"default_MSCOLAB": [mscolab_session_server]}) + return mscolab_session_server + + +@pytest.fixture(scope="session") +def mswms_app(): + """Fixture that provides the MSWMS WSGI app instance.""" + return mslib.mswms.mswms.application + + +@pytest.fixture(scope="session") +def mswms_server(mswms_app): + """Fixture that provides a running MSWMS server. + + :returns: The URL where the server is running. + """ + with _running_eventlet_server(mswms_app) as url: + yield url + + +@contextmanager +def _running_eventlet_server(app): + """Context manager that starts the app in an eventlet server and returns its URL.""" + scheme = "http" + host = "127.0.0.1" + socket = eventlet.listen((host, 0)) + port = socket.getsockname()[1] + url = f"{scheme}://{host}:{port}" + app.config['URL'] = url + ctx = multiprocessing.get_context("fork") + process = ctx.Process(target=eventlet.wsgi.server, args=(socket, app), daemon=True) + try: + process.start() + while not is_url_response_ok(urllib.parse.urljoin(url, "index")): + time.sleep(0.5) + yield url + finally: + process.terminate() + process.join(10) + process.close() diff --git a/tests/utils.py b/tests/utils.py index 5313d6fbd..8e4b226ee 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -28,20 +28,12 @@ import requests import time import fs -import socket -import multiprocessing - -from flask_testing import LiveServerTestCase from PyQt5 import QtTest from urllib.parse import urljoin from mslib.mscolab.server import register_user from flask import json from tests.constants import MSUI_CONFIG_PATH -from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.server import APP, initialize_managers, start_server -from mslib.mscolab.mscolab import handle_db_init -from mslib.utils.config import modify_config_file def callback_ok_image(status, response_headers): @@ -170,72 +162,17 @@ def mscolab_get_operation_id(app, msc_url, email, password, username, path): return p['op_id'] -def mscolab_check_free_port(all_ports, port): - _s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - _s.bind(("127.0.0.1", port)) - except (socket.error, IOError): - port = all_ports.pop() - port = mscolab_check_free_port(all_ports, port) - else: - _s.close() - return port +def create_msui_settings_file(content): + with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: + file_dir.writetext("msui_settings.json", content) -def mscolab_ping_server(port): - url = f"http://127.0.0.1:{port}/status" +def is_url_response_ok(url): try: - r = requests.get(url, timeout=(2, 10)) - data = json.loads(r.text) - if data['message'] == "Mscolab server" and isinstance(data['USE_SAML2'], bool): - return True - except requests.exceptions.ConnectionError: + response = requests.get(url) + return response.status_code == 200 + except: # noqa: E722 return False - return False - - -def mscolab_start_server(all_ports, mscolab_settings=mscolab_settings, timeout=10): - handle_db_init() - port = mscolab_check_free_port(all_ports, all_ports.pop()) - - url = f"http://localhost:{port}" - - # Update mscolab URL to avoid "Update Server List" message boxes - modify_config_file({"default_MSCOLAB": [url]}) - - _app = APP - _app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - _app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - _app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - _app.config['URL'] = url - - _app, sockio, cm, fm = initialize_managers(_app) - - # ToDo refactoring for spawn needed, fork is not implemented on windows, spawn is default on MAC and Windows - if multiprocessing.get_start_method(allow_none=True) != 'fork': - multiprocessing.set_start_method("fork") - process = multiprocessing.Process( - target=start_server, - args=(_app, sockio, cm, fm,), - kwargs={'port': port}) - process.start() - start_time = time.time() - while True: - elapsed_time = (time.time() - start_time) - if elapsed_time > timeout: - raise RuntimeError( - "Failed to start the server after %d seconds. " % timeout - ) - - if mscolab_ping_server(port): - break - - return process, url, _app, sockio, cm, fm - - -def create_msui_settings_file(content): - with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: - file_dir.writetext("msui_settings.json", content) def wait_until_signal(signal, timeout=5): @@ -273,32 +210,3 @@ def __init__(self, exc): def raise_exc(self, *args, **kwargs): raise self.exc - - -class LiveSocketTestCase(LiveServerTestCase): - - def _spawn_live_server(self): - self._process = None - port_value = self._port_value - app, sockio, cm, fm = initialize_managers(self.app) - self._process = multiprocessing.Process( - target=start_server, - args=(app, sockio, cm, fm,), - kwargs={'port': port_value.value}) - - self._process.start() - - # We must wait for the server to start listening, but give up - # after a specified maximum timeout - timeout = self.app.config.get('LIVESERVER_TIMEOUT', 5) - start_time = time.time() - - while True: - elapsed_time = (time.time() - start_time) - if elapsed_time > timeout: - raise RuntimeError( - "Failed to start the server after %d seconds. " % timeout - ) - - if self._can_ping_server(): - break From 9a1496182eec2c14bad5a391d83ee735f8e272f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Tue, 16 Jan 2024 16:12:02 +0100 Subject: [PATCH 097/176] Remove wrong config file modification (#2152) The functionality under test here is that MSS does update the config file with the new credentials when creating a user, therefore modifying the file in the test defeated its purpose. --- tests/_test_msui/test_mscolab.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 5b2eb0c1e..756ec53bd 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -212,7 +212,6 @@ def test_add_users_with_updating_credentials_in_config_file(self, mockmessage): assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" self._connect_to_mscolab() assert self.window.mscolab_server_url is not None - modify_config_file({"MSS_auth": {self.url: "anand@something.org"}}) self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings assert config_loader(dataset="MSS_auth").get(self.url) == "anand@something.org" From 68b77bd6e0e0f38eb6130936ee730e105546f9ad Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 16 Jan 2024 17:23:56 +0100 Subject: [PATCH 098/176] using an IDP, email is already validated, we activate the profile (#2119) * using an IDP, email is already validated, we activate the profile * add a delay of 1 sec --- mslib/mscolab/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index e4a485020..f59ab150f 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -220,7 +220,9 @@ def create_or_update_idp_user(email, username, token, authentication_backend): """ user = User.query.filter_by(emailid=email).first() if not user: - user = User(email, username, password=token, confirmed=False, confirmed_on=None, + # using an IDP for a new account/profile, e-mail is already verified by the IDP + confirm_time = datetime.datetime.now() + datetime.timedelta(seconds=1) + user = User(email, username, password=token, confirmed=True, confirmed_on=confirm_time, authentication_backend=authentication_backend) result = fm.modify_user(user, action="create") else: From fd2e1a5ce392aff4be2727388f7b18a2ebfc04cc Mon Sep 17 00:00:00 2001 From: Pranith_1604 <116240849+Beeram12@users.noreply.github.com> Date: Sat, 20 Jan 2024 17:45:41 +0530 Subject: [PATCH 099/176] refactor: Renamed USE_SAML2 to use_saml2 (#2155) * Renamed USE_SAML2 to use_saml2 * Update 1 --- mslib/mscolab/server.py | 6 +++--- mslib/msui/mscolab.py | 2 +- tests/_test_mscolab/test_server.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index f59ab150f..dd0b81538 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -244,18 +244,18 @@ def hello(): auth.login_required() return json.dumps({ 'message': "Mscolab server", - 'USE_SAML2': mscolab_settings.USE_SAML2, + 'use_saml2': mscolab_settings.USE_SAML2, 'direct_login': mscolab_settings.DIRECT_LOGIN }) return json.dumps({ 'message': "Mscolab server", - 'USE_SAML2': mscolab_settings.USE_SAML2, + 'use_saml2': mscolab_settings.USE_SAML2, 'direct_login': mscolab_settings.DIRECT_LOGIN }) else: return json.dumps({ 'message': "Mscolab server", - 'USE_SAML2': mscolab_settings.USE_SAML2, + 'use_saml2': mscolab_settings.USE_SAML2, 'direct_login': mscolab_settings.DIRECT_LOGIN }) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 008a1d2a8..7ecb18782 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -235,7 +235,7 @@ def connect_handler(self): self.loginPasswordLe.setEnabled(True) try: - idp_enabled = json.loads(r.text)["USE_SAML2"] + idp_enabled = json.loads(r.text)["use_saml2 "] except (json.decoder.JSONDecodeError, KeyError): idp_enabled = False diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 50e3b2c8b..502ada378 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -65,7 +65,7 @@ def test_hello(self): response = test_client.get('/status') data = json.loads(response.text) assert "Mscolab server" in data['message'] - assert True or False in data['USE_SAML2'] + assert True or False in data['use_saml2 '] def test_register_user(self): with self.app.test_client(): From bcd719356fcb198f1b7a2f97cb8a0744c697fe5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Sat, 20 Jan 2024 13:22:09 +0100 Subject: [PATCH 100/176] Remove unnecessary development dependencies (#2140) * Remove unnecessary development dependencies We have a GitHub Action that runs flake8, which in turn wraps PyFlakes, pycodestyle and McCabe. Since pep8 was renamed to pycodestyle those two are the same. Adding flake8 to the dependencies therefore means we can remove pep8 and pycodestyle. The pytest-pep8, which is really old, and pytest-flake8 plugins also become redundant since we have the CI action anyway. The pytest-cache plugin is redundant since it was integrated into pytest in 2015 (pytest v2.8.0). * Update development docs to use flake8 directly --- docs/development.rst | 4 +--- requirements.d/development.txt | 6 +----- setup.cfg | 18 ------------------ 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index f920c1b0c..80a594076 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -342,9 +342,7 @@ Use the -v option to get a verbose result. By the -k option you could select one Verify Code Style ................. -A flake8 only test is done by `py.test --flake8 -m flake8` or `pytest --flake8 -m flake8` - -Instead of running a ibrary module as a script by the -m option you may also use the pytest command. +A flake8 only test is done with `flake8 mslib tests`. Coverage ........ diff --git a/requirements.d/development.txt b/requirements.d/development.txt index 07f90dca1..c34b4fdb9 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -1,15 +1,11 @@ # This file may be used to create an environment using: # $ conda create --name --file # platform: linux-64 -pep8 +flake8 flake8-builtins py mock -pycodestyle pytest -pytest-cache -pytest-pep8 -pytest-flake8 pytest-qt pytest-xdist pytest-cov diff --git a/setup.cfg b/setup.cfg index 0ae5bec02..8377ca717 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,25 +34,7 @@ omit = tests/* [tool:pytest] -addopts = --flake8 -flake8-max-line-length = 120 -flake8-ignore = - *.py E402 W504 N801 N802 N803 N805 N806 N813 - conftest.py F821 - setup.py F821 - docs/conf.py ALL - mslib/__init__.py F401 - mslib/msui/mss_qt.py F401 # lots of imports for importing code - mslib/msui/mss_pyui.py F401 # nappy imported for testing - mslib/msui/qt5/*.py ALL # ignore all pyuic5 created files -pep8maxlinelength = 120 norecursedirs = .git .idea .cache -pep8ignore = - *.py E402 # futurize requires some code between imports - *.py E124 # closing bracket does not match visual indentation (behaves strange!?) - *.py E125 # continuation line does not distinguish itself from next logical line (difficult to avoid!) - mslib/msui/qt5/*.py ALL # ignore all pyuic5 created files - docs/conf.py ALL [pycodestyle] ignore = E124,E125,E402,W504 From 2d224209a5fee2ef1267446099690ee37a9b63ea Mon Sep 17 00:00:00 2001 From: Rohit prasad Date: Wed, 24 Jan 2024 13:13:32 +0530 Subject: [PATCH 101/176] Fix: ci issues and cherry-pick from stable branch. (#2163) * fix: the flake8 max line failure (#2134) * Fix additional issue --- .github/workflows/python-flake8.yml | 2 +- tests/_test_msui/test_mscolab_merge_waypoints.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index 99ec6eb87..94ef3b712 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -31,4 +31,4 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 flake8-builtins - flake8 --count --max-line-length=127 --statistics mslib tests + flake8 --count --statistics mslib tests diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 3e83b1596..2c841d00e 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -132,7 +132,9 @@ def test_save_overwrite_to_server(self, mockbox): assert wp_server_before.lat != wp_local_before.lat # trigger save to server action from server options combobox - with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickOverwriteMscolabMergeWaypointsDialog): + with mock.patch( + "mslib.msui.mscolab.MscolabMergeWaypointsDialog", + AutoClickOverwriteMscolabMergeWaypointsDialog): self.window.serverOptionsCb.setCurrentIndex(2) QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server From f3b6b50203f89639fa750962c54a8bcf1267b77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:36:50 +0100 Subject: [PATCH 102/176] Remove test retries in CI (#2153) * Do not retry failed tests in CI Tests that do not consistently pass are broken tests and should be fixed. Failed tests should not be retried to avoid accidentally adding new broken tests. * Remove some race conditions in tests Most Qt related actions are asynchronous by design. Just doing them and then asserting the result therefore is a race condition. Having a qWait or processEvents call is also not enough, since it is not guaranteed that they will ensure the Qt action to have happened. A qWait will also always be either too long or too short. The pytest-qt plugin instead provides multiple different wait methods that wait until a given event has happened (e.g. a signal was emitted or a assertion callback no longer errors) or a timeout is reached, giving the Qt event loop time to do its thing in between the checks. These methods do not suffer from the same race conditions and should be used instead. * Increase sio.sleep duration There was an issue in CI where it was possible that the get_messages call for the current timestamp returned both messages instead of none. Maybe this was because not enough time elapsed between creating the messages and getting them. * Add MSCOLAB_SSO_DIR to generated test config This should fix a bug in CI where it was possible that this path was still in the home directory instead of in a temporary one. * Skip problematic tests * Give Qt some time to handle its events In these tests in test_mscolab_admin_window.py it is possible that a late signal triggers a call to render_new_permission, at which point the mscolab_server_url is unset, which in turn leads to a TypeError in the tests teardown. Adding a qWait at the ends of these tests should reduce the likelihood of this issue, but won't eliminate it completely. I have no idea what to wait for instead to fix this properly though. --- .github/workflows/testing.yml | 12 +- .github/workflows/testing_gsoc.yml | 13 +- conftest.py | 1 + tests/_test_mscolab/test_sockets_manager.py | 2 +- tests/_test_msui/test_linearview.py | 7 +- tests/_test_msui/test_mscolab.py | 153 +++++++++--------- tests/_test_msui/test_mscolab_admin_window.py | 3 + .../test_mscolab_merge_waypoints.py | 91 ++++++----- tests/_test_msui/test_mscolab_operation.py | 65 ++++---- .../test_mscolab_version_history.py | 25 +-- tests/_test_msui/test_sideview.py | 8 +- tests/_test_msui/test_wms_control.py | 23 ++- 12 files changed, 208 insertions(+), 195 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index cd8ac3029..e95d0ee07 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -81,11 +81,7 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-${{ inputs.branch_name }}-env \ - && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests \ - || (for i in {1..5} \ - ; do xvfb-run pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ - && break \ - ; done) + && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests - name: Run tests in parallel if: ${{ success() && inputs.xdist == 'yes' }} @@ -95,11 +91,7 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-${{ inputs.branch_name }}-env \ - && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 4 tests \ - || (for i in {1..5} \ - ; do xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ - && break \ - ; done) + && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 4 tests - name: Collect coverage if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && inputs.xdist == 'no'}} diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml index b6646731d..0e0eab745 100644 --- a/.github/workflows/testing_gsoc.yml +++ b/.github/workflows/testing_gsoc.yml @@ -69,12 +69,7 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-develop-env \ - && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests \ - || (for i in {1..5} \ - ; do xvfb-run pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ - && break \ - ; done) - + && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests - name: Run tests in parallel if: ${{ success() }} @@ -84,11 +79,7 @@ jobs: && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-develop-env \ - && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests \ - || (for i in {1..5} \ - ; do xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ - && break \ - ; done) + && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests - name: Collect coverage if: ${{ success() }} diff --git a/conftest.py b/conftest.py index 818483ec3..083a9092c 100644 --- a/conftest.py +++ b/conftest.py @@ -122,6 +122,7 @@ def generate_initial_config(): DATA_DIR = fs.path.join(ROOT_DIR, 'colabTestData') # mscolab data directory MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') +MSCOLAB_SSO_DIR = fs.path.join(DATA_DIR, 'datasso') # In the unit days when Operations get archived because not used ARCHIVE_THRESHOLD = 30 diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index 682eda8f0..c6da2b293 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -185,7 +185,7 @@ def test_get_messages(self): "message_text": "message from 1", "reply_id": -1 }) - sio.sleep(1) + sio.sleep(5) with self.app.app_context(): messages = self.cm.get_messages(1) assert messages[0]["text"] == "message from 1" diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 070446bdf..3c49c7a1e 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -141,12 +141,11 @@ def query_server(self, url): wait_until_signal(self.wms_control.cpdlg.canceled) @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self, mockbox, qtbot): """ assert that a getmap call to a WMS server displays an image """ self.query_server(self.url) - QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.wms_control.image_displayed) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 756ec53bd..b4f749a60 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -288,6 +288,8 @@ def setup(self, qapp, mscolab_app, mscolab_server): self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() + + self.total_created_operations = 0 yield self.window.mscolab.logout() if self.window.mscolab.version_window: @@ -406,10 +408,10 @@ def test_work_locally_toggle(self): @pytest.mark.skip("fails often on github on a timeout >60s") @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") @mock.patch("mslib.msui.mscolab.get_open_filename", return_value=os.path.join(sample_path, u"example.ftml")) - def test_browse_add_operation(self, mockopen, mockmessage): + def test_browse_add_operation(self, mockopen, mockmessage, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") + self._create_user(qtbot, "something", "something@something.org", "something") assert self.window.listOperationsMSC.model().rowCount() == 0 self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() @@ -432,53 +434,39 @@ def test_browse_add_operation(self, mockopen, mockmessage): assert item.operation_path == "example" assert item.access_level == "creator" - def test_add_operation(self): + def test_add_operation(self, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") - assert self.window.usernameLabel.text() == 'something' - assert self.window.connectBtn.isVisible() is False - with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: - self._create_operation("Alpha", "Description Alpha") - m.assert_called_once_with( - self.window, - "Creation successful", - "Your operation was created successfully.", - ) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "Alpha", "Description Alpha") with (mock.patch("PyQt5.QtWidgets.QLineEdit.text", return_value=None), mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m): - self._create_operation("Alpha2", "Description Alpha") + self._create_operation_unchecked("Alpha2", "Description Alpha") m.assert_called_once_with("Path can't be empty") with (mock.patch("PyQt5.QtWidgets.QTextEdit.toPlainText", return_value=None), mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m): - self._create_operation("Alpha3", "Description Alpha") + self._create_operation_unchecked("Alpha3", "Description Alpha") m.assert_called_once_with("Description can't be empty") with mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m: - self._create_operation("/", "Description Alpha") + self._create_operation_unchecked("/", "Description Alpha") m.assert_called_once_with("Path can't contain spaces or special characters") - assert self.window.listOperationsMSC.model().rowCount() == 1 - with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: - self._create_operation("reproduce-test", "Description Test") - m.assert_called_once() - assert self.window.listOperationsMSC.model().rowCount() == 2 + self._create_operation(qtbot, "reproduce-test", "Description Test") self._activate_operation_at_index(0) assert self.window.mscolab.active_operation_name == "Alpha" self._activate_operation_at_index(1) assert self.window.mscolab.active_operation_name == "reproduce-test" @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)) - def test_handle_delete_operation(self, mocktext): + def test_handle_delete_operation(self, mocktext, qtbot): # pytest.skip('needs a review for the delete button pressed. Seems to delete a None operation') self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) - self._create_user("berta", "berta@something.org", "something") + self._create_user(qtbot, "berta", "berta@something.org", "something") assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 operation_name = "flight7" - with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: - self._create_operation(operation_name, "Description flight7") - m.assert_called_once() + self._create_operation(qtbot, operation_name, "Description flight7") # check for operation dir is created on server assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) self._activate_operation_at_index(0) @@ -488,7 +476,9 @@ def test_handle_delete_operation(self, mocktext): with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionDeleteOperation.trigger() QtWidgets.QApplication.processEvents() - m.assert_called_once_with(self.window, "Success", 'Operation "flight7" was deleted!') + qtbot.wait_until( + lambda: m.assert_called_once_with(self.window, "Success", 'Operation "flight7" was deleted!') + ) op_id = self.window.mscolab.get_recent_op_id() assert op_id is None QtWidgets.QApplication.processEvents() @@ -497,7 +487,7 @@ def test_handle_delete_operation(self, mocktext): assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) is False @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_handle_leave_operation(self, mockmessage): + def test_handle_leave_operation(self, mockmessage, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: self.userdata3[0]}}) @@ -522,18 +512,19 @@ def test_handle_leave_operation(self, mockmessage): self.window.actionLeaveOperation.trigger() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) - assert self.window.mscolab.active_op_id is None - assert self.window.listViews.count() == 0 - assert self.window.listOperationsMSC.model().rowCount() == 0 + + def assert_leave_operation_done(): + assert self.window.mscolab.active_op_id is None + assert self.window.listViews.count() == 0 + assert self.window.listOperationsMSC.model().rowCount() == 0 + qtbot.wait_until(assert_leave_operation_done) @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_name", True)) - def test_handle_rename_operation(self, mocktext): + def test_handle_rename_operation(self, mocktext, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") - with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: - self._create_operation("flight1234", "Description flight1234") - m.assert_called_once() + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None @@ -546,13 +537,11 @@ def test_handle_rename_operation(self, mocktext): assert self.window.mscolab.active_operation_name == "new_name" @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_description", True)) - def test_update_description(self, mocktext): + def test_update_description(self, mocktext, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") - with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: - self._create_operation("flight1234", "Description flight1234") - m.assert_called_once() + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None @@ -565,13 +554,11 @@ def test_update_description(self, mocktext): assert self.window.mscolab.active_operation_description == "new_description" @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_category", True)) - def test_update_category(self, mocktext): + def test_update_category(self, mocktext, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") - with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: - self._create_operation("flight1234", "Description flight1234") - m.assert_called_once() + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 assert self.window.mscolab.active_operation_category == "example" self._activate_operation_at_index(0) @@ -585,13 +572,13 @@ def test_update_category(self, mocktext): assert self.window.mscolab.active_operation_category == "new_category" @mock.patch("PyQt5.QtWidgets.QMessageBox.information") - def test_any_special_category(self, mockbox): + def test_any_special_category(self, mockbox, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") QtTest.QTest.qWait(0) - self._create_operation("flight5678", "Description flight5678", category="furtherexample") + self._create_operation(qtbot, "flight5678", "Description flight5678", category="furtherexample") # all operations of two defined categories are found assert self.window.mscolab.selected_category == "*ANY*" operation_pathes = [self.window.mscolab.ui.listOperationsMSC.item(i).operation_path for i in @@ -606,42 +593,42 @@ def test_any_special_category(self, mockbox): assert ["flight5678"] == operation_pathes @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) - def test_get_recent_op_id(self, mockbox): + def test_get_recent_op_id(self, mockbox, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "anton@something.org"}}) - self._create_user("anton", "anton@something.org", "something") + self._create_user(qtbot, "anton", "anton@something.org", "something") QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'anton' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 - self._create_operation("flight2", "Description flight2") + self._create_operation(qtbot, "flight2", "Description flight2") current_op_id = self.window.mscolab.get_recent_op_id() - self._create_operation("flight3", "Description flight3") - self._create_operation("flight4", "Description flight4") + self._create_operation(qtbot, "flight3", "Description flight3") + self._create_operation(qtbot, "flight4", "Description flight4") # ToDo fix number after cleanup initial data assert self.window.mscolab.get_recent_op_id() == current_op_id + 2 @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) - def test_get_recent_operation(self, mockbox): + def test_get_recent_operation(self, mockbox, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) - self._create_user("berta", "berta@something.org", "something") + self._create_user(qtbot, "berta", "berta@something.org", "something") QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 - self._create_operation("flight1234", "Description flight1234") + self._create_operation(qtbot, "flight1234", "Description flight1234") self._activate_operation_at_index(0) operation = self.window.mscolab.get_recent_operation() assert operation["path"] == "flight1234" assert operation["access_level"] == "creator" @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) - def test_open_chat_window(self, mockbox): + def test_open_chat_window(self, mockbox, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None @@ -651,12 +638,11 @@ def test_open_chat_window(self, mockbox): assert self.window.mscolab.chat_window is not None @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) - def test_close_chat_window(self, mockbox): + def test_close_chat_window(self, mockbox, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") - assert self.window.listOperationsMSC.model().rowCount() == 1 + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None self.window.actionChat.trigger() @@ -665,24 +651,24 @@ def test_close_chat_window(self, mockbox): assert self.window.mscolab.chat_window is None @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) - def test_delete_operation_from_list(self, mockbox): + def test_delete_operation_from_list(self, mockbox, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "other@something.org"}}) - self._create_user("other", "other@something.org", "something") + self._create_user(qtbot, "other", "other@something.org", "something") assert self.window.usernameLabel.text() == 'other' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 - self._create_operation("flight3", "Description flight3") + self._create_operation(qtbot, "flight3", "Description flight3") self._activate_operation_at_index(0) op_id = self.window.mscolab.get_recent_op_id() self.window.mscolab.delete_operation_from_list(op_id) assert self.window.mscolab.active_op_id is None @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_user_delete(self, mockmessage): + def test_user_delete(self, mockmessage, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") + self._create_user(qtbot, "something", "something@something.org", "something") u_id = self.window.mscolab.user['id'] self.window.mscolab.open_profile_window() QtTest.QTest.mouseClick(self.window.mscolab.profile_dialog.deleteAccountBtn, QtCore.Qt.LeftButton) @@ -726,10 +712,10 @@ def test_create_dir_exceptions(self, mockexit, mockbox): assert mockexit.call_count == 2 @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_profile_dialog(self, mockbox): + def test_profile_dialog(self, mockbox, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) - self._create_user("something", "something@something.org", "something") + self._create_user(qtbot, "something", "something@something.org", "something") self.window.mscolab.profile_action.trigger() QtWidgets.QApplication.processEvents() # case: default gravatar is set and no messagebox is called @@ -756,7 +742,7 @@ def _login(self, emailid, password): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) - def _create_user(self, username, email, password): + def _create_user(self, qtbot, username, email, password): QtTest.QTest.mouseClick(self.connect_window.addUserBtn, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() self.connect_window.newUsernameLe.setText(str(username)) @@ -771,7 +757,12 @@ def _create_user(self, username, email, password): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - def _create_operation(self, path, description, category="example"): + def assert_user_created(): + assert self.window.usernameLabel.text() == username + assert self.window.connectBtn.isVisible() is False + qtbot.wait_until(assert_user_created) + + def _create_operation_unchecked(self, path, description, category="example"): self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.path.setText(str(path)) @@ -785,6 +776,20 @@ def _create_operation(self, path, description, category="example"): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() + def _create_operation(self, qtbot, path, description, category="example"): + self.total_created_operations = self.total_created_operations + 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation_unchecked(path, description, category) + m.assert_called_once_with( + self.window, + "Creation successful", + "Your operation was created successfully.", + ) + + def assert_operation_is_listed(): + assert self.window.listOperationsMSC.model().rowCount() == self.total_created_operations + qtbot.wait_until(assert_operation_is_listed) + def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index b1a04170e..1e73b9373 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -151,6 +151,7 @@ def test_add_permissions(self): self._check_users_present(self.admin_window.modifyUsersTable, users, "admin") assert len_unadded_users - 2 == self.admin_window.addUsersTable.rowCount() assert len_added_users + 2 == self.admin_window.modifyUsersTable.rowCount() + QtTest.QTest.qWait(1000) def test_modify_permissions(self): users = ["name1", "name2"] @@ -167,6 +168,7 @@ def test_modify_permissions(self): QtWidgets.QApplication.processEvents() # Check if the permission has been updated self._check_users_present(self.admin_window.modifyUsersTable, users, "viewer") + QtTest.QTest.qWait(1000) def test_delete_permissions(self): # Select users in the add users table @@ -186,6 +188,7 @@ def test_delete_permissions(self): self._check_users_present(self.admin_window.addUsersTable, users) assert len_unadded_users + 2 == self.admin_window.addUsersTable.rowCount() assert len_added_users - 2 == self.admin_window.modifyUsersTable.rowCount() + QtTest.QTest.qWait(1000) def test_import_permissions(self): index = self.admin_window.importPermissionsCB.findText("paris", QtCore.Qt.MatchFixedString) diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 2c841d00e..72edb3cf7 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -116,38 +116,45 @@ def __init__(self, *args, **kwargs): class Test_Overwrite_To_Server(Test_Mscolab_Merge_Waypoints): @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_overwrite_to_server(self, mockbox): + def test_save_overwrite_to_server(self, mockbox, qtbot): self.emailid = "save_overwrite@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_local.lat == wp_server_before.lat + + def assert_(): + wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_local.lat == wp_server_before.lat + qtbot.wait_until(assert_) + self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) + + def assert_(): + wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_server_before.lat != wp_local_before.lat + qtbot.wait_until(assert_) wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_server_before.lat != wp_local_before.lat # trigger save to server action from server options combobox with mock.patch( "mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickOverwriteMscolabMergeWaypointsDialog): self.window.serverOptionsCb.setCurrentIndex(2) - QtWidgets.QApplication.processEvents() - # get the updated waypoints model from the server - # ToDo understand why requesting in follow up test self.window.waypoints_model not working - server_xml = self.window.mscolab.request_wps_from_server() - server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) - new_local_wp = server_waypoints_model.waypoint_data(0) - assert wp_local_before.lat == new_local_wp.lat + + def assert_(): + # get the updated waypoints model from the server + server_xml = self.window.mscolab.request_wps_from_server() + server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) + new_local_wp = server_waypoints_model.waypoint_data(0) + assert wp_local_before.lat == new_local_wp.lat + qtbot.wait_until(assert_) + self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_local_before.lat == new_server_wp.lat + + def assert_(): + new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_local_before.lat == new_server_wp.lat + qtbot.wait_until(assert_) class AutoClickKeepMscolabMergeWaypointsDialog(mslib.msui.mscolab.MscolabMergeWaypointsDialog): @@ -158,38 +165,44 @@ def __init__(self, *args, **kwargs): class Test_Save_Keep_Server_Points(Test_Mscolab_Merge_Waypoints): @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_keep_server_points(self, mockbox): + def test_save_keep_server_points(self, mockbox, qtbot): self.emailid = "save_keepe@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_local.lat == wp_server_before.lat + + def assert_(): + wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_local.lat == wp_server_before.lat + qtbot.wait_until(assert_) + self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) + + def assert_(): + wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_server_before.lat != wp_local_before.lat + qtbot.wait_until(assert_) wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_server_before.lat != wp_local_before.lat # trigger save to server action from server options combobox with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog): self.window.serverOptionsCb.setCurrentIndex(2) - QtWidgets.QApplication.processEvents() - # get the updated waypoints model from the server - # ToDo understand why requesting in follow up test self.window.waypoints_model not working - server_xml = self.window.mscolab.request_wps_from_server() - server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) - new_local_wp = server_waypoints_model.waypoint_data(0) - assert wp_local_before.lat != new_local_wp.lat - assert new_local_wp.lat == wp_server_before.lat + def assert_(): + # get the updated waypoints model from the server + server_xml = self.window.mscolab.request_wps_from_server() + server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) + new_local_wp = server_waypoints_model.waypoint_data(0) + assert wp_local_before.lat != new_local_wp.lat + assert new_local_wp.lat == wp_server_before.lat + qtbot.wait_until(assert_) + self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_server_before.lat == new_server_wp.lat + + def assert_(): + new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_server_before.lat == new_server_wp.lat + qtbot.wait_until(assert_) class Test_Fetch_From_Server(Test_Mscolab_Merge_Waypoints): diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 951636a3b..14f120003 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -81,15 +81,15 @@ def setup(self, qapp, mscolab_app, mscolab_server): self.window.hide() QtWidgets.QApplication.processEvents() - def test_send_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_send_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") with self.app.app_context(): assert Message.query.filter_by(text='**test message**').count() == 2 - def test_search_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_search_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") message_index = self.chat_window.messageList.count() - 1 # self.window.chat_window.searchMessageLineEdit.setText("test message") self.chat_window.searchMessageLineEdit.setText("test message") @@ -104,38 +104,42 @@ def test_search_message(self): QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index).isSelected() is True - def test_copy_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_copy_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") self._activate_context_menu_action(Actions.COPY) assert QtWidgets.QApplication.clipboard().text() == "**test message**" - def test_reply_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_reply_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") parent_message_id = self._get_message_id(self.chat_window.messageList.count() - 1) self._activate_context_menu_action(Actions.REPLY) self.chat_window.messageText.setPlainText('test reply') QtTest.QTest.mouseClick(self.chat_window.sendMessageBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(100) - with self.app.app_context(): - message = Message.query.filter_by(text='test reply') - assert message.count() == 1 - assert message.first().reply_id == parent_message_id - def test_edit_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def assert_(): + with self.app.app_context(): + message = Message.query.filter_by(text='test reply') + assert message.count() == 1 + assert message.first().reply_id == parent_message_id + qtbot.wait_until(assert_) + + def test_edit_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") self._activate_context_menu_action(Actions.EDIT) self.chat_window.messageText.setPlainText('test edit') QtTest.QTest.mouseClick(self.chat_window.editMessageBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(100) - with self.app.app_context(): - assert Message.query.filter_by(text='test edit').count() == 1 - def test_delete_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def assert_(): + with self.app.app_context(): + assert Message.query.filter_by(text='test edit').count() == 1 + qtbot.wait_until(assert_) + + def test_delete_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") self._activate_context_menu_action(Actions.DELETE) QtTest.QTest.qWait(100) with self.app.app_context(): @@ -170,11 +174,14 @@ def _activate_context_menu_action(self, action_index): message_widget = self.chat_window.messageList.itemWidget(item) message_widget.context_menu.actions()[action_index].trigger() - def _send_message(self, text): + def _send_message(self, qtbot, text): + num_messages_before = self.chat_window.messageList.count() self.chat_window.messageText.setPlainText(text) QtTest.QTest.mouseClick(self.chat_window.sendMessageBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) + + def assert_(): + assert self.chat_window.messageList.count() == num_messages_before + 1 + qtbot.wait_until(assert_) def _get_message_id(self, index): item = self.chat_window.messageList.item(index) diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 5036c7e51..bffb76b0e 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -71,20 +71,18 @@ def setup(self, qapp, mscolab_server): if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - def test_changes(self): + def test_changes(self, qtbot): self._change_version_filter(1) len_prev = self.version_window.changes.count() # make a changes self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - self.version_window.load_all_changes() - QtWidgets.QApplication.processEvents() - len_after = self.version_window.changes.count() - assert len_prev == (len_after - 2) + + def assert_(): + self.version_window.load_all_changes() + len_after = self.version_window.changes.count() + assert len_prev == (len_after - 2) + qtbot.wait_until(assert_) @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) def test_set_version_name(self, mockbox): @@ -105,14 +103,19 @@ def test_version_name_delete(self, mockbox): assert self.version_window.changes.currentItem().version_name is None @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_undo_changes(self, mockbox): + def test_undo_changes(self, mockbox, qtbot): self._change_version_filter(1) + assert self.version_window.changes.count() == 0 # make changes for i in range(2): self.window.mscolab.waypoints_model.invert_direction() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) - self.version_window.load_all_changes() + + def assert_(): + self.version_window.load_all_changes() + assert self.version_window.changes.count() == 2 + qtbot.wait_until(assert_) QtWidgets.QApplication.processEvents() changes_count = self.version_window.changes.count() self._activate_change_at_index(1) diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 5aae94cd0..adefebb2d 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -198,15 +198,15 @@ def query_server(self, url): wait_until_signal(self.wms_control.cpdlg.canceled) @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self, mockbox, qtbot): """ assert that a getmap call to a WMS server displays an image """ self.query_server(self.url) - QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.wms_control.image_displayed) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) assert self.window.getView().plotter.image is not None + self.window.getView().plotter.clear_figure() assert self.window.getView().plotter.image is None assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index a97a65bfc..ff2f02b23 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -155,6 +155,7 @@ def test_connection_error(self, mockbox): self.query_server(f"{self.scheme}://.....{self.host}:{self.port}") assert mockbox.critical.call_count == 1 + @pytest.mark.skip("Breaks other tests in this class because of a lingering message box, for some reason") @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_forward_backward_clicks(self, mockbox): self.query_server(self.url) @@ -174,6 +175,7 @@ def test_forward_backward_clicks(self, mockbox): pass assert mockbox.critical.call_count == 0 + @pytest.mark.skip("Has a race condition where the abort might not happen fast enough") @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_server_abort_getmap(self, mockbox): """ @@ -193,15 +195,14 @@ def test_server_abort_getmap(self, mockbox): self.view.reset_mock() @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self, mockbox, qtbot): """ assert that a getmap call to a WMS server displays an image """ self.query_server(self.url) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 @@ -210,15 +211,14 @@ def test_server_getmap(self, mockbox): self.view.reset_mock() @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap_cached(self, mockbox): + def test_server_getmap_cached(self, mockbox, qtbot): """ assert that a getmap call to a WMS server displays an image """ self.query_server(self.url) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) # assert mockbox.critical.call_count == 0 @@ -473,15 +473,14 @@ def setup(self, qapp): self._teardown() @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self, mockbox, qtbot): pytest.skip("unknown problem") """ assert that a getmap call to a WMS server displays an image """ self.query_server(self.url) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 From 95a152da072bd1b96ce7f6f49f3d30872114ab4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 29 Jan 2024 14:15:39 +0100 Subject: [PATCH 103/176] Simplify mslib.utils.qt.Worker mock (#2167) --- tests/_test_msui/test_updater.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tests/_test_msui/test_updater.py b/tests/_test_msui/test_updater.py index c2caa3161..5670ec952 100644 --- a/tests/_test_msui/test_updater.py +++ b/tests/_test_msui/test_updater.py @@ -53,17 +53,7 @@ def __init__(self, args=None, **named_args): self.args = args -def create_mock(function, on_success=None, on_failure=None, start=True): - worker = Worker(function) - if on_success: - worker.finished.connect(on_success) - if on_failure: - worker.failed.connect(on_failure) - if start: - worker.run() - return worker - - +@mock.patch("mslib.utils.qt.Worker.start", Worker.run) class Test_MSS_ShortcutDialog: @pytest.fixture(autouse=True) def setup(self, qapp): @@ -89,7 +79,6 @@ def status_signal(s): @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) @mock.patch("subprocess.run", new=SubprocessDifferentVersionMock) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_update_recognised(self): self.updater.run() @@ -103,7 +92,6 @@ def test_update_recognised(self): @mock.patch("subprocess.Popen", new=SubprocessSameMock) @mock.patch("subprocess.run", new=SubprocessSameMock) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_no_update(self): self.updater.run() assert self.status == "Your MSS is up to date." @@ -112,7 +100,6 @@ def test_no_update(self): @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) @mock.patch("subprocess.run", new=SubprocessDifferentVersionMock) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_update_failed(self): self.updater.run() assert self.updater.new_version == "999.999.999" @@ -124,7 +111,6 @@ def test_update_failed(self): @mock.patch("subprocess.Popen", new=no_conda) @mock.patch("subprocess.run", new=no_conda) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_no_conda(self): self.updater.run() assert self.updater.new_version is None and self.updater.old_version is None @@ -133,7 +119,6 @@ def test_no_conda(self): @mock.patch("subprocess.Popen", new=no_conda) @mock.patch("subprocess.run", new=no_conda) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_exception(self): self.updater.new_version = "999.999.999" self.updater.old_version = "999.999.999" @@ -144,7 +129,6 @@ def test_exception(self): @mock.patch("subprocess.Popen", new=SubprocessSameMock) @mock.patch("subprocess.run", new=SubprocessSameMock) @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Yes) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_ui(self, mock): ui = UpdaterUI() ui.updater.on_update_available.emit("", "") From dba5fc4810bd9a3624f2a28942bfccebfc6c0120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:04:08 +0100 Subject: [PATCH 104/176] Fix ConversionError with pint=0.23 (#2168) The error happens when trying to use a pint Quantity in a matplotlib axis that has no units set, because the attempted conversion to `units=None` fails. This change sets the units of the axis to be the same as the ticks' units, but only if the ticks have units set and the axis does not. This is effectively a fallback to the Quantity's units if nothing else is set. --- localbuild/meta.yaml | 2 +- mslib/msui/mpl_qtwidget.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index af1baf62a..e475c87cf 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -77,7 +77,7 @@ requirements: - PyMySQL >=0.9.3 - validate_email - multidict - - pint <=0.22 + - pint - python-socketio >=5 - python-engineio >=4 - markdown diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index 320c0dd7e..9dfc7b050 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -475,6 +475,10 @@ def redraw_yaxis(self): for ax, typ in zip((self.ax, self.ax2), (vaxis, vaxis2)): ylabel, major_ticks, minor_ticks, labels = self._determine_ticks_labels(typ) + major_ticks_units = getattr(major_ticks, "units", None) + if ax.yaxis.units is None and major_ticks_units is not None: + ax.yaxis.set_units(major_ticks_units) + ax.set_ylabel(ylabel, fontsize=plot_title_size) ax.set_yticks(minor_ticks, minor=True) ax.set_yticks(major_ticks, minor=False) From d5f142b85667c116cd89bb2602448a0af6ddbe79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:04:57 +0100 Subject: [PATCH 105/176] Consolidate skips due to start method "fork" (#2166) There is just one place where the start method "fork" is used, which is in tests.fixtures. By adding a conditional skip at that location all other skips for this reason become obsolete, since the tests will be automatically skipped if they request one of the fixtures that require this start method from multiprocessing and the start method is unsupported on the given system. --- tests/_test_mscolab/test_file_manager.py | 3 --- tests/_test_mscolab/test_files.py | 2 -- tests/_test_mscolab/test_files_api.py | 3 --- tests/_test_mscolab/test_server.py | 3 --- tests/_test_mscolab/test_server_auth_required.py | 3 --- tests/_test_mscolab/test_utils.py | 4 ---- tests/_test_msui/test_linearview.py | 2 -- tests/_test_msui/test_mscolab.py | 2 -- tests/_test_msui/test_mscolab_admin_window.py | 3 --- tests/_test_msui/test_mscolab_merge_waypoints.py | 3 --- tests/_test_msui/test_mscolab_operation.py | 3 --- tests/_test_msui/test_mscolab_save_merge_points.py | 3 --- tests/_test_msui/test_mscolab_version_history.py | 3 --- tests/_test_msui/test_sideview.py | 2 -- tests/_test_msui/test_topview.py | 2 -- tests/_test_msui/test_wms_control.py | 4 ---- tests/fixtures.py | 2 ++ 17 files changed, 2 insertions(+), 45 deletions(-) diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 1cdef6d5e..9e2841ffe 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import datetime import pytest @@ -32,8 +31,6 @@ from mslib.mscolab.seed import add_user, get_user -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_FileManager: @pytest.fixture(autouse=True) def setup(self, mscolab_app, mscolab_managers): diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index 3f3d5a90f..ee42c4687 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -34,8 +34,6 @@ from mslib.mscolab.utils import get_recent_op_id -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Files: @pytest.fixture(autouse=True) def setup(self, mscolab_app, mscolab_managers): diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 760c9915d..1523eb18e 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import fs import pytest @@ -32,8 +31,6 @@ from mslib.mscolab.seed import add_user, get_user -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Files: @pytest.fixture(autouse=True) def setup(self, mscolab_app, mscolab_managers): diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 502ada378..d7e8995c0 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import pytest import json import io @@ -36,8 +35,6 @@ from mslib.mscolab.seed import add_user, get_user -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Server: @pytest.fixture(autouse=True) def setup(self, mscolab_app): diff --git a/tests/_test_mscolab/test_server_auth_required.py b/tests/_test_mscolab/test_server_auth_required.py index c924634cb..05fbf46f1 100644 --- a/tests/_test_mscolab/test_server_auth_required.py +++ b/tests/_test_mscolab/test_server_auth_required.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import pytest from mslib.mscolab.conf import mscolab_settings @@ -37,8 +36,6 @@ "e.g. pytest tests/_test_mscolab/test_server_auth_required.py", allow_module_level=True) -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Server_Auth_Not_Valid: @pytest.fixture(autouse=True) def setup(self, mscolab_app): diff --git a/tests/_test_mscolab/test_utils.py b/tests/_test_mscolab/test_utils.py index fa8d9be6f..897da0672 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -50,8 +50,6 @@ def strftime(value): pass -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Utils: @pytest.fixture(autouse=True) def setup(self, mscolab_app, mscolab_managers): @@ -62,8 +60,6 @@ def setup(self, mscolab_app, mscolab_managers): with self.app.app_context(): yield - @pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") def test_get_recent_oid(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) assert add_user(self.anotheruserdata[0], self.anotheruserdata[1], self.anotheruserdata[2]) diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 3c49c7a1e..11c015a7c 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -103,8 +103,6 @@ def test_options(self, mockdlg, mockbox): assert mockdlg.return_value.destroy.call_count == 1 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_LinearViewWMS: @pytest.fixture(autouse=True) def setup(self, qapp, mswms_server): diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index b4f749a60..5474fe0b7 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -250,8 +250,6 @@ def _create_user(self, username, email, password): QtWidgets.QApplication.processEvents() -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Mscolab: sample_path = os.path.join(os.path.dirname(__file__), "..", "data") # import/export plugins diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index 1e73b9373..e29a3d1b0 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import mock import pytest @@ -36,8 +35,6 @@ from mslib.utils.config import modify_config_file -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_MscolabAdminWindow: @pytest.fixture(autouse=True) def setup(self, qapp, mscolab_server): diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 72edb3cf7..9342fb13d 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import fs import mock import pytest @@ -39,8 +38,6 @@ from mslib.msui import msui -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Mscolab_Merge_Waypoints: @pytest.fixture(autouse=True) def setup(self, qapp, mscolab_app, mscolab_server): diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 14f120003..fc216c865 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import pytest from mslib.mscolab.conf import mscolab_settings @@ -44,8 +43,6 @@ class Actions: DELETE = 4 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_MscolabOperation: @pytest.fixture(autouse=True) def setup(self, qapp, mscolab_app, mscolab_server): diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index 2ade4a11f..24c3ceb74 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import mock import pytest from tests._test_msui.test_mscolab_merge_waypoints import Test_Mscolab_Merge_Waypoints @@ -33,8 +32,6 @@ @pytest.mark.skip("Uses QTimer, which can break other unrelated tests") -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_save_merge_points(self, mockbox): diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index bffb76b0e..d29fb9695 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import pytest import mock @@ -36,8 +35,6 @@ from mslib.utils.config import modify_config_file -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_MscolabVersionHistory: @pytest.fixture(autouse=True) def setup(self, qapp, mscolab_server): diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index adefebb2d..8dc5d2627 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -160,8 +160,6 @@ def test_y_axes(self, mockbox): assert mockbox.critical.call_count == 0 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_SideViewWMS: @pytest.fixture(autouse=True) def setup(self, qapp, mswms_server): diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index fed0a753d..ad47e9d1e 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -283,8 +283,6 @@ def test_map_options(self, mockbox): assert mockbox.critical.call_count == 0 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_TopViewWMS: @pytest.fixture(autouse=True) def setup(self, qapp, mswms_server): diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index ff2f02b23..54c0063df 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -105,8 +105,6 @@ def query_server(self, url): wait_until_signal(self.window.cpdlg.canceled) -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_HSecWMSControlWidget(WMSControlWidgetSetup): @pytest.fixture(autouse=True) def setup(self, qapp): @@ -463,8 +461,6 @@ def test_server_no_thread(self, mockbox, mockthread): assert self.view.draw_metadata.call_count == 1 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_VSecWMSControlWidget(WMSControlWidgetSetup): @pytest.fixture(autouse=True) def setup(self, qapp): diff --git a/tests/fixtures.py b/tests/fixtures.py index 24a9255e8..bd8fee4b7 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -175,6 +175,8 @@ def _running_eventlet_server(app): port = socket.getsockname()[1] url = f"{scheme}://{host}:{port}" app.config['URL'] = url + if "fork" not in multiprocessing.get_all_start_methods(): + pytest.skip("requires the multiprocessing start_method 'fork', which is unavailable on this system") ctx = multiprocessing.get_context("fork") process = ctx.Process(target=eventlet.wsgi.server, args=(socket, app), daemon=True) try: From 6bf94d35b4d49f1c24a161ff61a96542f9e6ac8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 5 Feb 2024 15:15:14 +0100 Subject: [PATCH 106/176] Remove unnecessary config settings (#2171) Since pycodestyle is not used directly, but only as part of flake8, the flake8 settings apply already. Therefore it is unnecessary to duplicate these settings. --- setup.cfg | 5 ----- 1 file changed, 5 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8377ca717..20ed0d901 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,11 +36,6 @@ omit = [tool:pytest] norecursedirs = .git .idea .cache -[pycodestyle] -ignore = E124,E125,E402,W504 -max-line-length = 120 -exclude = mslib/msui/qt5/*.py - [flake8] ignore = E124,E125,E402,W504 max-line-length = 120 From 5a3114cb7c7685b01b13d547f5817f30bbf8bb05 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 8 Feb 2024 17:33:52 +0100 Subject: [PATCH 107/176] flake8 --- mslib/mscolab/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index f36f5e6ad..ecfe5c0f1 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -29,7 +29,6 @@ import logging import datetime import secrets -import fs import socketio import sqlalchemy.exc import werkzeug From 0a0499887c0612469c83659eca1c7c0e20cebd78 Mon Sep 17 00:00:00 2001 From: "Jets@ps2004" <120110796+Preetam-Das26@users.noreply.github.com> Date: Thu, 8 Feb 2024 23:17:09 +0530 Subject: [PATCH 108/176] Fix : UTC based timezone (#2177) --- mslib/mscolab/models.py | 2 +- mslib/mscolab/server.py | 4 ++-- tests/_test_mscolab/test_file_manager.py | 6 +++--- tests/_test_mscolab/test_sockets_manager.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 1ec9cb2a3..893c393a0 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -50,7 +50,7 @@ def __init__(self, emailid, username, password, confirmed=False, confirmed_on=No self.username = username self.emailid = emailid self.hash_password(password) - self.registered_on = datetime.datetime.now() + self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc) self.confirmed = confirmed self.confirmed_on = confirmed_on self.authentication_backend = authentication_backend diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index ecfe5c0f1..628982370 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -222,7 +222,7 @@ def create_or_update_idp_user(email, username, token, authentication_backend): user = User.query.filter_by(emailid=email).first() if not user: # using an IDP for a new account/profile, e-mail is already verified by the IDP - confirm_time = datetime.datetime.now() + datetime.timedelta(seconds=1) + confirm_time = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(seconds=1) user = User(email, username, password=token, confirmed=True, confirmed_on=confirm_time, authentication_backend=authentication_backend) result = fm.modify_user(user, action="create") @@ -338,7 +338,7 @@ def confirm_email(token): if user.confirmed: return render_template('user/confirmed.html', username=user.username) else: - fm.modify_user(user, attribute="confirmed_on", value=datetime.datetime.now()) + fm.modify_user(user, attribute="confirmed_on", value=datetime.datetime.now(tz=datetime.timezone.utc)) fm.modify_user(user, attribute="confirmed", value=True) return render_template('user/confirmed.html', username=user.username) diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 9e2841ffe..b9111a4ab 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -65,7 +65,7 @@ def test_modify_user(self): user = User("user@example.com", "user", "password") assert user.id is None assert User.query.filter_by(emailid=user.emailid).first() is None - # creeat the user + # create the user self.fm.modify_user(user, action="create") user_query = User.query.filter_by(emailid=user.emailid).first() assert user_query.id is not None @@ -74,12 +74,12 @@ def test_modify_user(self): # cannot create a user a second time assert self.fm.modify_user(user, action="create") is False # confirming the user - confirm_time = datetime.datetime.now() + datetime.timedelta(days=1) + confirm_time = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) self.fm.modify_user(user_query, attribute="confirmed_on", value=confirm_time) self.fm.modify_user(user_query, attribute="confirmed", value=True) user_query = User.query.filter_by(id=user.id).first() assert user_query.confirmed is True - assert user_query.confirmed_on == confirm_time + assert user_query.confirmed_on.replace(tzinfo=None) == confirm_time.replace(tzinfo=None) assert user_query.confirmed_on > user_query.registered_on # deleting the user self.fm.modify_user(user_query, action="delete") diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index c6da2b293..55cac4182 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -195,7 +195,7 @@ def test_get_messages(self): messages = self.cm.get_messages(1, timestamp) assert len(messages) == 2 assert messages[0]["u_id"] == self.user.id - timestamp = datetime.datetime.now().strftime("%Y-%m-%d, %H:%M:%S") + timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S") messages = self.cm.get_messages(1, timestamp) assert len(messages) == 0 From 3a0eb968516da8714361e1f284bd955715833cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:07:26 +0100 Subject: [PATCH 109/176] Remove the serial test suite execution from CI (#2158) --- .github/workflows/testing-develop.yml | 14 +------------ .github/workflows/testing-scheduled.yml | 13 ++++-------- .github/workflows/testing-stable.yml | 17 ++------------- .github/workflows/testing.yml | 28 +++++++++---------------- .github/workflows/testing_gsoc.yml | 20 +++++++----------- 5 files changed, 25 insertions(+), 67 deletions(-) diff --git a/.github/workflows/testing-develop.yml b/.github/workflows/testing-develop.yml index 8884a76d9..0f4860c4c 100644 --- a/.github/workflows/testing-develop.yml +++ b/.github/workflows/testing-develop.yml @@ -11,22 +11,10 @@ on: jobs: test-develop: - uses: + uses: ./.github/workflows/testing.yml with: - xdist: no - branch_name: develop - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} - - test-develop-xdist: - uses: - ./.github/workflows/testing.yml - with: - xdist: yes branch_name: develop event_name: ${{ github.event_name }} secrets: PAT: ${{ secrets.PAT }} - diff --git a/.github/workflows/testing-scheduled.yml b/.github/workflows/testing-scheduled.yml index f97be66e3..aefe7afaa 100644 --- a/.github/workflows/testing-scheduled.yml +++ b/.github/workflows/testing-scheduled.yml @@ -2,29 +2,24 @@ name: new dependency test (scheduled) on: schedule: - - cron: '30 5 * * 1' + - cron: '30 5 * * 1' -jobs: +jobs: test-stable-scheduled: - uses: + uses: ./.github/workflows/testing.yml with: - xdist: no branch_name: stable event_name: ${{ github.event_name }} secrets: PAT: ${{ secrets.PAT }} test-develop-scheduled: - uses: + uses: ./.github/workflows/testing.yml with: - xdist: no branch_name: develop event_name: ${{ github.event_name }} secrets: PAT: ${{ secrets.PAT }} - - - diff --git a/.github/workflows/testing-stable.yml b/.github/workflows/testing-stable.yml index 6f280a9b8..f9baca67f 100644 --- a/.github/workflows/testing-stable.yml +++ b/.github/workflows/testing-stable.yml @@ -8,25 +8,12 @@ on: branches: - stable -jobs: +jobs: test-stable: - uses: + uses: ./.github/workflows/testing.yml with: - xdist: no branch_name: stable event_name: ${{ github.event_name }} secrets: PAT: ${{ secrets.PAT }} - - test-stable-xdist: - uses: - ./.github/workflows/testing.yml - with: - xdist: yes - branch_name: stable - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} - - diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index e95d0ee07..f84eb5aee 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,9 +3,6 @@ name: Pytest MSS on: workflow_call: inputs: - xdist: - required: true - type: string branch_name: required: true type: string @@ -31,6 +28,11 @@ jobs: container: image: openmss/testing-${{ inputs.branch_name }} + strategy: + fail-fast: false + matrix: + order: ["normal", "reverse"] + steps: - name: Trust My Directory run: git config --global --add safe.directory /__w/MSS/MSS @@ -74,27 +76,17 @@ jobs: && mamba list - name: Run tests - if: ${{ success() && inputs.xdist == 'no' }} - timeout-minutes: 25 - run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests - - - name: Run tests in parallel - if: ${{ success() && inputs.xdist == 'yes' }} - timeout-minutes: 25 + timeout-minutes: 10 run: | cd $GITHUB_WORKSPACE \ && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-${{ inputs.branch_name }}-env \ - && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 4 tests + && xvfb-run pytest -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib \ + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage - if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && inputs.xdist == 'no'}} + if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && matrix.order == 'normal' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -106,7 +98,7 @@ jobs: && coveralls --service=github - name: Invoke dockertesting image creation - if: ${{ always() && inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && inputs.xdist == 'no'}} + if: ${{ always() && inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} uses: benc-uk/workflow-dispatch@v1.2.2 with: workflow: Update Image testing-${{ inputs.branch_name }} diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml index 0e0eab745..60336a0ab 100644 --- a/.github/workflows/testing_gsoc.yml +++ b/.github/workflows/testing_gsoc.yml @@ -24,6 +24,11 @@ jobs: container: image: openmss/testing-develop + strategy: + fail-fast: false + matrix: + order: ["normal", "reverse"] + steps: - name: Trust My Directory run: git config --global --add safe.directory /__w/MSS/MSS @@ -63,23 +68,14 @@ jobs: - name: Run tests if: ${{ success() }} - timeout-minutes: 25 - run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-develop-env \ - && xvfb-run pytest -v --durations=20 --reverse --cov=mslib tests - - - name: Run tests in parallel - if: ${{ success() }} - timeout-minutes: 25 + timeout-minutes: 10 run: | cd $GITHUB_WORKSPACE \ && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-develop-env \ - && xvfb-run pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests + && xvfb-run pytest -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib \ + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage if: ${{ success() }} From baa099e03aa2da67a664e1a8e7fe715cb2d375e7 Mon Sep 17 00:00:00 2001 From: Rohit prasad Date: Thu, 15 Feb 2024 19:27:45 +0530 Subject: [PATCH 110/176] Fix: Scheduled test now triggers testing-stable.yml using workflow_dispatch (#2196) * Fix scheduled test to trigger testing-stable workflow using workflow_dispatch * updated workflow_dispatch devlop * requested changes regarding space --- .github/workflows/testing-scheduled.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/testing-scheduled.yml b/.github/workflows/testing-scheduled.yml index aefe7afaa..985b533d5 100644 --- a/.github/workflows/testing-scheduled.yml +++ b/.github/workflows/testing-scheduled.yml @@ -6,14 +6,15 @@ on: jobs: - test-stable-scheduled: - uses: - ./.github/workflows/testing.yml - with: - branch_name: stable - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} + trigger-testing-stable: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - uses: benc-uk/workflow-dispatch@v1.2.2 + with: + workflow: testing-stable.yml + ref: stable test-develop-scheduled: uses: From 167af85de1ae22293bacb423886991af0e70c7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 15 Feb 2024 15:28:32 +0100 Subject: [PATCH 111/176] Remove unnecessary mocking (#2169) The removed QMessageBox mocks are unnecessary since there is a check in tests.fixtures that makes sure no open message boxes remain after each test and many of these mocks are actually detrimental since they mock all types of message boxes but only check for one. --- .../_test_msui/test_kmloverlay_dockwidget.py | 29 ++++----- tests/_test_msui/test_linearview.py | 24 ++----- tests/_test_msui/test_mscolab.py | 51 ++++++++------- .../test_mscolab_merge_waypoints.py | 23 ++++--- .../test_mscolab_save_merge_points.py | 4 +- tests/_test_msui/test_msui.py | 52 +++++---------- tests/_test_msui/test_sideview.py | 40 ++++-------- tests/_test_msui/test_tableview.py | 8 +-- tests/_test_msui/test_topview.py | 65 ++++--------------- tests/_test_msui/test_wms_capabilities.py | 13 +--- tests/_test_msui/test_wms_control.py | 60 ++++------------- tests/_test_utils/test_airdata.py | 3 - 12 files changed, 123 insertions(+), 249 deletions(-) diff --git a/tests/_test_msui/test_kmloverlay_dockwidget.py b/tests/_test_msui/test_kmloverlay_dockwidget.py index d3c42a9ed..4440f32bb 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -87,8 +87,7 @@ def test_get_file(self, mockopen): # Tests opening of QFileDialog QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_select_file(self, mockbox): + def test_select_file(self): """ Test All geometries and styles are being parsed without crashing """ @@ -100,27 +99,26 @@ def test_select_file(self, mockbox): assert self.window.listWidget.item(index).checkState() == QtCore.Qt.Checked index = index + 1 assert self.window.directory_location == path - assert mockbox.critical.call_count == 0 assert self.window.listWidget.count() == index assert len(self.window.dict_files) == index assert self.count_patches() == 9 self.window.select_all() self.window.remove_file() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_select_file_error(self, mockbox): + def test_select_file_error(self): """ Test that program mitigates loading a non-existing file """ - # load a non existing path - self.window.select_all() - self.window.remove_file() - path = fs.path.join(sample_path, "satellite_predictor.txt") - filename = (path,) # converted to tuple - self.window.select_file(filename) - self.window.load_file() - QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: + # load a non existing path + self.window.select_all() + self.window.remove_file() + path = fs.path.join(sample_path, "satellite_predictor.txt") + filename = (path,) # converted to tuple + self.window.select_file(filename) + self.window.load_file() + QtWidgets.QApplication.processEvents() + critbox.assert_called_once() self.window.listWidget.clear() self.window.dict_files = {} @@ -150,9 +148,8 @@ def test_remove_all_files(self): assert self.window.dict_files == {} # Dictionary should be empty assert self.count_patches() == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.kmloverlay_dockwidget.get_save_filename", return_value=save_kml) - def test_merge_file(self, mocksave, mockbox): + def test_merge_file(self, mocksave): """ Test merging files into a single file without crashing """ diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 11c015a7c..d6ae17ceb 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -49,14 +49,11 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_show(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_show(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_get(self, mockcrit): + def test_get(self): self.window.get_settings() - assert mockcrit.critical.call_count == 0 class Test_MSSLinearViewWindow: @@ -77,26 +74,21 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_wms(self, mockbox): + def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_mouse_over(self, mockbox): + def test_mouse_over(self): # Test mouse over QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(782, 266), -1) QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(100, 100), -1) QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.linearview.MSUI_LV_Options_Dialog") - def test_options(self, mockdlg, mockbox): + def test_options(self, mockdlg): QtTest.QTest.mouseClick(self.window.lvoptionbtn, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert mockdlg.call_count == 1 assert mockdlg.return_value.setModal.call_count == 1 assert mockdlg.return_value.exec_.call_count == 1 @@ -138,12 +130,10 @@ def query_server(self, url): QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox, qtbot): + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ self.query_server(self.url) with qtbot.wait_signal(self.wms_control.image_displayed): QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) - assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 5474fe0b7..11bbc56b6 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -309,8 +309,7 @@ def test_activate_operation(self): assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_name == self.operation_name - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_view_open(self, mockbox): + def test_view_open(self): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) @@ -334,10 +333,14 @@ def test_view_open(self, mockbox): active_windows = self.window.get_active_views() topview = active_windows[1] tableview = active_windows[0] - self.window.mscolab.handle_update_permission(operation, uid, "viewer") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.mscolab.handle_update_permission(operation, uid, "viewer") + m.assert_called_once() assert not tableview.btAddWayPointToFlightTrack.isEnabled() assert any(action.text() == "Ins WP" and not action.isEnabled() for action in topview.mpl.navbar.actions()) - self.window.mscolab.handle_update_permission(operation, uid, "creator") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.mscolab.handle_update_permission(operation, uid, "creator") + m.assert_called_once() assert tableview.btAddWayPointToFlightTrack.isEnabled() assert any(action.text() == "Ins WP" and action.isEnabled() for action in topview.mpl.navbar.actions()) @@ -362,8 +365,7 @@ def test_handle_export(self, mockbox): ("example.csv", "actionImportFlightTrackCSV", 5), ("example.txt", "actionImportFlightTrackTXT", 5), ("flitestar.txt", "actionImportFlightTrackFliteStar", 10)]) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_import_file(self, mockbox, name): + def test_import_file(self, name): self.window.remove_plugins() with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") @@ -372,13 +374,16 @@ def test_import_file(self, mockbox, name): # with parametrize it is maybe too fast QtTest.QTest.qWait(100) self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) wp = self.window.mscolab.waypoints_model assert len(wp.waypoints) == 2 for action in self.window.menuImportFlightTrack.actions(): if action.objectName() == name[1]: - action.trigger() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + action.trigger() + m.assert_called_once() break assert mockopen.call_count == 1 imported_wp = self.window.mscolab.waypoints_model @@ -569,8 +574,7 @@ def test_update_category(self, mocktext, qtbot): assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_category == "new_category" - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") - def test_any_special_category(self, mockbox, qtbot): + def test_any_special_category(self, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") @@ -694,34 +698,35 @@ def test_close_help_dialog(self): QtWidgets.QApplication.processEvents() assert self.window.mscolab.help_dialog is None - @mock.patch("PyQt5.QtWidgets.QMessageBox") - @mock.patch("sys.exit") - def test_create_dir_exceptions(self, mockexit, mockbox): - with mock.patch("fs.open_fs", new=ExceptionMock(fs.errors.CreateFailed).raise_exc): + def test_create_dir_exceptions(self): + with mock.patch("fs.open_fs", new=ExceptionMock(fs.errors.CreateFailed).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox, \ + mock.patch("sys.exit") as mockexit: self.window.mscolab.data_dir = "://" self.window.mscolab.create_dir() - assert mockbox.critical.call_count == 1 - assert mockexit.call_count == 1 + critbox.assert_called_once() + mockexit.assert_called_once() - with mock.patch("fs.open_fs", new=ExceptionMock(fs.opener.errors.UnsupportedProtocol).raise_exc): + with mock.patch("fs.open_fs", new=ExceptionMock(fs.opener.errors.UnsupportedProtocol).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox, \ + mock.patch("sys.exit") as mockexit: self.window.mscolab.data_dir = "://" self.window.mscolab.create_dir() - assert mockbox.critical.call_count == 2 - assert mockexit.call_count == 2 + critbox.assert_called_once() + mockexit.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_profile_dialog(self, mockbox, qtbot): + def test_profile_dialog(self, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self.window.mscolab.profile_action.trigger() QtWidgets.QApplication.processEvents() # case: default gravatar is set and no messagebox is called - assert mockbox.critical.call_count == 0 assert self.window.mscolab.prof_diag is not None # case: trying to fetch non-existing gravatar - self.window.mscolab.fetch_gravatar(refresh=True) - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: + self.window.mscolab.fetch_gravatar(refresh=True) + critbox.assert_called_once() assert not self.window.mscolab.profile_dialog.gravatarLabel.pixmap().isNull() def _connect_to_mscolab(self): diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 9342fb13d..1803929b5 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -36,6 +36,7 @@ mscolab_delete_all_operations, mscolab_delete_user) from mslib.msui import mscolab from mslib.msui import msui +from mslib.utils.config import modify_config_file class Test_Mscolab_Merge_Waypoints: @@ -85,6 +86,7 @@ def _connect_to_mscolab(self): QtTest.QTest.qWait(500) def _login(self, emailid="merge_waypoints_user", password="password"): + modify_config_file({"MSS_auth": {self.url: self.emailid}}) self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) @@ -112,8 +114,7 @@ def __init__(self, *args, **kwargs): class Test_Overwrite_To_Server(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_overwrite_to_server(self, mockbox, qtbot): + def test_save_overwrite_to_server(self, qtbot): self.emailid = "save_overwrite@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) @@ -135,8 +136,10 @@ def assert_(): # trigger save to server action from server options combobox with mock.patch( "mslib.msui.mscolab.MscolabMergeWaypointsDialog", - AutoClickOverwriteMscolabMergeWaypointsDialog): + AutoClickOverwriteMscolabMergeWaypointsDialog), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: self.window.serverOptionsCb.setCurrentIndex(2) + m.assert_called_once() def assert_(): # get the updated waypoints model from the server @@ -161,8 +164,7 @@ def __init__(self, *args, **kwargs): class Test_Save_Keep_Server_Points(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_keep_server_points(self, mockbox, qtbot): + def test_save_keep_server_points(self, qtbot): self.emailid = "save_keepe@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) @@ -182,8 +184,10 @@ def assert_(): wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) # trigger save to server action from server options combobox - with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog): + with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: self.window.serverOptionsCb.setCurrentIndex(2) + m.assert_called_once() def assert_(): # get the updated waypoints model from the server @@ -203,8 +207,7 @@ def assert_(): class Test_Fetch_From_Server(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_fetch_from_server(self, mockbox): + def test_fetch_from_server(self): self.emailid = "fetch_from_server@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) @@ -218,8 +221,10 @@ def test_fetch_from_server(self, mockbox): assert wp_server_before.lat != wp_local_before.lat # trigger save to server action from server options combobox - with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog): + with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: self.window.serverOptionsCb.setCurrentIndex(1) + m.assert_called_once() QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index 24c3ceb74..24334650b 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import mock import pytest from tests._test_msui.test_mscolab_merge_waypoints import Test_Mscolab_Merge_Waypoints from mslib.msui import flighttrack as ft @@ -33,8 +32,7 @@ @pytest.mark.skip("Uses QTimer, which can break other unrelated tests") class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_merge_points(self, mockbox): + def test_save_merge_points(self): self.emailid = "mergepoints@alpha.org" self._create_user_data(emailid=self.emailid) self.window.workLocallyCheckbox.setChecked(True) diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index b99ceecbd..e78229178 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -198,70 +198,53 @@ def setup(self, qapp): def test_no_updater(self): assert not hasattr(self.window, "updater") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_app_start(self, mockbox): - assert mockbox.critical.call_count == 0 + def test_app_start(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_new_flightrack(self, mockbox): + def test_new_flightrack(self): assert self.window.listFlightTracks.count() == 1 self.window.actionNewFlightTrack.trigger() QtWidgets.QApplication.processEvents() assert self.window.listFlightTracks.count() == 2 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_topview(self, mockbox): + def test_open_topview(self): assert self.window.listViews.count() == 0 self.window.actionTopView.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert self.window.listViews.count() == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_sideview(self, mockbox): + def test_open_sideview(self): assert self.window.listViews.count() == 0 self.window.actionSideView.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert self.window.listViews.count() == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_tableview(self, mockbox): + def test_open_tableview(self): assert self.window.listViews.count() == 0 self.window.actionTableView.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert self.window.listViews.count() == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_linearview(self, mockbox): + def test_open_linearview(self): assert self.window.listViews.count() == 0 self.window.actionLinearView.trigger() self.window.listViews.itemActivated.emit(self.window.listViews.item(0)) QtWidgets.QApplication.processEvents() assert self.window.listViews.count() == 1 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_about(self, mockbox): + def test_open_about(self): self.window.actionAboutMSUI.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_config(self, mockbox): + def test_open_config(self): pytest.skip("To be done") self.window.actionConfigurationEditor.trigger() QtWidgets.QApplication.processEvents() self.window.config_editor.close() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_shortcut(self, mockbox): + def test_open_shortcut(self): self.window.actionShortcuts.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 @pytest.mark.parametrize("save_file", [[save_ftml]]) def test_plugin_saveas(self, save_file): @@ -316,7 +299,6 @@ def test_plugin_export(self, save_file): os.remove(save_file[0]) @pytest.mark.skip("needs to be refactored to become independent") - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=export_plugins) def test_add_plugins(self, mockopen, mockbox): assert len(self.window.menuImportFlightTrack.actions()) == 2 @@ -331,21 +313,22 @@ def test_add_plugins(self, mockopen, mockbox): assert len(self.window.export_plugins) == 1 assert len(self.window.menuImportFlightTrack.actions()) == 2 assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 - assert mockbox.critical.call_count == 0 self.window.remove_plugins() - with mock.patch("importlib.import_module", new=ExceptionMock(Exception()).raise_exc): + with mock.patch("importlib.import_module", new=ExceptionMock(Exception()).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: self.window.add_import_plugins("qt") self.window.add_export_plugins("qt") - assert mockbox.critical.call_count == 2 + assert critbox.call_count == 2 self.window.remove_plugins() with mock.patch("mslib.msui.ms" "ui.MSUIMainWindow.add_plugin_submenu", - new=ExceptionMock(Exception()).raise_exc): + new=ExceptionMock(Exception()).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: self.window.add_import_plugins("qt") self.window.add_export_plugins("qt") - assert mockbox.critical.call_count == 4 + assert critbox.call_count == 2 self.window.remove_plugins() assert len(self.window.import_plugins) == 0 @@ -353,13 +336,12 @@ def test_add_plugins(self, mockopen, mockbox): assert len(self.window.menuImportFlightTrack.actions()) == 1 assert len(self.window.menuExportActiveFlightTrack.actions()) == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") @mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("mslib.msui.msui_mainwindow.get_save_filename", return_value=save_ftml) @mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[save_ftml]) - def test_flight_track_io(self, mockload, mocksave, mockq, mocki, mockw, mockbox): + def test_flight_track_io(self, mockload, mocksave, mockq, mocki, mockw): self.window.actionCloseSelectedFlightTrack.trigger() assert mocki.call_count == 1 self.window.actionNewFlightTrack.trigger() diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 8dc5d2627..5d2cb7ae2 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -50,26 +50,19 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_show(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_show(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_get(self, mockcrit): + def test_get(self): self.window.get_settings() - assert mockcrit.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_addLevel(self, mockcrit): + def test_addLevel(self): QtTest.QTest.mouseClick(self.window.btAdd, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockcrit.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_removeLevel(self, mockcrit): + def test_removeLevel(self): QtTest.QTest.mouseClick(self.window.btDelete, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockcrit.critical.call_count == 0 def test_getFlightLevels(self): levels = self.window.get_flight_levels() @@ -104,34 +97,28 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_wms(self, mockbox): + def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_mouse_over(self, mockbox): + def test_mouse_over(self): # Test mouse over QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(782, 266), -1) QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(20, 20), -1) QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.sideview.MSUI_SV_OptionsDialog") - def test_options(self, mockdlg, mockbox): + def test_options(self, mockdlg): QtTest.QTest.mouseClick(self.window.btOptions, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert mockdlg.call_count == 1 assert mockdlg.return_value.setModal.call_count == 1 assert mockdlg.return_value.exec_.call_count == 1 assert mockdlg.return_value.destroy.call_count == 1 @pytest.mark.skip("fails with mockbox.critical.call_count in reverse order") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_insert_point(self, mockbox): + def test_insert_point(self): """ Test inserting a point inside and outside the canvas """ @@ -149,15 +136,12 @@ def test_insert_point(self, mockbox): # click again on same position QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_y_axes(self, mockbox): + def test_y_axes(self): self.window.getView().get_settings()["secondary_axis"] = "pressure altitude" self.window.getView().set_settings(self.window.getView().get_settings()) self.window.getView().get_settings()["secondary_axis"] = "flight level" self.window.getView().set_settings(self.window.getView().get_settings()) - assert mockbox.critical.call_count == 0 class Test_SideViewWMS: @@ -195,8 +179,7 @@ def query_server(self, url): QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox, qtbot): + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ @@ -207,4 +190,3 @@ def test_server_getmap(self, mockbox, qtbot): self.window.getView().plotter.clear_figure() assert self.window.getView().plotter.image is None - assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index d4bc47357..d1b4a1822 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -97,11 +97,10 @@ def test_insertremove_hexagon(self, mockbox): assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 5 - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") @mock.patch("mslib.msui.performance_settings.get_open_filename", return_value=os.path.join( os.path.dirname(__file__), "..", "data", "performance_simple.json")) - def test_performance(self, mockopen, mockcrit): + def test_performance(self, mockopen): """ Check effect of performance settings on TableView """ @@ -126,7 +125,6 @@ def test_performance(self, mockopen, mockcrit): QtTest.QTest.mouseClick(self.window.docks[1].widget().pbLoadPerformance, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 - assert mockcrit.call_count == 0 def test_insert_point(self): """ @@ -224,8 +222,7 @@ def test_drag_point(self): wps_after = list(self.window.waypoints_model.waypoints) assert wps_before != wps_after, (wps_before, wps_after) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_roundtrip(self, mockbox): + def test_roundtrip(self): """ Test connecting the last and first point Test connecting the first point to itself @@ -251,4 +248,3 @@ def test_roundtrip(self, mockbox): # Remove connection self.window.waypoints_model.removeRows(count, 1) assert len(self.window.waypoints_model.waypoints) == count - assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index ad47e9d1e..fe03480a0 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -50,15 +50,11 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_show(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_show(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_get(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_get(self): self.window.get_settings() - assert mockcrit.critical.call_count == 0 class Test_MSSTopViewWindow: @@ -78,20 +74,15 @@ def setup(self, qapp): self.window.hide() QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_wms(self, mockbox): + def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_sat(self, mockbox): + def test_open_sat(self): self.window.cbTools.currentIndexChanged.emit(2) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_rs(self, mockcrit): + def test_open_rs(self): self.window.cbTools.currentIndexChanged.emit(3) QtWidgets.QApplication.processEvents() rsdock = self.window.docks[2].widget() @@ -104,16 +95,12 @@ def test_open_rs(self, mockcrit): QtTest.QTest.mouseClick(rsdock.cbDrawTangents, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() rsdock.cbShowSolarAngle.setChecked(True) - assert mockcrit.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_kml(self, mockbox): + def test_open_kml(self): self.window.cbTools.currentIndexChanged.emit(4) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_insert_point(self, mockbox): + def test_insert_point(self): """ Test inserting a point inside and outside the canvas """ @@ -130,12 +117,10 @@ def test_insert_point(self, mockbox): # click again on same position QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 - assert mockbox.critical.call_count == 0 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") - def test_remove_point_yes(self, mockcrit, mockbox): + def test_remove_point_yes(self, mockbox): self.window.mpl.navbar._actions['insert_wp'].trigger() QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 @@ -147,14 +132,12 @@ def test_remove_point_yes(self, mockcrit, mockbox): QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockcrit.call_count == 0 assert len(self.window.waypoints_model.waypoints) == 3 assert mockbox.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") - def test_remove_point_no(self, mockcrit, mockbox): + def test_remove_point_no(self, mockbox): self.window.mpl.navbar._actions['insert_wp'].trigger() QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 @@ -172,10 +155,8 @@ def test_remove_point_no(self, mockcrit, mockbox): QtWidgets.QApplication.processEvents() assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 4 - assert mockcrit.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_move_point(self, mockbox): + def test_move_point(self): self.window.mpl.navbar._actions['insert_wp'].trigger() QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 @@ -195,10 +176,8 @@ def test_move_point(self, mockbox): self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=point) QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_roundtrip(self, mockbox): + def test_roundtrip(self): """ Test connecting the last and first point Test connecting the first point to itself @@ -224,63 +203,49 @@ def test_roundtrip(self, mockbox): # Remove connection self.window.waypoints_model.removeRows(count, 1) assert len(self.window.waypoints_model.waypoints) == count - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_map_options(self, mockbox): + def test_map_options(self): self.window.mpl.canvas.map.set_graticule_visible(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_graticule_visible(False) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_fillcontinents_visible(False) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_fillcontinents_visible(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_coastlines_visible(False) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_coastlines_visible(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[{"type": "small_airport", "name": "Test", "latitude_deg": 52, "longitude_deg": 13, "elevation_ft": 0}]): self.window.mpl.canvas.map.set_draw_airports(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[]): self.window.mpl.canvas.map.set_draw_airports(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[{"type": "small_airport", "name": "Test", "latitude_deg": -52, "longitude_deg": -13, "elevation_ft": 0}]): self.window.mpl.canvas.map.set_draw_airports(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[{"name": "Test", "top": 1, "bottom": 0, "polygon": [(13, 52), (14, 53), (13, 52)], "country": "DE"}]): self.window.mpl.canvas.map.set_draw_airspaces(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[]): self.window.mpl.canvas.map.set_draw_airspaces(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[{"name": "Test", "top": 1, "bottom": 0, "polygon": [(-13, -52), (-14, -53), (-13, -52)], "country": "DE"}]): self.window.mpl.canvas.map.set_draw_airspaces(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 class Test_TopViewWMS: @@ -320,8 +285,7 @@ def query_server(self, url): QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self): """ assert that a getmap call to a WMS server displays an image """ @@ -334,7 +298,6 @@ def test_server_getmap(self, mockbox): self.window.getView().clear_figure() assert self.window.getView().map.image is None self.window.mpl.canvas.redraw_map() - assert mockbox.critical.call_count == 0 class Test_MSUITopViewWindow: diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index d36b9ebf4..da3b76041 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -58,23 +58,16 @@ def start_window(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_window_start(self, mockbox): + def test_window_start(self): self.start_window() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_window_contact_none(self, mockbox): + def test_window_contact_none(self): self.capabilities.provider.contact = None self.start_window() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_switch_view(self, mockbox): + def test_switch_view(self): self.start_window() QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 54c0063df..107ef145a 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -154,8 +154,7 @@ def test_connection_error(self, mockbox): assert mockbox.critical.call_count == 1 @pytest.mark.skip("Breaks other tests in this class because of a lingering message box, for some reason") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_forward_backward_clicks(self, mockbox): + def test_forward_backward_clicks(self): self.query_server(self.url) self.window.init_time_back_click() self.window.init_time_fwd_click() @@ -171,11 +170,9 @@ def test_forward_backward_clicks(self, mockbox): self.window.secs_from_timestep("Wrong") except ValueError: pass - assert mockbox.critical.call_count == 0 @pytest.mark.skip("Has a race condition where the abort might not happen fast enough") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_abort_getmap(self, mockbox): + def test_server_abort_getmap(self): """ assert that an aborted getmap call does not change the displayed image """ @@ -192,8 +189,7 @@ def test_server_abort_getmap(self, mockbox): assert self.view.draw_metadata.call_count == 0 self.view.reset_mock() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox, qtbot): + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ @@ -202,14 +198,11 @@ def test_server_getmap(self, mockbox, qtbot): with qtbot.wait_signal(self.window.image_displayed): QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - self.view.reset_mock() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap_cached(self, mockbox, qtbot): + def test_server_getmap_cached(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ @@ -218,8 +211,6 @@ def test_server_getmap_cached(self, mockbox, qtbot): with qtbot.wait_signal(self.window.image_displayed): QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - # assert mockbox.critical.call_count == 0 - assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 @@ -231,20 +222,16 @@ def test_server_getmap_cached(self, mockbox, qtbot): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 - assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 @pytest.mark.skip("needs a review") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_service_cache(self, mockbox): + def test_server_service_cache(self): """ assert that changing between servers still allows image retrieval """ self.query_server(self.url) - assert mockbox.critical.call_count == 0 QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) @@ -252,30 +239,25 @@ def test_server_service_cache(self, mockbox): QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.window.cpdlg.canceled) - assert mockbox.critical.call_count == 1 assert self.view.draw_image.call_count == 0 assert self.view.draw_legend.call_count == 0 assert self.view.draw_metadata.call_count == 0 - mockbox.reset_mock() QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, ord(str(self.port)[3])) QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Slash) QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.window.cpdlg.canceled) - assert mockbox.critical.call_count == 0 QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_handling(self, mockbox): + def test_multilayer_handling(self): """ assert that multilayers get created, handled and drawn properly """ @@ -313,14 +295,12 @@ def test_multilayer_handling(self, mockbox): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 @pytest.mark.skip("Fails testing reverse order") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_filter_handling(self, mockbox): + def test_filter_handling(self): self.query_server(self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] @@ -363,10 +343,8 @@ def test_filter_handling(self, mockbox): self.window.multilayers.check_icon_clicked(server) assert len(self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)) == 0 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_singlelayer_handling(self, mockbox): + def test_singlelayer_handling(self): """ assert that singlelayer mode behaves as expected """ @@ -397,13 +375,11 @@ def test_singlelayer_handling(self, mockbox): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_syncing(self, mockbox): + def test_multilayer_syncing(self): """ assert that synced layers share their options """ @@ -432,11 +408,9 @@ def test_multilayer_syncing(self, mockbox): assert layer_a.get_level() == layer_b.get_level() assert layer_a.get_vtime() == layer_b.get_vtime() assert layer_a.get_itime() == layer_a.get_itimes()[-1] - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.wms_control.WMSMapFetcher.moveToThread") - def test_server_no_thread(self, mockbox, mockthread): + def test_server_no_thread(self, mockthread): self.query_server(self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] @@ -455,7 +429,6 @@ def test_server_no_thread(self, mockbox, mockthread): self.window.fetcher.fetch_legend(urlstr, use_cache=False, md5_filename=md5_filname) self.window.fetcher.fetch_legend(urlstr, use_cache=True, md5_filename=md5_filname) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 @@ -468,8 +441,7 @@ def setup(self, qapp): yield self._teardown() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox, qtbot): + def test_server_getmap(self, qtbot): pytest.skip("unknown problem") """ assert that a getmap call to a WMS server displays an image @@ -478,15 +450,12 @@ def test_server_getmap(self, mockbox, qtbot): with qtbot.wait_signal(self.wms_control.image_displayed): QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - self.view.reset_mock() @pytest.mark.skip("IndexError: list index out of range") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_drawing(self, mockbox): + def test_multilayer_drawing(self): """ assert that drawing a layer through code doesn't fail for vsec """ @@ -496,8 +465,6 @@ def test_multilayer_drawing(self, mockbox): server.child(0).draw() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 - class TestWMSControlWidgetSetupSimple: xml = """ @@ -796,8 +763,7 @@ def test_xml_othertimeformat(self): assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ ['2012-10-16T12:00:00Z', '2012-10-17T12:00:00Z'] - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_xml_time_error(self, mockbox): + def test_xml_time_error(self): dimext_time_error = """ a2012-10-17T12:00:00Z/2012-10-18T00:00:00Z/PT6H """ diff --git a/tests/_test_utils/test_airdata.py b/tests/_test_utils/test_airdata.py index 3d417a2cb..817a958dc 100644 --- a/tests/_test_utils/test_airdata.py +++ b/tests/_test_utils/test_airdata.py @@ -132,7 +132,6 @@ def test_get_downloaded_airports(mockbox): airports = get_airports(force_download=True) assert len(airports) > 0 assert 'continent' in airports[0].keys() - assert mockbox.critical.call_count == 0 def test_get_available_airspaces(): @@ -150,7 +149,6 @@ def test_update_airspace(mockbox): with open(example_file, 'r') as f: text = f.read() assert "" in text - assert mockbox.critical.call_count == 0 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) @@ -204,7 +202,6 @@ def test_get_airspaces(mockbox): (22.739444444444445, 42.88527777777778)] } ] - assert mockbox.critical.call_count == 0 @mock.patch("mslib.utils.airdata.download_progress", _download_incomplete_airspace) From 1089e365b6144c68771785ab9793986f83f08395 Mon Sep 17 00:00:00 2001 From: Aryan Gupta <113938175+workaryangupta@users.noreply.github.com> Date: Fri, 16 Feb 2024 17:24:45 +0530 Subject: [PATCH 112/176] Removed redundant truthy line (#2203) --- mslib/index.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mslib/index.py b/mslib/index.py index 0fc47928f..346e23ca1 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -85,7 +85,6 @@ def create_app(name="", imprint=None, gdpr=None): APP.jinja_env.globals["imprint"] = imprint_file APP.jinja_env.globals["gdpr"] = gdpr_file - @APP.route('/xstatic//', defaults=dict(filename='')) @APP.route('/xstatic//') def files(name, filename): From c73d9baa3b204f1cf1d80fedb3bcf9912822d93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Fri, 16 Feb 2024 13:32:10 +0100 Subject: [PATCH 113/176] Remove pytest option to pass msui settings (#2170) This option is, to my knowledge, unused and it does not work anymore since the configuration is reset before each test. --- conftest.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/conftest.py b/conftest.py index 083a9092c..a1aa82e37 100644 --- a/conftest.py +++ b/conftest.py @@ -74,18 +74,6 @@ def keyring_reset(): keyring.get_keyring().reset() -def pytest_addoption(parser): - parser.addoption("--msui_settings", action="store") - - -def pytest_generate_tests(metafunc): - option_value = metafunc.config.option.msui_settings - if option_value is not None: - msui_settings_file_fs = fs.open_fs(constants.MSUI_CONFIG_PATH) - msui_settings_file_fs.writetext("msui_settings.json", option_value) - msui_settings_file_fs.close() - - def generate_initial_config(): """Generate an initial state for the configuration directory in tests.constants.ROOT_FS """ From 579e577d41539215941295d8cf0039e7ed5ccd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Fri, 16 Feb 2024 13:32:55 +0100 Subject: [PATCH 114/176] Remove unnecessary skips (#2165) --- tests/_test_msui/test_mscolab.py | 10 ++--- .../test_mscolab_save_merge_points.py | 7 ++-- tests/_test_msui/test_mss.py | 2 - tests/_test_msui/test_msui.py | 21 +++++----- tests/_test_msui/test_sideview.py | 1 - tests/_test_msui/test_wms_control.py | 41 ++++++++----------- tests/_test_mswms/test_mss_plot_driver.py | 3 -- tests/_test_utils/test_config.py | 16 -------- 8 files changed, 35 insertions(+), 66 deletions(-) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 11bbc56b6..5b8a8fc23 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -389,7 +389,6 @@ def test_import_file(self, name): imported_wp = self.window.mscolab.waypoints_model assert len(imported_wp.waypoints) == name[2] - @pytest.mark.skip("Runs in a timeout locally > 60s") def test_work_locally_toggle(self): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) @@ -408,10 +407,8 @@ def test_work_locally_toggle(self): wpdata_server = self.window.mscolab.waypoints_model.waypoint_data(0) assert wpdata_local.lat != wpdata_server.lat - @pytest.mark.skip("fails often on github on a timeout >60s") - @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") @mock.patch("mslib.msui.mscolab.get_open_filename", return_value=os.path.join(sample_path, u"example.ftml")) - def test_browse_add_operation(self, mockopen, mockmessage, qtbot): + def test_browse_add_operation(self, mockopen, qtbot): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") @@ -428,7 +425,9 @@ def test_browse_add_operation(self, mockopen, mockmessage, qtbot): QtWidgets.QApplication.processEvents() okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) - QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) + m.assert_called_once() # we need to wait for the update of the operation list QtTest.QTest.qWait(200) QtWidgets.QApplication.processEvents() @@ -461,7 +460,6 @@ def test_add_operation(self, qtbot): @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)) def test_handle_delete_operation(self, mocktext, qtbot): - # pytest.skip('needs a review for the delete button pressed. Seems to delete a None operation') self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) self._create_user(qtbot, "berta", "berta@something.org", "something") diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index 24334650b..2844ac286 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -24,13 +24,12 @@ See the License for the specific language governing permissions and limitations under the License. """ -import pytest +import mock from tests._test_msui.test_mscolab_merge_waypoints import Test_Mscolab_Merge_Waypoints from mslib.msui import flighttrack as ft from PyQt5 import QtCore, QtTest, QtWidgets -@pytest.mark.skip("Uses QTimer, which can break other unrelated tests") class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): def test_save_merge_points(self): self.emailid = "mergepoints@alpha.org" @@ -53,7 +52,9 @@ def handle_merge_dialog(): QtCore.QTimer.singleShot(3000, handle_merge_dialog) # QtTest.QTest.mouseClick(self.window.save_ft, QtCore.Qt.LeftButton, delay=1) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(2) + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.serverOptionsCb.setCurrentIndex(2) + m.assert_called_once() QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working diff --git a/tests/_test_msui/test_mss.py b/tests/_test_msui/test_mss.py index c9ab2379f..5875d31dd 100644 --- a/tests/_test_msui/test_mss.py +++ b/tests/_test_msui/test_mss.py @@ -24,12 +24,10 @@ See the License for the specific language governing permissions and limitations under the License. """ -import pytest from PyQt5 import QtWidgets, QtTest, QtCore from mslib.msui import mss -@pytest.mark.skip(reason='needs review, assert missing') def test_mss_rename_message(qapp): main_window = mss.MSSMainWindow() main_window.show() diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index e78229178..b886050bc 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -237,10 +237,10 @@ def test_open_about(self): QtWidgets.QApplication.processEvents() def test_open_config(self): - pytest.skip("To be done") - self.window.actionConfigurationEditor.trigger() + self.window.actionConfiguration.trigger() QtWidgets.QApplication.processEvents() - self.window.config_editor.close() + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window.config_editor.close() def test_open_shortcut(self): self.window.actionShortcuts.trigger() @@ -298,21 +298,20 @@ def test_plugin_export(self, save_file): assert os.path.exists(save_file[0]) os.remove(save_file[0]) - @pytest.mark.skip("needs to be refactored to become independent") @mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=export_plugins) - def test_add_plugins(self, mockopen, mockbox): + def test_add_plugins(self, mockopen): assert len(self.window.menuImportFlightTrack.actions()) == 2 assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 - assert len(self.window.import_plugins) == 1 - assert len(self.window.export_plugins) == 1 + assert len(self.window.import_plugins) == 0 + assert len(self.window.export_plugins) == 0 self.window.remove_plugins() self.window.add_import_plugins("qt") self.window.add_export_plugins("qt") assert len(self.window.import_plugins) == 1 assert len(self.window.export_plugins) == 1 - assert len(self.window.menuImportFlightTrack.actions()) == 2 - assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 + assert len(self.window.menuImportFlightTrack.actions()) == 3 + assert len(self.window.menuExportActiveFlightTrack.actions()) == 3 self.window.remove_plugins() with mock.patch("importlib.import_module", new=ExceptionMock(Exception()).raise_exc), \ @@ -333,8 +332,8 @@ def test_add_plugins(self, mockopen, mockbox): self.window.remove_plugins() assert len(self.window.import_plugins) == 0 assert len(self.window.export_plugins) == 0 - assert len(self.window.menuImportFlightTrack.actions()) == 1 - assert len(self.window.menuExportActiveFlightTrack.actions()) == 1 + assert len(self.window.menuImportFlightTrack.actions()) == 2 + assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 @mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Yes) diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 5d2cb7ae2..152edd144 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -117,7 +117,6 @@ def test_options(self, mockdlg): assert mockdlg.return_value.exec_.call_count == 1 assert mockdlg.return_value.destroy.call_count == 1 - @pytest.mark.skip("fails with mockbox.critical.call_count in reverse order") def test_insert_point(self): """ Test inserting a point inside and outside the canvas diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 107ef145a..fbff5d98b 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -144,7 +144,6 @@ def test_invalid_url(self, mockbox): self.query_server(f"{self.scheme}://???{self.host}:{self.port}") assert mockbox.critical.call_count == 1 - @pytest.mark.skip("problem in urllib3") @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_connection_error(self, mockbox): """ @@ -226,32 +225,29 @@ def test_server_getmap_cached(self, qtbot): assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @pytest.mark.skip("needs a review") - def test_server_service_cache(self): + def test_server_service_cache(self, qtbot): """ assert that changing between servers still allows image retrieval """ self.query_server(self.url) - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.cpdlg.canceled) + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as qm_critical: + with qtbot.wait_signal(self.window.cpdlg.canceled): + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) + qm_critical.assert_called_once() assert self.view.draw_image.call_count == 0 assert self.view.draw_legend.call_count == 0 assert self.view.draw_metadata.call_count == 0 - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, ord(str(self.port)[3])) - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Slash) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.cpdlg.canceled) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.cpdlg.canceled): + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, ord(str(self.port)[-1])) + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Slash) + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) + + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 @@ -299,7 +295,6 @@ def test_multilayer_handling(self): assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @pytest.mark.skip("Fails testing reverse order") def test_filter_handling(self): self.query_server(self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", @@ -309,13 +304,13 @@ def test_filter_handling(self): assert "header" in self.window.multilayers.layers[f"{self.url}/"] assert "wms" in self.window.multilayers.layers[f"{self.url}/"] - starts_at = 40 * self.window.multilayers.scale + starts_at = int(40 * self.window.multilayers.scale) icon_start_fav = starts_at + 3 if self.window.multilayers.cbMultilayering.isChecked(): checkbox_width = round(self.window.multilayers.height * 0.75) icon_start_fav += checkbox_width + 6 - starts_at = 20 * self.window.multilayers.scale + starts_at = int(20 * self.window.multilayers.scale) icon_start_del = starts_at + 3 # Check layer filter is working @@ -442,19 +437,17 @@ def setup(self, qapp): self._teardown() def test_server_getmap(self, qtbot): - pytest.skip("unknown problem") """ assert that a getmap call to a WMS server displays an image """ self.query_server(self.url) - with qtbot.wait_signal(self.wms_control.image_displayed): + with qtbot.wait_signal(self.window.image_displayed): QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @pytest.mark.skip("IndexError: list index out of range") def test_multilayer_drawing(self): """ assert that drawing a layer through code doesn't fail for vsec diff --git a/tests/_test_mswms/test_mss_plot_driver.py b/tests/_test_mswms/test_mss_plot_driver.py index 681226104..b4c0143ef 100644 --- a/tests/_test_mswms/test_mss_plot_driver.py +++ b/tests/_test_mswms/test_mss_plot_driver.py @@ -186,7 +186,6 @@ def test_VS_ProbabilityOfWCBStyle_01(self): assert noframe != img def test_VS_LagrantoTrajStyle_PL_01(self): - pytest.skip("data not available") img = self.plot(mpl_vsec_styles.VS_LagrantoTrajStyle_PL_01(driver=self.vsec)) assert img is not None noframe = self.plot(mpl_vsec_styles.VS_LagrantoTrajStyle_PL_01(driver=self.vsec), noframe=True) @@ -199,7 +198,6 @@ def test_VS_EMACEyja_Style_01(self): assert noframe != img def test_VS_gallery_template(self): - pytest.skip('Test can be biased. In pytest-reverse when there is not a plot_examples it can''t import') # ToDo Test Data have to be written to a random tmp dir and that may become purged afterwards templates_location = os.path.join(mslib.mswms.gallery_builder.DOCS_LOCATION, "plot_examples") sys.path.append(templates_location) @@ -504,7 +502,6 @@ def test_HS_Meteosat_BT108_01(self): assert noframe != img def test_HS_gallery_template(self): - pytest.skip('Test can be biased. In pytest-reverse when there is not a plot_examples it can''t import') # ToDo Test Data have to be written to a random tmp dir and that may become purged afterwards templates_location = os.path.join(mslib.mswms.gallery_builder.DOCS_LOCATION, "plot_examples") sys.path.append(templates_location) diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index 5b3a069fb..30c50f167 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -121,8 +121,6 @@ def test_existing_empty_config_file(self): """ on a user defined empty msui_settings_json this test should return the default value for num_labels """ - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert ":" not in file_content @@ -143,8 +141,6 @@ def test_existing_config_file_different_parameters(self): on a user defined msui_settings_json without a defined num_labels this test should return its default value """ create_msui_settings_file('{"num_interpolation_points": 20 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_labels" not in file_content @@ -168,8 +164,6 @@ def test_existing_config_file_defined_parameters(self): on a user defined msui_settings_json without a defined num_labels this test should return its default value """ create_msui_settings_file('{"num_interpolation_points": 201, "num_labels": 10 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_labels" in file_content @@ -187,8 +181,6 @@ def test_existing_config_file_invalid_parameters(self): on a user defined msui_settings_json with duplicate and empty keys should raise FatalUserError """ create_msui_settings_file('{"num_interpolation_points": 201, "num_interpolation_points": 10 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_interpolation_points" in file_content @@ -197,8 +189,6 @@ def test_existing_config_file_invalid_parameters(self): read_config_file(path=config_file) create_msui_settings_file('{"": 201, "num_labels": 10 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_labels" in file_content @@ -209,8 +199,6 @@ def test_modify_config_file_with_empty_parameters(self): """ Test to check if modify_config_file properly stores a key-value pair in an empty config file """ - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { "num_labels": 20 } @@ -225,8 +213,6 @@ def test_modify_config_file_with_existing_parameters(self): Test to check if modify_config_file properly modifies a key-value pair in the config file """ create_msui_settings_file('{"num_labels": 14}') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { "num_labels": 20 } @@ -240,8 +226,6 @@ def test_modify_config_file_with_invalid_parameters(self): """ Test to check if modify_config_file raises a KeyError when a key is empty """ - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { "": "sree", "num_labels": "20" From 1052476f7dcf09d1e89571d9dbf298847d8ac2fc Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 16 Feb 2024 16:49:40 +0100 Subject: [PATCH 115/176] enable newer fastkml versions (#2215) --- localbuild/meta.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index e475c87cf..1c146bff0 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -39,7 +39,7 @@ requirements: - basemap >=1.3.3 - chameleon - execnet - - fastkml =0.11 + - fastkml >=0.11 - shapely >=2.0.0 - pygeoif <1.0.0 - isodate From 868cd27692e8c1fe51b3aaf004f1e820948e5ef3 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 16 Feb 2024 18:15:34 +0100 Subject: [PATCH 116/176] any member of an operation should see who created the operation (#2216) * enables to see from any operations user the creator * test and changes to allow for any operations member to get the creators name * improved --- mslib/mscolab/file_manager.py | 3 ++- tests/_test_mscolab/test_file_manager.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 0fd506d3f..abfde98e1 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -495,7 +495,8 @@ def fetch_users_with_permission(self, op_id, u_id): return users def fetch_operation_creator(self, op_id, u_id): - if not self.is_admin(u_id, op_id) and not self.is_creator(u_id, op_id): + if not self.is_member(u_id, op_id): + # any participant of the OP is allowed to see who is the creator return False current_operation_creator = Permission.query.filter_by(op_id=op_id, access_level="creator").first() return current_operation_creator.user.username diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index b9111a4ab..a91db1959 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -97,8 +97,18 @@ def test_modify_user_special_cases(self): def test_fetch_operation_creator(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="more_than_one") + self.fm.add_bulk_permission(operation.id, self.user, [self.collaboratoruser.id], "collaborator") + self.fm.add_bulk_permission(operation.id, self.user, [self.vieweruser.id], "viewer") + self.fm.add_bulk_permission(operation.id, self.user, [self.adminuser.id], "admin") + assert operation.path == flight_path assert self.fm.fetch_operation_creator(operation.id, self.user.id) == self.user.username + assert self.fm.fetch_operation_creator(operation.id, self.collaboratoruser.id) == self.user.username + assert self.fm.fetch_operation_creator(operation.id, self.vieweruser.id) == self.user.username + assert self.fm.fetch_operation_creator(operation.id, self.adminuser.id) == self.user.username + + # this user is not defined in that OP + assert self.fm.fetch_operation_creator(operation.id, self.op2user.id) is False def test_create_operation(self): with self.app.test_client(): From 4e54830fbaf9b4690c6967a32aa4a54abeb8df61 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 16 Feb 2024 18:41:10 +0100 Subject: [PATCH 117/176] separate urls and menu to server parts (#2211) * extracted from index.py * refactored index.py, moved server dependent parts to theire modules * updated --- mslib/index.py | 81 ++--------------------------------- mslib/mscolab/app/__init__.py | 13 +++++- mslib/mswms/app/__init__.py | 15 ++++++- mslib/mswms/wms.py | 39 ++++++++++++++++- mslib/utils/get_content.py | 47 ++++++++++++++++++++ 5 files changed, 113 insertions(+), 82 deletions(-) create mode 100644 mslib/utils/get_content.py diff --git a/mslib/index.py b/mslib/index.py index 346e23ca1..b5e2cce36 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -25,21 +25,15 @@ limitations under the License. """ -import sys import os -import codecs import mslib -import werkzeug from flask import render_template from flask import send_from_directory, send_file, url_for from flask import abort -from flask import request -from flask import Response -from markdown import Markdown from xstatic.main import XStatic from mslib.msui.icons import icons -from mslib.mswms.gallery_builder import STATIC_LOCATION +from mslib.utils.get_content import get_content # set the operation root directory as the static folder DOCS_SERVER_PATH = os.path.dirname(os.path.abspath(mslib.__file__)) @@ -77,9 +71,9 @@ def create_app(name="", imprint=None, gdpr=None): gdpr_file = gdpr if "mscolab.server" in name: - from mslib.mscolab.app import APP + from mslib.mscolab.app import APP, get_topmenu else: - from mslib.mswms.app import APP + from mslib.mswms.app import APP, get_topmenu APP.jinja_env.globals.update(file_exists=file_exists) APP.jinja_env.globals["imprint"] = imprint_file @@ -103,45 +97,8 @@ def mss_theme(name, filename): base_path = os.path.join(DOCS_SERVER_PATH, 'static', 'img') return send_from_directory(base_path, filename) - def get_topmenu(): - if "mscolab" in " ".join(sys.argv): - menu = [ - (url_for('index'), 'Mission Support System', - ((url_for('about'), 'About'), - (url_for('install'), 'Install'), - (url_for('help'), 'Help'), - )), - ] - else: - menu = [ - (url_for('index'), 'Mission Support System', - ((url_for('about'), 'About'), - (url_for('install'), 'Install'), - (url_for("plots"), 'Gallery'), - (url_for('help'), 'Help'), - )), - ] - - return menu - APP.jinja_env.globals.update(get_topmenu=get_topmenu) - def get_content(filename, md_overrides=None, html_overrides=None): - markdown = Markdown(extensions=["fenced_code"]) - content = "" - if os.path.isfile(filename): - with codecs.open(filename, 'r', 'utf-8') as f: - md_data = f.read() - md_data = md_data.replace(':ref:', '') - if md_overrides is not None: - v1, v2 = md_overrides - md_data = md_data.replace(v1, v2) - content = markdown.convert(md_data) - if html_overrides is not None: - v1, v2 = html_overrides - content = content.replace(v1, v2) - return content - @APP.route("/index") def index(): return render_template("/index.html") @@ -164,38 +121,6 @@ def install(): content = get_content(_file) return render_template("/content.html", act="install", content=content) - @APP.route("/mss/plots") - def plots(): - if STATIC_LOCATION != "" and os.path.exists(os.path.join(STATIC_LOCATION, 'plots.html')): - _file = os.path.join(STATIC_LOCATION, 'plots.html') - content = get_content(_file) - else: - content = "Gallery was not generated for this server.
    " \ - "For further info on how to generate it, run the " \ - "gallery --help command line parameter of mswms.
    " \ - "An example of the gallery can be seen " \ - "
    here" - return render_template("/content.html", act="plots", content=content) - - @APP.route("/mss/code/") - def code(filename): - download = request.args.get("download", False) - _file = werkzeug.security.safe_join(STATIC_LOCATION, "code", filename) - if _file is None: - abort(404) - content = get_content(_file) - if not download: - return render_template("/content.html", act="code", content=content) - else: - if not os.path.isfile(_file): - abort(404) - with open(_file) as f: - text = f.read() - return Response("".join([s.replace("\t", "", 1) for s in text.split("```python")[-1] - .splitlines(keepends=True)][1:-2]), - mimetype="text/plain", - headers={"Content-disposition": f"attachment; filename={filename.split('-')[0]}.py"}) - @APP.route("/mss/help") def help(): # noqa: A001 _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'help.md') diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py index b034b3b27..987bf42f7 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -30,7 +30,7 @@ import mslib -from flask import Flask +from flask import Flask, url_for from mslib.mscolab.conf import mscolab_settings from flask_sqlalchemy import SQLAlchemy from mslib.mswms.gallery_builder import STATIC_LOCATION @@ -65,3 +65,14 @@ db = SQLAlchemy(APP) migrate = Migrate(APP, db, render_as_batch=True) + + +def get_topmenu(): + menu = [ + (url_for('index'), 'Mission Support System', + ((url_for('about'), 'About'), + (url_for('install'), 'Install'), + (url_for('help'), 'Help'), + )), + ] + return menu diff --git a/mslib/mswms/app/__init__.py b/mslib/mswms/app/__init__.py index e53968228..1f047ff40 100644 --- a/mslib/mswms/app/__init__.py +++ b/mslib/mswms/app/__init__.py @@ -27,7 +27,7 @@ import os import mslib -from flask import Flask +from flask import Flask, url_for from mslib.mswms.gallery_builder import STATIC_LOCATION from mslib.utils import prefix_route @@ -42,3 +42,16 @@ static_folder=STATIC_LOCATION) APP.config.from_object(__name__) APP.route = prefix_route(APP.route, SCRIPT_NAME) + + +def get_topmenu(): + menu = [ + (url_for('index'), 'Mission Support System', + ((url_for('about'), 'About'), + (url_for('install'), 'Install'), + (url_for("plots"), 'Gallery'), + (url_for('help'), 'Help'), + )), + ] + + return menu diff --git a/mslib/mswms/wms.py b/mslib/mswms/wms.py index fc06e19f4..4f3c7e8f0 100644 --- a/mslib/mswms/wms.py +++ b/mslib/mswms/wms.py @@ -49,6 +49,7 @@ import shutil import tempfile import traceback +import werkzeug import urllib.parse from xml.etree import ElementTree @@ -56,11 +57,11 @@ from owslib.crs import axisorder_yx from PIL import Image import numpy as np -from flask import request, make_response, render_template +from flask import request, make_response, render_template, Response, abort from flask_httpauth import HTTPBasicAuth - from multidict import CIMultiDict from mslib.utils import conditional_decorator +from mslib.utils.get_content import get_content from mslib.utils.time import parse_iso_datetime from mslib.index import create_app from mslib.mswms.gallery_builder import add_image, write_html, add_levels, add_times, \ @@ -998,3 +999,37 @@ def application(): for response_header in response_headers: res.headers[response_header[0]] = response_header[1] return res + + +@app.route("/mss/plots") +def plots(): + if STATIC_LOCATION != "" and os.path.exists(os.path.join(STATIC_LOCATION, 'plots.html')): + _file = os.path.join(STATIC_LOCATION, 'plots.html') + content = get_content(_file) + else: + content = "Gallery was not generated for this server.
    " \ + "For further info on how to generate it, run the " \ + "gallery --help command line parameter of mswms.
    " \ + "An example of the gallery can be seen " \ + "here" + return render_template("/content.html", act="plots", content=content) + + +@app.route("/mss/code/") +def code(filename): + download = request.args.get("download", False) + _file = werkzeug.security.safe_join(STATIC_LOCATION, "code", filename) + if _file is None: + abort(404) + content = get_content(_file) + if not download: + return render_template("/content.html", act="code", content=content) + else: + if not os.path.isfile(_file): + abort(404) + with open(_file) as f: + text = f.read() + return Response("".join([s.replace("\t", "", 1) for s in text.split("```python")[-1] + .splitlines(keepends=True)][1:-2]), + mimetype="text/plain", + headers={"Content-disposition": f"attachment; filename={filename.split('-')[0]}.py"}) diff --git a/mslib/utils/get_content.py b/mslib/utils/get_content.py new file mode 100644 index 000000000..9bac2ae13 --- /dev/null +++ b/mslib/utils/get_content.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.get_content + ~~~~~~~~~~~~~~~~~~~~~~~ + + Returns the content of a markdown file as html + + This file is part of MSS. + + :copyright: Copyright 2020-2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import codecs +import os + +from markdown import Markdown + + +def get_content(filename, md_overrides=None, html_overrides=None): + markdown = Markdown(extensions=["fenced_code"]) + content = "" + if os.path.isfile(filename): + with codecs.open(filename, 'r', 'utf-8') as f: + md_data = f.read() + md_data = md_data.replace(':ref:', '') + if md_overrides is not None: + v1, v2 = md_overrides + md_data = md_data.replace(v1, v2) + content = markdown.convert(md_data) + if html_overrides is not None: + v1, v2 = html_overrides + content = content.replace(v1, v2) + return content From 6df4172c34ab5f8428e6c30ab0c3c578cfb70f6b Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 16 Feb 2024 18:52:18 +0100 Subject: [PATCH 118/176] catch exception when USE_SAML2 is True but configuration not completed (#2214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * catch exception * improved * Update mslib/mscolab/server.py Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> --------- Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> --- mslib/mscolab/server.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 628982370..424fd7cad 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -833,10 +833,13 @@ def acs_post_handler(): # Implementation for handling configured SAML assertion consumer endpoints for idp_config in setup_saml2_backend.CONFIGURED_IDPS: - for assertion_consumer_endpoint in idp_config['idp_data']['assertion_consumer_endpoints']: - # Dynamically add the route for the current endpoint - APP.add_url_rule(f'/{assertion_consumer_endpoint}/', assertion_consumer_endpoint, - create_acs_post_handler(idp_config), methods=['POST']) + try: + for assertion_consumer_endpoint in idp_config['idp_data']['assertion_consumer_endpoints']: + # Dynamically add the route for the current endpoint + APP.add_url_rule(f'/{assertion_consumer_endpoint}/', assertion_consumer_endpoint, + create_acs_post_handler(idp_config), methods=['POST']) + except (NameError, AttributeError, KeyError) as ex: + logging.warning("USE_SAML2 is %s, Failure is: %s", mscolab_settings.USE_SAML2, ex) @APP.route('/idp_login_auth/', methods=['POST']) def idp_login_auth(): From 6dc1cb80b73a148c520f23fa889168ab5fb9251d Mon Sep 17 00:00:00 2001 From: Rohit prasad Date: Tue, 20 Feb 2024 13:26:35 +0530 Subject: [PATCH 119/176] Fix: Refined QMessageBox mocking (#2220) --- tests/_test_msui/test_wms_control.py | 40 ++++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index fbff5d98b..353c04a91 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -112,45 +112,45 @@ def setup(self, qapp): yield self._teardown() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_no_server(self, mockbox): + def test_no_server(self): """ assert that a message box informs about server troubles """ - self.query_server(f"{self.scheme}://{self.host}:{self.port-1}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.scheme}://{self.host}:{self.port-1}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_no_schema(self, mockbox): + def test_no_schema(self): """ assert that a message box informs about server troubles """ - self.query_server(f"{self.host}:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.host}:{self.port}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_invalid_schema(self, mockbox): + def test_invalid_schema(self): """ assert that a message box informs about server troubles """ - self.query_server(f"hppd://{self.host}:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"hppd://{self.host}:{self.port}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_invalid_url(self, mockbox): + def test_invalid_url(self): """ assert that a message box informs about server troubles """ - self.query_server(f"{self.scheme}://???{self.host}:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.scheme}://???{self.host}:{self.port}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_connection_error(self, mockbox): + def test_connection_error(self): """ assert that a message box informs about server troubles """ - self.query_server(f"{self.scheme}://.....{self.host}:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.scheme}://.....{self.host}:{self.port}") + mock_critical.assert_called_once() @pytest.mark.skip("Breaks other tests in this class because of a lingering message box, for some reason") def test_forward_backward_clicks(self): From d4d7722a2bcf35946ec98d511ac912fe7b6f4c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Tue, 20 Feb 2024 15:19:35 +0100 Subject: [PATCH 120/176] Enable dependabot updates for GitHub Actions (#2223) This configuration will check the GitHub Actions workflows for outdated versions of actions and will open update PRs for those every week on Monday (the current default for a weekly schedule). --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5ace4600a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From 389a0953e78a45e00329e4cda31c7c4d8f3fa165 Mon Sep 17 00:00:00 2001 From: "Jets@ps2004" <120110796+Preetam-Das26@users.noreply.github.com> Date: Tue, 20 Feb 2024 22:17:45 +0530 Subject: [PATCH 121/176] Return aware datetime objects (#2209) * 15/02/2024 22:14 * 16/02/2024 14:34 * 16/02/2024 18:06 * 16/02/2024 18:06 * 16/02/2024 20:13 * 16/02/2024 20:13 * 16/02/2024 21:59 * 16/02/2024 22:53 * 20/02/2024 11:39 * 20/02/2024 14:01 --- mslib/mscolab/models.py | 23 ++++++++++++++++++++--- tests/_test_mscolab/test_file_manager.py | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 893c393a0..8d1cc133b 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -28,11 +28,28 @@ import datetime import logging import jwt + from passlib.apps import custom_app_context as pwd_context +import sqlalchemy.types + from mslib.mscolab.app import db from mslib.mscolab.message_type import MessageType +class AwareDateTime(sqlalchemy.types.TypeDecorator): + impl = sqlalchemy.types.DateTime + + def process_bind_param(self, value, dialect): + if value is not None: + return value.astimezone(datetime.timezone.utc) + return value + + def process_result_value(self, value, dialect): + if value is not None: + return value.replace(tzinfo=datetime.timezone.utc) + return value + + class User(db.Model): __tablename__ = 'users' @@ -40,9 +57,9 @@ class User(db.Model): username = db.Column(db.String(255)) emailid = db.Column(db.String(255), unique=True) password = db.Column(db.String(255), unique=True) - registered_on = db.Column(db.DateTime, nullable=False) + registered_on = db.Column(AwareDateTime, nullable=False) confirmed = db.Column(db.Boolean, nullable=False, default=False) - confirmed_on = db.Column(db.DateTime, nullable=True) + confirmed_on = db.Column(AwareDateTime, nullable=True) permissions = db.relationship('Permission', cascade='all,delete,delete-orphan', backref='user') authentication_backend = db.Column(db.String(255), nullable=False, default='local') @@ -145,7 +162,7 @@ def __init__(self, path, description, last_used=None, category="default", active self.category = category self.active = active if self.last_used is None: - self.last_used = datetime.datetime.utcnow() + self.last_used = datetime.datetime.now(tz=datetime.timezone.utc) else: self.last_used = last_used diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index a91db1959..424aea351 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -79,7 +79,7 @@ def test_modify_user(self): self.fm.modify_user(user_query, attribute="confirmed", value=True) user_query = User.query.filter_by(id=user.id).first() assert user_query.confirmed is True - assert user_query.confirmed_on.replace(tzinfo=None) == confirm_time.replace(tzinfo=None) + assert user_query.confirmed_on == confirm_time assert user_query.confirmed_on > user_query.registered_on # deleting the user self.fm.modify_user(user_query, action="delete") From d52483c15812ff46daff20ca493608a29fede457 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Feb 2024 21:39:56 +0100 Subject: [PATCH 122/176] Bump actions/setup-python from 4 to 5 (#2224) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-flake8.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index 94ef3b712..06a0c4454 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Lint with flake8 From c439a5f9a70cbc244431ca5e8f257ebe0b6d36eb Mon Sep 17 00:00:00 2001 From: Aryan Gupta <113938175+workaryangupta@users.noreply.github.com> Date: Wed, 21 Feb 2024 12:46:25 +0530 Subject: [PATCH 123/176] Hardcoded img conventionf or route (#2218) --- mslib/index.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mslib/index.py b/mslib/index.py index b5e2cce36..abcc6516e 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -89,11 +89,8 @@ def files(name, filename): abort(404) return send_from_directory(base_path, filename) - @APP.route('/mss_theme//', defaults=dict(filename='')) - @APP.route('/mss_theme//') - def mss_theme(name, filename): - if name != 'img': - abort(404) + @APP.route('/mss_theme/img/') + def mss_theme(filename): base_path = os.path.join(DOCS_SERVER_PATH, 'static', 'img') return send_from_directory(base_path, filename) From 24fb70643e10c39fb51112cb9fffc124c1f6a536 Mon Sep 17 00:00:00 2001 From: "Jets@ps2004" <120110796+Preetam-Das26@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:31:28 +0530 Subject: [PATCH 124/176] Do not use datetime.datetime.utcnow() (#2225) use always tz=datetime.timezone.utc --- mslib/mscolab/file_manager.py | 5 +++-- mslib/mscolab/models.py | 2 +- mslib/mscolab/seed.py | 5 ++++- mslib/mscolab/server.py | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index abfde98e1..1e1917bbe 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -64,7 +64,7 @@ def create_operation(self, path, description, user, last_used=None, content=None if proj_available is not None: return False if last_used is None: - last_used = datetime.datetime.utcnow() + last_used = datetime.datetime.now(tz=datetime.timezone.utc) operation = Operation(path, description, last_used, category, active=active) db.session.add(operation) db.session.flush() @@ -120,7 +120,8 @@ def list_operations(self, user, skip_archived=False): for permission in permissions: operation = Operation.query.filter_by(id=permission.op_id).first() if operation.last_used is not None and ( - datetime.datetime.utcnow() - operation.last_used).days > mscolab_settings.ARCHIVE_THRESHOLD: + datetime.datetime.now(tz=datetime.timezone.utc) - operation.last_used + ).days > mscolab_settings.ARCHIVE_THRESHOLD: # outdated OPs get archived self.update_operation(permission.op_id, "active", False, user) # new query to get uptodate data diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 8d1cc133b..bec78f606 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -149,7 +149,7 @@ class Operation(db.Model): category = db.Column(db.String(255)) description = db.Column(db.String(255)) active = db.Column(db.Boolean) - last_used = db.Column(db.DateTime) + last_used = db.Column(AwareDateTime) def __init__(self, path, description, last_used=None, category="default", active=True): """ diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index dbef1d7ff..5c8165abc 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -218,7 +218,10 @@ def archive_operation(path=None, emailid=None): elif perm.access_level != "creator": return False operation.active = False - operation.last_used = datetime.datetime.utcnow() - dateutil.relativedelta.relativedelta(months=2) + operation.last_used = ( + datetime.datetime.now(tz=datetime.timezone.utc) - + dateutil.relativedelta.relativedelta(months=2) + ) db.session.commit() diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 424fd7cad..77b6b41ad 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -426,7 +426,7 @@ def create_operation(): description = request.form.get('description', None) category = request.form.get('category', "default") active = (request.form.get('active', "True") == "True") - last_used = datetime.datetime.utcnow() + last_used = datetime.datetime.now(tz=datetime.timezone.utc) user = g.user r = str(fm.create_operation(path, description, user, last_used, content=content, category=category, active=active)) @@ -546,7 +546,7 @@ def set_last_used(): user = g.user days_ago = int(request.form.get('days', 0)) fm.update_operation(int(op_id), 'last_used', - datetime.datetime.utcnow() - datetime.timedelta(days=days_ago), + datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=days_ago), user) if days_ago > mscolab_settings.ARCHIVE_THRESHOLD: fm.update_operation(int(op_id), "active", False, user) From f5008ff282f8f2b716d2df8d904e3d960fd9d34e Mon Sep 17 00:00:00 2001 From: Maira Usman Date: Fri, 23 Feb 2024 10:14:09 +0500 Subject: [PATCH 125/176] Updated urls in document `NOTICE` (#2228) Signed-off-by: Myrausman --- NOTICE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index a28a0596b..7622905bf 100755 --- a/NOTICE +++ b/NOTICE @@ -107,8 +107,8 @@ Icons We use icons in mslib.mui.editor from the tango-icon-library Author: Jakub Steiner jimmac@gmail.com -License: https://github.com/freedesktop/tango-icon-library/blob/master/COPYING.PublicDomain -Further Information: http://tango.freedesktop.org +License: https://gitlab.freedesktop.org/tango/tango-icon-library/-/blob/master/COPYING.PublicDomain +Further Information: https://gitlab.freedesktop.org/tango/tango-icon-library Airports Data ------------- From 09758d0dc48a885d62b5c724204e1195f22f34a0 Mon Sep 17 00:00:00 2001 From: So njaGi singer <54586339+gisi90@users.noreply.github.com> Date: Fri, 23 Feb 2024 13:50:00 +0100 Subject: [PATCH 126/176] Fix typo for flight level coordinate in netCDF4tools.py (#2235) --- mslib/utils/netCDF4tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/utils/netCDF4tools.py b/mslib/utils/netCDF4tools.py index b7155325e..8ca8cb865 100644 --- a/mslib/utils/netCDF4tools.py +++ b/mslib/utils/netCDF4tools.py @@ -37,7 +37,7 @@ "pl": "atmosphere_pressure_coordinate", "pv": "atmosphere_ertel_potential_vorticity_coordinate", "tl": "atmosphere_potential_temperature_coordinate", - "fl": "fligth_level_coordinate", + "fl": "flight_level_coordinate", } # NETCDF FILE TOOLS From 5ef7bf824453b7644e94b7e95f7539d208551bdf Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Fri, 23 Feb 2024 19:38:13 +0530 Subject: [PATCH 127/176] Refactor: Implement consistent usage of mscolab_settings.ARCHIVE_THRESHOLD in seed module (#2234) --- mslib/mscolab/seed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index 5c8165abc..4be17c114 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -220,7 +220,7 @@ def archive_operation(path=None, emailid=None): operation.active = False operation.last_used = ( datetime.datetime.now(tz=datetime.timezone.utc) - - dateutil.relativedelta.relativedelta(months=2) + dateutil.relativedelta.relativedelta(days=mscolab_settings.ARCHIVE_THRESHOLD) ) db.session.commit() From 6c762b3cbbf957f455f17fdf43a752a5c59b7072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 26 Feb 2024 08:44:14 +0100 Subject: [PATCH 128/176] Remove all occurrences of processEvents in tests/ (#2237) * Remove all occurrences of processEvents in tests/ * Add a test that ensures no test uses processEvents --- tests/_test_msui/test_editor.py | 1 - .../_test_msui/test_kmloverlay_dockwidget.py | 16 +---- tests/_test_msui/test_linearview.py | 19 +----- tests/_test_msui/test_mscolab.py | 61 ------------------- tests/_test_msui/test_mscolab_admin_window.py | 22 ------- .../test_mscolab_merge_waypoints.py | 11 +--- tests/_test_msui/test_mscolab_operation.py | 11 ---- .../test_mscolab_save_merge_points.py | 6 +- .../test_mscolab_version_history.py | 20 ------ tests/_test_msui/test_mss.py | 3 +- tests/_test_msui/test_msui.py | 16 ----- .../test_multiple_flightpath_dockwidget.py | 5 +- tests/_test_msui/test_satellite_dockwidget.py | 8 +-- tests/_test_msui/test_sideview.py | 27 +------- tests/_test_msui/test_suffix.py | 6 +- tests/_test_msui/test_tableview.py | 17 ------ tests/_test_msui/test_topview.py | 58 ------------------ tests/_test_msui/test_updater.py | 1 - tests/_test_msui/test_wms_capabilities.py | 6 +- tests/_test_msui/test_wms_control.py | 36 +---------- tests/test_meta.py | 39 ++++++++++++ 21 files changed, 50 insertions(+), 339 deletions(-) create mode 100644 tests/test_meta.py diff --git a/tests/_test_msui/test_editor.py b/tests/_test_msui/test_editor.py index 2d1bfba5b..fa13e1934 100644 --- a/tests/_test_msui/test_editor.py +++ b/tests/_test_msui/test_editor.py @@ -50,7 +50,6 @@ def setup(self, qapp): if os.path.exists(self.save_file_name): os.remove(self.save_file_name) self.window.hide() - QtWidgets.QApplication.processEvents() @mock.patch("mslib.msui.editor.get_open_filename", return_value=sample_file) def test_file_open(self, mockfile): diff --git a/tests/_test_msui/test_kmloverlay_dockwidget.py b/tests/_test_msui/test_kmloverlay_dockwidget.py index 4440f32bb..7366be364 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -29,7 +29,7 @@ import fs import mock import pytest -from PyQt5 import QtWidgets, QtCore, QtTest, QtGui +from PyQt5 import QtCore, QtTest, QtGui from tests.constants import ROOT_DIR import mslib.msui.kmloverlay_dockwidget as kd @@ -50,14 +50,11 @@ def setup(self, qapp): self.window = kd.KMLOverlayControlWidget(view=self.view) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) # start load test self.window.select_all() self.window.remove_file() - QtWidgets.QApplication.processEvents() yield - QtWidgets.QApplication.processEvents() self.window.close() if os.path.exists(save_kml): os.remove(save_kml) @@ -70,7 +67,6 @@ def select_file(self, file): # Utility function for single file filename = (path,) # converted to tuple self.window.select_file(filename) self.window.load_file() - QtWidgets.QApplication.processEvents() return path def select_files(self): # Utility function for multiple files @@ -78,13 +74,11 @@ def select_files(self): # Utility function for multiple files path = fs.path.join(sample_path, sample) filename = (path,) # converted to tuple self.window.select_file(filename) - QtWidgets.QApplication.processEvents() @mock.patch("mslib.msui.kmloverlay_dockwidget.get_open_filenames", return_value=[fs.path.join(sample_path, "line.kml")]) def test_get_file(self, mockopen): # Tests opening of QFileDialog QtTest.QTest.mouseClick(self.window.btSelectFile, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 def test_select_file(self): @@ -117,7 +111,6 @@ def test_select_file_error(self): filename = (path,) # converted to tuple self.window.select_file(filename) self.window.load_file() - QtWidgets.QApplication.processEvents() critbox.assert_called_once() self.window.listWidget.clear() self.window.dict_files = {} @@ -127,7 +120,6 @@ def test_remove_file(self): Test removing all files except one """ self.select_files() - QtWidgets.QApplication.processEvents() self.window.listWidget.item(0).setCheckState(QtCore.Qt.Unchecked) QtTest.QTest.mouseClick(self.window.pushButton_remove, QtCore.Qt.LeftButton) assert self.window.listWidget.count() == 1 @@ -140,10 +132,8 @@ def test_remove_all_files(self): Test removing all files """ self.select_files() - QtWidgets.QApplication.processEvents() assert self.window.listWidget.count() == 5 QtTest.QTest.mouseClick(self.window.pushButton_remove, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.window.listWidget.count() == 0 # No items in list assert self.window.dict_files == {} # Dictionary should be empty assert self.count_patches() == 0 @@ -154,9 +144,7 @@ def test_merge_file(self, mocksave): Test merging files into a single file without crashing """ self.select_files() - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.pushButton_merge, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mocksave.call_count == 1 assert os.path.exists(save_kml) @@ -174,11 +162,9 @@ def test_customize_kml(self, mock_colour_button): QtTest.QTest.mouseClick(self.window.listWidget.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) - QtWidgets.QApplication.processEvents() # Clicking on Push Button Colour QtTest.QTest.mouseClick(self.window.pushButton_color, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mock_colour_button.call_count == 1 # Testing the Double Spin Box for linewidth diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index d6ae17ceb..0bed2923a 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -30,7 +30,7 @@ import pytest import shutil import tempfile -from PyQt5 import QtWidgets, QtTest, QtCore +from PyQt5 import QtTest, QtCore from mslib.msui import flighttrack as ft import mslib.msui.linearview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_LINEARVIEW @@ -42,12 +42,9 @@ class Test_MSS_LV_Options_Dialog: def setup(self, qapp): self.window = tv.MSUI_LV_Options_Dialog(settings=_DEFAULT_SETTINGS_LINEARVIEW) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_show(self): pass @@ -67,28 +64,21 @@ def setup(self, qapp): self.window = tv.MSUILinearViewWindow(model=waypoints_model) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() def test_mouse_over(self): # Test mouse over QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(782, 266), -1) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(100, 100), -1) - QtWidgets.QApplication.processEvents() @mock.patch("mslib.msui.linearview.MSUI_LV_Options_Dialog") def test_options(self, mockdlg): QtTest.QTest.mouseClick(self.window.lvoptionbtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockdlg.call_count == 1 assert mockdlg.return_value.setModal.call_count == 1 assert mockdlg.return_value.exec_.call_count == 1 @@ -109,25 +99,18 @@ def setup(self, qapp, mswms_server): 0, rows=len(initial_waypoints), waypoints=initial_waypoints) self.window = tv.MSUILinearViewWindow(model=waypoints_model) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") yield self.window.hide() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) def query_server(self, url): - QtWidgets.QApplication.processEvents() QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) def test_server_getmap(self, qtbot): diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 5b8a8fc23..1d9f84231 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -71,7 +71,6 @@ def setup(self, qapp, mscolab_server): self.main_window.mscolab.logout() self.window.hide() self.main_window.hide() - QtWidgets.QApplication.processEvents() def test_url_combo(self): assert self.window.urlCb.count() >= 1 @@ -114,7 +113,6 @@ def test_login(self): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) - QtWidgets.QApplication.processEvents() # show logged in widgets assert self.main_window.usernameLabel.text() == self.userdata[1] assert self.main_window.connectBtn.isVisible() is False @@ -128,7 +126,6 @@ def test_login_with_different_account_shows_update_credentials_popup(self, mockb self._connect_to_mscolab() connect_window = self.main_window.mscolab.connect_window self._login(self.userdata[0], self.userdata[2]) - QtWidgets.QApplication.processEvents() mockbox.assert_called_once_with( connect_window, "Update Credentials", @@ -149,11 +146,9 @@ def test_logout_action_trigger(self): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) - QtWidgets.QApplication.processEvents() assert self.main_window.usernameLabel.text() == self.userdata[1] # Logout self.main_window.mscolab.logout_action.trigger() - QtWidgets.QApplication.processEvents() assert self.main_window.listOperationsMSC.model().rowCount() == 0 assert self.main_window.mscolab.conn is None assert self.main_window.local_active is True @@ -164,7 +159,6 @@ def test_logout(self): self._connect_to_mscolab() modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) - QtWidgets.QApplication.processEvents() assert self.main_window.usernameLabel.text() == self.userdata[1] # Logout self.main_window.mscolab.logout() @@ -224,30 +218,22 @@ def _connect_to_mscolab(self, password=""): self.window.urlCb.setEditText(self.url) self.window.httpPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _login(self, emailid="a", password="a"): self.window.loginEmailLe.setText(emailid) self.window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _create_user(self, username, email, password): QtTest.QTest.mouseClick(self.window.addUserBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() self.window.newUsernameLe.setText(str(username)) - QtWidgets.QApplication.processEvents() self.window.newEmailLe.setText(str(email)) - QtWidgets.QApplication.processEvents() self.window.newPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() self.window.newConfirmPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() okWidget = self.window.newUserBb.button(self.window.newUserBb.Ok) QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() class Test_Mscolab: @@ -316,16 +302,12 @@ def test_view_open(self): # test after activating operation self._activate_operation_at_index(0) self.window.actionTableView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 1 self.window.actionTopView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 2 self.window.actionSideView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 3 self.window.actionLinearView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 4 operation = self.window.mscolab.active_op_id @@ -353,7 +335,6 @@ def test_handle_export(self, mockbox): self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.actionExportFlightTrackFTML.trigger() - QtWidgets.QApplication.processEvents() exported_waypoints = WaypointsTableModel(filename=fs.path.join(self.window.mscolab.data_dir, 'test_export.ftml')) wp_count = len(self.window.mscolab.waypoints_model.waypoints) @@ -395,14 +376,11 @@ def test_work_locally_toggle(self): self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) wpdata_local = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) wpdata_server = self.window.mscolab.waypoints_model.waypoint_data(0) assert wpdata_local.lat != wpdata_server.lat @@ -414,15 +392,10 @@ def test_browse_add_operation(self, mockopen, qtbot): self._create_user(qtbot, "something", "something@something.org", "something") assert self.window.listOperationsMSC.model().rowCount() == 0 self.window.actionAddOperation.trigger() - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.path.setText(str("example")) - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.description.setText(str("example")) - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.category.setText(str("example")) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mscolab.add_proj_dialog.browse, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: @@ -430,7 +403,6 @@ def test_browse_add_operation(self, mockopen, qtbot): m.assert_called_once() # we need to wait for the update of the operation list QtTest.QTest.qWait(200) - QtWidgets.QApplication.processEvents() assert self.window.listOperationsMSC.model().rowCount() == 1 item = self.window.listOperationsMSC.item(0) assert item.operation_path == "example" @@ -476,13 +448,11 @@ def test_handle_delete_operation(self, mocktext, qtbot): assert self.window.listOperationsMSC.model().rowCount() == 1 with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionDeleteOperation.trigger() - QtWidgets.QApplication.processEvents() qtbot.wait_until( lambda: m.assert_called_once_with(self.window, "Success", 'Operation "flight7" was deleted!') ) op_id = self.window.mscolab.get_recent_op_id() assert op_id is None - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) # check operation dir name removed assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) is False @@ -493,7 +463,6 @@ def test_handle_leave_operation(self, mockmessage, qtbot): modify_config_file({"MSS_auth": {self.url: self.userdata3[0]}}) self._login(self.userdata3[0], self.userdata3[2]) - QtWidgets.QApplication.processEvents() assert self.window.usernameLabel.text() == self.userdata3[1] assert self.window.connectBtn.isVisible() is False @@ -504,14 +473,11 @@ def test_handle_leave_operation(self, mockmessage, qtbot): assert op_id is not None self.window.actionTopView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 1 self.window.actionSideView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 2 self.window.actionLeaveOperation.trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) def assert_leave_operation_done(): @@ -531,7 +497,6 @@ def test_handle_rename_operation(self, mocktext, qtbot): assert self.window.mscolab.active_op_id is not None with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionRenameOperation.trigger() - QtWidgets.QApplication.processEvents() m.assert_called_once_with(self.window, "Rename successful", "Operation is renamed successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None @@ -548,7 +513,6 @@ def test_update_description(self, mocktext, qtbot): assert self.window.mscolab.active_op_id is not None with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionChangeDescription.trigger() - QtWidgets.QApplication.processEvents() m.assert_called_once_with(self.window, "Update successful", "Description is updated successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None @@ -566,7 +530,6 @@ def test_update_category(self, mocktext, qtbot): assert self.window.mscolab.active_op_id is not None with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionChangeCategory.trigger() - QtWidgets.QApplication.processEvents() m.assert_called_once_with(self.window, "Update successful", "Category is updated successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None @@ -585,7 +548,6 @@ def test_any_special_category(self, qtbot): range(self.window.mscolab.ui.listOperationsMSC.count())] assert ["flight1234", "flight5678"] == operation_pathes self.window.mscolab.ui.filterCategoryCb.setCurrentIndex(2) - QtWidgets.QApplication.processEvents() # only operation of furtherexample are found assert self.window.mscolab.selected_category == "furtherexample" operation_pathes = [self.window.mscolab.ui.listOperationsMSC.item(i).operation_path for i in @@ -633,7 +595,6 @@ def test_open_chat_window(self, mockbox, qtbot): self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None self.window.actionChat.trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) assert self.window.mscolab.chat_window is not None @@ -646,7 +607,6 @@ def test_close_chat_window(self, mockbox, qtbot): self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None self.window.actionChat.trigger() - QtWidgets.QApplication.processEvents() self.window.mscolab.close_chat_window() assert self.window.mscolab.chat_window is None @@ -672,7 +632,6 @@ def test_user_delete(self, mockmessage, qtbot): u_id = self.window.mscolab.user['id'] self.window.mscolab.open_profile_window() QtTest.QTest.mouseClick(self.window.mscolab.profile_dialog.deleteAccountBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.window.listOperationsMSC.model().rowCount() == 0 assert self.window.usernameLabel.isVisible() is False assert self.window.connectBtn.isVisible() is True @@ -682,18 +641,14 @@ def test_user_delete(self, mockmessage, qtbot): def test_open_help_dialog(self): self.window.actionMSColabHelp.trigger() - QtWidgets.QApplication.processEvents() assert self.window.mscolab.help_dialog is not None def test_close_help_dialog(self): self.window.actionMSColabHelp.trigger() - QtWidgets.QApplication.processEvents() assert self.window.mscolab.help_dialog is not None QtTest.QTest.mouseClick( self.window.mscolab.help_dialog.okayBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(50) - QtWidgets.QApplication.processEvents() assert self.window.mscolab.help_dialog is None def test_create_dir_exceptions(self): @@ -718,7 +673,6 @@ def test_profile_dialog(self, qtbot): modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self.window.mscolab.profile_action.trigger() - QtWidgets.QApplication.processEvents() # case: default gravatar is set and no messagebox is called assert self.window.mscolab.prof_diag is not None # case: trying to fetch non-existing gravatar @@ -733,30 +687,22 @@ def _connect_to_mscolab(self): self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _create_user(self, qtbot, username, email, password): QtTest.QTest.mouseClick(self.connect_window.addUserBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() self.connect_window.newUsernameLe.setText(str(username)) - QtWidgets.QApplication.processEvents() self.connect_window.newEmailLe.setText(str(email)) - QtWidgets.QApplication.processEvents() self.connect_window.newPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() self.connect_window.newConfirmPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() okWidget = self.connect_window.newUserBb.button(self.connect_window.newUserBb.Ok) QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() def assert_user_created(): assert self.window.usernameLabel.text() == username @@ -765,17 +711,12 @@ def assert_user_created(): def _create_operation_unchecked(self, path, description, category="example"): self.window.actionAddOperation.trigger() - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.path.setText(str(path)) - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.description.setText(str(description)) - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.category.setText(category) - QtWidgets.QApplication.processEvents() okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() def _create_operation(self, qtbot, path, description, category="example"): self.total_created_operations = self.total_created_operations + 1 @@ -795,6 +736,4 @@ def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index e29a3d1b0..c1b916fc5 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -69,10 +69,8 @@ def setup(self, qapp, mscolab_server): # activate operation and open chat window self._activate_operation_at_index(0) self.window.actionManageUsers.trigger() - QtWidgets.QApplication.processEvents() self.admin_window = self.window.mscolab.admin_window QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.mscolab.logout() if self.window.mscolab.admin_window: @@ -81,19 +79,16 @@ def setup(self, qapp, mscolab_server): self.window.mscolab.conn.disconnect() with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): self.window.close() - QtWidgets.QApplication.processEvents() def test_permission_filter(self): len_added_users = self.admin_window.modifyUsersTable.rowCount() # Change filter to viewer self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("viewer") - QtWidgets.QApplication.processEvents() # Check how many users are visible visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 1 # Change it back to all self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("all") - QtWidgets.QApplication.processEvents() # Check how many rows are visible visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == len_added_users @@ -103,33 +98,27 @@ def test_text_search_filter(self): len_added_users = self.admin_window.modifyUsersTable.rowCount() # Text Search in add users Table QtTest.QTest.keyClicks(self.admin_window.addUsersSearch, "name1") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.addUsersTable) assert visible_row_count == 1 self.admin_window.addUsersSearch.setText("") QtTest.QTest.keyClicks(self.admin_window.addUsersSearch, "") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.addUsersTable) assert visible_row_count == len_unadded_users # Text Search in modify users Table QtTest.QTest.keyClicks(self.admin_window.modifyUsersSearch, "example") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 1 self.admin_window.modifyUsersSearch.setText("") QtTest.QTest.keyClicks(self.admin_window.modifyUsersSearch, "") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == len_added_users def test_permission_and_text_together(self): QtTest.QTest.keyClicks(self.admin_window.modifyUsersSearch, "viewer") self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("viewer") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 1 self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("admin") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 0 @@ -143,7 +132,6 @@ def test_add_permissions(self): if index >= 0: self.admin_window.addUsersPermission.setCurrentIndex(index) QtTest.QTest.mouseClick(self.admin_window.addUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Check if they have been added in the modify users table self._check_users_present(self.admin_window.modifyUsersTable, users, "admin") assert len_unadded_users - 2 == self.admin_window.addUsersTable.rowCount() @@ -155,14 +143,12 @@ def test_modify_permissions(self): # Select users in the add users table self._select_users(self.admin_window.addUsersTable, users) QtTest.QTest.mouseClick(self.admin_window.addUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Select users in the modify users table self._select_users(self.admin_window.modifyUsersTable, users) # Update their permission to viewer index = self.admin_window.modifyUsersPermission.findText("viewer", QtCore.Qt.MatchFixedString) self.admin_window.modifyUsersPermission.setCurrentIndex(index) QtTest.QTest.mouseClick(self.admin_window.modifyUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Check if the permission has been updated self._check_users_present(self.admin_window.modifyUsersTable, users, "viewer") QtTest.QTest.qWait(1000) @@ -172,7 +158,6 @@ def test_delete_permissions(self): users = ["name1", "name2"] self._select_users(self.admin_window.addUsersTable, users) QtTest.QTest.mouseClick(self.admin_window.addUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() len_unadded_users = self.admin_window.addUsersTable.rowCount() len_added_users = self.admin_window.modifyUsersTable.rowCount() @@ -180,7 +165,6 @@ def test_delete_permissions(self): self._select_users(self.admin_window.modifyUsersTable, users) # Click on delete permissions QtTest.QTest.mouseClick(self.admin_window.deleteUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Check if the deleted users can be found in the add users table self._check_users_present(self.admin_window.addUsersTable, users) assert len_unadded_users + 2 == self.admin_window.addUsersTable.rowCount() @@ -191,7 +175,6 @@ def test_import_permissions(self): index = self.admin_window.importPermissionsCB.findText("paris", QtCore.Qt.MatchFixedString) self.admin_window.importPermissionsCB.setCurrentIndex(index) QtTest.QTest.mouseClick(self.admin_window.importPermissionsBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) assert self.admin_window.modifyUsersTable.rowCount() == 1 @@ -201,23 +184,19 @@ def _connect_to_mscolab(self): self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _select_users(self, table, users): @@ -227,7 +206,6 @@ def _select_users(self, table, users): if username in users: point = table.visualItemRect(item).center() QtTest.QTest.mouseClick(table.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() assert len(table.selectionModel().selectedRows()) == 2 def _get_visible_row_count(self, table): diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 1803929b5..b70d8ef1b 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -31,7 +31,7 @@ import mslib.utils.auth from mslib.msui import flighttrack as ft from mslib.mscolab.conf import mscolab_settings -from PyQt5 import QtCore, QtTest, QtWidgets +from PyQt5 import QtCore, QtTest from tests.utils import (mscolab_register_and_login, mscolab_create_operation, mscolab_delete_all_operations, mscolab_delete_user) from mslib.msui import mscolab @@ -62,7 +62,6 @@ def setup(self, qapp, mscolab_app, mscolab_server): self.window.mscolab.version_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - QtWidgets.QApplication.processEvents() def _create_user_data(self, emailid='merge@alpha.org'): with self.app.app_context(): @@ -82,7 +81,6 @@ def _connect_to_mscolab(self): self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _login(self, emailid="merge_waypoints_user", password="password"): @@ -90,21 +88,17 @@ def _login(self, emailid="merge_waypoints_user", password="password"): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() def _select_waypoints(self, table): for row in range(table.model().rowCount()): table.selectRow(row) - QtWidgets.QApplication.processEvents() class AutoClickOverwriteMscolabMergeWaypointsDialog(mslib.msui.mscolab.MscolabMergeWaypointsDialog): @@ -212,7 +206,6 @@ def test_fetch_from_server(self): self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) assert wp_local.lat == wp_server_before.lat @@ -225,7 +218,6 @@ def test_fetch_from_server(self): mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: self.window.serverOptionsCb.setCurrentIndex(1) m.assert_called_once() - QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working server_xml = self.window.mscolab.request_wps_from_server() @@ -234,6 +226,5 @@ def test_fetch_from_server(self): assert len(new_local_wp.waypoints) == 2 assert new_local_wp.waypoint_data(0).lat == wp_server_before.lat self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) assert self.window.mscolab.waypoints_model.waypoint_data(0).lat == wp_server_before.lat diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index fc216c865..5cfb70a41 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -65,10 +65,8 @@ def setup(self, qapp, mscolab_app, mscolab_server): # activate operation and open chat window self._activate_operation_at_index(0) self.window.actionChat.trigger() - QtWidgets.QApplication.processEvents() self.chat_window = self.window.mscolab.chat_window QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.mscolab.logout() if self.window.mscolab.chat_window: @@ -76,7 +74,6 @@ def setup(self, qapp, mscolab_app, mscolab_server): if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() self.window.hide() - QtWidgets.QApplication.processEvents() def test_send_message(self, qtbot): self._send_message(qtbot, "**test message**") @@ -90,15 +87,11 @@ def test_search_message(self, qtbot): message_index = self.chat_window.messageList.count() - 1 # self.window.chat_window.searchMessageLineEdit.setText("test message") self.chat_window.searchMessageLineEdit.setText("test message") - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.chat_window.searchPrevBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index).isSelected() is True QtTest.QTest.mouseClick(self.chat_window.searchPrevBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index - 1).isSelected() is True QtTest.QTest.mouseClick(self.chat_window.searchNextBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index).isSelected() is True def test_copy_message(self, qtbot): @@ -148,23 +141,19 @@ def _connect_to_mscolab(self): self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() def _activate_context_menu_action(self, action_index): item = self.chat_window.messageList.item(self.chat_window.messageList.count() - 1) diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index 2844ac286..2ea41030a 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -27,7 +27,7 @@ import mock from tests._test_msui.test_mscolab_merge_waypoints import Test_Mscolab_Merge_Waypoints from mslib.msui import flighttrack as ft -from PyQt5 import QtCore, QtTest, QtWidgets +from PyQt5 import QtCore, QtTest class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): @@ -35,7 +35,6 @@ def test_save_merge_points(self): self.emailid = "mergepoints@alpha.org" self._create_user_data(emailid=self.emailid) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() merge_waypoints_model = None @@ -46,7 +45,6 @@ def handle_merge_dialog(): self._select_waypoints(self.window.mscolab.merge_dialog.serverWaypointsTable) merge_waypoints_model = self.window.mscolab.merge_dialog.merge_waypoints_model QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.saveBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) QtCore.QTimer.singleShot(3000, handle_merge_dialog) @@ -55,7 +53,6 @@ def handle_merge_dialog(): with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: self.window.serverOptionsCb.setCurrentIndex(2) m.assert_called_once() - QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working server_xml = self.window.mscolab.request_wps_from_server() @@ -67,7 +64,6 @@ def handle_merge_dialog(): for wp_index in range(new_wp_count): assert new_local_wp.waypoint_data(wp_index).lat == merge_waypoints_model.waypoint_data(wp_index).lat self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) new_server_wp = self.window.mscolab.waypoints_model assert len(new_server_wp.waypoints) == new_wp_count diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index d29fb9695..7d4be36e8 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -56,11 +56,9 @@ def setup(self, qapp, mscolab_server): # activate operation and open chat window self._activate_operation_at_index(0) self.window.actionVersionHistory.trigger() - QtWidgets.QApplication.processEvents() self.version_window = self.window.mscolab.version_window assert self.version_window is not None QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.mscolab.logout() if self.window.mscolab.version_window: @@ -94,7 +92,6 @@ def test_version_name_delete(self, mockbox): QtTest.QTest.qWait(100) assert self.version_window.changes.currentItem().version_name == "MyVersionName" QtTest.QTest.mouseClick(self.version_window.deleteVersionNameBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) assert self.version_window.changes.count() == 1 assert self.version_window.changes.currentItem().version_name is None @@ -106,18 +103,15 @@ def test_undo_changes(self, mockbox, qtbot): # make changes for i in range(2): self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) def assert_(): self.version_window.load_all_changes() assert self.version_window.changes.count() == 2 qtbot.wait_until(assert_) - QtWidgets.QApplication.processEvents() changes_count = self.version_window.changes.count() self._activate_change_at_index(1) QtTest.QTest.mouseClick(self.version_window.checkoutBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(200) new_changes_count = self.version_window.changes.count() assert changes_count + 1 == new_changes_count @@ -126,13 +120,10 @@ def test_refresh(self): self._change_version_filter(1) changes_count = self.version_window.changes.count() self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) QtTest.QTest.mouseClick(self.version_window.refreshBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) new_changes_count = self.version_window.changes.count() assert new_changes_count == changes_count + 2 @@ -144,7 +135,6 @@ def _connect_to_mscolab(self): self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _login(self, emailid, password): @@ -152,7 +142,6 @@ def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): @@ -160,9 +149,7 @@ def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() def _activate_change_at_index(self, index): assert self.version_window is not None @@ -170,9 +157,7 @@ def _activate_change_at_index(self, index): item = self.version_window.changes.item(index) point = self.version_window.changes.visualItemRect(item).center() QtTest.QTest.mouseClick(self.version_window.changes.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.keyClick(self.version_window.changes.viewport(), QtCore.Qt.Key_Return) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) def _change_version_filter(self, index): @@ -180,18 +165,13 @@ def _change_version_filter(self, index): assert index < self.version_window.versionFilterCB.count() self.version_window.versionFilterCB.setCurrentIndex(index) self.version_window.versionFilterCB.currentIndexChanged.emit(index) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) def _set_version_name(self): self._change_version_filter(1) # make a changes self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) self.version_window.load_all_changes() - QtWidgets.QApplication.processEvents() self._activate_change_at_index(0) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.version_window.nameVersionBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() diff --git a/tests/_test_msui/test_mss.py b/tests/_test_msui/test_mss.py index 5875d31dd..1b636005d 100644 --- a/tests/_test_msui/test_mss.py +++ b/tests/_test_msui/test_mss.py @@ -24,7 +24,7 @@ See the License for the specific language governing permissions and limitations under the License. """ -from PyQt5 import QtWidgets, QtTest, QtCore +from PyQt5 import QtTest, QtCore from mslib.msui import mss @@ -32,4 +32,3 @@ def test_mss_rename_message(qapp): main_window = mss.MSSMainWindow() main_window.show() QtTest.QTest.mouseClick(main_window.pushButton, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index b886050bc..9d065944d 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -78,7 +78,6 @@ def setup(self, qapp): self.tutorial_dir = fs.path.combine(MSUI_CONFIG_PATH, 'tutorial_images') yield self.main_window.hide() - QtWidgets.QApplication.processEvents() def test_tutorial_dir(self): dir_name, name = fs.path.split(self.tutorial_dir) @@ -100,7 +99,6 @@ def setup(self, qapp): self.window = msui_mw.MSUI_AboutDialog() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_milestone_url(self): with urlopen(self.window.milestone_url) as f: @@ -118,7 +116,6 @@ def setup(self, qapp): yield self.shortcuts.hide() self.main_window.hide() - QtWidgets.QApplication.processEvents() def test_shortcuts_present(self): # Assert list gets filled properly @@ -181,9 +178,7 @@ def setup(self, qapp): self.window = msui.MSUIMainWindow() self.window.create_new_flight_track() self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield config_file = os.path.join( self.sample_path, @@ -193,7 +188,6 @@ def setup(self, qapp): for i in range(self.window.listViews.count()): self.window.listViews.item(i).window.hide() self.window.hide() - QtWidgets.QApplication.processEvents() def test_no_updater(self): assert not hasattr(self.window, "updater") @@ -204,47 +198,39 @@ def test_app_start(self): def test_new_flightrack(self): assert self.window.listFlightTracks.count() == 1 self.window.actionNewFlightTrack.trigger() - QtWidgets.QApplication.processEvents() assert self.window.listFlightTracks.count() == 2 def test_open_topview(self): assert self.window.listViews.count() == 0 self.window.actionTopView.trigger() - QtWidgets.QApplication.processEvents() assert self.window.listViews.count() == 1 def test_open_sideview(self): assert self.window.listViews.count() == 0 self.window.actionSideView.trigger() - QtWidgets.QApplication.processEvents() assert self.window.listViews.count() == 1 def test_open_tableview(self): assert self.window.listViews.count() == 0 self.window.actionTableView.trigger() - QtWidgets.QApplication.processEvents() assert self.window.listViews.count() == 1 def test_open_linearview(self): assert self.window.listViews.count() == 0 self.window.actionLinearView.trigger() self.window.listViews.itemActivated.emit(self.window.listViews.item(0)) - QtWidgets.QApplication.processEvents() assert self.window.listViews.count() == 1 def test_open_about(self): self.window.actionAboutMSUI.trigger() - QtWidgets.QApplication.processEvents() def test_open_config(self): self.window.actionConfiguration.trigger() - QtWidgets.QApplication.processEvents() with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): self.window.config_editor.close() def test_open_shortcut(self): self.window.actionShortcuts.trigger() - QtWidgets.QApplication.processEvents() @pytest.mark.parametrize("save_file", [[save_ftml]]) def test_plugin_saveas(self, save_file): @@ -255,7 +241,6 @@ def test_plugin_saveas(self, save_file): assert mocksave.call_count == 0 self.window.last_save_directory = ROOT_DIR self.window.actionSaveActiveFlightTrackAs.trigger() - QtWidgets.QApplication.processEvents() assert mocksave.call_count == 1 assert os.path.exists(save_file[0]) os.remove(save_file[0]) @@ -293,7 +278,6 @@ def test_plugin_export(self, save_file): if obj_name == action.objectName(): action.trigger() break - QtWidgets.QApplication.processEvents() assert mocksave.call_count == 1 assert os.path.exists(save_file[0]) os.remove(save_file[0]) diff --git a/tests/_test_msui/test_multiple_flightpath_dockwidget.py b/tests/_test_msui/test_multiple_flightpath_dockwidget.py index de318a86a..6e05b456d 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -25,7 +25,7 @@ limitations under the License. """ import pytest -from PyQt5 import QtWidgets, QtTest +from PyQt5 import QtTest from mslib.msui import msui from mslib.msui.multiple_flightpath_dockwidget import MultipleFlightpathControlWidget from mslib.msui import flighttrack as ft @@ -47,12 +47,9 @@ def setup(self, qapp): self.widget = tv.MSUITopViewWindow(model=self.waypoints_model, mainwindow=self.window) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_initialization(self): widget = MultipleFlightpathControlWidget(parent=self.widget, diff --git a/tests/_test_msui/test_satellite_dockwidget.py b/tests/_test_msui/test_satellite_dockwidget.py index 6ff633e71..6801b4d9b 100644 --- a/tests/_test_msui/test_satellite_dockwidget.py +++ b/tests/_test_msui/test_satellite_dockwidget.py @@ -28,7 +28,7 @@ import os import mock import pytest -from PyQt5 import QtWidgets, QtCore, QtTest +from PyQt5 import QtCore, QtTest import mslib.msui.satellite_dockwidget as sd @@ -38,30 +38,24 @@ def setup(self, qapp): self.view = mock.Mock() self.window = sd.SatelliteControlWidget(view=self.view) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_load(self): path = os.path.join(os.path.dirname(__file__), "../", "data", "satellite_predictor.txt") self.window.leFile.setText(path) assert self.window.cbSatelliteOverpasses.count() == 0 QtTest.QTest.mouseClick(self.window.btLoadFile, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.window.cbSatelliteOverpasses.count() == 11 assert self.view.plot_satellite_overpass.call_count == 1 self.window.cbSatelliteOverpasses.currentIndexChanged.emit(2) - QtWidgets.QApplication.processEvents() assert self.view.plot_satellite_overpass.call_count == 2 self.view.reset_mock() @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") def test_load_no_file(self, mockbox): QtTest.QTest.mouseClick(self.window.btLoadFile, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.window.cbSatelliteOverpasses.count() == 0 mockbox.assert_called_once_with( self.window, diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 152edd144..1d14864f9 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -31,7 +31,7 @@ import pytest import shutil import tempfile -from PyQt5 import QtWidgets, QtTest, QtCore, QtGui +from PyQt5 import QtTest, QtCore, QtGui from mslib.msui import flighttrack as ft import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW @@ -43,12 +43,9 @@ class Test_MSS_SV_OptionsDialog: def setup(self, qapp): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_show(self): pass @@ -58,24 +55,20 @@ def test_get(self): def test_addLevel(self): QtTest.QTest.mouseClick(self.window.btAdd, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() def test_removeLevel(self): QtTest.QTest.mouseClick(self.window.btDelete, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() def test_getFlightLevels(self): levels = self.window.get_flight_levels() assert all(x == y for x, y in zip(levels, [300, 320, 340])) QtTest.QTest.mouseClick(self.window.btAdd, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() levels = self.window.get_flight_levels() assert all(x == y for x, y in zip(levels, [0, 300, 320, 340])) @mock.patch("PyQt5.QtWidgets.QColorDialog.getColor", return_value=QtGui.QColor()) def test_setColour(self, mockdlg): QtTest.QTest.mouseClick(self.window.btFillColour, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockdlg.call_count == 1 @@ -90,28 +83,21 @@ def setup(self, qapp): self.window = tv.MSUISideViewWindow(model=waypoints_model) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() def test_mouse_over(self): # Test mouse over QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(782, 266), -1) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(20, 20), -1) - QtWidgets.QApplication.processEvents() @mock.patch("mslib.msui.sideview.MSUI_SV_OptionsDialog") def test_options(self, mockdlg): QtTest.QTest.mouseClick(self.window.btOptions, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockdlg.call_count == 1 assert mockdlg.return_value.setModal.call_count == 1 assert mockdlg.return_value.exec_.call_count == 1 @@ -122,18 +108,14 @@ def test_insert_point(self): Test inserting a point inside and outside the canvas """ self.window.mpl.navbar._actions['insert_wp'].trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 point = self.window.mpl.canvas.rect().center() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=QtCore.QPoint(1, 1)) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) # click again on same position - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 def test_y_axes(self): @@ -157,25 +139,18 @@ def setup(self, qapp, mswms_server): 0, rows=len(initial_waypoints), waypoints=initial_waypoints) self.window = tv.MSUISideViewWindow(model=waypoints_model) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") yield self.window.hide() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) def query_server(self, url): - QtWidgets.QApplication.processEvents() QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) def test_server_getmap(self, qtbot): diff --git a/tests/_test_msui/test_suffix.py b/tests/_test_msui/test_suffix.py index b639322d9..33a2cc220 100644 --- a/tests/_test_msui/test_suffix.py +++ b/tests/_test_msui/test_suffix.py @@ -28,7 +28,7 @@ """ import pytest -from PyQt5 import QtWidgets, QtTest +from PyQt5 import QtTest import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW @@ -38,16 +38,12 @@ class Test_SuffixChange: def setup(self, qapp): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_suffixchange(self): suffix = [' hPa', ' km', ' hft'] for i in range(len(suffix)): self.window.cbVerticalAxis.setCurrentIndex(i) - QtWidgets.QApplication.processEvents() assert self.window.sbPtop.suffix() == suffix[i] diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index d1b4a1822..232f6c1c1 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -52,19 +52,15 @@ def setup(self, qapp): self.window = tv.MSUITableViewWindow(model=waypoints_model) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_open_hex(self): """ Tests opening the hexagon dock widget. """ self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() assert len(self.window.docks) == 2 assert self.window.docks[0] is not None assert self.window.docks[1] is None @@ -74,7 +70,6 @@ def test_open_perf_settings(self): Tests opening the performance settings dock widget. """ self.window.cbTools.currentIndexChanged.emit(2) - QtWidgets.QApplication.processEvents() assert len(self.window.docks) == 2 assert self.window.docks[0] is None assert self.window.docks[1] is not None @@ -86,14 +81,11 @@ def test_insertremove_hexagon(self, mockbox): Test inserting and removing hexagons in TableView using the Hexagon dockwidget """ self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 QtTest.QTest.mouseClick(self.window.docks[0].widget().pbAddHexagon, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 12 assert mockbox.call_count == 0 QtTest.QTest.mouseClick(self.window.docks[0].widget().pbRemoveHexagon, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 5 @@ -105,7 +97,6 @@ def test_performance(self, mockopen): Check effect of performance settings on TableView """ self.window.cbTools.currentIndexChanged.emit(2) - QtWidgets.QApplication.processEvents() self.window.waypoints_model.performance_settings = DEFAULT_PERFORMANCE self.window.waypoints_model.update_distances(0) @@ -123,7 +114,6 @@ def test_performance(self, mockopen): assert self.window.waypoints_model.columnCount() == 15 # todo this does not check that actually something happens QtTest.QTest.mouseClick(self.window.docks[1].widget().pbLoadPerformance, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 def test_insert_point(self): @@ -138,7 +128,6 @@ def test_insert_point(self): assert len(self.window.waypoints_model.waypoints) == 5 wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btAddWayPointToFlightTrack, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert len(self.window.waypoints_model.waypoints) == 6 assert all(_x == _y for _x, _y in zip(wps[:3], wps2[:3])), (wps, wps2) @@ -156,7 +145,6 @@ def test_clone_point(self): assert len(self.window.waypoints_model.waypoints) == 5 wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btCloneWaypoint, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert len(self.window.waypoints_model.waypoints) == 6 assert all(_x == _y for _x, _y in zip(wps[:3], wps2[:3])), (wps, wps2) @@ -176,7 +164,6 @@ def test_remove_point(self, mockbox): assert len(self.window.waypoints_model.waypoints) == 5 wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btDeleteWayPoint, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 4 @@ -189,7 +176,6 @@ def test_reverse_points(self): """ wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btInvertDirection, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert all([_x == _y for _x, _y in zip(wps[::-1], wps2)]) @@ -209,15 +195,12 @@ def test_drag_point(self): QtTest.QTest.mousePress( self.window.tableWayPoints.viewport(), QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, item1.center()) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove( self.window.tableWayPoints.viewport(), item2.center()) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseRelease( self.window.tableWayPoints.viewport(), QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, item2.center()) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 wps_after = list(self.window.waypoints_model.waypoints) assert wps_before != wps_after, (wps_before, wps_after) diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index fe03480a0..24de04f73 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -43,12 +43,9 @@ class Test_MSS_TV_MapAppearanceDialog: def setup(self, qapp): self.window = tv.MSUI_TV_MapAppearanceDialog(settings=_DEFAULT_SETTINGS_TOPVIEW) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_show(self): pass @@ -67,71 +64,51 @@ def setup(self, qapp): 0, rows=len(initial_waypoints), waypoints=initial_waypoints) self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() def test_open_sat(self): self.window.cbTools.currentIndexChanged.emit(2) - QtWidgets.QApplication.processEvents() def test_open_rs(self): self.window.cbTools.currentIndexChanged.emit(3) - QtWidgets.QApplication.processEvents() rsdock = self.window.docks[2].widget() - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(rsdock.cbDrawTangents, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() rsdock.dsbTangentHeight.setValue(6) - QtWidgets.QApplication.processEvents() rsdock.dsbObsAngleAzimuth.setValue(70) QtTest.QTest.mouseClick(rsdock.cbDrawTangents, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() rsdock.cbShowSolarAngle.setChecked(True) def test_open_kml(self): self.window.cbTools.currentIndexChanged.emit(4) - QtWidgets.QApplication.processEvents() def test_insert_point(self): """ Test inserting a point inside and outside the canvas """ self.window.mpl.navbar._actions['insert_wp'].trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=QtCore.QPoint(1, 1)) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) # click again on same position - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_remove_point_yes(self, mockbox): self.window.mpl.navbar._actions['insert_wp'].trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 self.window.mpl.navbar._actions['delete_wp'].trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 assert mockbox.call_count == 1 @@ -139,42 +116,27 @@ def test_remove_point_yes(self, mockbox): return_value=QtWidgets.QMessageBox.No) def test_remove_point_no(self, mockbox): self.window.mpl.navbar._actions['insert_wp'].trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 self.window.mpl.navbar._actions['delete_wp'].trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.mousePress(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseRelease(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtWidgets.QApplication.processEvents() - QtWidgets.QApplication.processEvents() assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 4 def test_move_point(self): self.window.mpl.navbar._actions['insert_wp'].trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 self.window.mpl.navbar._actions['move_wp'].trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.mousePress(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() point = QtCore.QPoint((self.window.width() // 3), self.window.height() // 2) QtTest.QTest.mouseMove( self.window.mpl.canvas, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseRelease( self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 def test_roundtrip(self): @@ -206,46 +168,34 @@ def test_roundtrip(self): def test_map_options(self): self.window.mpl.canvas.map.set_graticule_visible(True) - QtWidgets.QApplication.processEvents() self.window.mpl.canvas.map.set_graticule_visible(False) - QtWidgets.QApplication.processEvents() self.window.mpl.canvas.map.set_fillcontinents_visible(False) - QtWidgets.QApplication.processEvents() self.window.mpl.canvas.map.set_fillcontinents_visible(True) - QtWidgets.QApplication.processEvents() self.window.mpl.canvas.map.set_coastlines_visible(False) - QtWidgets.QApplication.processEvents() self.window.mpl.canvas.map.set_coastlines_visible(True) - QtWidgets.QApplication.processEvents() with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[{"type": "small_airport", "name": "Test", "latitude_deg": 52, "longitude_deg": 13, "elevation_ft": 0}]): self.window.mpl.canvas.map.set_draw_airports(True) - QtWidgets.QApplication.processEvents() with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[]): self.window.mpl.canvas.map.set_draw_airports(True) - QtWidgets.QApplication.processEvents() with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[{"type": "small_airport", "name": "Test", "latitude_deg": -52, "longitude_deg": -13, "elevation_ft": 0}]): self.window.mpl.canvas.map.set_draw_airports(True) - QtWidgets.QApplication.processEvents() with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[{"name": "Test", "top": 1, "bottom": 0, "polygon": [(13, 52), (14, 53), (13, 52)], "country": "DE"}]): self.window.mpl.canvas.map.set_draw_airspaces(True) - QtWidgets.QApplication.processEvents() with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[]): self.window.mpl.canvas.map.set_draw_airspaces(True) - QtWidgets.QApplication.processEvents() with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[{"name": "Test", "top": 1, "bottom": 0, "polygon": [(-13, -52), (-14, -53), (-13, -52)], "country": "DE"}]): self.window.mpl.canvas.map.set_draw_airspaces(True) - QtWidgets.QApplication.processEvents() class Test_TopViewWMS: @@ -264,25 +214,18 @@ def setup(self, qapp, mswms_server): mainwindow = MSUIMainWindow() self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") yield self.window.hide() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) def query_server(self, url): - QtWidgets.QApplication.processEvents() QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) def test_server_getmap(self): @@ -291,7 +234,6 @@ def test_server_getmap(self): """ self.query_server(self.url) QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.image_displayed) assert self.window.getView().map.image is not None self.window.getView().set_settings({}) diff --git a/tests/_test_msui/test_updater.py b/tests/_test_msui/test_updater.py index 5670ec952..bcd06402e 100644 --- a/tests/_test_msui/test_updater.py +++ b/tests/_test_msui/test_updater.py @@ -75,7 +75,6 @@ def status_signal(s): self.updater.on_status_update.connect(status_signal) self.updater.on_update_finished.connect(update_finished_signal) yield - QtWidgets.QApplication.processEvents() @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) @mock.patch("subprocess.run", new=SubprocessDifferentVersionMock) diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index da3b76041..c58bb2a7e 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -28,7 +28,7 @@ import mock import pytest -from PyQt5 import QtWidgets, QtTest, QtCore +from PyQt5 import QtTest, QtCore import mslib.msui.wms_capabilities as wc @@ -48,14 +48,12 @@ def setup(self, qapp): self.capabilities.provider.contact.postcode = None self.capabilities.provider.contact.city = None yield - QtWidgets.QApplication.processEvents() def start_window(self): self.window = wc.WMSCapabilitiesBrowser( url="http://example.com", capabilities=self.capabilities) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) def test_window_start(self): @@ -68,6 +66,4 @@ def test_window_contact_none(self): def test_switch_view(self): self.start_window() QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 353c04a91..442f04064 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -32,7 +32,7 @@ import pytest import hashlib import urllib -from PyQt5 import QtWidgets, QtCore, QtTest +from PyQt5 import QtCore, QtTest from mslib.msui import flighttrack as ft import mslib.msui.wms_control as wc from tests.utils import wait_until_signal @@ -82,26 +82,20 @@ def _setup(self, widget_type): server = self.window.multilayers.listLayers.findItems(url, QtCore.Qt.MatchFixedString)[0] self.window.multilayers.delete_server(server) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() def _teardown(self): self.window.hide() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) def query_server(self, url): while len(self.window.multilayers.cbWMS_URL.currentText()) > 0: QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) - QtWidgets.QApplication.processEvents() QtTest.QTest.keyClicks(self.window.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(2000) # time for the server to start up QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.window.cpdlg.canceled) @@ -177,10 +171,8 @@ def test_server_abort_getmap(self): """ self.query_server(self.url) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(20) QtTest.QTest.keyClick(self.window.pdlg, QtCore.Qt.Key_Enter) - QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) assert self.view.draw_image.call_count == 0 @@ -216,9 +208,7 @@ def test_server_getmap_cached(self, qtbot): self.view.reset_mock() QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) assert self.view.draw_image.call_count == 1 @@ -288,7 +278,6 @@ def test_multilayer_handling(self): # Check drawing not causing errors QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) assert self.view.draw_image.call_count == 1 @@ -324,7 +313,6 @@ def test_filter_handling(self): self.window.multilayers.filter_favourite_toggled() QtTest.QTest.qWait(100) QtTest.QTest.mouseMove(self.window.multilayers.listLayers, QtCore.QPoint(icon_start_fav + 3, 0), -1) - QtWidgets.QApplication.processEvents() self.window.multilayers.check_icon_clicked(server.child(0)) self.window.multilayers.filter_favourite_toggled() # ToDo The next assert fails in reverse test order @@ -334,7 +322,6 @@ def test_filter_handling(self): # Check deleting server is working QtTest.QTest.mouseMove(self.window.multilayers.listLayers, QtCore.QPoint(icon_start_del + 3, 0), -1) - QtWidgets.QApplication.processEvents() self.window.multilayers.check_icon_clicked(server) assert len(self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)) == 0 @@ -367,7 +354,6 @@ def test_singlelayer_handling(self): # Check drawing not causing errors QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) assert self.view.draw_image.call_count == 1 @@ -416,7 +402,6 @@ def test_server_no_thread(self, mockthread): server.child(1).setCheckState(0, 2) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) urlstr = f"{self.url}/mss/logo.png" @@ -527,15 +512,12 @@ def setup(self, qapp): server = self.window.multilayers.listLayers.findItems(url, QtCore.Qt.MatchFixedString)[0] self.window.multilayers.delete_server(server) - QtWidgets.QApplication.processEvents() yield self.window.hide() - QtWidgets.QApplication.processEvents() def test_xml(self): testxml = self.xml.format("", self.srs_base, self.dimext_time + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -552,7 +534,6 @@ def test_xml_currenttag(self): 2014-10-17T12:00:00Z/current/P1Y """ testxml = self.xml.format("", self.srs_base, dimext_time + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() print([self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())]) assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())][:4] == \ ['2014-10-17T12:00:00Z', '2015-10-17T12:00:00Z', '2016-10-17T12:00:00Z', '2017-10-17T12:00:00Z'] @@ -569,7 +550,6 @@ def test_xml_emptyextent(self): testxml = self.xml.format( "", self.srs_base, dimext_time_empty + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == [] assert [self.window.cbLevel.itemText(i) for i in range(self.window.cbLevel.count())] == [] @@ -582,7 +562,6 @@ def test_xml_onlytimedim(self): testxml = self.xml.format("", self.srs_base, dimext_time_noext + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert not self.window.cbValidTime.isEnabled() assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -597,7 +576,6 @@ def test_xml_separatedim(self): testxml = self.xml.format( dimext_time_dim, self.srs_base, dimext_time_ext + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -609,7 +587,6 @@ def test_xml_separate_leafs(self): testxml = self.xml.format( self.dimext_inittime, self.srs_base, self.dimext_time + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -622,7 +599,6 @@ def test_xml_time_forecast(self): testxml = self.xml.format( "", self.srs_base, dimext_time_forecast + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2013-10-17T12:00:00Z', '2013-10-17T18:00:00Z', '2013-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -637,7 +613,6 @@ def test_xml_inittime_reference(self): testxml = self.xml.format( "", self.srs_base, self.dimext_time + dimext_inittime_reference + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -648,7 +623,6 @@ def test_xml_inittime_reference(self): def test_xml_no_elevation(self): testxml = self.xml.format("", self.srs_base, self.dimext_time + self.dimext_inittime) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -659,7 +633,6 @@ def test_xml_no_elevation(self): def test_xml_no_validtime(self): testxml = self.xml.format("", self.srs_base, self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert not self.window.cbValidTime.isEnabled() assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -671,7 +644,6 @@ def test_xml_no_inittime(self): testxml = self.xml.format( "", self.srs_base, self.dimext_time + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == [] @@ -686,7 +658,6 @@ def test_xml_time_period(self): testxml = self.xml.format( "", self.srs_base, dimext_time_period + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -703,7 +674,6 @@ def test_xml_time_multiperiod(self): testxml = self.xml.format( "", self.srs_base, dimext_time_period + dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() self.window.cbAutoUpdate.setCheckState(False) self.window.cbInitTime.setCurrentIndex(0) assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ @@ -720,7 +690,6 @@ def test_valid_before_init(self): testxml = self.xml.format( "", self.srs_base, dimext_time_period + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -733,7 +702,6 @@ def test_xml_time_init_period(self): testxml = self.xml.format( "", self.srs_base, self.dimext_time + dimext_inittime_period + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -748,7 +716,6 @@ def test_xml_othertimeformat(self): testxml = self.xml.format( "", self.srs_base, dimext_time_format + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() self.window.cbAutoUpdate.setCheckState(False) self.window.cbInitTime.setCurrentIndex(0) assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ @@ -763,6 +730,5 @@ def test_xml_time_error(self): testxml = self.xml.format( "", self.srs_base, dimext_time_error + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == [] diff --git a/tests/test_meta.py b/tests/test_meta.py new file mode 100644 index 000000000..e2931f819 --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" + + tests.test_meta + ~~~~~~~~~~~~~~~ + + This module provides tests that are "meta" in some way, i.e. that don't test + application code but test that e.g. other tests follow some convention. + + This file is part of MSS. + + :copyright: Copyright 2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import pathlib + + +def test_processEvents_is_not_used_in_tests(request): + """Check that no test is calling PyQt5.QtWidgets.QApplication.processEvents() explicitly.""" + tests_path = pathlib.Path(request.config.rootdir) / "tests" + for test_file in tests_path.rglob("*.py"): + if str(test_file) == request.fspath: + # Skip the current file + continue + assert ( + "processEvents" not in test_file.read_text() + ), "processEvents is mentioned in {}".format(test_file.relative_to(request.config.rootdir)) From 73fd51da55bec632cc85781152a98dc7602e9974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Mon, 26 Feb 2024 14:00:20 +0100 Subject: [PATCH 129/176] Remove all occurrences of qWait in tests/ (#2246) * Remove all occurrences of qWait in tests/ * Fix some race conditions * Add a test that ensures no test uses qWait --- .../_test_msui/test_kmloverlay_dockwidget.py | 1 - tests/_test_msui/test_linearview.py | 1 - tests/_test_msui/test_mscolab.py | 190 +++++++++--------- tests/_test_msui/test_mscolab_admin_window.py | 19 +- .../test_mscolab_merge_waypoints.py | 24 +-- tests/_test_msui/test_mscolab_operation.py | 15 +- .../test_mscolab_save_merge_points.py | 7 +- .../test_mscolab_version_history.py | 25 +-- tests/_test_msui/test_sideview.py | 1 - tests/_test_msui/test_topview.py | 1 - tests/_test_msui/test_updater.py | 3 +- tests/_test_msui/test_wms_capabilities.py | 1 - tests/_test_msui/test_wms_control.py | 5 - tests/test_meta.py | 16 ++ 14 files changed, 152 insertions(+), 157 deletions(-) diff --git a/tests/_test_msui/test_kmloverlay_dockwidget.py b/tests/_test_msui/test_kmloverlay_dockwidget.py index 7366be364..90275a589 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -89,7 +89,6 @@ def test_select_file(self): assert self.window.listWidget.count() == 0 for sample in ["folder.kml", "line.kml", "color.kml", "style.kml"]: path = self.select_file(sample) - QtTest.QTest.qWait(250) assert self.window.listWidget.item(index).checkState() == QtCore.Qt.Checked index = index + 1 assert self.window.directory_location == path diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 0bed2923a..848f40d4c 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -99,7 +99,6 @@ def setup(self, qapp, mswms_server): 0, rows=len(initial_waypoints), waypoints=initial_waypoints) self.window = tv.MSUILinearViewWindow(model=waypoints_model) self.window.show() - QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) self.window.cbTools.currentIndexChanged.emit(1) self.wms_control = self.window.docks[0].widget() diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 1d9f84231..49a9f0992 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -55,7 +55,6 @@ def setup(self, qapp, mscolab_server): assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - QtTest.QTest.qWait(500) self.main_window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.main_window.create_new_flight_track() self.main_window.show() @@ -94,38 +93,41 @@ def test_connect_denied(self, mockset): @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) - def test_connect_success(self, mockbox, mockset): + def test_connect_success(self, mockbox, mockset, qtbot): assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") != "fnord" - self._connect_to_mscolab(password="fnord") + self._connect_to_mscolab(qtbot, password="fnord") assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") == "fnord" assert mockset.call_args_list == [mock.call("color: green;")] - def test_disconnect(self): - self._connect_to_mscolab() + def test_disconnect(self, qtbot): + self._connect_to_mscolab(qtbot) assert self.window.mscolab_server_url is not None QtTest.QTest.mouseClick(self.window.disconnectBtn, QtCore.Qt.LeftButton) assert self.window.mscolab_server_url is None # set ui_name_winodw default assert self.main_window.usernameLabel.text() == 'User' - def test_login(self): - self._connect_to_mscolab() + def test_login(self, qtbot): + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(self.userdata[0], self.userdata[2]) - # show logged in widgets - assert self.main_window.usernameLabel.text() == self.userdata[1] - assert self.main_window.connectBtn.isVisible() is False - assert self.main_window.mscolab.connect_window is None - assert self.main_window.local_active is True - # test operation listing visibility - assert self.main_window.listOperationsMSC.model().rowCount() == 1 + self._login(qtbot, self.userdata[0], self.userdata[2]) + + def assert_(): + # show logged in widgets + assert self.main_window.usernameLabel.text() == self.userdata[1] + assert self.main_window.connectBtn.isVisible() is False + assert self.main_window.mscolab.connect_window is None + assert self.main_window.local_active is True + # test operation listing visibility + assert self.main_window.listOperationsMSC.model().rowCount() == 1 + qtbot.wait_until(assert_) @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_login_with_different_account_shows_update_credentials_popup(self, mockbox): - self._connect_to_mscolab() + def test_login_with_different_account_shows_update_credentials_popup(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) connect_window = self.main_window.mscolab.connect_window - self._login(self.userdata[0], self.userdata[2]) + self._login(qtbot, self.userdata[0], self.userdata[2]) mockbox.assert_called_once_with( connect_window, "Update Credentials", @@ -141,11 +143,11 @@ def test_login_with_different_account_shows_update_credentials_popup(self, mockb # test operation listing visibility assert self.main_window.listOperationsMSC.model().rowCount() == 1 - def test_logout_action_trigger(self): + def test_logout_action_trigger(self, qtbot): # Login - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(self.userdata[0], self.userdata[2]) + self._login(qtbot, self.userdata[0], self.userdata[2]) assert self.main_window.usernameLabel.text() == self.userdata[1] # Logout self.main_window.mscolab.logout_action.trigger() @@ -154,11 +156,11 @@ def test_logout_action_trigger(self): assert self.main_window.local_active is True assert self.main_window.usernameLabel.text() == "User" - def test_logout(self): + def test_logout(self, qtbot): # Login - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(self.userdata[0], self.userdata[2]) + self._login(qtbot, self.userdata[0], self.userdata[2]) assert self.main_window.usernameLabel.text() == self.userdata[1] # Logout self.main_window.mscolab.logout() @@ -169,8 +171,8 @@ def test_logout(self): assert self.main_window.local_active is True @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_add_user(self, mockmessage): - self._connect_to_mscolab() + def test_add_user(self, mockmessage, qtbot): + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" @@ -181,12 +183,12 @@ def test_add_user(self, mockmessage): assert self.main_window.mscolab.connect_window is None @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) - def test_add_users_without_updating_credentials_in_config_file(self, mockmessage): + def test_add_users_without_updating_credentials_in_config_file(self, mockmessage, qtbot): create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') read_config_file() # check current settings assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) assert self.window.mscolab_server_url is not None self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings @@ -197,14 +199,14 @@ def test_add_users_without_updating_credentials_in_config_file(self, mockmessage assert self.main_window.usernameLabel.text() == "anand" @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_add_users_with_updating_credentials_in_config_file(self, mockmessage): + def test_add_users_with_updating_credentials_in_config_file(self, mockmessage, qtbot): create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') mslib.utils.auth.save_password_to_keyring(service_name=self.url, username="something@something.org", password="something") read_config_file() # check current settings assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) assert self.window.mscolab_server_url is not None self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings @@ -214,17 +216,24 @@ def test_add_users_with_updating_credentials_in_config_file(self, mockmessage): # check user is logged in assert self.main_window.usernameLabel.text() == "anand" - def _connect_to_mscolab(self, password=""): + def _connect_to_mscolab(self, qtbot, password=""): self.window.urlCb.setEditText(self.url) self.window.httpPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.connectBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) - def _login(self, emailid="a", password="a"): + def assert_(): + assert not self.window.connectBtn.isVisible() + assert self.window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) + + def _login(self, qtbot, emailid="a", password="a"): self.window.loginEmailLe.setText(emailid) self.window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.loginBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) + + def assert_(): + assert self.main_window.mscolab.connect_window is None + qtbot.wait_until(assert_) def _create_user(self, username, email, password): QtTest.QTest.mouseClick(self.window.addUserBtn, QtCore.Qt.LeftButton) @@ -268,7 +277,6 @@ def setup(self, qapp, mscolab_app, mscolab_server): assert add_user(self.userdata3[0], self.userdata3[1], self.userdata3[2]) assert add_user_to_operation(path=self.operation_name3, access_level="collaborator", emailid=self.userdata3[0]) - QtTest.QTest.qWait(500) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() @@ -286,19 +294,19 @@ def setup(self, qapp, mscolab_app, mscolab_server): # close all hanging operation option windows self.window.mscolab.close_external_windows() - def test_activate_operation(self): - self._connect_to_mscolab() + def test_activate_operation(self, qtbot): + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(emailid=self.userdata[0], password=self.userdata[2]) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) # activate a operation self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_name == self.operation_name - def test_view_open(self): - self._connect_to_mscolab() + def test_view_open(self, qtbot): + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(emailid=self.userdata[0], password=self.userdata[2]) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) # test after activating operation self._activate_operation_at_index(0) self.window.actionTableView.trigger() @@ -329,10 +337,10 @@ def test_view_open(self): @mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, 'test_export.ftml'), "Flight track (*.ftml)")) - def test_handle_export(self, mockbox): - self._connect_to_mscolab() + def test_handle_export(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(emailid=self.userdata[0], password=self.userdata[2]) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.actionExportFlightTrackFTML.trigger() exported_waypoints = WaypointsTableModel(filename=fs.path.join(self.window.mscolab.data_dir, @@ -346,17 +354,15 @@ def test_handle_export(self, mockbox): ("example.csv", "actionImportFlightTrackCSV", 5), ("example.txt", "actionImportFlightTrackTXT", 5), ("flitestar.txt", "actionImportFlightTrackFliteStar", 10)]) - def test_import_file(self, name): + def test_import_file(self, name, qtbot): self.window.remove_plugins() with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") file_path = fs.path.join(self.sample_path, name[0]) with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[file_path]) as mockopen: - # with parametrize it is maybe too fast - QtTest.QTest.qWait(100) - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(emailid=self.userdata[0], password=self.userdata[2]) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) wp = self.window.mscolab.waypoints_model assert len(wp.waypoints) == 2 @@ -370,24 +376,21 @@ def test_import_file(self, name): imported_wp = self.window.mscolab.waypoints_model assert len(imported_wp.waypoints) == name[2] - def test_work_locally_toggle(self): - self._connect_to_mscolab() + def test_work_locally_toggle(self, qtbot): + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) - self._login(emailid=self.userdata[0], password=self.userdata[2]) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.workLocallyCheckbox.setChecked(True) - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtTest.QTest.qWait(100) wpdata_local = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(False) - QtTest.QTest.qWait(100) wpdata_server = self.window.mscolab.waypoints_model.waypoint_data(0) assert wpdata_local.lat != wpdata_server.lat @mock.patch("mslib.msui.mscolab.get_open_filename", return_value=os.path.join(sample_path, u"example.ftml")) def test_browse_add_operation(self, mockopen, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") assert self.window.listOperationsMSC.model().rowCount() == 0 @@ -401,15 +404,16 @@ def test_browse_add_operation(self, mockopen, qtbot): with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) m.assert_called_once() - # we need to wait for the update of the operation list - QtTest.QTest.qWait(200) - assert self.window.listOperationsMSC.model().rowCount() == 1 - item = self.window.listOperationsMSC.item(0) - assert item.operation_path == "example" - assert item.access_level == "creator" + + def assert_(): + assert self.window.listOperationsMSC.model().rowCount() == 1 + item = self.window.listOperationsMSC.item(0) + assert item.operation_path == "example" + assert item.access_level == "creator" + qtbot.wait_until(assert_) def test_add_operation(self, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self._create_operation(qtbot, "Alpha", "Description Alpha") @@ -432,7 +436,7 @@ def test_add_operation(self, qtbot): @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)) def test_handle_delete_operation(self, mocktext, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) self._create_user(qtbot, "berta", "berta@something.org", "something") assert self.window.usernameLabel.text() == 'berta' @@ -453,16 +457,15 @@ def test_handle_delete_operation(self, mocktext, qtbot): ) op_id = self.window.mscolab.get_recent_op_id() assert op_id is None - QtTest.QTest.qWait(0) # check operation dir name removed assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) is False @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_handle_leave_operation(self, mockmessage, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata3[0]}}) - self._login(self.userdata3[0], self.userdata3[2]) + self._login(qtbot, self.userdata3[0], self.userdata3[2]) assert self.window.usernameLabel.text() == self.userdata3[1] assert self.window.connectBtn.isVisible() is False @@ -478,7 +481,6 @@ def test_handle_leave_operation(self, mockmessage, qtbot): assert len(self.window.get_active_views()) == 2 self.window.actionLeaveOperation.trigger() - QtTest.QTest.qWait(0) def assert_leave_operation_done(): assert self.window.mscolab.active_op_id is None @@ -488,7 +490,7 @@ def assert_leave_operation_done(): @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_name", True)) def test_handle_rename_operation(self, mocktext, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self._create_operation(qtbot, "flight1234", "Description flight1234") @@ -498,13 +500,12 @@ def test_handle_rename_operation(self, mocktext, qtbot): with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionRenameOperation.trigger() m.assert_called_once_with(self.window, "Rename successful", "Operation is renamed successfully.") - QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_name == "new_name" @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_description", True)) def test_update_description(self, mocktext, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self._create_operation(qtbot, "flight1234", "Description flight1234") @@ -514,13 +515,12 @@ def test_update_description(self, mocktext, qtbot): with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionChangeDescription.trigger() m.assert_called_once_with(self.window, "Update successful", "Description is updated successfully.") - QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_description == "new_description" @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_category", True)) def test_update_category(self, mocktext, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self._create_operation(qtbot, "flight1234", "Description flight1234") @@ -531,16 +531,14 @@ def test_update_category(self, mocktext, qtbot): with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionChangeCategory.trigger() m.assert_called_once_with(self.window, "Update successful", "Category is updated successfully.") - QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_category == "new_category" def test_any_special_category(self, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self._create_operation(qtbot, "flight1234", "Description flight1234") - QtTest.QTest.qWait(0) self._create_operation(qtbot, "flight5678", "Description flight5678", category="furtherexample") # all operations of two defined categories are found assert self.window.mscolab.selected_category == "*ANY*" @@ -556,10 +554,9 @@ def test_any_special_category(self, qtbot): @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) def test_get_recent_op_id(self, mockbox, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "anton@something.org"}}) self._create_user(qtbot, "anton", "anton@something.org", "something") - QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'anton' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 @@ -572,10 +569,9 @@ def test_get_recent_op_id(self, mockbox, qtbot): @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) def test_get_recent_operation(self, mockbox, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) self._create_user(qtbot, "berta", "berta@something.org", "something") - QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 @@ -587,7 +583,7 @@ def test_get_recent_operation(self, mockbox, qtbot): @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) def test_open_chat_window(self, mockbox, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self._create_operation(qtbot, "flight1234", "Description flight1234") @@ -595,12 +591,11 @@ def test_open_chat_window(self, mockbox, qtbot): self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None self.window.actionChat.trigger() - QtTest.QTest.qWait(0) assert self.window.mscolab.chat_window is not None @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) def test_close_chat_window(self, mockbox, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self._create_operation(qtbot, "flight1234", "Description flight1234") @@ -612,7 +607,7 @@ def test_close_chat_window(self, mockbox, qtbot): @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) def test_delete_operation_from_list(self, mockbox, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "other@something.org"}}) self._create_user(qtbot, "other", "other@something.org", "something") assert self.window.usernameLabel.text() == 'other' @@ -626,7 +621,7 @@ def test_delete_operation_from_list(self, mockbox, qtbot): @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_user_delete(self, mockmessage, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") u_id = self.window.mscolab.user['id'] @@ -643,13 +638,15 @@ def test_open_help_dialog(self): self.window.actionMSColabHelp.trigger() assert self.window.mscolab.help_dialog is not None - def test_close_help_dialog(self): + def test_close_help_dialog(self, qtbot): self.window.actionMSColabHelp.trigger() assert self.window.mscolab.help_dialog is not None QtTest.QTest.mouseClick( self.window.mscolab.help_dialog.okayBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(50) - assert self.window.mscolab.help_dialog is None + + def assert_(): + assert self.window.mscolab.help_dialog is None + qtbot.wait_until(assert_) def test_create_dir_exceptions(self): with mock.patch("fs.open_fs", new=ExceptionMock(fs.errors.CreateFailed).raise_exc), \ @@ -669,7 +666,7 @@ def test_create_dir_exceptions(self): mockexit.assert_called_once() def test_profile_dialog(self, qtbot): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user(qtbot, "something", "something@something.org", "something") self.window.mscolab.profile_action.trigger() @@ -681,19 +678,26 @@ def test_profile_dialog(self, qtbot): critbox.assert_called_once() assert not self.window.mscolab.profile_dialog.gravatarLabel.pixmap().isNull() - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) - def _login(self, emailid, password): + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) + + def _login(self, qtbot, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) + + def assert_(): + assert self.window.mscolab.connect_window is None + qtbot.wait_until(assert_) def _create_user(self, qtbot, username, email, password): QtTest.QTest.mouseClick(self.connect_window.addUserBtn, QtCore.Qt.LeftButton) diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index c1b916fc5..cbeb64211 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -37,7 +37,7 @@ class Test_MscolabAdminWindow: @pytest.fixture(autouse=True) - def setup(self, qapp, mscolab_server): + def setup(self, qtbot, mscolab_server): self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" @@ -58,12 +58,11 @@ def setup(self, qapp, mscolab_server): assert add_operation("tokyo", "test tokyo") assert add_user_to_operation(path="tokyo", emailid=self.userdata[0], access_level="creator") - QtTest.QTest.qWait(500) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() # connect and login to mscolab - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # activate operation and open chat window @@ -136,7 +135,6 @@ def test_add_permissions(self): self._check_users_present(self.admin_window.modifyUsersTable, users, "admin") assert len_unadded_users - 2 == self.admin_window.addUsersTable.rowCount() assert len_added_users + 2 == self.admin_window.modifyUsersTable.rowCount() - QtTest.QTest.qWait(1000) def test_modify_permissions(self): users = ["name1", "name2"] @@ -151,7 +149,6 @@ def test_modify_permissions(self): QtTest.QTest.mouseClick(self.admin_window.modifyUsersBtn, QtCore.Qt.LeftButton) # Check if the permission has been updated self._check_users_present(self.admin_window.modifyUsersTable, users, "viewer") - QtTest.QTest.qWait(1000) def test_delete_permissions(self): # Select users in the add users table @@ -169,35 +166,35 @@ def test_delete_permissions(self): self._check_users_present(self.admin_window.addUsersTable, users) assert len_unadded_users + 2 == self.admin_window.addUsersTable.rowCount() assert len_added_users - 2 == self.admin_window.modifyUsersTable.rowCount() - QtTest.QTest.qWait(1000) def test_import_permissions(self): index = self.admin_window.importPermissionsCB.findText("paris", QtCore.Qt.MatchFixedString) self.admin_window.importPermissionsCB.setCurrentIndex(index) QtTest.QTest.mouseClick(self.admin_window.importPermissionsBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(100) assert self.admin_window.modifyUsersTable.rowCount() == 1 - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtTest.QTest.qWait(500) def _select_users(self, table, users): for row_num in range(table.rowCount()): diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index b70d8ef1b..3e2e88c1e 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -44,7 +44,6 @@ class Test_Mscolab_Merge_Waypoints: def setup(self, qapp, mscolab_app, mscolab_server): self.app = mscolab_app self.url = mscolab_server - QtTest.QTest.qWait(500) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.emailid = 'merge@alpha.org' @@ -63,9 +62,9 @@ def setup(self, qapp, mscolab_app, mscolab_server): if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - def _create_user_data(self, emailid='merge@alpha.org'): + def _create_user_data(self, qtbot, emailid='merge@alpha.org'): with self.app.app_context(): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) response = mscolab_register_and_login(self.app, self.url, emailid, 'abcdef', 'alpha') assert response.status == '200 OK' @@ -75,20 +74,23 @@ def _create_user_data(self, emailid='merge@alpha.org'): self._login(emailid, 'abcdef') self._activate_operation_at_index(0) - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid="merge_waypoints_user", password="password"): modify_config_file({"MSS_auth": {self.url: self.emailid}}) self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) @@ -110,7 +112,7 @@ def __init__(self, *args, **kwargs): class Test_Overwrite_To_Server(Test_Mscolab_Merge_Waypoints): def test_save_overwrite_to_server(self, qtbot): self.emailid = "save_overwrite@alpha.org" - self._create_user_data(emailid=self.emailid) + self._create_user_data(qtbot, emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) @@ -160,7 +162,7 @@ def __init__(self, *args, **kwargs): class Test_Save_Keep_Server_Points(Test_Mscolab_Merge_Waypoints): def test_save_keep_server_points(self, qtbot): self.emailid = "save_keepe@alpha.org" - self._create_user_data(emailid=self.emailid) + self._create_user_data(qtbot, emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) @@ -201,12 +203,11 @@ def assert_(): class Test_Fetch_From_Server(Test_Mscolab_Merge_Waypoints): - def test_fetch_from_server(self): + def test_fetch_from_server(self, qtbot): self.emailid = "fetch_from_server@alpha.org" - self._create_user_data(emailid=self.emailid) + self._create_user_data(qtbot, emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) - QtTest.QTest.qWait(100) wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) assert wp_local.lat == wp_server_before.lat self.window.mscolab.waypoints_model.invert_direction() @@ -226,5 +227,4 @@ def test_fetch_from_server(self): assert len(new_local_wp.waypoints) == 2 assert new_local_wp.waypoint_data(0).lat == wp_server_before.lat self.window.workLocallyCheckbox.setChecked(False) - QtTest.QTest.qWait(100) assert self.window.mscolab.waypoints_model.waypoint_data(0).lat == wp_server_before.lat diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 5cfb70a41..450c0569f 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -45,7 +45,7 @@ class Actions: class Test_MscolabOperation: @pytest.fixture(autouse=True) - def setup(self, qapp, mscolab_app, mscolab_server): + def setup(self, qtbot, mscolab_app, mscolab_server): self.app = mscolab_app self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' @@ -54,12 +54,11 @@ def setup(self, qapp, mscolab_app, mscolab_server): assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - QtTest.QTest.qWait(500) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() # connect and login to mscolab - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window @@ -131,23 +130,25 @@ def test_delete_message(self, qtbot): self._send_message(qtbot, "**test message**") self._send_message(qtbot, "**test message**") self._activate_context_menu_action(Actions.DELETE) - QtTest.QTest.qWait(100) with self.app.app_context(): assert Message.query.filter_by(text='test edit').count() == 0 - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index 2ea41030a..5b96e7dc6 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -31,11 +31,10 @@ class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): - def test_save_merge_points(self): + def test_save_merge_points(self, qtbot): self.emailid = "mergepoints@alpha.org" - self._create_user_data(emailid=self.emailid) + self._create_user_data(qtbot, emailid=self.emailid) self.window.workLocallyCheckbox.setChecked(True) - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() merge_waypoints_model = None @@ -45,7 +44,6 @@ def handle_merge_dialog(): self._select_waypoints(self.window.mscolab.merge_dialog.serverWaypointsTable) merge_waypoints_model = self.window.mscolab.merge_dialog.merge_waypoints_model QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.saveBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(100) QtCore.QTimer.singleShot(3000, handle_merge_dialog) # QtTest.QTest.mouseClick(self.window.save_ft, QtCore.Qt.LeftButton, delay=1) @@ -64,7 +62,6 @@ def handle_merge_dialog(): for wp_index in range(new_wp_count): assert new_local_wp.waypoint_data(wp_index).lat == merge_waypoints_model.waypoint_data(wp_index).lat self.window.workLocallyCheckbox.setChecked(False) - QtTest.QTest.qWait(100) new_server_wp = self.window.mscolab.waypoints_model assert len(new_server_wp.waypoints) == new_wp_count for wp_index in range(new_wp_count): diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 7d4be36e8..e12ef8c3c 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -37,7 +37,7 @@ class Test_MscolabVersionHistory: @pytest.fixture(autouse=True) - def setup(self, qapp, mscolab_server): + def setup(self, qtbot, mscolab_server): self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" @@ -45,12 +45,11 @@ def setup(self, qapp, mscolab_server): assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - QtTest.QTest.qWait(500) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) self.window.create_new_flight_track() self.window.show() # connect and login to mscolab - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window @@ -82,17 +81,14 @@ def assert_(): @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) def test_set_version_name(self, mockbox): self._set_version_name() - QtTest.QTest.qWait(100) assert self.version_window.changes.currentItem().version_name == "MyVersionName" assert self.version_window.changes.count() == 1 @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) def test_version_name_delete(self, mockbox): self._set_version_name() - QtTest.QTest.qWait(100) assert self.version_window.changes.currentItem().version_name == "MyVersionName" QtTest.QTest.mouseClick(self.version_window.deleteVersionNameBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) assert self.version_window.changes.count() == 1 assert self.version_window.changes.currentItem().version_name is None @@ -103,7 +99,6 @@ def test_undo_changes(self, mockbox, qtbot): # make changes for i in range(2): self.window.mscolab.waypoints_model.invert_direction() - QtTest.QTest.qWait(100) def assert_(): self.version_window.load_all_changes() @@ -112,7 +107,6 @@ def assert_(): changes_count = self.version_window.changes.count() self._activate_change_at_index(1) QtTest.QTest.mouseClick(self.version_window.checkoutBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(200) new_changes_count = self.version_window.changes.count() assert changes_count + 1 == new_changes_count @@ -120,29 +114,29 @@ def test_refresh(self): self._change_version_filter(1) changes_count = self.version_window.changes.count() self.window.mscolab.waypoints_model.invert_direction() - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtTest.QTest.qWait(100) QtTest.QTest.mouseClick(self.version_window.refreshBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(100) new_changes_count = self.version_window.changes.count() assert new_changes_count == changes_count + 2 - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window assert self.connect_window is not None self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid, password): assert self.connect_window is not None self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): assert index < self.window.listOperationsMSC.count() @@ -158,20 +152,17 @@ def _activate_change_at_index(self, index): point = self.version_window.changes.visualItemRect(item).center() QtTest.QTest.mouseClick(self.version_window.changes.viewport(), QtCore.Qt.LeftButton, pos=point) QtTest.QTest.keyClick(self.version_window.changes.viewport(), QtCore.Qt.Key_Return) - QtTest.QTest.qWait(100) def _change_version_filter(self, index): assert self.version_window is not None assert index < self.version_window.versionFilterCB.count() self.version_window.versionFilterCB.setCurrentIndex(index) self.version_window.versionFilterCB.currentIndexChanged.emit(index) - QtTest.QTest.qWait(100) def _set_version_name(self): self._change_version_filter(1) # make a changes self.window.mscolab.waypoints_model.invert_direction() - QtTest.QTest.qWait(100) self.version_window.load_all_changes() self._activate_change_at_index(0) QtTest.QTest.mouseClick(self.version_window.nameVersionBtn, QtCore.Qt.LeftButton) diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 1d14864f9..0201862bf 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -139,7 +139,6 @@ def setup(self, qapp, mswms_server): 0, rows=len(initial_waypoints), waypoints=initial_waypoints) self.window = tv.MSUISideViewWindow(model=waypoints_model) self.window.show() - QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) self.window.cbTools.currentIndexChanged.emit(1) self.wms_control = self.window.docks[0].widget() diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index 24de04f73..f939281d7 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -214,7 +214,6 @@ def setup(self, qapp, mswms_server): mainwindow = MSUIMainWindow() self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) self.window.show() - QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) self.window.cbTools.currentIndexChanged.emit(1) self.wms_control = self.window.docks[0].widget() diff --git a/tests/_test_msui/test_updater.py b/tests/_test_msui/test_updater.py index bcd06402e..d2e442fe6 100644 --- a/tests/_test_msui/test_updater.py +++ b/tests/_test_msui/test_updater.py @@ -26,7 +26,7 @@ """ import mock import pytest -from PyQt5 import QtWidgets, QtTest +from PyQt5 import QtWidgets from mslib.msui.updater import UpdaterUI, Updater from mslib.utils.qt import Worker @@ -131,6 +131,5 @@ def test_exception(self): def test_ui(self, mock): ui = UpdaterUI() ui.updater.on_update_available.emit("", "") - QtTest.QTest.qWait(100) assert ui.statusLabel.text() == "Update successful. Please restart MSS." assert ui.btRestart.isEnabled() diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index c58bb2a7e..f410e6797 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -54,7 +54,6 @@ def start_window(self): url="http://example.com", capabilities=self.capabilities) QtTest.QTest.qWaitForWindowExposed(self.window) - QtTest.QTest.qWait(100) def test_window_start(self): self.start_window() diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 442f04064..afe84783f 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -66,7 +66,6 @@ def _setup(self, widget_type): self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - QtTest.QTest.qWait(3000) if widget_type == "hsec": self.window = wc.HSecWMSControlWidget(view=self.view, wms_cache=self.tempdir) else: @@ -82,7 +81,6 @@ def _setup(self, widget_type): server = self.window.multilayers.listLayers.findItems(url, QtCore.Qt.MatchFixedString)[0] self.window.multilayers.delete_server(server) - QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) @@ -94,7 +92,6 @@ def query_server(self, url): while len(self.window.multilayers.cbWMS_URL.currentText()) > 0: QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) QtTest.QTest.keyClicks(self.window.multilayers.cbWMS_URL, url) - QtTest.QTest.qWait(2000) # time for the server to start up QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) wait_until_signal(self.window.cpdlg.canceled) @@ -171,7 +168,6 @@ def test_server_abort_getmap(self): """ self.query_server(self.url) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(20) QtTest.QTest.keyClick(self.window.pdlg, QtCore.Qt.Key_Enter) wait_until_signal(self.window.image_displayed) @@ -311,7 +307,6 @@ def test_filter_handling(self): self.window.multilayers.filter_favourite_toggled() assert server.isHidden() self.window.multilayers.filter_favourite_toggled() - QtTest.QTest.qWait(100) QtTest.QTest.mouseMove(self.window.multilayers.listLayers, QtCore.QPoint(icon_start_fav + 3, 0), -1) self.window.multilayers.check_icon_clicked(server.child(0)) self.window.multilayers.filter_favourite_toggled() diff --git a/tests/test_meta.py b/tests/test_meta.py index e2931f819..c3d24b94a 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -37,3 +37,19 @@ def test_processEvents_is_not_used_in_tests(request): assert ( "processEvents" not in test_file.read_text() ), "processEvents is mentioned in {}".format(test_file.relative_to(request.config.rootdir)) + + +def test_qWait_is_not_used_in_tests(request): + """Check that no test is calling PyQt5.QtTest.QTest.qWait explicitly.""" + tests_path = pathlib.Path(request.config.rootdir) / "tests" + for test_file in tests_path.rglob("*.py"): + if str(test_file) == request.fspath: + # Skip the current file + continue + if str(test_file.relative_to(request.config.rootdir)) == "tests/utils.py": + # Skip tests/utils.py + # This skip can be removed once wait_until_signal is no longer used + continue + assert ( + "qWait(" not in test_file.read_text() + ), "qWait is mentioned in {}".format(test_file.relative_to(request.config.rootdir)) From b8a200d53bb3fd1948278cadfef4cc1465594626 Mon Sep 17 00:00:00 2001 From: Maira Usman Date: Mon, 26 Feb 2024 18:34:39 +0500 Subject: [PATCH 130/176] refactor : Used urljoin instead of string concatenation (#2240) Signed-off-by: Myrausman --- mslib/msui/mscolab.py | 42 +++++++++++--------- mslib/msui/multiple_flightpath_dockwidget.py | 7 +++- mslib/utils/verify_user_token.py | 4 +- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 7ecb18782..ed8f1fd87 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -325,8 +325,8 @@ def login_handler(self): s = requests.Session() s.auth = self.auth s.headers.update({'x-test': 'true'}) - url = f'{self.mscolab_server_url}/token' - url_recover_password = f'{self.mscolab_server_url}/reset_request' + url = urljoin(self.mscolab_server_url, "token") + url_recover_password = urljoin(self.mscolab_server_url, "reset_request") try: r = s.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.status_code == 401: @@ -350,13 +350,13 @@ def login_handler(self): def idp_login_handler(self): """Handle IDP login Button""" - url_idp_login = f'{self.mscolab_server_url}/available_idps' + url_idp_login = urljoin(self.mscolab_server_url, "available_idps") webbrowser.open(url_idp_login, new=2) self.stackedWidget.setCurrentWidget(self.idpAuthPage) def idp_auth_token_submit_handler(self): """Handle IDP authentication token submission""" - url_idp_login_auth = f'{self.mscolab_server_url}/idp_login_auth' + url_idp_login_auth = urljoin(self.mscolab_server_url, "idp_login_auth") user_token = self.idpAuthPasswordLe.text() try: @@ -379,7 +379,7 @@ def idp_auth_token_submit_handler(self): s = requests.Session() s.auth = self.auth s.headers.update({'x-test': 'true'}) - url = f'{self.mscolab_server_url}/token' + url = urljoin(self.mscolab_server_url, "token") r = s.post(url, data=data, timeout=(2, 10)) if r.status_code == 401: @@ -428,7 +428,7 @@ def new_user_handler(self): s = requests.Session() s.auth = self.auth s.headers.update({'x-test': 'true'}) - url = f'{self.mscolab_server_url}/register' + url = urljoin(self.mscolab_server_url, "register") try: r = s.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as ex: @@ -583,7 +583,7 @@ def view_description(self): "token": self.token, "op_id": self.active_op_id } - url = urljoin(self.mscolab_server_url, "/creator_of_operation") + url = urljoin(self.mscolab_server_url, "creator_of_operation") r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) creator_name = "unknown" if r.text != "False": @@ -824,7 +824,8 @@ def delete_account(self): } try: - r = requests.post(self.mscolab_server_url + '/delete_own_account', data=data, + url = urljoin(self.mscolab_server_url, "delete_own_account") + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) @@ -923,7 +924,8 @@ def add_operation(self): if self.add_proj_dialog.f_content is not None: data["content"] = self.add_proj_dialog.f_content try: - r = requests.post(f'{self.mscolab_server_url}/create_operation', data=data, + url = urljoin(self.mscolab_server_url, "create_operation") + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) @@ -955,7 +957,8 @@ def get_recent_op_id(self): "token": self.token, "skip_archived": skip_archived } - r = requests.get(self.mscolab_server_url + '/operations', data=data) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1442,8 +1445,8 @@ def get_recent_operation(self): data = { "token": self.token } - r = requests.get(self.mscolab_server_url + '/operations', data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1483,8 +1486,8 @@ def render_new_permission(self, op_id, u_id): data = { 'token': self.token } - r = requests.get(self.mscolab_server_url + '/user', data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + url = urljoin(self.mscolab_server_url, "user") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) if _json['user']['id'] == u_id: @@ -1609,9 +1612,9 @@ def show_categories_to_ui(self, ops=None): data = { "token": self.token } + url = urljoin(self.mscolab_server_url, "operations") try: - r = requests.get(f'{self.mscolab_server_url}/operations', data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.MissingSchema: show_popup(self.ui, "Error", "Session expired, new login required") if r is not None and r.text != "False": @@ -1641,8 +1644,8 @@ def add_operations_to_ui(self): "token": self.token, "skip_archived": skip_archived } - r = requests.get(f'{self.mscolab_server_url}/operations', data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) self.operations = _json["operations"] @@ -1887,7 +1890,8 @@ def request_wps_from_server(self): "token": self.token, "op_id": self.active_op_id } - r = requests.get(self.mscolab_server_url + '/get_operation_by_id', data=data) + url = urljoin(self.mscolab_server_url, "get_operation_by_id") + r = requests.get(url, data=data) if r.text != "False": xml_content = json.loads(r.text)["content"] return xml_content diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index c34c85e60..10d6c867d 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -34,6 +34,7 @@ from mslib.utils.verify_user_token import verify_user_token from mslib.utils.qt import Worker from mslib.utils.config import config_loader +from urllib.parse import urljoin class QMscolabOperationsListWidgetItem(QtWidgets.QListWidgetItem): @@ -557,7 +558,8 @@ def get_wps_from_server(self): "token": self.token, "skip_archived": skip_archived } - r = requests.get(self.mscolab_server_url + "/operations", data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -572,7 +574,8 @@ def request_wps_from_server(self, op_id): "token": self.token, "op_id": op_id } - r = requests.get(self.mscolab_server_url + '/get_operation_by_id', data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "get_operation_by_id") + r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": xml_content = json.loads(r.text)["content"] return xml_content diff --git a/mslib/utils/verify_user_token.py b/mslib/utils/verify_user_token.py index 18a8bbd0f..23cb1ed36 100644 --- a/mslib/utils/verify_user_token.py +++ b/mslib/utils/verify_user_token.py @@ -28,6 +28,7 @@ import logging import requests from mslib.utils.config import config_loader +from urllib.parse import urljoin def verify_user_token(mscolab_server_url, token): @@ -39,7 +40,8 @@ def verify_user_token(mscolab_server_url, token): "token": token } try: - r = requests.get(f'{mscolab_server_url}/test_authorized', data=data, timeout=(2, 10)) + url = urljoin(mscolab_server_url, "test_authorized") + r = requests.get(url, data=data, timeout=(2, 10)) except requests.exceptions.SSLError: logging.debug("Certificate Verification Failed") return False From d4e8d6b39ea8ca83c1aed405df0d49c8d19ddb4f Mon Sep 17 00:00:00 2001 From: Maira Usman Date: Wed, 28 Feb 2024 00:58:20 +0500 Subject: [PATCH 131/176] removed section `Setup msui_settings.json for special tests` (#2252) --- docs/development.rst | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 80a594076..26f4dca81 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -229,15 +229,6 @@ We have implemented demodata as data base for testing. On first call of pytest a in a /tmp/mss* folder. If you have installed gitpython a postfix of the revision head is added. -Setup msui_settings.json for special tests -.......................................... - -On default all tests use default configuration defined in mslib.msui.MissionSupportSystemDefaultConfig. -If you want to overwrite this setup and try out a special configuration add an msui_settings.json -file to the testings base dir in your tmp directory. You call it by the custom `--msui_settings` option - - - Setup MSWMS server ------------------ From 3f1851eca3c5213244edaca7e93c5f3879a16693 Mon Sep 17 00:00:00 2001 From: "Jets@ps2004" <120110796+Preetam-Das26@users.noreply.github.com> Date: Wed, 28 Feb 2024 15:31:10 +0530 Subject: [PATCH 132/176] Use of Aware datetime objects (#2239) Datetime objects are treated by many datetime methods as local times, it is preferred to use aware datetimes to represent times in UTC --- mslib/mscolab/chat_manager.py | 4 ++-- mslib/mscolab/file_manager.py | 2 +- mslib/mscolab/models.py | 4 ++-- mslib/mscolab/server.py | 2 +- mslib/mscolab/utils.py | 2 +- mslib/msui/mscolab_chat.py | 2 +- mslib/msui/mscolab_version_history.py | 2 +- mslib/msui/wms_control.py | 1 + tests/_test_mscolab/test_files_api.py | 9 +++++++++ tests/_test_mscolab/test_server.py | 9 +++++++++ tests/_test_mscolab/test_sockets_manager.py | 8 ++++---- 11 files changed, 32 insertions(+), 13 deletions(-) diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index da8555893..a5580c5af 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -62,9 +62,9 @@ def get_messages(self, op_id, timestamp=None): timestamp: if provided, messages only after this time stamp is provided """ if timestamp is None: - timestamp = datetime.datetime(1970, 1, 1) + timestamp = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) else: - timestamp = datetime.datetime.strptime(timestamp, "%Y-%m-%d, %H:%M:%S") + timestamp = datetime.datetime.strptime(timestamp, "%Y-%m-%d, %H:%M:%S %z") messages = Message.query \ .filter(Message.op_id == op_id) \ .filter(Message.reply_id.is_(None)) \ diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 1e1917bbe..523e90e29 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -399,7 +399,7 @@ def get_all_changes(self, op_id, user, named_version=False): 'comment': change.comment, 'version_name': change.version_name, 'username': change.user.username, - 'created_at': change.created_at.strftime("%Y-%m-%d, %H:%M:%S") + 'created_at': change.created_at.strftime("%Y-%m-%d, %H:%M:%S %z") }, changes)) def get_change_content(self, ch_id, user): diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index bec78f606..e8fac6261 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -181,7 +181,7 @@ class Message(db.Model): text = db.Column(db.Text) message_type = db.Column(db.Enum(MessageType), default=MessageType.TEXT) reply_id = db.Column(db.Integer, db.ForeignKey('messages.id')) - created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + created_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) user = db.relationship('User') replies = db.relationship('Message', cascade='all,delete,delete-orphan', single_parent=True) @@ -205,7 +205,7 @@ class Change(db.Model): commit_hash = db.Column(db.String(255), default=None) version_name = db.Column(db.String(255), default=None) comment = db.Column(db.String(255), default=None) - created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + created_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) user = db.relationship('User') def __init__(self, op_id, u_id, commit_hash, version_name=None, comment=None): diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 77b6b41ad..56d54c1eb 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -367,7 +367,7 @@ def messages(): user = g.user op_id = request.args.get("op_id", request.form.get("op_id", None)) if fm.is_member(user.id, op_id): - timestamp = request.args.get("timestamp", request.form.get("timestamp", "1970-01-01, 00:00:00")) + timestamp = request.args.get("timestamp", request.form.get("timestamp", "1970-01-01, 00:00:00 +00:00")) chat_messages = cm.get_messages(op_id, timestamp) return jsonify({"messages": chat_messages}) return "False" diff --git a/mslib/mscolab/utils.py b/mslib/mscolab/utils.py index dd2e5b18f..98f6ae530 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -55,7 +55,7 @@ def get_message_dict(message): "message_type": message.message_type, "reply_id": message.reply_id, "replies": [], - "time": message.created_at.strftime("%Y-%m-%d, %H:%M:%S") + "time": message.created_at.strftime("%Y-%m-%d, %H:%M:%S %z") } diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index aa9f34191..bcf765b23 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -349,7 +349,7 @@ def load_all_messages(self): data = { "token": self.token, "op_id": self.op_id, - "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") } # returns an array of messages url = urljoin(self.mscolab_server_url, "messages") diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index 869ef2527..40175facf 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -144,7 +144,7 @@ def load_all_changes(self): changes = json.loads(r.text)["changes"] self.changes.clear() for change in changes: - created_at = datetime.strptime(change["created_at"], "%Y-%m-%d, %H:%M:%S") + created_at = datetime.strptime(change["created_at"], "%Y-%m-%d, %H:%M:%S %z") local_time = utc_to_local_datetime(created_at) date = local_time.strftime('%d/%m/%Y') time = local_time.strftime('%I:%M %p') diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index a1b16a8b5..a01af591f 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -474,6 +474,7 @@ def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): self.wms_cache = None # Initialise date/time fields with current day, 00 UTC. + # Todo Before refactoring to an aware datetime object add a test to verify the WMS part. self.dteInitTime.setDateTime(QtCore.QDateTime( datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0))) self.dteValidTime.setDateTime(QtCore.QDateTime( diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 1523eb18e..748eb9382 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -24,6 +24,7 @@ See the License for the specific language governing permissions and limitations under the License. """ +import time import fs import pytest @@ -171,15 +172,23 @@ def test_get_all_changes(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V11") assert self.fm.save_file(operation.id, "content1", self.user) + # we need to wait to get an updated created_at + time.sleep(1) assert self.fm.save_file(operation.id, "content2", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) + # the newest change is on index 0, because it has a recent created_at time assert len(all_changes) == 2 + assert all_changes[0]["id"] == 2 + assert all_changes[0]["id"] > all_changes[1]["id"] + assert all_changes[0]["created_at"] > all_changes[1]["created_at"] def test_get_change_content(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V12", content='initial') assert self.fm.save_file(operation.id, "content1", self.user) + time.sleep(1) assert self.fm.save_file(operation.id, "content2", self.user) + time.sleep(1) assert self.fm.save_file(operation.id, "content3", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) previous_change = self.fm.get_change_content(all_changes[2]["id"], self.user) diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index d7e8995c0..4dd80aff2 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -24,6 +24,7 @@ See the License for the specific language governing permissions and limitations under the License. """ +import time import pytest import json import io @@ -233,18 +234,26 @@ def test_get_all_changes(self): with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) fm, user = self._save_content(operation, self.userdata) + time.sleep(1) fm.save_file(operation.id, "content2", user) + all_changes = fm.get_all_changes(operation.id, user) + # the newest change is on index 0, because it has a recent created_at time response = test_client.get('/get_all_changes', data={"token": token, "op_id": operation.id}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) assert len(data["changes"]) == 2 + assert all_changes[0]["id"] == 2 + assert all_changes[0]["id"] > all_changes[1]["id"] + assert all_changes[0]["created_at"] > all_changes[1]["created_at"] def test_get_change_content(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) fm, user = self._save_content(operation, self.userdata) + # we need to wait to get an updated created_at + time.sleep(1) fm.save_file(operation.id, "content2", user) all_changes = fm.get_all_changes(operation.id, user) response = test_client.get('/get_change_content', data={"token": token, diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index 55cac4182..6ae9b3620 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -191,11 +191,11 @@ def test_get_messages(self): assert messages[0]["text"] == "message from 1" assert len(messages) == 2 assert messages[0]["u_id"] == self.user.id - timestamp = datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + timestamp = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") messages = self.cm.get_messages(1, timestamp) assert len(messages) == 2 assert messages[0]["u_id"] == self.user.id - timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S") + timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") messages = self.cm.get_messages(1, timestamp) assert len(messages) == 0 @@ -221,7 +221,7 @@ def test_get_messages_api(self): data = { "token": token, "op_id": self.operation.id, - "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") } # returns an array of messages url = urljoin(self.url, 'messages') @@ -257,7 +257,7 @@ def test_edit_message(self): data = { "token": token, "op_id": self.operation.id, - "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") } # returns an array of messages url = urljoin(self.url, 'messages') From f5034357cef3602afdb59ece9be683a990896c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 28 Feb 2024 15:47:53 +0100 Subject: [PATCH 133/176] Fix some race conditions (#2255) --- .../test_mscolab_version_history.py | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index e12ef8c3c..df2556e3d 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -78,19 +78,17 @@ def assert_(): assert len_prev == (len_after - 2) qtbot.wait_until(assert_) - @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) - def test_set_version_name(self, mockbox): - self._set_version_name() - assert self.version_window.changes.currentItem().version_name == "MyVersionName" - assert self.version_window.changes.count() == 1 - - @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) - def test_version_name_delete(self, mockbox): - self._set_version_name() - assert self.version_window.changes.currentItem().version_name == "MyVersionName" + def test_set_version_name(self, qtbot): + self._set_version_name(qtbot) + + def test_version_name_delete(self, qtbot): + self._set_version_name(qtbot) QtTest.QTest.mouseClick(self.version_window.deleteVersionNameBtn, QtCore.Qt.LeftButton) - assert self.version_window.changes.count() == 1 - assert self.version_window.changes.currentItem().version_name is None + + def assert_(): + assert self.version_window.changes.count() == 1 + assert self.version_window.changes.currentItem().version_name is None + qtbot.wait_until(assert_) @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_undo_changes(self, mockbox, qtbot): @@ -159,10 +157,23 @@ def _change_version_filter(self, index): self.version_window.versionFilterCB.setCurrentIndex(index) self.version_window.versionFilterCB.currentIndexChanged.emit(index) - def _set_version_name(self): + def _set_version_name(self, qtbot): self._change_version_filter(1) + num_changes_before = self.version_window.changes.count() # make a changes self.window.mscolab.waypoints_model.invert_direction() - self.version_window.load_all_changes() + + # Ensure that the change is visible + def assert_(): + self.version_window.load_all_changes() + assert self.version_window.changes.count() == num_changes_before + 1 + qtbot.wait_until(assert_) + self._activate_change_at_index(0) - QtTest.QTest.mouseClick(self.version_window.nameVersionBtn, QtCore.Qt.LeftButton) + with mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]): + QtTest.QTest.mouseClick(self.version_window.nameVersionBtn, QtCore.Qt.LeftButton) + + # Ensure that the name change is fully processed + def assert_(): + assert self.version_window.changes.currentItem().version_name == "MyVersionName" + qtbot.wait_until(assert_) From 138390f76a83b7cca09a536f992bbf61818227b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 29 Feb 2024 16:03:40 +0100 Subject: [PATCH 134/176] Simplify GitHub Actions workflows (#2173) This removes a bit of unnecessary code. The resulting workflows should do effectively the same as before. The changes and reasoning are these: - remove `success()` in `if:` blocks, since that is the implicit default - replace `source` and `activate` calls with just `mamba run`, because that is more concise and readable - remove ineffective `inputs:` definition for pull_request event, because it does nothing - remove the specification of bash as the shell, since the default of `sh` works just fine - rearrange shell invocations to remove unnecessary backslashes, because I find that to be much more pleasant to read - replace `always()` with `!cancelled()` in `if:`, since the first would even run if the workflow is manually cancelled - move trusting the git repository to the location where it is needed - do not hardcode specific GSoC branches - remove unused env variables --- .github/workflows/build_docs_gallery.yml | 23 +------- .github/workflows/python-flake8.yml | 6 +- .github/workflows/testing-develop.yml | 1 - .github/workflows/testing-scheduled.yml | 1 - .github/workflows/testing.yml | 75 ++++++++---------------- .github/workflows/testing_gsoc.yml | 70 ++++++---------------- 6 files changed, 48 insertions(+), 128 deletions(-) diff --git a/.github/workflows/build_docs_gallery.yml b/.github/workflows/build_docs_gallery.yml index b5732cc43..2ca8f13a6 100644 --- a/.github/workflows/build_docs_gallery.yml +++ b/.github/workflows/build_docs_gallery.yml @@ -2,36 +2,19 @@ name: Build Gallery on: pull_request: - inputs: - branch_name: - required: true - type: string - secrets: - PAT: - required: true - -env: - PAT: ${{ secrets.PAT }} jobs: Test-MSS-docs: runs-on: ubuntu-latest - defaults: - run: - shell: bash - container: image: openmss/testing-develop steps: - - name: Trust My Directory - run: git config --global --add safe.directory /__w/MSS/MSS - uses: actions/checkout@v4 - name: Create gallery - timeout-minutes: 25 + timeout-minutes: 5 run: | - source /opt/conda/bin/activate mssenv \ - && cd $GITHUB_WORKSPACE/docs \ - && python conf.py + cd docs + mamba run --no-capture-output -n mssenv python conf.py diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index 06a0c4454..7cd34993e 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -8,14 +8,12 @@ on: branches: - develop - stable - - GSOC2023-NilupulManodya - - GSOC2023-ShubhGaur + - 'GSOC**' pull_request: branches: - develop - stable - - GSOC2023-NilupulManodya - - GSOC2023-ShubhGaur + - 'GSOC**' jobs: lint: diff --git a/.github/workflows/testing-develop.yml b/.github/workflows/testing-develop.yml index 0f4860c4c..4eb7b2f78 100644 --- a/.github/workflows/testing-develop.yml +++ b/.github/workflows/testing-develop.yml @@ -4,7 +4,6 @@ on: push: branches: - develop - pull_request: branches: - develop diff --git a/.github/workflows/testing-scheduled.yml b/.github/workflows/testing-scheduled.yml index 985b533d5..c83341797 100644 --- a/.github/workflows/testing-scheduled.yml +++ b/.github/workflows/testing-scheduled.yml @@ -4,7 +4,6 @@ on: schedule: - cron: '30 5 * * 1' - jobs: trigger-testing-stable: runs-on: ubuntu-latest diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index f84eb5aee..f4a569e7a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -14,17 +14,12 @@ on: required: true env: - PAT: ${{ secrets.PAT }} - EVENT: ${{ inputs.event_name }} + mamba-env: mss-${{ inputs.branch_name }}-env jobs: Test-MSS: runs-on: ubuntu-latest - defaults: - run: - shell: bash - container: image: openmss/testing-${{ inputs.branch_name }} @@ -34,71 +29,49 @@ jobs: order: ["normal", "reverse"] steps: - - name: Trust My Directory - run: git config --global --add safe.directory /__w/MSS/MSS - - uses: actions/checkout@v4 - name: Check for changed dependencies - run: | - cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt \ - || (echo Dependencies differ \ - && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) + run: cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt || + (echo Dependencies differ && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) - name: Always rebuild dependencies for scheduled builds - if: ${{ success() && inputs.event_name == 'schedule' && inputs.branch_name == 'stable' && env.triggerdockerbuild != 'yes' }} - run: | - echo "triggerdockerbuild=yes" >> $GITHUB_ENV + if: ${{ inputs.event_name == 'schedule' && inputs.branch_name == 'stable' && env.triggerdockerbuild != 'yes' }} + run: echo "triggerdockerbuild=yes" >> $GITHUB_ENV - name: Reinstall dependencies if changed - if: ${{ success() && env.triggerdockerbuild == 'yes' }} + if: ${{ env.triggerdockerbuild == 'yes' }} run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && mamba deactivate \ - && cat localbuild/meta.yaml \ - | sed -n '/^requirements:/,/^test:/p' \ - | sed -e "s/.*- //" \ - | sed -e "s/menuinst.*//" \ - | sed -e "s/.*://" > reqs.txt \ - && cat requirements.d/development.txt >> reqs.txt \ - && cat reqs.txt \ - && mamba env remove -n mss-${{ inputs.branch_name }}-env \ - && mamba create -y -n mss-${{ inputs.branch_name }}-env --file reqs.txt + cat localbuild/meta.yaml | + sed -n '/^requirements:/,/^test:/p' | + sed -e "s/.*- //" | + sed -e "s/menuinst.*//" | + sed -e "s/.*://" > reqs.txt + cat requirements.d/development.txt >> reqs.txt + cat reqs.txt + mamba env remove -n ${{ env.mamba-env }} + mamba create -y -n ${{ env.mamba-env }} --file reqs.txt - name: Print conda list - run: | - source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && mamba list + run: mamba run --no-capture-output -n ${{ env.mamba-env }} mamba list - name: Run tests timeout-minutes: 10 - run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && xvfb-run pytest -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib \ - ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests + run: mamba run --no-capture-output -n ${{ env.mamba-env }} xvfb-run pytest + -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage - if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && matrix.order == 'normal' }} + if: ${{ inputs.event_name == 'push' && inputs.branch_name == 'develop' && matrix.order == 'normal' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && mamba install coveralls \ - && coveralls --service=github + git config --global --add safe.directory /__w/MSS/MSS + mamba install -n ${{ env.mamba-env }} coveralls + mamba run --no-capture-output -n ${{ env.mamba-env }} coveralls --service=github - name: Invoke dockertesting image creation - if: ${{ always() && inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} + if: ${{ !cancelled() && inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} uses: benc-uk/workflow-dispatch@v1.2.2 with: workflow: Update Image testing-${{ inputs.branch_name }} diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml index 60336a0ab..c0a546cb1 100644 --- a/.github/workflows/testing_gsoc.yml +++ b/.github/workflows/testing_gsoc.yml @@ -4,23 +4,14 @@ on: push: branches: - 'GSOC**' - pull_request: branches: - 'GSOC**' -env: - PAT: ${{ secrets.PAT }} - - jobs: Test-MSS-GSOC: runs-on: ubuntu-latest - defaults: - run: - shell: bash - container: image: openmss/testing-develop @@ -30,61 +21,38 @@ jobs: order: ["normal", "reverse"] steps: - - name: Trust My Directory - run: git config --global --add safe.directory /__w/MSS/MSS - - uses: actions/checkout@v4 - name: Check for changed dependencies - run: | - cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt \ - || (echo Dependencies differ \ - && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) + run: cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt || + (echo Dependencies differ && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) - name: Reinstall dependencies if changed - if: ${{ success() && env.triggerdockerbuild == 'yes' }} + if: ${{ env.triggerdockerbuild == 'yes' }} run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-develop-env \ - && mamba deactivate \ - && cat localbuild/meta.yaml \ - | sed -n '/^requirements:/,/^test:/p' \ - | sed -e "s/.*- //" \ - | sed -e "s/menuinst.*//" \ - | sed -e "s/.*://" > reqs.txt \ - && cat requirements.d/development.txt >> reqs.txt \ - && cat reqs.txt \ - && mamba env remove -n mss-develop-env \ - && mamba create -y -n mss-develop-env --file reqs.txt + cat localbuild/meta.yaml | + sed -n '/^requirements:/,/^test:/p' | + sed -e "s/.*- //" | + sed -e "s/menuinst.*//" | + sed -e "s/.*://" > reqs.txt + cat requirements.d/development.txt >> reqs.txt + cat reqs.txt + mamba env remove -n mss-develop-env + mamba create -y -n mss-develop-env --file reqs.txt - name: Print conda list - run: | - source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-develop-env \ - && mamba list + run: mamba run --no-capture-output -n mss-develop-env mamba list - name: Run tests - if: ${{ success() }} timeout-minutes: 10 - run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-develop-env \ - && xvfb-run pytest -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib \ - ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests + run: mamba run --no-capture-output -n mss-develop-env xvfb-run pytest + -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage - if: ${{ success() }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-develop-env \ - && mamba install coveralls \ - && coveralls --service=github + git config --global --add safe.directory /__w/MSS/MSS + mamba install -n mss-develop-env coveralls + mamba run --no-capture-output -n mss-develop-env coveralls --service=github From 8e7c164231e6346207983249e23bb3f28bed94a5 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 1 Mar 2024 10:46:19 +0100 Subject: [PATCH 135/176] mscolab app does not need a static location from the gallery_builder (#2250) --- mslib/mscolab/app/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py index 987bf42f7..3b9890ad2 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -33,7 +33,6 @@ from flask import Flask, url_for from mslib.mscolab.conf import mscolab_settings from flask_sqlalchemy import SQLAlchemy -from mslib.mswms.gallery_builder import STATIC_LOCATION from mslib.utils import prefix_route @@ -43,8 +42,7 @@ # in memory database for testing # app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' -APP = Flask(__name__, template_folder=os.path.join(DOCS_SERVER_PATH, 'static', 'templates'), static_url_path="/static", - static_folder=STATIC_LOCATION) +APP = Flask(__name__, template_folder=os.path.join(DOCS_SERVER_PATH, 'static', 'templates')) APP.config.from_object(__name__) APP.route = prefix_route(APP.route, SCRIPT_NAME) From 6587ece5342e912153df923af40384483a76c473 Mon Sep 17 00:00:00 2001 From: Rohit prasad Date: Fri, 1 Mar 2024 20:44:31 +0530 Subject: [PATCH 136/176] Replace: occurrences of tests.utils.wait_until_signal with qtbot.wait_signal (#2247) --- tests/_test_msui/test_linearview.py | 9 ++- tests/_test_msui/test_sideview.py | 9 ++- tests/_test_msui/test_topview.py | 15 +++-- tests/_test_msui/test_wms_control.py | 94 ++++++++++++++-------------- tests/test_meta.py | 4 -- tests/utils.py | 25 -------- 6 files changed, 61 insertions(+), 95 deletions(-) diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 848f40d4c..5904cb63d 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -34,7 +34,6 @@ from mslib.msui import flighttrack as ft import mslib.msui.linearview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_LINEARVIEW -from tests.utils import wait_until_signal class Test_MSS_LV_Options_Dialog: @@ -107,15 +106,15 @@ def setup(self, qapp, mswms_server): self.window.hide() shutil.rmtree(self.tempdir) - def query_server(self, url): + def query_server(self, qtbot, url): QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - wait_until_signal(self.wms_control.cpdlg.canceled) + with qtbot.wait_signal(self.wms_control.cpdlg.canceled): + QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(self.url) + self.query_server(qtbot, self.url) with qtbot.wait_signal(self.wms_control.image_displayed): QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 0201862bf..2971a4fb3 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -35,7 +35,6 @@ from mslib.msui import flighttrack as ft import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW -from tests.utils import wait_until_signal class Test_MSS_SV_OptionsDialog: @@ -147,16 +146,16 @@ def setup(self, qapp, mswms_server): self.window.hide() shutil.rmtree(self.tempdir) - def query_server(self, url): + def query_server(self, qtbot, url): QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - wait_until_signal(self.wms_control.cpdlg.canceled) + with qtbot.wait_signal(self.wms_control.cpdlg.canceled): + QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(self.url) + self.query_server(qtbot, self.url) with qtbot.wait_signal(self.wms_control.image_displayed): QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) assert self.window.getView().plotter.image is not None diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index f939281d7..00dc58daf 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -35,7 +35,6 @@ from mslib.msui import flighttrack as ft from mslib.msui.msui import MSUIMainWindow from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_TOPVIEW -from tests.utils import wait_until_signal class Test_MSS_TV_MapAppearanceDialog: @@ -222,18 +221,18 @@ def setup(self, qapp, mswms_server): self.window.hide() shutil.rmtree(self.tempdir) - def query_server(self, url): + def query_server(self, qtbot, url): QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - wait_until_signal(self.wms_control.cpdlg.canceled) + with qtbot.wait_signal(self.wms_control.cpdlg.canceled): + QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - def test_server_getmap(self): + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(self.url) - QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) - wait_until_signal(self.wms_control.image_displayed) + self.query_server(qtbot, self.url) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) assert self.window.getView().map.image is not None self.window.getView().set_settings({}) self.window.getView().clear_figure() diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index afe84783f..c0a50b394 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -35,7 +35,6 @@ from PyQt5 import QtCore, QtTest from mslib.msui import flighttrack as ft import mslib.msui.wms_control as wc -from tests.utils import wait_until_signal class HSecViewMockup(mock.Mock): @@ -88,12 +87,12 @@ def _teardown(self): self.window.hide() shutil.rmtree(self.tempdir) - def query_server(self, url): + def query_server(self, qtbot, url): while len(self.window.multilayers.cbWMS_URL.currentText()) > 0: QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) QtTest.QTest.keyClicks(self.window.multilayers.cbWMS_URL, url) - QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - wait_until_signal(self.window.cpdlg.canceled) + with qtbot.wait_signal(self.window.cpdlg.canceled): + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) class Test_HSecWMSControlWidget(WMSControlWidgetSetup): @@ -103,49 +102,49 @@ def setup(self, qapp): yield self._teardown() - def test_no_server(self): + def test_no_server(self, qtbot): """ assert that a message box informs about server troubles """ with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: - self.query_server(f"{self.scheme}://{self.host}:{self.port-1}") + self.query_server(qtbot, f"{self.scheme}://{self.host}:{self.port-1}") mock_critical.assert_called_once() - def test_no_schema(self): + def test_no_schema(self, qtbot): """ assert that a message box informs about server troubles """ with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: - self.query_server(f"{self.host}:{self.port}") + self.query_server(qtbot, f"{self.host}:{self.port}") mock_critical.assert_called_once() - def test_invalid_schema(self): + def test_invalid_schema(self, qtbot): """ assert that a message box informs about server troubles """ with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: - self.query_server(f"hppd://{self.host}:{self.port}") + self.query_server(qtbot, f"hppd://{self.host}:{self.port}") mock_critical.assert_called_once() - def test_invalid_url(self): + def test_invalid_url(self, qtbot): """ assert that a message box informs about server troubles """ with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: - self.query_server(f"{self.scheme}://???{self.host}:{self.port}") + self.query_server(qtbot, f"{self.scheme}://???{self.host}:{self.port}") mock_critical.assert_called_once() - def test_connection_error(self): + def test_connection_error(self, qtbot): """ assert that a message box informs about server troubles """ with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: - self.query_server(f"{self.scheme}://.....{self.host}:{self.port}") + self.query_server(qtbot, f"{self.scheme}://.....{self.host}:{self.port}") mock_critical.assert_called_once() @pytest.mark.skip("Breaks other tests in this class because of a lingering message box, for some reason") - def test_forward_backward_clicks(self): - self.query_server(self.url) + def test_forward_backward_clicks(self, qtbot): + self.query_server(qtbot, self.url) self.window.init_time_back_click() self.window.init_time_fwd_click() self.window.valid_time_fwd_click() @@ -162,15 +161,14 @@ def test_forward_backward_clicks(self): pass @pytest.mark.skip("Has a race condition where the abort might not happen fast enough") - def test_server_abort_getmap(self): + def test_server_abort_getmap(self, qtbot): """ assert that an aborted getmap call does not change the displayed image """ - self.query_server(self.url) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtTest.QTest.keyClick(self.window.pdlg, QtCore.Qt.Key_Enter) - wait_until_signal(self.window.image_displayed) - + self.query_server(qtbot, self.url) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) + QtTest.QTest.keyClick(self.window.pdlg, QtCore.Qt.Key_Enter) assert self.view.draw_image.call_count == 0 assert self.view.draw_legend.call_count == 0 assert self.view.draw_metadata.call_count == 0 @@ -180,7 +178,7 @@ def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(self.url) + self.query_server(qtbot, self.url) with qtbot.wait_signal(self.window.image_displayed): QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) @@ -193,7 +191,7 @@ def test_server_getmap_cached(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(self.url) + self.query_server(qtbot, self.url) with qtbot.wait_signal(self.window.image_displayed): QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) @@ -204,8 +202,8 @@ def test_server_getmap_cached(self, qtbot): self.view.reset_mock() QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 @@ -215,7 +213,7 @@ def test_server_service_cache(self, qtbot): """ assert that changing between servers still allows image retrieval """ - self.query_server(self.url) + self.query_server(qtbot, self.url) with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as qm_critical: with qtbot.wait_signal(self.window.cpdlg.canceled): @@ -239,11 +237,11 @@ def test_server_service_cache(self, qtbot): assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - def test_multilayer_handling(self): + def test_multilayer_handling(self, qtbot): """ assert that multilayers get created, handled and drawn properly """ - self.query_server(self.url) + self.query_server(qtbot, self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) @@ -273,15 +271,15 @@ def test_multilayer_handling(self): assert self.window.multilayers.listLayers.itemWidget(server.child(0), 2).currentText() == "1" # Check drawing not causing errors - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - def test_filter_handling(self): - self.query_server(self.url) + def test_filter_handling(self, qtbot): + self.query_server(qtbot, self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) @@ -321,11 +319,11 @@ def test_filter_handling(self): assert len(self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)) == 0 - def test_singlelayer_handling(self): + def test_singlelayer_handling(self, qtbot): """ assert that singlelayer mode behaves as expected """ - self.query_server(self.url) + self.query_server(qtbot, self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) @@ -348,18 +346,18 @@ def test_singlelayer_handling(self): assert self.window.lLayerName.text().endswith(server.child(1).text(0)) # Check drawing not causing errors - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - def test_multilayer_syncing(self): + def test_multilayer_syncing(self, qtbot): """ assert that synced layers share their options """ - self.query_server(self.url) + self.query_server(qtbot, self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) @@ -386,8 +384,8 @@ def test_multilayer_syncing(self): assert layer_a.get_itime() == layer_a.get_itimes()[-1] @mock.patch("mslib.msui.wms_control.WMSMapFetcher.moveToThread") - def test_server_no_thread(self, mockthread): - self.query_server(self.url) + def test_server_no_thread(self, mockthread, qtbot): + self.query_server(qtbot, self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) @@ -396,8 +394,8 @@ def test_server_no_thread(self, mockthread): server.child(0).setCheckState(0, 2) server.child(1).setCheckState(0, 2) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) urlstr = f"{self.url}/mss/logo.png" md5_filname = os.path.join(self.window.wms_cache, hashlib.md5(urlstr.encode('utf-8')).hexdigest() + ".png") @@ -420,7 +418,7 @@ def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(self.url) + self.query_server(qtbot, self.url) with qtbot.wait_signal(self.window.image_displayed): QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) @@ -428,15 +426,15 @@ def test_server_getmap(self, qtbot): assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - def test_multilayer_drawing(self): + def test_multilayer_drawing(self, qtbot): """ assert that drawing a layer through code doesn't fail for vsec """ - self.query_server(self.url) + self.query_server(qtbot, self.url) server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] - server.child(0).draw() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + server.child(0).draw() class TestWMSControlWidgetSetupSimple: diff --git a/tests/test_meta.py b/tests/test_meta.py index c3d24b94a..7fcc83ab1 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -46,10 +46,6 @@ def test_qWait_is_not_used_in_tests(request): if str(test_file) == request.fspath: # Skip the current file continue - if str(test_file.relative_to(request.config.rootdir)) == "tests/utils.py": - # Skip tests/utils.py - # This skip can be removed once wait_until_signal is no longer used - continue assert ( "qWait(" not in test_file.read_text() ), "qWait is mentioned in {}".format(test_file.relative_to(request.config.rootdir)) diff --git a/tests/utils.py b/tests/utils.py index 8e4b226ee..4f3b79465 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -26,10 +26,8 @@ limitations under the License. """ import requests -import time import fs -from PyQt5 import QtTest from urllib.parse import urljoin from mslib.mscolab.server import register_user from flask import json @@ -175,29 +173,6 @@ def is_url_response_ok(url): return False -def wait_until_signal(signal, timeout=5): - """ - Blocks the calling thread until the signal emits or the timeout expires. - """ - init_time = time.time() - finished = False - - def done(*args): - nonlocal finished - finished = True - - signal.connect(done) - while not finished and time.time() - init_time < timeout: - QtTest.QTest.qWait(100) - - try: - signal.disconnect(done) - except TypeError: - pass - finally: - return finished - - class ExceptionMock: """ Replace function calls with raised exceptions From ec1f2a1fded26fab8711ac719ff26ecab9dfca24 Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Sun, 3 Mar 2024 16:49:41 +0530 Subject: [PATCH 137/176] fix: typo (#2264) --- mslib/msui/mscolab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index ed8f1fd87..e3905d918 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -235,7 +235,7 @@ def connect_handler(self): self.loginPasswordLe.setEnabled(True) try: - idp_enabled = json.loads(r.text)["use_saml2 "] + idp_enabled = json.loads(r.text)["use_saml2"] except (json.decoder.JSONDecodeError, KeyError): idp_enabled = False From 431283949c28b1ae34b9981ce06d1c8d6c5b76a6 Mon Sep 17 00:00:00 2001 From: Maira Usman Date: Wed, 6 Mar 2024 20:57:18 +0500 Subject: [PATCH 138/176] refactor test (#2268) --- tests/_test_mscolab/test_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 4dd80aff2..a77d49fa5 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -236,13 +236,13 @@ def test_get_all_changes(self): fm, user = self._save_content(operation, self.userdata) time.sleep(1) fm.save_file(operation.id, "content2", user) - all_changes = fm.get_all_changes(operation.id, user) # the newest change is on index 0, because it has a recent created_at time response = test_client.get('/get_all_changes', data={"token": token, "op_id": operation.id}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) - assert len(data["changes"]) == 2 + all_changes = data["changes"] + assert len(all_changes) == 2 assert all_changes[0]["id"] == 2 assert all_changes[0]["id"] > all_changes[1]["id"] assert all_changes[0]["created_at"] > all_changes[1]["created_at"] From 1d0a5ad604170f3a618c54b69b1ad949bba2ea91 Mon Sep 17 00:00:00 2001 From: "Jets@ps2004" <120110796+Preetam-Das26@users.noreply.github.com> Date: Mon, 11 Mar 2024 17:58:58 +0530 Subject: [PATCH 139/176] Coverage for all branches and PRs in Coveralls (#2272) --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b90d34292..1841085c6 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -69,7 +69,7 @@ jobs: ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage - if: ${{ inputs.event_name == 'push' && inputs.branch_name == 'develop' && matrix.order == 'normal' }} + if: ${{ (inputs.event_name == 'push' || inputs.event_name == 'pull_request') && matrix.order == 'normal' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | From 243fabe33744f90bf1fb5afd8a2059465974871d Mon Sep 17 00:00:00 2001 From: "Jets@ps2004" <120110796+Preetam-Das26@users.noreply.github.com> Date: Wed, 13 Mar 2024 15:48:12 +0530 Subject: [PATCH 140/176] Add new tests for mscolab.model (#2266) --- tests/_test_mscolab/test_models.py | 81 +++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index 5b39025ce..3ec9e9dd1 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -25,9 +25,88 @@ limitations under the License. """ import pytest +import datetime +import textwrap +import pytz from mslib.mscolab.server import register_user -from mslib.mscolab.models import User +from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, Change + + +def test_aware_datetime_conversion(): + aware_datetime_type = AwareDateTime() + curr_time = datetime.datetime.now(tz=datetime.timezone.utc) + + result_bind = aware_datetime_type.process_bind_param(curr_time, None) + assert result_bind is not None + assert result_bind == curr_time + + result_result = aware_datetime_type.process_result_value(curr_time, None) + assert result_result is not None + assert result_result == curr_time + + result_none = aware_datetime_type.process_bind_param(None, None) + assert result_none is None + + cet_time = datetime.datetime.now(tz=pytz.timezone("CET")) + result_cet = aware_datetime_type.process_bind_param(cet_time, None) + assert result_cet == cet_time + assert result_cet is not None + + +def test_permission_creation(): + permission = Permission(1, 1, "admin") + + assert permission.u_id == 1 + assert permission.op_id == 1 + assert permission.access_level == "admin" + + +@pytest.mark.parametrize("access_level", ["collaborator", "viewer", "creator"]) +def test_permission_repr_values(access_level): + permission = Permission(1, 1, access_level) + expected_repr = textwrap.dedent(f'''\ + ''').strip() + + assert repr(permission).strip() == expected_repr + + +def test_operation_creation(): + operation = Operation("/path/to/operation", "Description of the operation", category="test_category") + + assert operation.path == "/path/to/operation" + assert operation.description == "Description of the operation" + assert operation.category == "test_category" + assert operation.active is True + + +def test_operation_repr(): + operation = Operation("/path/to/operation", "Description of the operation", category="test_category") + expected_repr = f' ' + + assert repr(operation) == expected_repr + + +def test_message_creation(): + message = Message(1, 1, "Hello, this is a test message", "TEXT", None) + + assert message.op_id == 1 + assert message.u_id == 1 + assert message.text == "Hello, this is a test message" + assert message.message_type == "TEXT" + assert message.reply_id is None + + +def test_change_creation(): + change = Change(1, 1, "#abcdef123456", "v1.0", "Initial commit") + + assert change.op_id == 1 + assert change.u_id == 1 + assert change.commit_hash == "#abcdef123456" + assert change.version_name == "v1.0" + assert change.comment == "Initial commit" class Test_User: From 416b4e8c4bc268b269ea4cff6d6bfa1ec052b0a6 Mon Sep 17 00:00:00 2001 From: Maira Usman Date: Fri, 15 Mar 2024 16:36:25 +0500 Subject: [PATCH 141/176] fix: Resolve warning of TypeDecorator AwareDateTime() in `chat_manager.py` and `file_manager.py` (#2271) Enable cache for AwareDateTime --------- Signed-off-by: Myrausman --- conftest.py | 3 +++ docs/samples/config/mscolab/mscolab_settings.py.sample | 3 +++ mslib/mscolab/app/__init__.py | 1 + mslib/mscolab/conf.py | 3 +++ mslib/mscolab/models.py | 1 + 5 files changed, 11 insertions(+) diff --git a/conftest.py b/conftest.py index a1aa82e37..cf56850de 100644 --- a/conftest.py +++ b/conftest.py @@ -151,6 +151,9 @@ def generate_initial_config(): SQLALCHEMY_DB_URI = 'sqlite:///' + urljoin(DATA_DIR, 'mscolab.db') +# enable SQLALCHEMY_ECHO +SQLALCHEMY_ECHO = True + # mscolab file upload settings UPLOAD_FOLDER = fs.path.join(DATA_DIR, 'uploads') MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index e66d4a89e..d4b240a51 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -69,6 +69,9 @@ GROUP_POSTFIX = "Group" # SQLite: "sqlite:///" SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') +# Set to True for testing and False for production +SQLALCHEMY_ECHO = False + enable_basic_http_authentication = False # text to be written in new mscolab based ftml files. diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py index 3b9890ad2..8d39b9832 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -49,6 +49,7 @@ APP.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR APP.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI APP.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +APP.config['SQLALCHEMY_ECHO'] = mscolab_settings.SQLALCHEMY_ECHO APP.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER APP.config['MAX_CONTENT_LENGTH'] = mscolab_settings.MAX_UPLOAD_SIZE APP.config['SECRET_KEY'] = mscolab_settings.SECRET_KEY diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 30bf80495..ad3ae78e9 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -63,6 +63,9 @@ class default_mscolab_settings: # MYSQL CONNECTION STRING: "mysql+pymysql://:@:/?charset=utf8mb4" SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') + # Set to True for testing and False for production + SQLALCHEMY_ECHO = False + # mscolab file upload settings UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index e8fac6261..73d54a22f 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -38,6 +38,7 @@ class AwareDateTime(sqlalchemy.types.TypeDecorator): impl = sqlalchemy.types.DateTime + cache_ok = True def process_bind_param(self, value, dialect): if value is not None: From de7d3363069f6694ce15e38c29c7efa0619ccc40 Mon Sep 17 00:00:00 2001 From: Maira Usman Date: Mon, 18 Mar 2024 20:48:00 +0500 Subject: [PATCH 142/176] refactor: Replace pytz usage with zoneinfo module (#2280) --- localbuild/meta.yaml | 1 - tests/_test_mscolab/test_models.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 1c146bff0..a5fa39de7 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -47,7 +47,6 @@ requirements: - netcdf4 - hdf4 - pillow - - pytz - pyqt >=5.15.0 - qt >=5.15.0 - requests >=2.31.0 diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index 3ec9e9dd1..f7f34faa0 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -27,7 +27,7 @@ import pytest import datetime import textwrap -import pytz +from zoneinfo import ZoneInfo from mslib.mscolab.server import register_user from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, Change @@ -48,7 +48,7 @@ def test_aware_datetime_conversion(): result_none = aware_datetime_type.process_bind_param(None, None) assert result_none is None - cet_time = datetime.datetime.now(tz=pytz.timezone("CET")) + cet_time = datetime.datetime.now(tz=ZoneInfo("CET")) result_cet = aware_datetime_type.process_bind_param(cet_time, None) assert result_cet == cet_time assert result_cet is not None From 6950e266a81c461133127c1fde6a4d17f4438374 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 20 Mar 2024 20:17:53 +0100 Subject: [PATCH 143/176] xmlsec1 problems with windows (#2222) Limited installation of msidp to none windows systems. Described missing xmlsec1 for windows in the docs. --- docs/conf_sso_test_msscolab.rst | 3 +++ localbuild/meta.yaml | 8 ++++---- mslib/msidp/idp_conf.py | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst index 85ee7054f..e688b2597 100644 --- a/docs/conf_sso_test_msscolab.rst +++ b/docs/conf_sso_test_msscolab.rst @@ -2,6 +2,9 @@ Configuration MSS Colab Server with Testing IdP for SSO ======================================================= Testing IDP (`mslib/msidp`) is specifically designed for testing the Single Sign-On (SSO) process with the mscolab server using PySAML2. +.. note:: + The important module `xmlsec1 `_ is not provided for windows operations systems. Therefore this feature is not available on windows. + Here is documentation that explains the configuration of the MSS Colab Server with the testing IdP. Getting started diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index a5fa39de7..babfe7aad 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -9,7 +9,7 @@ source: path: ../ build: - skip: true # [py<38] + skip: true # [py<310] number: 1000 script: "{{ PYTHON }} -m pip install . --no-deps -vv" # [not win] entry_points: @@ -19,7 +19,7 @@ build: - mswms_demodata = mslib.mswms.demodata:main - mscolab = mslib.mscolab.mscolab:main - mssautoplot = mslib.utils.mssautoplot:main - - msidp = mslib.msidp.idp:main + - msidp = mslib.msidp.idp:main # [not win] requirements: build: @@ -95,7 +95,7 @@ requirements: - python-slugify - flask-login - pysaml2 - - libxmlsec1 + - libxmlsec1 # [not win] test: imports: @@ -105,7 +105,7 @@ test: - mswms_demodata -h - msui -h - mscolab -h - - msidp -h + - msidp -h # [not win] about: summary: 'A web service based tool to plan atmospheric research flights.' diff --git a/mslib/msidp/idp_conf.py b/mslib/msidp/idp_conf.py index 86ced60a0..21d726a4e 100644 --- a/mslib/msidp/idp_conf.py +++ b/mslib/msidp/idp_conf.py @@ -25,7 +25,7 @@ """ # Parts of the code - +import logging import os.path from saml2 import BINDING_HTTP_ARTIFACT @@ -38,6 +38,8 @@ from saml2.saml import NAMEID_FORMAT_TRANSIENT XMLSEC_PATH = os.path.join(os.environ["CONDA_PREFIX"], "bin", "xmlsec1") +if not os.path.exists(XMLSEC_PATH): + logging.warning("%s not found", XMLSEC_PATH) # CRTs and metadata files can be generated through the mscolab server. # if configured that way CRTs DIRs should be same in both IDP and mscolab server. From daf8f2ae64f9907c9e27114660659a5f5d1cb6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:25:38 +0100 Subject: [PATCH 144/176] Test on macOS 13 and 14 (#2249) * Add a workflow to test on macOS 13 and 14 runners The macOS 13 runners use Intel processors while the macOS 14 runners use Apple's ARM processors. * Disable some problematic tests on macOS * Fix a potential race condition * Increase pytest timeout The test_tutorial_dir seems to be able to run into a 30 second timeout under normal operation on the macOS 14 runners, so this increases the timeout a bit to accommodate. --- .github/workflows/testing-all-oses.yml | 53 ++++++++++++++++++++++++++ pytest.ini | 2 +- tests/_test_msui/test_mscolab.py | 10 ++++- tests/_test_mswms/test_wms.py | 17 ++++++++- 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/testing-all-oses.yml diff --git a/.github/workflows/testing-all-oses.yml b/.github/workflows/testing-all-oses.yml new file mode 100644 index 000000000..2083af10a --- /dev/null +++ b/.github/workflows/testing-all-oses.yml @@ -0,0 +1,53 @@ +name: Test MSS + +on: + push: + branches: + - develop + - stable + - 'GSOC**' + pull_request: + branches: + - develop + - stable + - 'GSOC**' + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["macos-13", "macos-14", "ubuntu-latest"] + order: ["normal", "reverse"] + steps: + - uses: actions/checkout@v4 + - name: Build requirements.txt file + run: | + sed -n '/^requirements:/,/^test:/p' localbuild/meta.yaml | + sed -e "s/.*- //" | + sed -e "s/menuinst.*//" | + sed -e "s/.*://" > requirements.tmp.txt + cat requirements.d/development.txt >> requirements.tmp.txt + sed -e '/^$/d' -e '/^#.*$/d' requirements.tmp.txt > requirements.txt + rm requirements.tmp.txt + cat requirements.txt + - name: Get current year and calendar week + id: year-and-week + run: echo "year-and-week=$(date +%Y-%V)" >> "$GITHUB_OUTPUT" + - uses: mamba-org/setup-micromamba@v1 + with: + environment-file: requirements.txt + environment-name: ci + cache-environment: true + # Set the cache key in a way that the cache is invalidated every week on monday + cache-environment-key: environment-${{ steps.year-and-week.outputs.year-and-week }} + - name: Run tests + timeout-minutes: 20 + # The ignored files can somehow cause the test suite to timeout. + # I have no idea yet on why this happens and how to fix it. + # Even a module level skip is not enough, they need to be completely ignored. + # TODO: fix those tests and drop the ignores + run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib + --ignore=tests/_test_msui/test_sideview.py --ignore=tests/_test_msui/test_topview.py --ignore=tests/_test_msui/test_wms_control.py + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests diff --git a/pytest.ini b/pytest.ini index 33d560e1e..3f0e6d3f9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,7 +6,7 @@ log_file = pytest.log log_file_level = DEBUG log_file_format = %(asctime)s %(levelname)s %(message)s log_file_date_format = %Y-%m-%d %H:%M:%S -timeout = 30 +timeout = 60 filterwarnings = # These namespaces are declared in a way not conformant with PEP420. Not much we can do about that here, we should keep an eye on when this is fixed in our dependencies though. ignore:Deprecated call to `pkg_resources.declare_namespace\('(xstatic|xstatic\.pkg|mpl_toolkits|mpl_toolkits\.basemap_data|sphinxcontrib|zope|fs|fs\.opener)'\)`\.:DeprecationWarning diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 49a9f0992..acf80dc92 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -25,6 +25,7 @@ limitations under the License. """ import os +import sys import fs import fs.errors import fs.opener.errors @@ -350,6 +351,10 @@ def test_handle_export(self, mockbox, qtbot): for i in range(wp_count): assert exported_waypoints.waypoint_data(i).lat == self.window.mscolab.waypoints_model.waypoint_data(i).lat + @pytest.mark.skipif( + sys.platform == "darwin", + reason="This test is flaky on macOS because of some cleanup error in temporary files.", + ) @pytest.mark.parametrize("name", [("example.ftml", "actionImportFlightTrackFTML", 5), ("example.csv", "actionImportFlightTrackCSV", 5), ("example.txt", "actionImportFlightTrackTXT", 5), @@ -403,7 +408,10 @@ def test_browse_add_operation(self, mockopen, qtbot): self.window.mscolab.add_proj_dialog.buttonBox.Ok) with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - m.assert_called_once() + + def assert_(): + m.assert_called_once() + qtbot.wait_until(assert_) def assert_(): assert self.window.listOperationsMSC.model().rowCount() == 1 diff --git a/tests/_test_mswms/test_wms.py b/tests/_test_mswms/test_wms.py index 937c900ad..ccce3301b 100644 --- a/tests/_test_mswms/test_wms.py +++ b/tests/_test_mswms/test_wms.py @@ -25,7 +25,7 @@ See the License for the specific language governing permissions and limitations under the License. """ - +import sys import os from shutil import move @@ -388,6 +388,13 @@ def test_import_error(self): assert mslib.mswms.wms.mswms_settings.__file__ is not None assert mslib.mswms.wms.mswms_auth.__file__ is not None + @pytest.mark.skipif( + sys.platform == "darwin", + reason="""\ +There is a race condition between modifying with ncap2 and asserting that the file changed where the server might not +see the change before the request is made, which leads to a failure of the following assert. +""".strip(), + ) def test_files_changed(self): def do_test(): environ = { @@ -429,6 +436,14 @@ def do_test(): "data_access", new=watch_access): do_test() + @pytest.mark.skipif( + sys.platform == "darwin", + reason="""\ +This test changes global variables (e.g. DOCS_LOCATION) which can affect other tests depending on test order +(e.g. tests/_test_mswms/test_mss_plot_driver.py::Test_VSec::test_VS_gallery_template fails consistently in reverse order +on macOS 14). +""".strip(), + ) def test_gallery(self, tmpdir): tempdir = tmpdir.mkdir("static") docsdir = tmpdir.mkdir("docs") From af7352611868f1e29421fde8ff5d84cbc295f029 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 21 Mar 2024 19:08:29 +0100 Subject: [PATCH 145/176] cycle login with multiple_flightpath_dockwidget (#2278) cycle login with multiple_flightpath_dockwidget fixed. a test was added to cover multiple top views and selected operations for checking on cycling login. --- mslib/msui/multiple_flightpath_dockwidget.py | 16 ++-- mslib/msui/topview.py | 9 --- tests/_test_msui/test_mscolab.py | 79 ++++++++++++++++++++ 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 10d6c867d..5898c8321 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -180,14 +180,14 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, def logout(self): if self.operations is not None: self.operations.logout_mscolab() - self.ui.signal_listFlighttrack_doubleClicked.disconnect() - self.ui.signal_permission_revoked.disconnect() - self.ui.signal_render_new_permission.disconnect() - self.operations = None - self.flighttrack_list = True - self.operation_list = False - for idx in range(len(self.obb)): - del self.obb[idx] + self.ui.signal_listFlighttrack_doubleClicked.disconnect() + self.ui.signal_permission_revoked.disconnect() + self.ui.signal_render_new_permission.disconnect() + self.operations = None + self.flighttrack_list = True + self.operation_list = False + for idx in range(len(self.obb)): + del self.obb[idx] @QtCore.pyqtSlot(str, str) def login(self, url, token): diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index 24a19a5f5..c4c101f9f 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -371,21 +371,12 @@ def openTool(self, index): lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) if self.active_op_id is not None: self.signal_activate_operation.emit(self.active_op_id) - widget.signal_parent_closes.connect(self.closed) else: raise IndexError("invalid control index") # Create the actual dock widget containing . self.createDockWidget(index, title, widget) - def closed(self): - self.mainwindow_signal_login_mscolab.disconnect() - self.mainwindow_signal_logout_mscolab.disconnect() - self.mainwindow_signal_listFlighttrack_doubleClicked.disconnect() - self.mainwindow_signal_activate_operation.disconnect() - self.mainwindow_signal_permission_revoked.disconnect() - self.mainwindow_signal_render_new_permission.disconnect() - @QtCore.pyqtSlot() def disable_cbs(self): self.wms_connected = True diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index acf80dc92..aed8b0967 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -335,6 +335,82 @@ def test_view_open(self, qtbot): assert tableview.btAddWayPointToFlightTrack.isEnabled() assert any(action.text() == "Ins WP" and action.isEnabled() for action in topview.mpl.navbar.actions()) + def test_multiple_views_and_multiple_flightpath(self, qtbot): + """ + checks that we can have multiple topviews with the multiple flightpath dockingwidget + and we are able to cycle a login/logout + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + # test after activating operation + self._activate_operation_at_index(0) + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + topview_0 = self.window.listViews.item(0) + + # next topview + self.window.actionTopView.trigger() + topview_1 = self.window.listViews.item(1) + + def assert_active_views(): + # check 2 view opened + assert len(self.window.get_active_views()) == 2 + qtbot.wait_until(assert_active_views) + + # open multiple flightpath first window + topview_0.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_0.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + + # activate all operation, this enables them in the docking widget too + self._activate_operation_at_index(1) + self._activate_operation_at_index(2) + self._activate_operation_at_index(0) + # ToDo refactor to be able to activate/deactivate by the docking widget and that it can be checked + + # open multiple flightpath second window + topview_1.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_1.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + + # activate all operation, this enables them in the docking widget too + self._activate_operation_at_index(1) + self._activate_operation_at_index(2) + self._activate_operation_at_index(0) + # ToDo refactor to be able to activate/deactivate by the docking widget and that it can be checked + + def assert_label_text(): + # verify logged in + assert self.window.usernameLabel.text() == self.userdata[1] + qtbot.wait_until(assert_label_text) + + self.window.mscolab.logout() + + def assert_logout_text(): + assert self.window.usernameLabel.text() == "User" + qtbot.wait_until(assert_logout_text) + + self._connect_to_mscolab(qtbot) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + # verify logged in again + qtbot.wait_until(assert_label_text) + # ToDo verify all operations disabled again without a visual check + @mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, 'test_export.ftml'), "Flight track (*.ftml)")) @@ -745,6 +821,9 @@ def assert_operation_is_listed(): qtbot.wait_until(assert_operation_is_listed) def _activate_operation_at_index(self, index): + # The main window must be on top + self.window.activateWindow() + # get the item by its index item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) From e50e2583010ae55c23d40a6814d2fb53a431954f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:40:17 +0100 Subject: [PATCH 146/176] Fix docker image creation from develop branch (#2296) This changes the testing-develop.yml workflow to be triggered via workflow_dispatch, as is also already done for testing-stable.yml, and changes the logic in testing.yml to always rebuild the environment for runs triggered by workflow_dispatch, while only triggering image rebuilds on push events. --- .github/workflows/testing-develop.yml | 1 + .github/workflows/testing-scheduled.yml | 17 +++++++++-------- .github/workflows/testing.yml | 25 ++++++++++++++----------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.github/workflows/testing-develop.yml b/.github/workflows/testing-develop.yml index 4eb7b2f78..300c7e843 100644 --- a/.github/workflows/testing-develop.yml +++ b/.github/workflows/testing-develop.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - develop + workflow_dispatch: jobs: test-develop: diff --git a/.github/workflows/testing-scheduled.yml b/.github/workflows/testing-scheduled.yml index c83341797..0f0d02449 100644 --- a/.github/workflows/testing-scheduled.yml +++ b/.github/workflows/testing-scheduled.yml @@ -15,11 +15,12 @@ jobs: workflow: testing-stable.yml ref: stable - test-develop-scheduled: - uses: - ./.github/workflows/testing.yml - with: - branch_name: develop - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} + trigger-testing-develop: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - uses: benc-uk/workflow-dispatch@v1.2.2 + with: + workflow: testing-develop.yml + ref: develop diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 1841085c6..3b5817eaa 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -42,10 +42,22 @@ jobs: source /opt/conda/etc/profile.d/mamba.sh mamba install -n mss-${{ inputs.branch_name }}-env pyvirtualdisplay - - name: Always rebuild dependencies for scheduled builds - if: ${{ inputs.event_name == 'schedule' && inputs.branch_name == 'stable' && env.triggerdockerbuild != 'yes' }} + - name: Always rebuild dependencies for scheduled builds (started from testing-scheduled.yml) + if: ${{ inputs.event_name == 'workflow_dispatch' }} run: echo "triggerdockerbuild=yes" >> $GITHUB_ENV + - name: Invoke dockertesting image creation + # The image creation is intentionally only triggered for push events because + # scheduled tests should just check that new dependency versions do not break the + # tests, but should not update the image. + if: ${{ inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} + uses: benc-uk/workflow-dispatch@v1.2.2 + with: + workflow: Update Image testing-${{ inputs.branch_name }} + repo: Open-MSS/dockertesting + ref: main + token: ${{ secrets.PAT }} + - name: Reinstall dependencies if changed if: ${{ env.triggerdockerbuild == 'yes' }} run: | @@ -76,12 +88,3 @@ jobs: git config --global --add safe.directory /__w/MSS/MSS mamba install -n ${{ env.mamba-env }} coveralls mamba run --no-capture-output -n ${{ env.mamba-env }} coveralls --service=github - - - name: Invoke dockertesting image creation - if: ${{ !cancelled() && inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} - uses: benc-uk/workflow-dispatch@v1.2.2 - with: - workflow: Update Image testing-${{ inputs.branch_name }} - repo: Open-MSS/dockertesting - ref: main - token: ${{ secrets.PAT }} From 70ce65f273fd076213fe14c42f771210b952044a Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Wed, 27 Mar 2024 16:53:55 +0530 Subject: [PATCH 147/176] Updated the dummy email addresses used in the seed module to follow a valid format (#2305) --- mslib/mscolab/seed.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index 4be17c114..bbb87543d 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -234,52 +234,52 @@ def seed_data(): 'username': 'a', 'id': 8, 'password': 'a', - 'emailid': 'a' + 'emailid': 'a@notexisting.org' }, { 'username': 'b', 'id': 9, 'password': 'b', - 'emailid': 'b' + 'emailid': 'b@notexisting.org' }, { 'username': 'c', 'id': 10, 'password': 'c', - 'emailid': 'c' + 'emailid': 'c@notexisting.org' }, { 'username': 'd', 'id': 11, 'password': 'd', - 'emailid': 'd' + 'emailid': 'd@notexisting.org' }, { 'username': 'test1', 'id': 12, 'password': 'test1', - 'emailid': 'test1' + 'emailid': 'test1@notexisting.org' }, { 'username': 'test2', 'id': 13, 'password': 'test2', - 'emailid': 'test2' + 'emailid': 'test2@notexisting.org' }, { 'username': 'test3', 'id': 14, 'password': 'test3', - 'emailid': 'test3' + 'emailid': 'test3@notexisting.org' }, { 'username': 'test4', 'id': 15, 'password': 'test4', - 'emailid': 'test4' + 'emailid': 'test4@notexisting.org' }, { 'username': 'mscolab_user', 'id': 16, 'password': 'password', - 'emailid': 'mscolab_user' + 'emailid': 'mscolab_user@notexisting.org' }, { 'username': 'merge_waypoints_user', 'id': 17, 'password': 'password', - 'emailid': 'merge_waypoints_user' + 'emailid': 'merge_waypoints_user@notexisting.org' }] for user in users: db_user = User(user['emailid'], user['username'], user['password']) From 2fc32cb125099c368d17c6b420aa6e9396d5381b Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Wed, 27 Mar 2024 16:55:51 +0530 Subject: [PATCH 148/176] msidp : Update Docs, user settings and warn msgs (#2289) --- docs/conf_sso_test_msscolab.rst | 7 ++- docs/sso_via_saml_mscolab.rst | 2 +- mslib/msidp/idp.py | 19 +++----- mslib/msidp/idp_user.py | 83 +++------------------------------ mslib/msidp/idp_uwsgi.py | 7 +-- 5 files changed, 20 insertions(+), 98 deletions(-) diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst index e688b2597..1e51cf043 100644 --- a/docs/conf_sso_test_msscolab.rst +++ b/docs/conf_sso_test_msscolab.rst @@ -7,6 +7,11 @@ Testing IDP (`mslib/msidp`) is specifically designed for testing the Single Sign Here is documentation that explains the configuration of the MSS Colab Server with the testing IdP. +.. warning:: + When running publicly rather than in development, you should not use the built-in development server ( msidp / idp.py ). + + The development server is provided by MSS for convenience, but is not designed to be particularly efficient, stable, or secure. + Getting started --------------- @@ -117,4 +122,4 @@ When migrations finished, you can start mscolab server using the following comm $ msui * Login with identity provider through Qt Client application. -* To log in to the mscolab server through the identity provider, you can use the credentials specified in the ``PASSWD`` section of the ``MSS/mslib/msidp/idp.py`` file. Look for the relevant section in the file to find the necessary login credentials. +* To log in to the mscolab server through the identity provider, you can use the credentials specified in the ``USERS`` and ``PASSWD`` section of the ``MSS/mslib/msidp/idp_user.py`` file. Look for the relevant section in the file to find the necessary login credentials. diff --git a/docs/sso_via_saml_mscolab.rst b/docs/sso_via_saml_mscolab.rst index 69277ff14..4290b172c 100644 --- a/docs/sso_via_saml_mscolab.rst +++ b/docs/sso_via_saml_mscolab.rst @@ -38,7 +38,7 @@ In this documentation, you will go through the following topics. *************** This documentation will explain how to configure MSColab with an existing IdP or multiple IdPs, along with examples of implementation. -If you are not aware of how the SAML process works in the MSColab server, it is highly recommended to set up msidp and test it with MSColab as an initial step before configuring existing 3rd party IdPs. +If you are not aware of how the SAML process works in the MSColab server, it is highly recommended to set up msidp and test it with MSColab as an initial step before configuring existing 3rd party IdPs (msidp is solely for development and testing purposes, do not use in production environments). .. note:: You can find instructions to set up msidp by `conf_sso_test_msscolab.rst`. diff --git a/mslib/msidp/idp.py b/mslib/msidp/idp.py index 55d04c051..6ffa3316e 100644 --- a/mslib/msidp/idp.py +++ b/mslib/msidp/idp.py @@ -39,6 +39,7 @@ import re import time import sys +import warnings from mslib import msidp from http.cookies import SimpleCookie @@ -80,7 +81,7 @@ from werkzeug.serving import run_simple as WSGIServer from mslib.msidp.idp_user import EXTRA -from mslib.msidp.idp_user import USERS +from mslib.msidp.idp_user import USERS, PASSWD from mako.lookup import TemplateLookup from mslib.mscolab.conf import mscolab_settings @@ -555,17 +556,6 @@ def do_authentication(environ, start_response, authn_context, key, redirect_uri, # ----------------------------------------------------------------------------- - -PASSWD = { - "testuser": "qwerty", - "roland": "dianakra", - "babs": "howes", - "upper": "crust", - "testuser2": "abcd1234", - "testuser3": "ABCD1234", -} - - def username_password_authn(environ, start_response, reference, key, redirect_uri, headers=None): """ Display the login form @@ -786,7 +776,6 @@ def do(self, request, binding, relay_state="", encrypt_cert=None): msg = IdpServerSettings_.IDP.create_artifact_response(_req, _req.artifact.text) hinfo = IdpServerSettings_.IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) - resp = Response(hinfo["data"], headers=hinfo["headers"]) return resp(self.environ, self.start_response) @@ -1099,6 +1088,10 @@ def __init__(self): def main(): + warnings.warn( + '\033[91mWARNING: msidp is solely for development and ' + 'testing purposes; do not use in production environments.\033[0m' + ) parser = argparse.ArgumentParser() parser.add_argument("-p", dest="path", help="Path to configuration file.", default="./idp_conf.py") diff --git a/mslib/msidp/idp_user.py b/mslib/msidp/idp_user.py index 6d43edd1e..b6d983ba2 100644 --- a/mslib/msidp/idp_user.py +++ b/mslib/msidp/idp_user.py @@ -48,88 +48,17 @@ "norEduPersonNIN": "SE199012315555", "postaladdress": "postaladdress", "cn": "cn", - }, - "testuser2": { - "sn": "Testsson2", - "givenName": "Test2", - "eduPersonAffiliation": "student", - "eduPersonScopedAffiliation": "student2@example.com", - "eduPersonPrincipalName": "test2@example.com", - "uid": "testuser2", - "eduPersonTargetedID": ["one!for!all"], - "c": "SE", - "o": "Example Co.", - "ou": "IT", - "initials": "P", - "co": "co", - "mail": "mail", - "noreduorgacronym": "noreduorgacronym", - "schacHomeOrganization": "example.com", - "email": "test2@example.com", - "displayName": "Test Testsson", - "labeledURL": "http://www.example.com/test My homepage", - "norEduPersonNIN": "SE199012315555", - "postaladdress": "postaladdress", - "cn": "cn", - }, - "testuser3": { - "sn": "Testsson3", - "givenName": "Test3", - "eduPersonAffiliation": "student", - "eduPersonScopedAffiliation": "student3@example.com", - "eduPersonPrincipalName": "test3@example.com", - "uid": "testuser3", - "eduPersonTargetedID": ["one!for!all"], - "c": "SE", - "o": "Example Co.", - "ou": "IT", - "initials": "P", - "co": "co", - "mail": "mail", - "noreduorgacronym": "noreduorgacronym", - "schacHomeOrganization": "example.com", - "email": "test3@example.com", - "displayName": "Test Testsson", - "labeledURL": "http://www.example.com/test My homepage", - "norEduPersonNIN": "SE199012315555", - "postaladdress": "postaladdress", - "cn": "cn", - }, - "roland": { - "sn": "Hedberg", - "givenName": "Roland", - "email": "roland@example.com", - "eduPersonScopedAffiliation": "staff@example.com", - "eduPersonPrincipalName": "rohe@example.com", - "uid": "rohe", - "eduPersonTargetedID": ["one!for!all"], - "c": "SE", - "o": "Example Co.", - "ou": "IT", - "initials": "P", - "mail": "roland@example.com", - "displayName": "P. Roland Hedberg", - "labeledURL": "http://www.example.com/rohe My homepage", - "norEduPersonNIN": "SE197001012222", - }, - "babs": { - "surname": "Babs", - "givenName": "Ozzie", - "email": "babs@example.com", - "eduPersonAffiliation": "affiliate" - }, - "upper": { - "surname": "Jeter", - "givenName": "Derek", - "email": "upper@example.com", - "eduPersonAffiliation": "affiliate" - }, + } } EXTRA = { "roland": { "eduPersonEntitlement": "urn:mace:swamid.se:foo:bar", "schacGender": "male", - "schacUserPresenceID": "skype:pepe.perez", + "schacUserPresenceID": "sky:pepe.perez", } } + +PASSWD = { + "testuser": "qwerty", +} diff --git a/mslib/msidp/idp_uwsgi.py b/mslib/msidp/idp_uwsgi.py index 8e1bb3b43..7d86b6bbf 100644 --- a/mslib/msidp/idp_uwsgi.py +++ b/mslib/msidp/idp_uwsgi.py @@ -70,8 +70,7 @@ from saml2.s_utils import PolicyError, UnknownPrincipal, exception_trace, UnsupportedBinding, rndstr from saml2.sigver import encrypt_cert_from_item, verify_redirect_signature -from mslib.msidp.idp_user import EXTRA -from mslib.msidp.idp_user import USERS +from mslib.msidp.idp_user import EXTRA, USERS, PASSWD from mako.lookup import TemplateLookup @@ -538,10 +537,6 @@ def do_authentication(environ, start_response, authn_context, key, redirect_uri) # ----------------------------------------------------------------------------- -PASSWD = {"daev0001": "qwerty", "haho0032": "qwerty", - "roland": "dianakra", "babs": "howes", "upper": "crust"} - - def username_password_authn(environ, start_response, reference, key, redirect_uri): """ Display the login form From 30bd36ab6f421c3efd87a2cf157f086b2855778a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 27 Mar 2024 13:40:54 +0100 Subject: [PATCH 149/176] Run the test suite in a random order (#2287) --- .github/workflows/testing-all-oses.yml | 4 ++-- tests/_test_mswms/test_wms.py | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/testing-all-oses.yml b/.github/workflows/testing-all-oses.yml index 2083af10a..92f38804c 100644 --- a/.github/workflows/testing-all-oses.yml +++ b/.github/workflows/testing-all-oses.yml @@ -19,7 +19,6 @@ jobs: fail-fast: false matrix: os: ["macos-13", "macos-14", "ubuntu-latest"] - order: ["normal", "reverse"] steps: - uses: actions/checkout@v4 - name: Build requirements.txt file @@ -29,6 +28,7 @@ jobs: sed -e "s/menuinst.*//" | sed -e "s/.*://" > requirements.tmp.txt cat requirements.d/development.txt >> requirements.tmp.txt + echo "pytest-randomly" >> requirements.tmp.txt sed -e '/^$/d' -e '/^#.*$/d' requirements.tmp.txt > requirements.txt rm requirements.tmp.txt cat requirements.txt @@ -50,4 +50,4 @@ jobs: # TODO: fix those tests and drop the ignores run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib --ignore=tests/_test_msui/test_sideview.py --ignore=tests/_test_msui/test_topview.py --ignore=tests/_test_msui/test_wms_control.py - ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests + tests diff --git a/tests/_test_mswms/test_wms.py b/tests/_test_mswms/test_wms.py index ccce3301b..2cb33103c 100644 --- a/tests/_test_mswms/test_wms.py +++ b/tests/_test_mswms/test_wms.py @@ -25,7 +25,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys import os from shutil import move @@ -388,11 +387,11 @@ def test_import_error(self): assert mslib.mswms.wms.mswms_settings.__file__ is not None assert mslib.mswms.wms.mswms_auth.__file__ is not None - @pytest.mark.skipif( - sys.platform == "darwin", - reason="""\ + @pytest.mark.skip("""\ There is a race condition between modifying with ncap2 and asserting that the file changed where the server might not see the change before the request is made, which leads to a failure of the following assert. + +This test fails on macOS 14 and can also fail on Linux when the pytest test order is randomized. """.strip(), ) def test_files_changed(self): @@ -436,9 +435,7 @@ def do_test(): "data_access", new=watch_access): do_test() - @pytest.mark.skipif( - sys.platform == "darwin", - reason="""\ + @pytest.mark.skip("""\ This test changes global variables (e.g. DOCS_LOCATION) which can affect other tests depending on test order (e.g. tests/_test_mswms/test_mss_plot_driver.py::Test_VSec::test_VS_gallery_template fails consistently in reverse order on macOS 14). From 4dc3d67ba10343e4a584cf54ed336deff293c352 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Wed, 27 Mar 2024 22:29:04 +0530 Subject: [PATCH 150/176] removed-unique-pass-constraint (#2303) --- mslib/mscolab/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 73d54a22f..b6e8129e0 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -57,7 +57,7 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 username = db.Column(db.String(255)) emailid = db.Column(db.String(255), unique=True) - password = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) registered_on = db.Column(AwareDateTime, nullable=False) confirmed = db.Column(db.Boolean, nullable=False, default=False) confirmed_on = db.Column(AwareDateTime, nullable=True) From 68ac232847b3f36bdd3e420c55d6ecae7460b293 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Wed, 27 Mar 2024 22:46:40 +0530 Subject: [PATCH 151/176] Fix user registration validation to ensure valid data before creating user (#2304) --- mslib/mscolab/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 56d54c1eb..27803c0d0 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -154,7 +154,8 @@ def check_login(emailid, password): def register_user(email, password, username): - user = User(email, username, password) + if len(str(email.strip())) == 0 or len(str(username.strip())) == 0: + return {"success": False, "message": "Your username or email cannot be empty"} is_valid_username = True if username.find("@") == -1 else False is_valid_email = validate_email(email) if not is_valid_email: @@ -167,6 +168,7 @@ def register_user(email, password, username): user_exists = User.query.filter_by(username=str(username)).first() if user_exists: return {"success": False, "message": "This username is already registered"} + user = User(email, username, password) result = fm.modify_user(user, action="create") return {"success": result} From 6fc2977e3c1bf53f380ce2365bfaaa52f383a69b Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 11 Apr 2024 21:12:01 +0200 Subject: [PATCH 152/176] Fix flake8 error (#2317) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 20ed0d901..a8d5152f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,6 +37,6 @@ omit = norecursedirs = .git .idea .cache [flake8] -ignore = E124,E125,E402,W504 +ignore = E124,E125,E402,W504,A005 max-line-length = 120 exclude = mslib/msui/qt5/*.py, mslib/mscolab/migrations/*.py From ba18d0121f4e64efa91e07785138e5a2a44fac38 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Mon, 15 Apr 2024 12:34:15 +0200 Subject: [PATCH 153/176] fix logout problem with multipe flightpath (#2319) --- mslib/msui/topview.py | 5 +-- tests/_test_msui/test_mscolab.py | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index c4c101f9f..8af867720 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -201,9 +201,6 @@ def __init__(self, parent=None, mainwindow=None, model=None, _id=None, self.mainwindow_signal_permission_revoked = mainwindow.signal_permission_revoked self.mainwindow_signal_render_new_permission = mainwindow.signal_render_new_permission self.mainwindow_signal_activate_flighttrack = mainwindow.signal_activate_flighttrack - self.mainwindow_signal_activate_operation = mainwindow.signal_activate_operation - self.mainwindow_signal_login_mscolab = mainwindow.signal_login_mscolab - self.mainwindow_signal_logout_mscolab = mainwindow.signal_logout_mscolab self.mainwindow_listFlightTracks = mainwindow.listFlightTracks self.mainwindow_filterCategoryCb = mainwindow.filterCategoryCb self.mainwindow_listOperationsMSC = mainwindow.listOperationsMSC @@ -362,7 +359,7 @@ def openTool(self, index): mscolab_server_url=self.mscolab_server_url, token=self.token) - self.mainwindow_signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) + self.mainwindow_signal_logout_mscolab.connect(self.signal_logout_mscolab.emit) self.mainwindow_signal_listFlighttrack_doubleClicked.connect( lambda: self.signal_listFlighttrack_doubleClicked.emit()) self.mainwindow_signal_permission_revoked.connect( diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index aed8b0967..02b280afd 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -411,6 +411,74 @@ def assert_logout_text(): qtbot.wait_until(assert_label_text) # ToDo verify all operations disabled again without a visual check + def test_multiple_flightpath_switching_to_flighttrack_and_logout(self, qtbot): + """ + checks that we can switch in topviews with the multiple flightpath dockingwidget + between local flight track and operations, and we are able to cycle a login/logout + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + # test after activating operation + self._activate_operation_at_index(0) + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + topview_0 = self.window.listViews.item(0) + assert topview_0.window.tv_window_exists is True + topview_0.window.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + def assert_attribute(): + assert topview_0.window.testAttribute(QtCore.Qt.WA_DeleteOnClose) + qtbot.wait_until(assert_attribute) + + # open multiple flightpath first window + topview_0.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_0.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + + # activate all operation, this enables them in the docking widget too + self._activate_operation_at_index(1) + self._activate_operation_at_index(2) + self._activate_operation_at_index(0) + # ToDo refactor to be able to activate/deactivate by the docking widget and that it can be checked + + self._activate_flight_track_at_index(0) + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + topview_0.window.close() + + def assert_window_closed(): + assert topview_0.window.tv_window_exists is False + qtbot.wait_until(assert_window_closed) + + def assert_label_text(): + # verify logged in + assert self.window.usernameLabel.text() == self.userdata[1] + qtbot.wait_until(assert_label_text) + + self.window.mscolab.logout() + + def assert_logout_text(): + assert self.window.usernameLabel.text() == "User" + qtbot.wait_until(assert_logout_text) + + self._connect_to_mscolab(qtbot) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + # verify logged in again + qtbot.wait_until(assert_label_text) + # ToDo verify all operations disabled again without a visual check + @mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, 'test_export.ftml'), "Flight track (*.ftml)")) @@ -828,3 +896,12 @@ def _activate_operation_at_index(self, index): point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) + + def _activate_flight_track_at_index(self, index): + # The main window must be on top + self.window.activateWindow() + # get the item by its index + item = self.window.listFlightTracks.item(index) + point = self.window.listFlightTracks.visualItemRect(item).center() + QtTest.QTest.mouseClick(self.window.listFlightTracks.viewport(), QtCore.Qt.LeftButton, pos=point) + QtTest.QTest.mouseDClick(self.window.listFlightTracks.viewport(), QtCore.Qt.LeftButton, pos=point) From e71d9f38794e328b111fe5ab3eef19127dc40370 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Mon, 15 Apr 2024 17:07:23 +0200 Subject: [PATCH 154/176] fix DateTime elements have seconds precision, optimized for fractions (#2308) --- mslib/mscolab/chat_manager.py | 2 +- mslib/mscolab/file_manager.py | 2 +- mslib/mscolab/server.py | 2 +- mslib/mscolab/utils.py | 2 +- mslib/msui/mscolab_chat.py | 3 ++- mslib/msui/mscolab_version_history.py | 2 +- tests/_test_mscolab/test_files_api.py | 5 ----- tests/_test_mscolab/test_server.py | 4 ---- tests/_test_mscolab/test_sockets_manager.py | 11 +++++++---- 9 files changed, 14 insertions(+), 19 deletions(-) diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index a5580c5af..2663cbaca 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -64,7 +64,7 @@ def get_messages(self, op_id, timestamp=None): if timestamp is None: timestamp = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) else: - timestamp = datetime.datetime.strptime(timestamp, "%Y-%m-%d, %H:%M:%S %z") + timestamp = datetime.datetime.strptime(timestamp, "%Y-%m-%d, %H:%M:%S.%f %z") messages = Message.query \ .filter(Message.op_id == op_id) \ .filter(Message.reply_id.is_(None)) \ diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 523e90e29..c4877dc97 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -399,7 +399,7 @@ def get_all_changes(self, op_id, user, named_version=False): 'comment': change.comment, 'version_name': change.version_name, 'username': change.user.username, - 'created_at': change.created_at.strftime("%Y-%m-%d, %H:%M:%S %z") + 'created_at': change.created_at.strftime("%Y-%m-%d, %H:%M:%S.%f %z") }, changes)) def get_change_content(self, ch_id, user): diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 27803c0d0..a55694e66 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -369,7 +369,7 @@ def messages(): user = g.user op_id = request.args.get("op_id", request.form.get("op_id", None)) if fm.is_member(user.id, op_id): - timestamp = request.args.get("timestamp", request.form.get("timestamp", "1970-01-01, 00:00:00 +00:00")) + timestamp = request.args.get("timestamp", request.form.get("timestamp", "1970-01-01, 00:00:00.000000 +00:00")) chat_messages = cm.get_messages(op_id, timestamp) return jsonify({"messages": chat_messages}) return "False" diff --git a/mslib/mscolab/utils.py b/mslib/mscolab/utils.py index 98f6ae530..09626c178 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -55,7 +55,7 @@ def get_message_dict(message): "message_type": message.message_type, "reply_id": message.reply_id, "replies": [], - "time": message.created_at.strftime("%Y-%m-%d, %H:%M:%S %z") + "time": message.created_at.strftime("%Y-%m-%d, %H:%M:%S.%f %z") } diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index bcf765b23..ba3d1ea11 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -349,7 +349,8 @@ def load_all_messages(self): data = { "token": self.token, "op_id": self.op_id, - "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") + "timestamp": datetime.datetime(1970, 1, 1, + tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S.%f %z") } # returns an array of messages url = urljoin(self.mscolab_server_url, "messages") diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index 40175facf..9a083e91d 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -144,7 +144,7 @@ def load_all_changes(self): changes = json.loads(r.text)["changes"] self.changes.clear() for change in changes: - created_at = datetime.strptime(change["created_at"], "%Y-%m-%d, %H:%M:%S %z") + created_at = datetime.strptime(change["created_at"], "%Y-%m-%d, %H:%M:%S.%f %z") local_time = utc_to_local_datetime(created_at) date = local_time.strftime('%d/%m/%Y') time = local_time.strftime('%I:%M %p') diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 748eb9382..497ad384a 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import time import fs import pytest @@ -172,8 +171,6 @@ def test_get_all_changes(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V11") assert self.fm.save_file(operation.id, "content1", self.user) - # we need to wait to get an updated created_at - time.sleep(1) assert self.fm.save_file(operation.id, "content2", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) # the newest change is on index 0, because it has a recent created_at time @@ -186,9 +183,7 @@ def test_get_change_content(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V12", content='initial') assert self.fm.save_file(operation.id, "content1", self.user) - time.sleep(1) assert self.fm.save_file(operation.id, "content2", self.user) - time.sleep(1) assert self.fm.save_file(operation.id, "content3", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) previous_change = self.fm.get_change_content(all_changes[2]["id"], self.user) diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index a77d49fa5..ad22ee9d2 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import time import pytest import json import io @@ -234,7 +233,6 @@ def test_get_all_changes(self): with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) fm, user = self._save_content(operation, self.userdata) - time.sleep(1) fm.save_file(operation.id, "content2", user) # the newest change is on index 0, because it has a recent created_at time response = test_client.get('/get_all_changes', data={"token": token, @@ -252,8 +250,6 @@ def test_get_change_content(self): with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) fm, user = self._save_content(operation, self.userdata) - # we need to wait to get an updated created_at - time.sleep(1) fm.save_file(operation.id, "content2", user) all_changes = fm.get_all_changes(operation.id, user) response = test_client.get('/get_change_content', data={"token": token, diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index 6ae9b3620..975f3fdbb 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -191,11 +191,12 @@ def test_get_messages(self): assert messages[0]["text"] == "message from 1" assert len(messages) == 2 assert messages[0]["u_id"] == self.user.id - timestamp = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") + timestamp = datetime.datetime(1970, 1, 1, + tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S.%f %z") messages = self.cm.get_messages(1, timestamp) assert len(messages) == 2 assert messages[0]["u_id"] == self.user.id - timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") + timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S.%f %z") messages = self.cm.get_messages(1, timestamp) assert len(messages) == 0 @@ -221,7 +222,8 @@ def test_get_messages_api(self): data = { "token": token, "op_id": self.operation.id, - "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") + "timestamp": datetime.datetime(1970, 1, 1, + tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S.%f %z") } # returns an array of messages url = urljoin(self.url, 'messages') @@ -257,7 +259,8 @@ def test_edit_message(self): data = { "token": token, "op_id": self.operation.id, - "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S %z") + "timestamp": datetime.datetime(1970, 1, 1, + tzinfo=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S.%f %z") } # returns an array of messages url = urljoin(self.url, 'messages') From 2ef6f5e740457fda7c6ec8bc68fecd8de7fa47f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 18 Apr 2024 09:00:56 +0200 Subject: [PATCH 155/176] Partially fix non-deterministic coverage (#2318) * Partially fix non-deterministic coverage This is a band-aid fix that mostly resolves the issue with non-deterministic coverage. Without the wait, tests abruptly end and coverage can fluctuate. With the wait, Qt has time to handle its stuff for a while longer, which stabilizes the coverage. The proper fix would be to clean up everything Qt-related that is created in a test after each test, but I couldn't get that to work yet. There are also at least two more cases of non-deterministic coverage that seem to be unrelated to Qt and which aren't fixed with this. * Add docstring to qtbot overwrite --- tests/_test_msui/test_editor.py | 2 +- tests/_test_msui/test_kmloverlay_dockwidget.py | 2 +- tests/_test_msui/test_linearview.py | 6 +++--- tests/_test_msui/test_mpl_map.py | 2 +- tests/_test_msui/test_mscolab.py | 4 ++-- tests/_test_msui/test_mscolab_merge_waypoints.py | 2 +- tests/_test_msui/test_mss.py | 2 +- tests/_test_msui/test_msui.py | 8 ++++---- .../test_multiple_flightpath_dockwidget.py | 2 +- tests/_test_msui/test_remotesensing.py | 2 +- tests/_test_msui/test_satellite_dockwidget.py | 2 +- tests/_test_msui/test_sideview.py | 6 +++--- tests/_test_msui/test_suffix.py | 2 +- tests/_test_msui/test_tableview.py | 2 +- tests/_test_msui/test_topview.py | 8 ++++---- tests/_test_msui/test_updater.py | 2 +- tests/_test_msui/test_wms_capabilities.py | 2 +- tests/_test_msui/test_wms_control.py | 6 +++--- tests/fixtures.py | 12 ++++++++++-- 19 files changed, 41 insertions(+), 33 deletions(-) diff --git a/tests/_test_msui/test_editor.py b/tests/_test_msui/test_editor.py index fa13e1934..8467df83d 100644 --- a/tests/_test_msui/test_editor.py +++ b/tests/_test_msui/test_editor.py @@ -41,7 +41,7 @@ class Test_Editor: save_file_name = fs.path.join(ROOT_DIR, "testeditor_save.json") @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): self.window = editor.EditorMainWindow() self.save_file_name = self.save_file_name diff --git a/tests/_test_msui/test_kmloverlay_dockwidget.py b/tests/_test_msui/test_kmloverlay_dockwidget.py index 90275a589..2ee23737d 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -42,7 +42,7 @@ class Test_KmlOverlayDockWidget: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.view = mock.Mock() self.view.map = mock.Mock(side_effect=lambda x, y: (x, y)) self.view.map.plot = mock.Mock(return_value=[mock.Mock()]) diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 5904cb63d..30178e357 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -38,7 +38,7 @@ class Test_MSS_LV_Options_Dialog: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.window = tv.MSUI_LV_Options_Dialog(settings=_DEFAULT_SETTINGS_LINEARVIEW) self.window.show() QtTest.QTest.qWaitForWindowExposed(self.window) @@ -54,7 +54,7 @@ def test_get(self): class Test_MSSLinearViewWindow: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -86,7 +86,7 @@ def test_options(self, mockdlg): class Test_LinearViewWMS: @pytest.fixture(autouse=True) - def setup(self, qapp, mswms_server): + def setup(self, qtbot, mswms_server): self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): diff --git a/tests/_test_msui/test_mpl_map.py b/tests/_test_msui/test_mpl_map.py index bd477c666..c78b288ff 100644 --- a/tests/_test_msui/test_mpl_map.py +++ b/tests/_test_msui/test_mpl_map.py @@ -32,7 +32,7 @@ class Test_MapCanvas: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): kwargs = {'resolution': 'l', 'area_thresh': 1000.0, 'ax': plt.gca(), 'llcrnrlon': -15.0, 'llcrnrlat': 35.0, 'urcrnrlon': 30.0, 'urcrnrlat': 65.0, 'epsg': '4326'} self.map = MapCanvas(**kwargs) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 02b280afd..97404e42e 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -47,7 +47,7 @@ class Test_Mscolab_connect_window: @pytest.fixture(autouse=True) - def setup(self, qapp, mscolab_server): + def setup(self, qtbot, mscolab_server): self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" @@ -258,7 +258,7 @@ class Test_Mscolab: } @pytest.fixture(autouse=True) - def setup(self, qapp, mscolab_app, mscolab_server): + def setup(self, qtbot, mscolab_app, mscolab_server): self.app = mscolab_app self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 3e2e88c1e..71022d18f 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -41,7 +41,7 @@ class Test_Mscolab_Merge_Waypoints: @pytest.fixture(autouse=True) - def setup(self, qapp, mscolab_app, mscolab_server): + def setup(self, qtbot, mscolab_app, mscolab_server): self.app = mscolab_app self.url = mscolab_server self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) diff --git a/tests/_test_msui/test_mss.py b/tests/_test_msui/test_mss.py index 1b636005d..15a6291e9 100644 --- a/tests/_test_msui/test_mss.py +++ b/tests/_test_msui/test_mss.py @@ -28,7 +28,7 @@ from mslib.msui import mss -def test_mss_rename_message(qapp): +def test_mss_rename_message(qtbot): main_window = mss.MSSMainWindow() main_window.show() QtTest.QTest.mouseClick(main_window.pushButton, QtCore.Qt.LeftButton) diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 9d065944d..c898571bf 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -67,7 +67,7 @@ def test_main(): class Test_MSS_TutorialMode: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot, qapp): qapp.setApplicationDisplayName("MSUI") self.main_window = msui_mw.MSUIMainWindow(tutorial_mode=True) self.main_window.create_new_flight_track() @@ -95,7 +95,7 @@ def test_tutorial_dir(self): class Test_MSS_AboutDialog: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.window = msui_mw.MSUI_AboutDialog() yield self.window.hide() @@ -109,7 +109,7 @@ def test_milestone_url(self): class Test_MSS_ShortcutDialog: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.main_window = msui_mw.MSUIMainWindow() self.main_window.show() self.shortcuts = msui_mw.MSUI_ShortcutsDialog() @@ -169,7 +169,7 @@ class Test_MSSSideViewWindow: } @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.sample_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), '../', diff --git a/tests/_test_msui/test_multiple_flightpath_dockwidget.py b/tests/_test_msui/test_multiple_flightpath_dockwidget.py index 6e05b456d..b25ac2839 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -34,7 +34,7 @@ class Test_MultipleFlightpathControlWidget: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.window = msui.MSUIMainWindow() self.window.create_new_flight_track() diff --git a/tests/_test_msui/test_remotesensing.py b/tests/_test_msui/test_remotesensing.py index fb00f3377..ed19870f4 100644 --- a/tests/_test_msui/test_remotesensing.py +++ b/tests/_test_msui/test_remotesensing.py @@ -46,7 +46,7 @@ class Test_RemoteSensingControlWidget: Tests about RemoteSensingControlWidget """ @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.view = Mock() self.map = qt.TopViewPlotter() self.map.init_map() diff --git a/tests/_test_msui/test_satellite_dockwidget.py b/tests/_test_msui/test_satellite_dockwidget.py index 6801b4d9b..22438cc31 100644 --- a/tests/_test_msui/test_satellite_dockwidget.py +++ b/tests/_test_msui/test_satellite_dockwidget.py @@ -34,7 +34,7 @@ class Test_SatelliteDockWidget: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.view = mock.Mock() self.window = sd.SatelliteControlWidget(view=self.view) self.window.show() diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index 2971a4fb3..d1ad0e6d2 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -39,7 +39,7 @@ class Test_MSS_SV_OptionsDialog: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() QtTest.QTest.qWaitForWindowExposed(self.window) @@ -73,7 +73,7 @@ def test_setColour(self, mockdlg): class Test_MSSSideViewWindow: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -126,7 +126,7 @@ def test_y_axes(self): class Test_SideViewWMS: @pytest.fixture(autouse=True) - def setup(self, qapp, mswms_server): + def setup(self, qtbot, mswms_server): self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): diff --git a/tests/_test_msui/test_suffix.py b/tests/_test_msui/test_suffix.py index 33a2cc220..22cfd3460 100644 --- a/tests/_test_msui/test_suffix.py +++ b/tests/_test_msui/test_suffix.py @@ -35,7 +35,7 @@ class Test_SuffixChange: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() QtTest.QTest.qWaitForWindowExposed(self.window) diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index 232f6c1c1..797ce216e 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -37,7 +37,7 @@ class Test_TableView: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): # Create an initital flight track. initial_waypoints = [ft.Waypoint(flightlevel=0, location="EDMO", comments="take off OP"), ft.Waypoint(48.10, 10.27, 200), diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index 00dc58daf..9f7b6e641 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -39,7 +39,7 @@ class Test_MSS_TV_MapAppearanceDialog: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.window = tv.MSUI_TV_MapAppearanceDialog(settings=_DEFAULT_SETTINGS_TOPVIEW) self.window.show() QtTest.QTest.qWaitForWindowExposed(self.window) @@ -55,7 +55,7 @@ def test_get(self): class Test_MSSTopViewWindow: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): mainwindow = MSUIMainWindow() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") @@ -199,7 +199,7 @@ def test_map_options(self): class Test_TopViewWMS: @pytest.fixture(autouse=True) - def setup(self, qapp, mswms_server): + def setup(self, qtbot, mswms_server): self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): @@ -242,7 +242,7 @@ def test_server_getmap(self, qtbot): class Test_MSUITopViewWindow: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): pass def test_kwargs_update_does_not_harm(self): diff --git a/tests/_test_msui/test_updater.py b/tests/_test_msui/test_updater.py index d2e442fe6..81f472ed7 100644 --- a/tests/_test_msui/test_updater.py +++ b/tests/_test_msui/test_updater.py @@ -56,7 +56,7 @@ def __init__(self, args=None, **named_args): @mock.patch("mslib.utils.qt.Worker.start", Worker.run) class Test_MSS_ShortcutDialog: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.updater = Updater() self.status = "" self.update_available = False diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index f410e6797..968bcaff8 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -35,7 +35,7 @@ class Test_WMSCapabilities: @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.capabilities = mock.Mock() self.capabilities.capabilities_document = u"Hölla die Waldfee".encode("utf-8") self.capabilities.provider = mock.Mock() diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index c0a50b394..3a1cce684 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -97,7 +97,7 @@ def query_server(self, qtbot, url): class Test_HSecWMSControlWidget(WMSControlWidgetSetup): @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self._setup("hsec") yield self._teardown() @@ -409,7 +409,7 @@ def test_server_no_thread(self, mockthread, qtbot): class Test_VSecWMSControlWidget(WMSControlWidgetSetup): @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self._setup("vsec") yield self._teardown() @@ -495,7 +495,7 @@ class TestWMSControlWidgetSetupSimple: 500.0,600.0,700.0,900.0 """ @pytest.fixture(autouse=True) - def setup(self, qapp): + def setup(self, qtbot): self.view = HSecViewMockup() self.window = wc.HSecWMSControlWidget(view=self.view) self.window.show() diff --git a/tests/fixtures.py b/tests/fixtures.py index bd8fee4b7..88891a1b4 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -70,8 +70,16 @@ def close_remaining_widgets(): @pytest.fixture -def qapp(qapp, fail_if_open_message_boxes_left, close_remaining_widgets): - yield qapp +def qtbot(qtbot, fail_if_open_message_boxes_left, close_remaining_widgets): + """Fixture that re-defines the qtbot fixture from pytest-qt with additional checks.""" + yield qtbot + # Wait for a while after the requesting test has finished. At time of writing this + # is required to (mostly) stabilize the coverage reports, because tests don't + # properly close their Qt-related stuff and therefore there is no guarantee about + # what the Qt event loop has or hasn't done yet. Waiting just gives it a bit more + # time to converge on the same result every time the tests are executed. This is a + # band-aid fix, the proper fix is to make sure each test cleans up after itself. + qtbot.wait(5000) @pytest.fixture(scope="session") From 3e0f74ccabd4e5511445896f5fadf3e5b2fba92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Tue, 23 Apr 2024 17:33:14 +0200 Subject: [PATCH 156/176] Remove redundant testing_gsoc.yml workflow (#2333) --- .github/workflows/testing-develop.yml | 3 +- .github/workflows/testing-gsoc.yml | 16 ++++++++ .github/workflows/testing-stable.yml | 3 +- .github/workflows/testing.yml | 22 +++++----- .github/workflows/testing_gsoc.yml | 58 --------------------------- 5 files changed, 27 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/testing-gsoc.yml delete mode 100644 .github/workflows/testing_gsoc.yml diff --git a/.github/workflows/testing-develop.yml b/.github/workflows/testing-develop.yml index 300c7e843..b3ee2c3c5 100644 --- a/.github/workflows/testing-develop.yml +++ b/.github/workflows/testing-develop.yml @@ -14,7 +14,6 @@ jobs: uses: ./.github/workflows/testing.yml with: - branch_name: develop - event_name: ${{ github.event_name }} + image_suffix: develop secrets: PAT: ${{ secrets.PAT }} diff --git a/.github/workflows/testing-gsoc.yml b/.github/workflows/testing-gsoc.yml new file mode 100644 index 000000000..872f77d9c --- /dev/null +++ b/.github/workflows/testing-gsoc.yml @@ -0,0 +1,16 @@ +name: test GSoC + +on: + push: + branches: + - 'GSOC**' + pull_request: + branches: + - 'GSOC**' + +jobs: + test-gsoc: + uses: + ./.github/workflows/testing.yml + with: + image_suffix: develop diff --git a/.github/workflows/testing-stable.yml b/.github/workflows/testing-stable.yml index ffc306270..851dce7e2 100644 --- a/.github/workflows/testing-stable.yml +++ b/.github/workflows/testing-stable.yml @@ -14,7 +14,6 @@ jobs: uses: ./.github/workflows/testing.yml with: - branch_name: stable - event_name: ${{ github.event_name }} + image_suffix: stable secrets: PAT: ${{ secrets.PAT }} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 3b5817eaa..a3f502c94 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,25 +3,21 @@ name: Pytest MSS on: workflow_call: inputs: - branch_name: - required: true - type: string - event_name: + image_suffix: required: true type: string secrets: PAT: - required: true env: - mamba-env: mss-${{ inputs.branch_name }}-env + mamba-env: mss-${{ inputs.image_suffix }}-env jobs: Test-MSS: runs-on: ubuntu-latest container: - image: openmss/testing-${{ inputs.branch_name }} + image: openmss/testing-${{ inputs.image_suffix }} strategy: fail-fast: false @@ -36,24 +32,24 @@ jobs: (echo Dependencies differ && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) - name: Install pyvirtualdisplay if on stable - if: ${{ inputs.branch_name == 'stable' }} + if: ${{ inputs.image_suffix == 'stable' }} run: | source /opt/conda/etc/profile.d/conda.sh source /opt/conda/etc/profile.d/mamba.sh - mamba install -n mss-${{ inputs.branch_name }}-env pyvirtualdisplay + mamba install -n mss-${{ inputs.image_suffix }}-env pyvirtualdisplay - name: Always rebuild dependencies for scheduled builds (started from testing-scheduled.yml) - if: ${{ inputs.event_name == 'workflow_dispatch' }} + if: ${{ github.event_name == 'workflow_dispatch' }} run: echo "triggerdockerbuild=yes" >> $GITHUB_ENV - name: Invoke dockertesting image creation # The image creation is intentionally only triggered for push events because # scheduled tests should just check that new dependency versions do not break the # tests, but should not update the image. - if: ${{ inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} + if: ${{ (github.ref_name == 'stable' || github.ref_name == 'develop') && github.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} uses: benc-uk/workflow-dispatch@v1.2.2 with: - workflow: Update Image testing-${{ inputs.branch_name }} + workflow: Update Image testing-${{ inputs.image_suffix }} repo: Open-MSS/dockertesting ref: main token: ${{ secrets.PAT }} @@ -81,7 +77,7 @@ jobs: ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage - if: ${{ (inputs.event_name == 'push' || inputs.event_name == 'pull_request') && matrix.order == 'normal' }} + if: ${{ (github.event_name == 'push' || github.event_name == 'pull_request') && matrix.order == 'normal' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml deleted file mode 100644 index c0a546cb1..000000000 --- a/.github/workflows/testing_gsoc.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: test GSOC branches - -on: - push: - branches: - - 'GSOC**' - pull_request: - branches: - - 'GSOC**' - -jobs: - Test-MSS-GSOC: - runs-on: ubuntu-latest - - container: - image: openmss/testing-develop - - strategy: - fail-fast: false - matrix: - order: ["normal", "reverse"] - - steps: - - uses: actions/checkout@v4 - - - name: Check for changed dependencies - run: cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt || - (echo Dependencies differ && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) - - - name: Reinstall dependencies if changed - if: ${{ env.triggerdockerbuild == 'yes' }} - run: | - cat localbuild/meta.yaml | - sed -n '/^requirements:/,/^test:/p' | - sed -e "s/.*- //" | - sed -e "s/menuinst.*//" | - sed -e "s/.*://" > reqs.txt - cat requirements.d/development.txt >> reqs.txt - cat reqs.txt - mamba env remove -n mss-develop-env - mamba create -y -n mss-develop-env --file reqs.txt - - - name: Print conda list - run: mamba run --no-capture-output -n mss-develop-env mamba list - - - name: Run tests - timeout-minutes: 10 - run: mamba run --no-capture-output -n mss-develop-env xvfb-run pytest - -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib - ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - - - name: Collect coverage - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git config --global --add safe.directory /__w/MSS/MSS - mamba install -n mss-develop-env coveralls - mamba run --no-capture-output -n mss-develop-env coveralls --service=github From afa3061440ef1fbe51e5b7800e0a3df49b3ea5a6 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Mon, 6 May 2024 11:27:37 +0200 Subject: [PATCH 157/176] active_op_id needs to be shared (#2326) --- mslib/msui/multiple_flightpath_dockwidget.py | 11 ++- mslib/msui/topview.py | 3 +- mslib/msui/viewwindows.py | 1 + tests/_test_msui/test_mscolab.py | 71 ++++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 31484a20e..d80f10615 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -115,7 +115,8 @@ class MultipleFlightpathControlWidget(QtWidgets.QWidget, ui.Ui_MultipleViewWidge signal_parent_closes = QtCore.pyqtSignal() def __init__(self, parent=None, view=None, listFlightTracks=None, - listOperationsMSC=None, category=None, activeFlightTrack=None, mscolab_server_url=None, token=None): + listOperationsMSC=None, category=None, activeFlightTrack=None, active_op_id=None, + mscolab_server_url=None, token=None): super().__init__(parent) # ToDO: Remove all patches, on closing dockwidget. self.ui = parent @@ -124,6 +125,7 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.flight_path = None # flightpath object self.dict_flighttrack = {} # Dictionary of flighttrack data: patch,color,wp_model self.active_flight_track = activeFlightTrack + self.active_op_id = active_op_id self.msc_category = category # object of active category self.listOperationsMSC = listOperationsMSC self.listFlightTracks = listFlightTracks @@ -196,8 +198,11 @@ def login(self, url, token): self.connect_mscolab_server() def connect_mscolab_server(self): + if self.active_op_id is not None: + self.deactivate_all_flighttracks() self.operations = MultipleFlightpathOperations(self, self.mscolab_server_url, self.token, self.list_operation_track, + self.active_op_id, self.listOperationsMSC, self.view) self.obb.append(self.operations) @@ -510,10 +515,10 @@ class MultipleFlightpathOperations: on the TopView canvas. """ - def __init__(self, parent, mscolab_server_url, token, list_operation_track, listOperationsMSC, view): + def __init__(self, parent, mscolab_server_url, token, list_operation_track, active_op_id, listOperationsMSC, view): # Variables related to Mscolab Operations self.parent = parent - self.active_op_id = None + self.active_op_id = active_op_id self.mscolab_server_url = mscolab_server_url self.token = token self.view = view diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index fa7af8a53..62ced2e3a 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -221,7 +221,7 @@ def __init__(self, parent=None, mainwindow=None, model=None, _id=None, self.active_flighttrack = active_flighttrack # Stores active mscolab operation id - self.active_op_id = None + self.active_op_id = mainwindow.mscolab.active_op_id # Mscolab Server Url and token self.mscolab_server_url = mscolab_server_url @@ -356,6 +356,7 @@ def openTool(self, index): listOperationsMSC=self.mainwindow_listOperationsMSC, category=self.mainwindow_filterCategoryCb, activeFlightTrack=self.active_flighttrack, + active_op_id=self.active_op_id, mscolab_server_url=self.mscolab_server_url, token=self.token) diff --git a/mslib/msui/viewwindows.py b/mslib/msui/viewwindows.py index f2abd9e81..888766675 100644 --- a/mslib/msui/viewwindows.py +++ b/mslib/msui/viewwindows.py @@ -140,6 +140,7 @@ def createDockWidget(self, index, title, widget): # setWidget transfers the widget's ownership to Qt -- no setParent() # call is necessary: self.docks[index].setWidget(widget) + self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.docks[index]) # Check if another dock widget occupies the dock area. If yes, diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 6384d461c..f24344187 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -411,6 +411,77 @@ def assert_logout_text(): qtbot.wait_until(assert_label_text) # ToDo verify all operations disabled again without a visual check + def test_marked_bold_only_in_multiple_flight_path_operations_for_active_operation(self, qtbot): + """ + checks that when we use operations only the operations is bold marked not the flighttrack too + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + # test after activating operation + self._activate_operation_at_index(0) + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + + topview_0 = self.window.listViews.item(0) + assert topview_0.window.tv_window_exists is True + # open multiple flightpath first window + topview_0.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_0.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + assert topview_0.window.active_op_id is not None + + list_flighttrack = topview_0.window.docks[5].widget().list_flighttrack + list_operation_track = topview_0.window.docks[5].widget().list_operation_track + + for i in range(list_operation_track.count()): + listItem = list_operation_track.item(i) + if self.window.mscolab.active_op_id == listItem.op_id: + assert listItem.font().bold() is True + for i in range(list_flighttrack.count()): + listItem = list_flighttrack.item(i) + assert listItem.font().bold() is False + + def test_correct_active_op_id_in_topview(self, qtbot): + """ + checks that active_op_id is set + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + assert self.window.mscolab.active_op_id is None + # test after activating operation + self._activate_operation_at_index(0) + assert self.window.mscolab.active_op_id is not None + selected_op_id = self.window.mscolab.active_op_id + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + topview_0 = self.window.listViews.item(0) + assert topview_0.window.active_op_id is not None + assert topview_0.window.active_op_id == selected_op_id + def test_multiple_flightpath_switching_to_flighttrack_and_logout(self, qtbot): """ checks that we can switch in topviews with the multiple flightpath dockingwidget From 3ed347b43ed304da287e556b61c85de37a25a94d Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 7 May 2024 16:57:38 +0200 Subject: [PATCH 158/176] fix: missing y2024 (#2342) --- mslib/mscolab/message_type.py | 2 +- mslib/msui/msui_mainwindow.py | 2 +- mslib/utils/migration/config_before_nine.py | 2 +- mslib/utils/migration/update_json_file_to_version_nine.py | 2 +- tests/_test_mscolab/test_server_auth_required.py | 2 +- tests/fixtures.py | 2 +- tutorials/utils/picture.py | 2 +- tutorials/utils/platform_keys.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mslib/mscolab/message_type.py b/mslib/mscolab/message_type.py index 470cee761..49da165b5 100644 --- a/mslib/mscolab/message_type.py +++ b/mslib/mscolab/message_type.py @@ -7,7 +7,7 @@ This file is part of MSS. :copyright: Copyright 2019 Shivashis Padhi - :copyright: Copyright 2019-2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2019-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index aed56354e..f1ec9768b 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -13,7 +13,7 @@ :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) - :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/mslib/utils/migration/config_before_nine.py b/mslib/utils/migration/config_before_nine.py index d82dbb211..bee4bc1f7 100644 --- a/mslib/utils/migration/config_before_nine.py +++ b/mslib/utils/migration/config_before_nine.py @@ -10,7 +10,7 @@ :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) - :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/mslib/utils/migration/update_json_file_to_version_nine.py b/mslib/utils/migration/update_json_file_to_version_nine.py index d7febf47b..71b609d69 100644 --- a/mslib/utils/migration/update_json_file_to_version_nine.py +++ b/mslib/utils/migration/update_json_file_to_version_nine.py @@ -10,7 +10,7 @@ :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) - :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tests/_test_mscolab/test_server_auth_required.py b/tests/_test_mscolab/test_server_auth_required.py index 05fbf46f1..5280792a3 100644 --- a/tests/_test_mscolab/test_server_auth_required.py +++ b/tests/_test_mscolab/test_server_auth_required.py @@ -9,7 +9,7 @@ This file is part of MSS. :copyright: Copyright 2020 Reimar Bauer - :copyright: Copyright 2020-2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2020-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tests/fixtures.py b/tests/fixtures.py index 88891a1b4..4005225ec 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -8,7 +8,7 @@ This file is part of MSS. - :copyright: Copyright 2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2023-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tutorials/utils/picture.py b/tutorials/utils/picture.py index 34e1891d8..b9b784f4b 100644 --- a/tutorials/utils/picture.py +++ b/tutorials/utils/picture.py @@ -8,7 +8,7 @@ This file is part of MSS. - :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tutorials/utils/platform_keys.py b/tutorials/utils/platform_keys.py index e8e956038..e7abfbcde 100644 --- a/tutorials/utils/platform_keys.py +++ b/tutorials/utils/platform_keys.py @@ -7,7 +7,7 @@ This file is part of MSS. :copyright: Copyright 2021 Hrithik Kumar Verma - :copyright: Copyright 2021-2023 by the MSS team, see AUTHORS. + :copyright: Copyright 2021-2024 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); From 0b58ffa4cc3a299ad384656b1c2451e5cf8b15ab Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 8 May 2024 18:53:18 +0200 Subject: [PATCH 159/176] merge stable to develop --- .coveragerc | 4 +- .gitattributes | 1 + .github/workflows/lint.yml | 59 + .github/workflows/python-flake8.yml | 32 - CHANGES.rst | 21 +- CITATION.cff | 4 +- CONTRIBUTING.md | 8 +- LICENSE | 2 +- NOTICE | 6 +- README.md | 11 +- docs/_templates/breadcrumbs.html | 2 +- docs/conf_sso_test_msscolab.rst | 4 +- docs/index.rst | 1 - docs/make.bat | 562 +- docs/mscolab.rst | 6 +- docs/mssautoplot.rst | 10 +- docs/mswms.rst | 2 - docs/plugins.rst | 4 +- .../config/msui/mssautoplot.json.sample | 2 +- .../config/msui/snippets/proxies.sample | 1 - docs/samples/kml/World_Map.kml | 22914 ++++++++-------- docs/samples/kml/features.kml | 6 +- docs/samples/kml/geometry_collection.kml | 2 +- docs/samples/kml/line.kml | 6 +- docs/samples/kml/style.kml | 1 - docs/samples/plugins/navaid.rst | 3 +- .../sites-available/mss.yourserver.de.conf | 5 +- docs/samples/sites-available/mss_proxy.conf | 1 - .../proxy_demo.yourserver.de.conf | 9 +- .../samples/xml_templates/get_capabilities.pt | 1 - docs/sso_via_saml_mscolab.rst | 78 +- docs/tutorial.rst | 1 - docs/tutorials/tutorial_mscolab.rst | 1 - docs/tutorials/tutorial_msui_views.rst | 1 - docs/tutorials/tutorial_satellitetrack.rst | 1 - docs/usage.rst | 5 +- .../migrations/versions/cd8b7108713a_.py | 2 +- .../msui/icons/config_editor/Help-browser.svg | 2 +- mslib/msui/qt5/ui_add_user_dialog.py | 1 - mslib/msui/qt5/ui_customize_kml.py | 1 - mslib/msui/qt5/ui_kmloverlay_dockwidget.py | 1 - .../qt5/ui_mscolab_merge_waypoints_dialog.py | 1 - mslib/msui/qt5/ui_remotesensing_dockwidget.py | 1 - mslib/msui/qt5/ui_wms_capabilities.py | 1 - mslib/msui/qt5/ui_wms_password_dialog.py | 1 - mslib/mswms/xml_templates/get_capabilities.pt | 1 - .../xml_templates/get_capabilities130.pt | 229 +- .../xml_templates/service_exception130.pt | 14 +- mslib/static/docs/about.md | 1 - mslib/static/docs/help.md | 3 - mslib/static/docs/installation.md | 6 +- mslib/static/img/README | 1 - .../static/templates/idp/available_idps.html | 3 - .../templates/idp/idp_login_success.html | 2 +- mslib/static/templates/theme.html | 2 +- .../static/templates/user/reset_password.html | 12 +- .../static/templates/user/reset_request.html | 8 +- mslib/static/templates/user/status.html | 4 +- tests/_test_utils/test_version.py | 74 +- tests/data/features.kml | 6 +- tests/data/geometry_collection.kml | 2 +- tests/data/line.kml | 6 +- tests/data/style.kml | 1 - tutorials/start_tutorial.sh | 4 +- .../textfiles/tutorial_hexagoncontrol.txt | 8 +- tutorials/textfiles/tutorial_kml.txt | 12 +- tutorials/textfiles/tutorial_mscolab.txt | 4 +- .../tutorial_performancesettings.txt | 10 +- .../textfiles/tutorial_remotesensing.txt | 8 +- .../textfiles/tutorial_satellitetrack.txt | 2 +- tutorials/textfiles/tutorial_views.txt | 2 +- tutorials/textfiles/tutorial_waypoints.txt | 58 +- tutorials/textfiles/tutorial_wms.txt | 4 +- tutorials/tutorials.batch | 2 - 74 files changed, 12131 insertions(+), 12146 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/python-flake8.yml diff --git a/.coveragerc b/.coveragerc index 490c2061a..e6b792a8b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] -omit = +omit = _tests/* - */_tests/* + */_tests/* mslib/msui/qt5/* docs/* /tmp/* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..176a458f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..6c8e77ee5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,59 @@ +name: lint + +on: + push: + branches: + - develop + - stable + - 'GSOC**' + pull_request: + branches: + - develop + - stable + - 'GSOC**' + +jobs: + flake8: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Lint with flake8 + run: | + python -m pip install --upgrade pip + pip install flake8 flake8-builtins + flake8 --count --statistics mslib tests + + no-crlf-in-git: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Check for CRLF in the repository + run: | + files_with_crlf="$(git ls-files --eol | awk '$1 ~ "crlf"')" + echo "$files_with_crlf" + [ "$files_with_crlf" == "" ] || exit 1 + + no-whitespace-issues-in-git: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Check for whitespace issues in the repository + # The two example.txt files need to be excluded because whitespace at EOL is part + # of their format and they fail to parse otherwise. + # The leftover conflict marker warning is ignored for all rst files because + # section headings consisting of 7 "=" characters are mistaken to be part of a + # conflict marker. + run: | + issues="$( + git diff --check $(git hash-object -t tree /dev/null) HEAD :^tests/data/example.txt :^docs/samples/flight-tracks/example.txt | + { grep -v '^.*\.rst:.*leftover conflict marker$' || true; } + )" + echo "$issues" + [ "$issues" == "" ] || exit 1 diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml deleted file mode 100644 index 7cd34993e..000000000 --- a/.github/workflows/python-flake8.yml +++ /dev/null @@ -1,32 +0,0 @@ -# This workflow will install Python dependencies, lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: flake8 - -on: - push: - branches: - - develop - - stable - - 'GSOC**' - pull_request: - branches: - - develop - - stable - - 'GSOC**' - -jobs: - lint: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Lint with flake8 - run: | - python -m pip install --upgrade pip - pip install flake8 flake8-builtins - flake8 --count --statistics mslib tests diff --git a/CHANGES.rst b/CHANGES.rst index 8b508208f..81b041b10 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -253,7 +253,7 @@ Version 6.2.0 This release includes the use of the new basemap-1.3.3 release and enables packaging for python 3.10.x. We added the possibility of flight level as vertical coordinate. -A user can now leave on his own an operation. +A user can now leave on his own an operation. All changes: @@ -804,7 +804,7 @@ Bug Fixes: - mswms crashes on a wms server when the request object is None, #339, #342 - data_dir not used for default filepicker, #337 - post_link.sh update on conda-forge, #334 - + Version 1.7.2 ------------- @@ -1126,7 +1126,7 @@ New Features: - Suggest standard name for saving plots, #13 - KML Overlay introduced for overplot of flight region borders, #61, #97 - implemented demodata for standalone server and py.test, #80 - - simplified server setup, added demodata. + - simplified server setup, added demodata. - Always provide simplified aircraft range estimates in TableView. #85 - server data needs standard_name in data, #87 - plugin infrastructure introduced for supporting file formats for flight track saving/loading, #69, #88 @@ -1176,7 +1176,7 @@ Other Changes: -Version 1.2.2 +Version 1.2.2 ------------- Bug Fixes: @@ -1190,7 +1190,7 @@ New Features: Other Changes: - installation with conda-forge described#63 -Version 1.2.1 +Version 1.2.1 ------------- Bug Fixes: @@ -1223,17 +1223,17 @@ Other Changes: - improved documentations -Version 1.1.0 +Version 1.1.0 ------------- New Features: - Vertical section styles supported in standalone server, #10 - More formats for exchanging flight paths implemented, #7 - - Reverse flight path, #11 + - Reverse flight path, #11 - Displaying model data from CLaMS, #4 - Visualisation of gravity wave forecasts, #14 - Improved labels in plots, #8 - + Bug Fixes: - Improved debugging in standalone server, #9 - Fix for Labels accumulate in plots upon saving, #5 @@ -1243,7 +1243,6 @@ Bug Fixes: Other Changes: - Namespace refactored, all modules dependend to mslib #24 - Sphinx documentation introduced, #25, #26 - - Documentation on http://mss.rtfd.io - - Installation recipes based on conda + - Documentation on http://mss.rtfd.io + - Installation recipes based on conda - First public release on June 28, 2016 - diff --git a/CITATION.cff b/CITATION.cff index c903f71c7..2fdfad0ad 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,5 +1,5 @@ cff-version: "1.2.0" -contact: +contact: - website: https://github.com/Open-MSS/MSS/wiki/Contact name: "MSS community" license: "Apache-2.0" @@ -19,7 +19,7 @@ authors: email: "j.ungermann@fz-juelich.de" orcid: "https://orcid.org/0000-0001-9095-8332" - affiliation: "Forschungszentrum Jülich GmbH" - family-names: "Grooß" + family-names: "Grooß" given-names: "Jens-Uwe" email: "j.-u.grooss@fz-juelich.de" orcid: "https://orcid.org/0000-0002-9485-866X" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 501a7d8bc..fb98e7da2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,17 +1,17 @@ # Contributing When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the owners of this repository before making a change. +email, or any other method with the owners of this repository before making a change. Please note we have a code of conduct, please follow it in all your interactions with the project. ## Pull Request Process -1. Ensure any install or build dependencies are removed before the end of the layer when doing a +1. Ensure any install or build dependencies are removed before the end of the layer when doing a build. -2. Update the README.md with details of changes to the interface, this includes new environment +2. Update the README.md with details of changes to the interface, this includes new environment variables, exposed ports, useful file locations and container parameters. 3. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). -4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you +4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you do not have permission to do that, you may request the second reviewer to merge it for you. diff --git a/LICENSE b/LICENSE index 0ac444898..34c16cecf 100644 --- a/LICENSE +++ b/LICENSE @@ -206,7 +206,7 @@ *************************************************************************** *************************************************************************** -MISSION SUPPORT SYSTEM THIRD PARTY COMPONENTS: +MISSION SUPPORT SYSTEM THIRD PARTY COMPONENTS: The Mission Support System includes a number of components with separate copyright notices and license terms. Your use of the source diff --git a/NOTICE b/NOTICE index 1a39a94c9..f94f145cc 100644 --- a/NOTICE +++ b/NOTICE @@ -39,11 +39,11 @@ Package for working with OGC map, feature, and coverage services. Obtained from SVN (http://svn.gispython.org/svn/gispy/OWSLib/trunk), revision 1672, on 2010/08/11. -NOTE: The file "wms.py" has been modified for the MSS. Changes are marked +NOTE: The file "wms.py" has been modified for the MSS. Changes are marked with "(mss)". We renamed it to "ogcwms.py" (2017-04-28) We also did a PEP8 review of the ogcwms.py and adopted it to the recent 0.14 version -https://pypi.python.org/pypi/OWSLib/0.14.0 +https://pypi.python.org/pypi/OWSLib/0.14.0 NETCDF4-PYTHON @@ -62,7 +62,7 @@ https://code.google.com/p/netcdf4-python/ KML Examples ------------- -Below are links from where certain KML Files have been obtained, which are used +Below are links from where certain KML Files have been obtained, which are used as Test Samples in MSS (mss/docs/samples/kml/): World_Map.kml (mss/docs/samples/kml/World_Map.kml) diff --git a/README.md b/README.md index b014afc9d..ed3e5cb59 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Automatically Manually -------- -As **Beginner** start with an installation of Miniforge +As **Beginner** start with an installation of Miniforge Get [miniforge](https://github.com/conda-forge/miniforge#download) for your Operation System @@ -42,7 +42,7 @@ to leave out the 'source' here and below). For updating an existing MSS installation to the current version, it is best to install it into a new environment. If an existing environment shall be updated, it is important to update all packages in this -environment. +environment. ``` $ mamba activate mssenv @@ -74,7 +74,7 @@ Current release info [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.6572620.svg)](https://doi.org/10.5281/zenodo.6572620) [![Conda Platforms](https://img.shields.io/conda/pn/conda-forge/mss.svg)](https://anaconda.org/conda-forge/mss) [![DOCS](https://img.shields.io/badge/%F0%9F%95%AE-docs-green.svg)](http://mss.rtd.io) -[![Conda Recipe](https://img.shields.io/badge/recipe-mss-green.svg)](https://anaconda.org/conda-forge/mss) +[![Conda Recipe](https://img.shields.io/badge/recipe-mss-green.svg)](https://anaconda.org/conda-forge/mss) [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/mss.svg)](https://anaconda.org/conda-forge/mss) [![Coverage Status](https://coveralls.io/repos/github/Open-MSS/MSS/badge.svg?branch=develop)](https://coveralls.io/github/Open-MSS/MSS?branch=develop) @@ -100,11 +100,8 @@ application. The documents are available at: For copyright information, please see the files NOTICE and LICENSE, located in the same directory as this README file. - + When using this software, please be so kind and acknowledge its use by citing the above mentioned reference documentation in publications, presentations, reports, etc. that you create. Thank you very much. - - - diff --git a/docs/_templates/breadcrumbs.html b/docs/_templates/breadcrumbs.html index 1f48f57bf..1684a2dee 100644 --- a/docs/_templates/breadcrumbs.html +++ b/docs/_templates/breadcrumbs.html @@ -1,6 +1,6 @@ {# Support for Sphinx 1.3+ page_source_suffix, but don't break old builds. #} -{% if page_source_suffix %} +{% if page_source_suffix %} {% set suffix = page_source_suffix %} {% else %} {% set suffix = source_suffix %} diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst index 1e51cf043..ad9ecaf19 100644 --- a/docs/conf_sso_test_msscolab.rst +++ b/docs/conf_sso_test_msscolab.rst @@ -35,7 +35,7 @@ Before getting started, you should correctly activate the environments, set the 2. Generate Keys, Certificates, and backend_saml files ------------------------------------------------------ -This involves generating both `.key` files and `.crt` files for both the Identity provider and mscolab server and `backend_saml.yaml` file. +This involves generating both `.key` files and `.crt` files for both the Identity provider and mscolab server and `backend_saml.yaml` file. Before running the command make sure to set `USE_SAML2 = False` in your `mscolab_settings.py` file, You can accomplish this by following these steps: @@ -58,7 +58,7 @@ If everything is correctly set, you can generate keys and certificates simply by 3. Enable USE_SAML2 ------------------- -To enable SAML2-based login (identity provider-based login), +To enable SAML2-based login (identity provider-based login), - To start the process update `USE_SAML2 = True` in your `mscolab_settings.py` file. diff --git a/docs/index.rst b/docs/index.rst index b2657cb52..bc31a763b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,4 +46,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/make.bat b/docs/make.bat index d80cbd42f..7b3fdab48 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,281 +1,281 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\MSS-MissionSupportSystem.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\MSS-MissionSupportSystem.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\MSS-MissionSupportSystem.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\MSS-MissionSupportSystem.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/docs/mscolab.rst b/docs/mscolab.rst index cad627b9b..983cf2921 100644 --- a/docs/mscolab.rst +++ b/docs/mscolab.rst @@ -106,7 +106,7 @@ parameters of `flask-mail` :: A new user gets an email with an url including a token to become verified on the mscolab server. After the verification she can login. -If an existing user does not remember the password, she can reset the password by sending an email to the user's email +If an existing user does not remember the password, she can reset the password by sending an email to the user's email address and using the token that the system sent along with the email. Instructions to use mscolab wsgi @@ -265,7 +265,3 @@ Once you're done with all your local work and think you're ready to push your ch This would prompt you with a dialog where you can compare your local flight track and the common flight track on the server and select what you would like to keep. You can also fetch the common flight track to your local file at any time using the `Fetch from Server` button which prompts you with a similar dialog. You can turn the `Work Locally` toggle off at any points and work on the common shared file on the server. All your local changes are saved and you can return to them at any point by toggling the checkbox back on. - - - - diff --git a/docs/mssautoplot.rst b/docs/mssautoplot.rst index 3c8907616..9b6efacd5 100644 --- a/docs/mssautoplot.rst +++ b/docs/mssautoplot.rst @@ -2,7 +2,7 @@ mssautoplot - A CLI tool for automation ======================================= A CLI tool which enables users to download a set of plots according to the given configuration. The configuration for downloading the plots is located in "mssautoplot.json". In this file you can specify the default settings. - + How to use ---------- @@ -15,11 +15,11 @@ The CLI tool has the following parameters: +--------------+-------+----------------------------------------------------------------------+ | ``--ftrack`` | TEXT | Flight track. | +--------------+-------+----------------------------------------------------------------------+ -| ``--itime`` | TEXT | Initial time. | +| ``--itime`` | TEXT | Initial time. | +--------------+-------+----------------------------------------------------------------------+ -| ``--vtime`` | TEXT | Valid time. | +| ``--vtime`` | TEXT | Valid time. | +--------------+-------+----------------------------------------------------------------------+ -| ``--intv`` |INTEGER| Time interval in hours. | +| ``--intv`` |INTEGER| Time interval in hours. | +--------------+-------+----------------------------------------------------------------------+ | ``--stime`` | TEXT | Starting time for downloading multiple plots with a fixed interval.| +--------------+-------+----------------------------------------------------------------------+ @@ -28,7 +28,7 @@ The CLI tool has the following parameters: A short description of how to start the program is given by the ``--help`` option. -Examples +Examples ~~~~~~~~ Here are a few examples on how to use this tool, diff --git a/docs/mswms.rst b/docs/mswms.rst index a0dfd5893..79b3a1eff 100644 --- a/docs/mswms.rst +++ b/docs/mswms.rst @@ -628,5 +628,3 @@ and if you need authentication then use a Location based AuthType Basic For further information on apache2 server setup read ``_ - - diff --git a/docs/plugins.rst b/docs/plugins.rst index af2f1ac9c..107b7732c 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -53,8 +53,8 @@ and will be overwritten after reading back the file. "KML": ["kml", "mslib.plugins.io.kml", "save_to_kml"], "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] }, - - + + User contributed Plugins ------------------------ diff --git a/docs/samples/config/msui/mssautoplot.json.sample b/docs/samples/config/msui/mssautoplot.json.sample index a21327a4c..a60bdc137 100644 --- a/docs/samples/config/msui/mssautoplot.json.sample +++ b/docs/samples/config/msui/mssautoplot.json.sample @@ -79,7 +79,7 @@ ["", "", "", "", "", ""] ], "automated_plotting_hsecs": [ - ["", "", "", ""] + ["", "", "", ""] ], "automated_plotting_vsecs": [ ["","", "", ""] diff --git a/docs/samples/config/msui/snippets/proxies.sample b/docs/samples/config/msui/snippets/proxies.sample index 7338c520f..330bcd0d3 100644 --- a/docs/samples/config/msui/snippets/proxies.sample +++ b/docs/samples/config/msui/snippets/proxies.sample @@ -2,4 +2,3 @@ "http": "http://yoursquidproxy:3128", "https": "http://yoursquidproxy:3128" } - diff --git a/docs/samples/kml/World_Map.kml b/docs/samples/kml/World_Map.kml index 356226012..1b90bd9d4 100644 --- a/docs/samples/kml/World_Map.kml +++ b/docs/samples/kml/World_Map.kml @@ -13,13958 +13,13958 @@ #defaultStyle Afghanistan - - - - - - - 61.210817,35.650072 -62.230651,35.270664 -62.984662,35.404041 -63.193538,35.857166 -63.982896,36.007957 -64.546479,36.312073 -64.746105,37.111818 -65.588948,37.305217 -65.745631,37.661164 -66.217385,37.39379 -66.518607,37.362784 -67.075782,37.356144 -67.83,37.144994 -68.135562,37.023115 -68.859446,37.344336 -69.196273,37.151144 -69.518785,37.608997 -70.116578,37.588223 -70.270574,37.735165 -70.376304,38.138396 -70.806821,38.486282 -71.348131,38.258905 -71.239404,37.953265 -71.541918,37.905774 -71.448693,37.065645 -71.844638,36.738171 -72.193041,36.948288 -72.63689,37.047558 -73.260056,37.495257 -73.948696,37.421566 -74.980002,37.41999 -75.158028,37.133031 -74.575893,37.020841 -74.067552,36.836176 -72.920025,36.720007 -71.846292,36.509942 -71.262348,36.074388 -71.498768,35.650563 -71.613076,35.153203 -71.115019,34.733126 -71.156773,34.348911 -70.881803,33.988856 -69.930543,34.02012 -70.323594,33.358533 -69.687147,33.105499 -69.262522,32.501944 -69.317764,31.901412 -68.926677,31.620189 -68.556932,31.71331 -67.792689,31.58293 -67.683394,31.303154 -66.938891,31.304911 -66.381458,30.738899 -66.346473,29.887943 -65.046862,29.472181 -64.350419,29.560031 -64.148002,29.340819 -63.550261,29.468331 -62.549857,29.318572 -60.874248,29.829239 -61.781222,30.73585 -61.699314,31.379506 -60.941945,31.548075 -60.863655,32.18292 -60.536078,32.981269 -60.9637,33.528832 -60.52843,33.676446 -60.803193,34.404102 + + + + + + + 61.210817,35.650072 +62.230651,35.270664 +62.984662,35.404041 +63.193538,35.857166 +63.982896,36.007957 +64.546479,36.312073 +64.746105,37.111818 +65.588948,37.305217 +65.745631,37.661164 +66.217385,37.39379 +66.518607,37.362784 +67.075782,37.356144 +67.83,37.144994 +68.135562,37.023115 +68.859446,37.344336 +69.196273,37.151144 +69.518785,37.608997 +70.116578,37.588223 +70.270574,37.735165 +70.376304,38.138396 +70.806821,38.486282 +71.348131,38.258905 +71.239404,37.953265 +71.541918,37.905774 +71.448693,37.065645 +71.844638,36.738171 +72.193041,36.948288 +72.63689,37.047558 +73.260056,37.495257 +73.948696,37.421566 +74.980002,37.41999 +75.158028,37.133031 +74.575893,37.020841 +74.067552,36.836176 +72.920025,36.720007 +71.846292,36.509942 +71.262348,36.074388 +71.498768,35.650563 +71.613076,35.153203 +71.115019,34.733126 +71.156773,34.348911 +70.881803,33.988856 +69.930543,34.02012 +70.323594,33.358533 +69.687147,33.105499 +69.262522,32.501944 +69.317764,31.901412 +68.926677,31.620189 +68.556932,31.71331 +67.792689,31.58293 +67.683394,31.303154 +66.938891,31.304911 +66.381458,30.738899 +66.346473,29.887943 +65.046862,29.472181 +64.350419,29.560031 +64.148002,29.340819 +63.550261,29.468331 +62.549857,29.318572 +60.874248,29.829239 +61.781222,30.73585 +61.699314,31.379506 +60.941945,31.548075 +60.863655,32.18292 +60.536078,32.981269 +60.9637,33.528832 +60.52843,33.676446 +60.803193,34.404102 61.210817,35.650072 - + #defaultStyle Angola - - - - - - - 16.326528,-5.87747 -16.57318,-6.622645 -16.860191,-7.222298 -17.089996,-7.545689 -17.47297,-8.068551 -18.134222,-7.987678 -18.464176,-7.847014 -19.016752,-7.988246 -19.166613,-7.738184 -19.417502,-7.155429 -20.037723,-7.116361 -20.091622,-6.94309 -20.601823,-6.939318 -20.514748,-7.299606 -21.728111,-7.290872 -21.746456,-7.920085 -21.949131,-8.305901 -21.801801,-8.908707 -21.875182,-9.523708 -22.208753,-9.894796 -22.155268,-11.084801 -22.402798,-10.993075 -22.837345,-11.017622 -23.456791,-10.867863 -23.912215,-10.926826 -24.017894,-11.237298 -23.904154,-11.722282 -24.079905,-12.191297 -23.930922,-12.565848 -24.016137,-12.911046 -21.933886,-12.898437 -21.887843,-16.08031 -22.562478,-16.898451 -23.215048,-17.523116 -21.377176,-17.930636 -18.956187,-17.789095 -18.263309,-17.309951 -14.209707,-17.353101 -14.058501,-17.423381 -13.462362,-16.971212 -12.814081,-16.941343 -12.215461,-17.111668 -11.734199,-17.301889 -11.640096,-16.673142 -11.778537,-15.793816 -12.123581,-14.878316 -12.175619,-14.449144 -12.500095,-13.5477 -12.738479,-13.137906 -13.312914,-12.48363 -13.633721,-12.038645 -13.738728,-11.297863 -13.686379,-10.731076 -13.387328,-10.373578 -13.120988,-9.766897 -12.87537,-9.166934 -12.929061,-8.959091 -13.236433,-8.562629 -12.93304,-7.596539 -12.728298,-6.927122 -12.227347,-6.294448 -12.322432,-6.100092 -12.735171,-5.965682 -13.024869,-5.984389 -13.375597,-5.864241 + + + + + + + 16.326528,-5.87747 +16.57318,-6.622645 +16.860191,-7.222298 +17.089996,-7.545689 +17.47297,-8.068551 +18.134222,-7.987678 +18.464176,-7.847014 +19.016752,-7.988246 +19.166613,-7.738184 +19.417502,-7.155429 +20.037723,-7.116361 +20.091622,-6.94309 +20.601823,-6.939318 +20.514748,-7.299606 +21.728111,-7.290872 +21.746456,-7.920085 +21.949131,-8.305901 +21.801801,-8.908707 +21.875182,-9.523708 +22.208753,-9.894796 +22.155268,-11.084801 +22.402798,-10.993075 +22.837345,-11.017622 +23.456791,-10.867863 +23.912215,-10.926826 +24.017894,-11.237298 +23.904154,-11.722282 +24.079905,-12.191297 +23.930922,-12.565848 +24.016137,-12.911046 +21.933886,-12.898437 +21.887843,-16.08031 +22.562478,-16.898451 +23.215048,-17.523116 +21.377176,-17.930636 +18.956187,-17.789095 +18.263309,-17.309951 +14.209707,-17.353101 +14.058501,-17.423381 +13.462362,-16.971212 +12.814081,-16.941343 +12.215461,-17.111668 +11.734199,-17.301889 +11.640096,-16.673142 +11.778537,-15.793816 +12.123581,-14.878316 +12.175619,-14.449144 +12.500095,-13.5477 +12.738479,-13.137906 +13.312914,-12.48363 +13.633721,-12.038645 +13.738728,-11.297863 +13.686379,-10.731076 +13.387328,-10.373578 +13.120988,-9.766897 +12.87537,-9.166934 +12.929061,-8.959091 +13.236433,-8.562629 +12.93304,-7.596539 +12.728298,-6.927122 +12.227347,-6.294448 +12.322432,-6.100092 +12.735171,-5.965682 +13.024869,-5.984389 +13.375597,-5.864241 16.326528,-5.87747 - + - 12.436688,-5.684304 -12.182337,-5.789931 -11.914963,-5.037987 -12.318608,-4.60623 -12.62076,-4.438023 -12.995517,-4.781103 -12.631612,-4.991271 -12.468004,-5.248362 + 12.436688,-5.684304 +12.182337,-5.789931 +11.914963,-5.037987 +12.318608,-4.60623 +12.62076,-4.438023 +12.995517,-4.781103 +12.631612,-4.991271 +12.468004,-5.248362 12.436688,-5.684304 - + #defaultStyle Albania - - - - - - - 20.590247,41.855404 -20.463175,41.515089 -20.605182,41.086226 -21.02004,40.842727 -20.99999,40.580004 -20.674997,40.435 -20.615,40.110007 -20.150016,39.624998 -19.98,39.694993 -19.960002,39.915006 -19.406082,40.250773 -19.319059,40.72723 -19.40355,41.409566 -19.540027,41.719986 -19.371769,41.877548 -19.304486,42.195745 -19.738051,42.688247 -19.801613,42.500093 -20.0707,42.58863 -20.283755,42.32026 -20.52295,42.21787 + + + + + + + 20.590247,41.855404 +20.463175,41.515089 +20.605182,41.086226 +21.02004,40.842727 +20.99999,40.580004 +20.674997,40.435 +20.615,40.110007 +20.150016,39.624998 +19.98,39.694993 +19.960002,39.915006 +19.406082,40.250773 +19.319059,40.72723 +19.40355,41.409566 +19.540027,41.719986 +19.371769,41.877548 +19.304486,42.195745 +19.738051,42.688247 +19.801613,42.500093 +20.0707,42.58863 +20.283755,42.32026 +20.52295,42.21787 20.590247,41.855404 - + #defaultStyle United Arab Emirates - - - - - - - 51.579519,24.245497 -51.757441,24.294073 -51.794389,24.019826 -52.577081,24.177439 -53.404007,24.151317 -54.008001,24.121758 -54.693024,24.797892 -55.439025,25.439145 -56.070821,26.055464 -56.261042,25.714606 -56.396847,24.924732 -55.886233,24.920831 -55.804119,24.269604 -55.981214,24.130543 -55.528632,23.933604 -55.525841,23.524869 -55.234489,23.110993 -55.208341,22.70833 -55.006803,22.496948 -52.000733,23.001154 -51.617708,24.014219 + + + + + + + 51.579519,24.245497 +51.757441,24.294073 +51.794389,24.019826 +52.577081,24.177439 +53.404007,24.151317 +54.008001,24.121758 +54.693024,24.797892 +55.439025,25.439145 +56.070821,26.055464 +56.261042,25.714606 +56.396847,24.924732 +55.886233,24.920831 +55.804119,24.269604 +55.981214,24.130543 +55.528632,23.933604 +55.525841,23.524869 +55.234489,23.110993 +55.208341,22.70833 +55.006803,22.496948 +52.000733,23.001154 +51.617708,24.014219 51.579519,24.245497 - + #defaultStyle Argentina - + - -65.5,-55.2 --66.45,-55.25 --66.95992,-54.89681 --67.56244,-54.87001 --68.63335,-54.8695 --68.63401,-52.63637 --68.25,-53.1 --67.75,-53.85 --66.45,-54.45 --65.05,-54.7 + -65.5,-55.2 +-66.45,-55.25 +-66.95992,-54.89681 +-67.56244,-54.87001 +-68.63335,-54.8695 +-68.63401,-52.63637 +-68.25,-53.1 +-67.75,-53.85 +-66.45,-54.45 +-65.05,-54.7 -65.5,-55.2 - + - -64.964892,-22.075862 --64.377021,-22.798091 --63.986838,-21.993644 --62.846468,-22.034985 --62.685057,-22.249029 --60.846565,-23.880713 --60.028966,-24.032796 --58.807128,-24.771459 --57.777217,-25.16234 --57.63366,-25.603657 --58.618174,-27.123719 --57.60976,-27.395899 --56.486702,-27.548499 --55.695846,-27.387837 --54.788795,-26.621786 --54.625291,-25.739255 --54.13005,-25.547639 --53.628349,-26.124865 --53.648735,-26.923473 --54.490725,-27.474757 --55.162286,-27.881915 --56.2909,-28.852761 --57.625133,-30.216295 --57.874937,-31.016556 --58.14244,-32.044504 --58.132648,-33.040567 --58.349611,-33.263189 --58.427074,-33.909454 --58.495442,-34.43149 --57.22583,-35.288027 --57.362359,-35.97739 --56.737487,-36.413126 --56.788285,-36.901572 --57.749157,-38.183871 --59.231857,-38.72022 --61.237445,-38.928425 --62.335957,-38.827707 --62.125763,-39.424105 --62.330531,-40.172586 --62.145994,-40.676897 --62.745803,-41.028761 --63.770495,-41.166789 --64.73209,-40.802677 --65.118035,-41.064315 --64.978561,-42.058001 --64.303408,-42.359016 --63.755948,-42.043687 --63.458059,-42.563138 --64.378804,-42.873558 --65.181804,-43.495381 --65.328823,-44.501366 --65.565269,-45.036786 --66.509966,-45.039628 --67.293794,-45.551896 --67.580546,-46.301773 --66.597066,-47.033925 --65.641027,-47.236135 --65.985088,-48.133289 --67.166179,-48.697337 --67.816088,-49.869669 --68.728745,-50.264218 --69.138539,-50.73251 --68.815561,-51.771104 --68.149995,-52.349983 --68.571545,-52.299444 --69.498362,-52.142761 --71.914804,-52.009022 --72.329404,-51.425956 --72.309974,-50.67701 --72.975747,-50.74145 --73.328051,-50.378785 --73.415436,-49.318436 --72.648247,-48.878618 --72.331161,-48.244238 --72.447355,-47.738533 --71.917258,-46.884838 --71.552009,-45.560733 --71.659316,-44.973689 --71.222779,-44.784243 --71.329801,-44.407522 --71.793623,-44.207172 --71.464056,-43.787611 --71.915424,-43.408565 --72.148898,-42.254888 --71.746804,-42.051386 --71.915734,-40.832339 --71.680761,-39.808164 --71.413517,-38.916022 --70.814664,-38.552995 --71.118625,-37.576827 --71.121881,-36.658124 --70.364769,-36.005089 --70.388049,-35.169688 --69.817309,-34.193571 --69.814777,-33.273886 --70.074399,-33.09121 --70.535069,-31.36501 --69.919008,-30.336339 --70.01355,-29.367923 --69.65613,-28.459141 --69.001235,-27.521214 --68.295542,-26.89934 --68.5948,-26.506909 --68.386001,-26.185016 --68.417653,-24.518555 --67.328443,-24.025303 --66.985234,-22.986349 --67.106674,-22.735925 --66.273339,-21.83231 + -64.964892,-22.075862 +-64.377021,-22.798091 +-63.986838,-21.993644 +-62.846468,-22.034985 +-62.685057,-22.249029 +-60.846565,-23.880713 +-60.028966,-24.032796 +-58.807128,-24.771459 +-57.777217,-25.16234 +-57.63366,-25.603657 +-58.618174,-27.123719 +-57.60976,-27.395899 +-56.486702,-27.548499 +-55.695846,-27.387837 +-54.788795,-26.621786 +-54.625291,-25.739255 +-54.13005,-25.547639 +-53.628349,-26.124865 +-53.648735,-26.923473 +-54.490725,-27.474757 +-55.162286,-27.881915 +-56.2909,-28.852761 +-57.625133,-30.216295 +-57.874937,-31.016556 +-58.14244,-32.044504 +-58.132648,-33.040567 +-58.349611,-33.263189 +-58.427074,-33.909454 +-58.495442,-34.43149 +-57.22583,-35.288027 +-57.362359,-35.97739 +-56.737487,-36.413126 +-56.788285,-36.901572 +-57.749157,-38.183871 +-59.231857,-38.72022 +-61.237445,-38.928425 +-62.335957,-38.827707 +-62.125763,-39.424105 +-62.330531,-40.172586 +-62.145994,-40.676897 +-62.745803,-41.028761 +-63.770495,-41.166789 +-64.73209,-40.802677 +-65.118035,-41.064315 +-64.978561,-42.058001 +-64.303408,-42.359016 +-63.755948,-42.043687 +-63.458059,-42.563138 +-64.378804,-42.873558 +-65.181804,-43.495381 +-65.328823,-44.501366 +-65.565269,-45.036786 +-66.509966,-45.039628 +-67.293794,-45.551896 +-67.580546,-46.301773 +-66.597066,-47.033925 +-65.641027,-47.236135 +-65.985088,-48.133289 +-67.166179,-48.697337 +-67.816088,-49.869669 +-68.728745,-50.264218 +-69.138539,-50.73251 +-68.815561,-51.771104 +-68.149995,-52.349983 +-68.571545,-52.299444 +-69.498362,-52.142761 +-71.914804,-52.009022 +-72.329404,-51.425956 +-72.309974,-50.67701 +-72.975747,-50.74145 +-73.328051,-50.378785 +-73.415436,-49.318436 +-72.648247,-48.878618 +-72.331161,-48.244238 +-72.447355,-47.738533 +-71.917258,-46.884838 +-71.552009,-45.560733 +-71.659316,-44.973689 +-71.222779,-44.784243 +-71.329801,-44.407522 +-71.793623,-44.207172 +-71.464056,-43.787611 +-71.915424,-43.408565 +-72.148898,-42.254888 +-71.746804,-42.051386 +-71.915734,-40.832339 +-71.680761,-39.808164 +-71.413517,-38.916022 +-70.814664,-38.552995 +-71.118625,-37.576827 +-71.121881,-36.658124 +-70.364769,-36.005089 +-70.388049,-35.169688 +-69.817309,-34.193571 +-69.814777,-33.273886 +-70.074399,-33.09121 +-70.535069,-31.36501 +-69.919008,-30.336339 +-70.01355,-29.367923 +-69.65613,-28.459141 +-69.001235,-27.521214 +-68.295542,-26.89934 +-68.5948,-26.506909 +-68.386001,-26.185016 +-68.417653,-24.518555 +-67.328443,-24.025303 +-66.985234,-22.986349 +-67.106674,-22.735925 +-66.273339,-21.83231 -64.964892,-22.075862 - + #defaultStyle Armenia - + - 43.582746,41.092143 -44.97248,41.248129 -45.179496,40.985354 -45.560351,40.81229 -45.359175,40.561504 -45.891907,40.218476 -45.610012,39.899994 -46.034534,39.628021 -46.483499,39.464155 -46.50572,38.770605 -46.143623,38.741201 -45.735379,39.319719 -45.739978,39.473999 -45.298145,39.471751 -45.001987,39.740004 -44.79399,39.713003 -44.400009,40.005 -43.656436,40.253564 -43.752658,40.740201 + 43.582746,41.092143 +44.97248,41.248129 +45.179496,40.985354 +45.560351,40.81229 +45.359175,40.561504 +45.891907,40.218476 +45.610012,39.899994 +46.034534,39.628021 +46.483499,39.464155 +46.50572,38.770605 +46.143623,38.741201 +45.735379,39.319719 +45.739978,39.473999 +45.298145,39.471751 +45.001987,39.740004 +44.79399,39.713003 +44.400009,40.005 +43.656436,40.253564 +43.752658,40.740201 43.582746,41.092143 - + #defaultStyle Antarctica - + - -59.572095,-80.040179 --59.865849,-80.549657 --60.159656,-81.000327 --62.255393,-80.863178 --64.488125,-80.921934 --65.741666,-80.588827 --65.741666,-80.549657 --66.290031,-80.255773 --64.037688,-80.294944 --61.883246,-80.39287 --61.138976,-79.981371 --60.610119,-79.628679 + -59.572095,-80.040179 +-59.865849,-80.549657 +-60.159656,-81.000327 +-62.255393,-80.863178 +-64.488125,-80.921934 +-65.741666,-80.588827 +-65.741666,-80.549657 +-66.290031,-80.255773 +-64.037688,-80.294944 +-61.883246,-80.39287 +-61.138976,-79.981371 +-60.610119,-79.628679 -59.572095,-80.040179 - + - -159.208184,-79.497059 --161.127601,-79.634209 --162.439847,-79.281465 --163.027408,-78.928774 --163.066604,-78.869966 --163.712896,-78.595667 --163.105801,-78.223338 --161.245113,-78.380176 --160.246208,-78.693645 --159.482405,-79.046338 + -159.208184,-79.497059 +-161.127601,-79.634209 +-162.439847,-79.281465 +-163.027408,-78.928774 +-163.066604,-78.869966 +-163.712896,-78.595667 +-163.105801,-78.223338 +-161.245113,-78.380176 +-160.246208,-78.693645 +-159.482405,-79.046338 -159.208184,-79.497059 - + - -45.154758,-78.04707 --43.920828,-78.478103 --43.48995,-79.08556 --43.372438,-79.516645 --43.333267,-80.026123 --44.880537,-80.339644 --46.506174,-80.594357 --48.386421,-80.829485 --50.482107,-81.025442 --52.851988,-80.966685 --54.164259,-80.633528 --53.987991,-80.222028 --51.853134,-79.94773 --50.991326,-79.614623 --50.364595,-79.183487 --49.914131,-78.811209 --49.306959,-78.458569 --48.660616,-78.047018 --48.660616,-78.047019 --48.151396,-78.04707 --46.662857,-77.831476 + -45.154758,-78.04707 +-43.920828,-78.478103 +-43.48995,-79.08556 +-43.372438,-79.516645 +-43.333267,-80.026123 +-44.880537,-80.339644 +-46.506174,-80.594357 +-48.386421,-80.829485 +-50.482107,-81.025442 +-52.851988,-80.966685 +-54.164259,-80.633528 +-53.987991,-80.222028 +-51.853134,-79.94773 +-50.991326,-79.614623 +-50.364595,-79.183487 +-49.914131,-78.811209 +-49.306959,-78.458569 +-48.660616,-78.047018 +-48.660616,-78.047019 +-48.151396,-78.04707 +-46.662857,-77.831476 -45.154758,-78.04707 - + - -121.211511,-73.50099 --119.918851,-73.657725 --118.724143,-73.481353 --119.292119,-73.834097 --120.232217,-74.08881 --121.62283,-74.010468 --122.621735,-73.657778 --122.621735,-73.657777 --122.406245,-73.324619 + -121.211511,-73.50099 +-119.918851,-73.657725 +-118.724143,-73.481353 +-119.292119,-73.834097 +-120.232217,-74.08881 +-121.62283,-74.010468 +-122.621735,-73.657778 +-122.621735,-73.657777 +-122.406245,-73.324619 -121.211511,-73.50099 - + - -125.559566,-73.481353 --124.031882,-73.873268 --124.619469,-73.834097 --125.912181,-73.736118 --127.28313,-73.461769 --127.28313,-73.461768 --126.558472,-73.246226 + -125.559566,-73.481353 +-124.031882,-73.873268 +-124.619469,-73.834097 +-125.912181,-73.736118 +-127.28313,-73.461769 +-127.28313,-73.461768 +-126.558472,-73.246226 -125.559566,-73.481353 - + - -98.98155,-71.933334 --97.884743,-72.070535 --96.787937,-71.952971 --96.20035,-72.521205 --96.983765,-72.442864 --98.198083,-72.482035 --99.432013,-72.442864 --100.783455,-72.50162 --101.801868,-72.305663 --102.330725,-71.894164 --101.703967,-71.717792 --100.430919,-71.854993 + -98.98155,-71.933334 +-97.884743,-72.070535 +-96.787937,-71.952971 +-96.20035,-72.521205 +-96.983765,-72.442864 +-98.198083,-72.482035 +-99.432013,-72.442864 +-100.783455,-72.50162 +-101.801868,-72.305663 +-102.330725,-71.894164 +-101.703967,-71.717792 +-100.430919,-71.854993 -98.98155,-71.933334 - + - -68.451346,-70.955823 --68.333834,-71.406493 --68.510128,-71.798407 --68.784297,-72.170736 --69.959471,-72.307885 --71.075889,-72.503842 --72.388134,-72.484257 --71.8985,-72.092343 --73.073622,-72.229492 --74.19004,-72.366693 --74.953895,-72.072757 --75.012625,-71.661258 --73.915819,-71.269345 --73.915819,-71.269344 --73.230331,-71.15178 --72.074717,-71.190951 --71.780962,-70.681473 --71.72218,-70.309196 --71.741791,-69.505782 --71.173815,-69.035475 --70.253252,-68.87874 --69.724447,-69.251017 --69.489422,-69.623346 --69.058518,-70.074016 --68.725541,-70.505153 + -68.451346,-70.955823 +-68.333834,-71.406493 +-68.510128,-71.798407 +-68.784297,-72.170736 +-69.959471,-72.307885 +-71.075889,-72.503842 +-72.388134,-72.484257 +-71.8985,-72.092343 +-73.073622,-72.229492 +-74.19004,-72.366693 +-74.953895,-72.072757 +-75.012625,-71.661258 +-73.915819,-71.269345 +-73.915819,-71.269344 +-73.230331,-71.15178 +-72.074717,-71.190951 +-71.780962,-70.681473 +-71.72218,-70.309196 +-71.741791,-69.505782 +-71.173815,-69.035475 +-70.253252,-68.87874 +-69.724447,-69.251017 +-69.489422,-69.623346 +-69.058518,-70.074016 +-68.725541,-70.505153 -68.451346,-70.955823 - + - -58.614143,-64.152467 --59.045073,-64.36801 --59.789342,-64.211223 --60.611928,-64.309202 --61.297416,-64.54433 --62.0221,-64.799094 --62.51176,-65.09303 --62.648858,-65.484942 --62.590128,-65.857219 --62.120079,-66.190326 --62.805567,-66.425505 --63.74569,-66.503847 --64.294106,-66.837004 --64.881693,-67.150474 --65.508425,-67.58161 --65.665082,-67.953887 --65.312545,-68.365335 --64.783715,-68.678908 --63.961103,-68.913984 --63.1973,-69.227556 --62.785955,-69.619419 --62.570516,-69.991747 --62.276736,-70.383661 --61.806661,-70.716768 --61.512906,-71.089045 --61.375809,-72.010074 --61.081977,-72.382351 --61.003661,-72.774265 --60.690269,-73.166179 --60.827367,-73.695242 --61.375809,-74.106742 --61.96337,-74.439848 --63.295201,-74.576997 --63.74569,-74.92974 --64.352836,-75.262847 --65.860987,-75.635124 --67.192818,-75.79191 --68.446282,-76.007452 --69.797724,-76.222995 --70.600724,-76.634494 --72.206776,-76.673665 --73.969536,-76.634494 --75.555977,-76.712887 --77.24037,-76.712887 --76.926979,-77.104802 --75.399294,-77.28107 --74.282876,-77.55542 --73.656119,-77.908112 --74.772536,-78.221633 --76.4961,-78.123654 --77.925858,-78.378419 --77.984666,-78.789918 --78.023785,-79.181833 --76.848637,-79.514939 --76.633224,-79.887216 --75.360097,-80.259545 --73.244852,-80.416331 --71.442946,-80.69063 --70.013163,-81.004151 --68.191646,-81.317672 --65.704279,-81.474458 --63.25603,-81.748757 --61.552026,-82.042692 --59.691416,-82.37585 --58.712121,-82.846106 --58.222487,-83.218434 --57.008117,-82.865691 --55.362894,-82.571755 --53.619771,-82.258235 --51.543644,-82.003521 --49.76135,-81.729171 --47.273931,-81.709586 --44.825708,-81.846735 --42.808363,-82.081915 --42.16202,-81.65083 --40.771433,-81.356894 --38.244818,-81.337309 --36.26667,-81.121715 --34.386397,-80.906172 --32.310296,-80.769023 --30.097098,-80.592651 --28.549802,-80.337938 --29.254901,-79.985195 --29.685805,-79.632503 --29.685805,-79.260226 --31.624808,-79.299397 --33.681324,-79.456132 --35.639912,-79.456132 --35.914107,-79.083855 --35.77701,-78.339248 --35.326546,-78.123654 --33.896763,-77.888526 --32.212369,-77.65345 --30.998051,-77.359515 --29.783732,-77.065579 --28.882779,-76.673665 --27.511752,-76.497345 --26.160336,-76.360144 --25.474822,-76.281803 --23.927552,-76.24258 --22.458598,-76.105431 --21.224694,-75.909474 --20.010375,-75.674346 --18.913543,-75.439218 --17.522982,-75.125698 --16.641589,-74.79254 --15.701491,-74.498604 --15.40771,-74.106742 --16.46532,-73.871614 --16.112784,-73.460114 --15.446855,-73.146542 --14.408805,-72.950585 --13.311973,-72.715457 --12.293508,-72.401936 --11.510067,-72.010074 --11.020433,-71.539767 --10.295774,-71.265416 --9.101015,-71.324224 --8.611381,-71.65733 --7.416622,-71.696501 --7.377451,-71.324224 --6.868232,-70.93231 --5.790985,-71.030289 --5.536375,-71.402617 --4.341667,-71.461373 --3.048981,-71.285053 --1.795492,-71.167438 --0.659489,-71.226246 --0.228637,-71.637745 -0.868195,-71.304639 -1.886686,-71.128267 -3.022638,-70.991118 -4.139055,-70.853917 -5.157546,-70.618789 -6.273912,-70.462055 -7.13572,-70.246512 -7.742866,-69.893769 -8.48711,-70.148534 -9.525135,-70.011333 -10.249845,-70.48164 -10.817821,-70.834332 -11.953824,-70.638375 -12.404287,-70.246512 -13.422778,-69.972162 -14.734998,-70.030918 -15.126757,-70.403247 -15.949342,-70.030918 -17.026589,-69.913354 -18.201711,-69.874183 -19.259373,-69.893769 -20.375739,-70.011333 -21.452985,-70.07014 -21.923034,-70.403247 -22.569403,-70.697182 -23.666184,-70.520811 -24.841357,-70.48164 -25.977309,-70.48164 -27.093726,-70.462055 -28.09258,-70.324854 -29.150242,-70.20729 -30.031583,-69.93294 -30.971733,-69.75662 -31.990172,-69.658641 -32.754053,-69.384291 -33.302443,-68.835642 -33.870419,-68.502588 -34.908495,-68.659271 -35.300202,-69.012014 -36.16201,-69.247142 -37.200035,-69.168748 -37.905108,-69.52144 -38.649404,-69.776205 -39.667894,-69.541077 -40.020431,-69.109941 -40.921358,-68.933621 -41.959434,-68.600514 -42.938702,-68.463313 -44.113876,-68.267408 -44.897291,-68.051866 -45.719928,-67.816738 -46.503343,-67.601196 -47.44344,-67.718759 -48.344419,-67.366068 -48.990736,-67.091718 -49.930885,-67.111303 -50.753471,-66.876175 -50.949325,-66.523484 -51.791547,-66.249133 -52.614133,-66.053176 -53.613038,-65.89639 -54.53355,-65.818049 -55.414943,-65.876805 -56.355041,-65.974783 -57.158093,-66.249133 -57.255968,-66.680218 -58.137361,-67.013324 -58.744508,-67.287675 -59.939318,-67.405239 -60.605221,-67.679589 -61.427806,-67.953887 -62.387489,-68.012695 -63.19049,-67.816738 -64.052349,-67.405239 -64.992447,-67.620729 -65.971715,-67.738345 -66.911864,-67.855909 -67.891133,-67.934302 -68.890038,-67.934302 -69.712624,-68.972791 -69.673453,-69.227556 -69.555941,-69.678226 -68.596258,-69.93294 -67.81274,-70.305268 -67.949889,-70.697182 -69.066307,-70.677545 -68.929157,-71.069459 -68.419989,-71.441788 -67.949889,-71.853287 -68.71377,-72.166808 -69.869307,-72.264787 -71.024895,-72.088415 -71.573285,-71.696501 -71.906288,-71.324224 -72.454627,-71.010703 -73.08141,-70.716768 -73.33602,-70.364024 -73.864877,-69.874183 -74.491557,-69.776205 -75.62756,-69.737034 -76.626465,-69.619419 -77.644904,-69.462684 -78.134539,-69.07077 -78.428371,-68.698441 -79.113859,-68.326216 -80.093127,-68.071503 -80.93535,-67.875546 -81.483792,-67.542388 -82.051767,-67.366068 -82.776426,-67.209282 -83.775331,-67.30726 -84.676206,-67.209282 -85.655527,-67.091718 -86.752359,-67.150474 -87.477017,-66.876175 -87.986289,-66.209911 -88.358411,-66.484261 -88.828408,-66.954568 -89.67063,-67.150474 -90.630365,-67.228867 -91.5901,-67.111303 -92.608539,-67.189696 -93.548637,-67.209282 -94.17542,-67.111303 -95.017591,-67.170111 -95.781472,-67.385653 -96.682399,-67.248504 -97.759646,-67.248504 -98.68021,-67.111303 -99.718182,-67.248504 -100.384188,-66.915346 -100.893356,-66.58224 -101.578896,-66.30789 -102.832411,-65.563284 -103.478676,-65.700485 -104.242557,-65.974783 -104.90846,-66.327527 -106.181561,-66.934931 -107.160881,-66.954568 -108.081393,-66.954568 -109.15864,-66.837004 -110.235835,-66.699804 -111.058472,-66.425505 -111.74396,-66.13157 -112.860378,-66.092347 -113.604673,-65.876805 -114.388088,-66.072762 -114.897308,-66.386283 -115.602381,-66.699804 -116.699161,-66.660633 -117.384701,-66.915346 -118.57946,-67.170111 -119.832924,-67.268089 -120.871,-67.189696 -121.654415,-66.876175 -122.320369,-66.562654 -123.221296,-66.484261 -124.122274,-66.621462 -125.160247,-66.719389 -126.100396,-66.562654 -127.001427,-66.562654 -127.882768,-66.660633 -128.80328,-66.758611 -129.704259,-66.58224 -130.781454,-66.425505 -131.799945,-66.386283 -132.935896,-66.386283 -133.85646,-66.288304 -134.757387,-66.209963 -135.031582,-65.72007 -135.070753,-65.308571 -135.697485,-65.582869 -135.873805,-66.033591 -136.206705,-66.44509 -136.618049,-66.778197 -137.460271,-66.954568 -138.596223,-66.895761 -139.908442,-66.876175 -140.809421,-66.817367 -142.121692,-66.817367 -143.061842,-66.797782 -144.374061,-66.837004 -145.490427,-66.915346 -146.195552,-67.228867 -145.999699,-67.601196 -146.646067,-67.895131 -147.723263,-68.130259 -148.839629,-68.385024 -150.132314,-68.561292 -151.483705,-68.71813 -152.502247,-68.874813 -153.638199,-68.894502 -154.284567,-68.561292 -155.165857,-68.835642 -155.92979,-69.149215 -156.811132,-69.384291 -158.025528,-69.482269 -159.181013,-69.599833 -159.670699,-69.991747 -160.80665,-70.226875 -161.570479,-70.579618 -162.686897,-70.736353 -163.842434,-70.716768 -164.919681,-70.775524 -166.11444,-70.755938 -167.309095,-70.834332 -168.425616,-70.971481 -169.463589,-71.20666 -170.501665,-71.402617 -171.20679,-71.696501 -171.089227,-72.088415 -170.560422,-72.441159 -170.109958,-72.891829 -169.75737,-73.24452 -169.287321,-73.65602 -167.975101,-73.812806 -167.387489,-74.165498 -166.094803,-74.38104 -165.644391,-74.772954 -164.958851,-75.145283 -164.234193,-75.458804 -163.822797,-75.870303 -163.568239,-76.24258 -163.47026,-76.693302 -163.489897,-77.065579 -164.057873,-77.457442 -164.273363,-77.82977 -164.743464,-78.182514 -166.604126,-78.319611 -166.995781,-78.750748 -165.193876,-78.907483 -163.666217,-79.123025 -161.766385,-79.162248 -160.924162,-79.730482 -160.747894,-80.200737 -160.316964,-80.573066 -159.788211,-80.945395 -161.120016,-81.278501 -161.629287,-81.690001 -162.490992,-82.062278 -163.705336,-82.395435 -165.095949,-82.708956 -166.604126,-83.022477 -168.895665,-83.335998 -169.404782,-83.825891 -172.283934,-84.041433 -172.477049,-84.117914 -173.224083,-84.41371 -175.985672,-84.158997 -178.277212,-84.472518 -180.0,-84.71338 --179.942499,-84.721443 --179.058677,-84.139412 --177.256772,-84.452933 --177.140807,-84.417941 --176.084673,-84.099259 --175.947235,-84.110449 --175.829882,-84.117914 --174.382503,-84.534323 --173.116559,-84.117914 --172.889106,-84.061019 --169.951223,-83.884647 --168.999989,-84.117914 --168.530199,-84.23739 --167.022099,-84.570497 --164.182144,-84.82521 --161.929775,-85.138731 --158.07138,-85.37391 --155.192253,-85.09956 --150.942099,-85.295517 --148.533073,-85.609038 --145.888918,-85.315102 --143.107718,-85.040752 --142.892279,-84.570497 --146.829068,-84.531274 --150.060732,-84.296146 --150.902928,-83.904232 --153.586201,-83.68869 --153.409907,-83.23802 --153.037759,-82.82652 --152.665637,-82.454192 --152.861517,-82.042692 --154.526299,-81.768394 --155.29018,-81.41565 --156.83745,-81.102129 --154.408787,-81.160937 --152.097662,-81.004151 --150.648293,-81.337309 --148.865998,-81.043373 --147.22075,-80.671045 --146.417749,-80.337938 --146.770286,-79.926439 --148.062947,-79.652089 --149.531901,-79.358205 --151.588416,-79.299397 --153.390322,-79.162248 --155.329376,-79.064269 --155.975668,-78.69194 --157.268302,-78.378419 --158.051768,-78.025676 --158.365134,-76.889207 --157.875474,-76.987238 --156.974573,-77.300759 --155.329376,-77.202728 --153.742832,-77.065579 --152.920247,-77.496664 --151.33378,-77.398737 --150.00195,-77.183143 --148.748486,-76.908845 --147.612483,-76.575738 --146.104409,-76.47776 --146.143528,-76.105431 --146.496091,-75.733154 --146.20231,-75.380411 --144.909624,-75.204039 --144.322037,-75.537197 --142.794353,-75.34124 --141.638764,-75.086475 --140.209007,-75.06689 --138.85759,-74.968911 --137.5062,-74.733783 --136.428901,-74.518241 --135.214583,-74.302699 --134.431194,-74.361455 --133.745654,-74.439848 --132.257168,-74.302699 --130.925311,-74.479019 --129.554284,-74.459433 --128.242038,-74.322284 --126.890622,-74.420263 --125.402082,-74.518241 --124.011496,-74.479019 --122.562152,-74.498604 --121.073613,-74.518241 --119.70256,-74.479019 --118.684145,-74.185083 --117.469801,-74.028348 --116.216312,-74.243891 --115.021552,-74.067519 --113.944331,-73.714828 --113.297988,-74.028348 --112.945452,-74.38104 --112.299083,-74.714198 --111.261059,-74.420263 --110.066325,-74.79254 --108.714909,-74.910103 --107.559346,-75.184454 --106.149148,-75.125698 --104.876074,-74.949326 --103.367949,-74.988497 --102.016507,-75.125698 --100.645531,-75.302018 --100.1167,-74.870933 --100.763043,-74.537826 --101.252703,-74.185083 --102.545337,-74.106742 --103.113313,-73.734413 --103.328752,-73.362084 --103.681289,-72.61753 --102.917485,-72.754679 --101.60524,-72.813436 --100.312528,-72.754679 --99.13738,-72.911414 --98.118889,-73.20535 --97.688037,-73.558041 --96.336595,-73.616849 --95.043961,-73.4797 --93.672907,-73.283743 --92.439003,-73.166179 --91.420564,-73.401307 --90.088733,-73.322914 --89.226951,-72.558722 --88.423951,-73.009393 --87.268337,-73.185764 --86.014822,-73.087786 --85.192236,-73.4797 --83.879991,-73.518871 --82.665646,-73.636434 --81.470913,-73.851977 --80.687447,-73.4797 --80.295791,-73.126956 --79.296886,-73.518871 --77.925858,-73.420892 --76.907367,-73.636434 --76.221879,-73.969541 --74.890049,-73.871614 --73.852024,-73.65602 --72.833533,-73.401307 --71.619215,-73.264157 --70.209042,-73.146542 --68.935916,-73.009393 --67.956622,-72.79385 --67.369061,-72.480329 --67.134036,-72.049244 --67.251548,-71.637745 --67.56494,-71.245831 --67.917477,-70.853917 --68.230843,-70.462055 --68.485452,-70.109311 --68.544209,-69.717397 --68.446282,-69.325535 --67.976233,-68.953206 --67.5845,-68.541707 --67.427843,-68.149844 --67.62367,-67.718759 --67.741183,-67.326845 --67.251548,-66.876175 --66.703184,-66.58224 --66.056815,-66.209963 --65.371327,-65.89639 --64.568276,-65.602506 --64.176542,-65.171423 --63.628152,-64.897073 --63.001394,-64.642308 --62.041686,-64.583552 --61.414928,-64.270031 --60.709855,-64.074074 --59.887269,-63.95651 --59.162585,-63.701745 --58.594557,-63.388224 --57.811143,-63.27066 --57.223582,-63.525425 --57.59573,-63.858532 + -58.614143,-64.152467 +-59.045073,-64.36801 +-59.789342,-64.211223 +-60.611928,-64.309202 +-61.297416,-64.54433 +-62.0221,-64.799094 +-62.51176,-65.09303 +-62.648858,-65.484942 +-62.590128,-65.857219 +-62.120079,-66.190326 +-62.805567,-66.425505 +-63.74569,-66.503847 +-64.294106,-66.837004 +-64.881693,-67.150474 +-65.508425,-67.58161 +-65.665082,-67.953887 +-65.312545,-68.365335 +-64.783715,-68.678908 +-63.961103,-68.913984 +-63.1973,-69.227556 +-62.785955,-69.619419 +-62.570516,-69.991747 +-62.276736,-70.383661 +-61.806661,-70.716768 +-61.512906,-71.089045 +-61.375809,-72.010074 +-61.081977,-72.382351 +-61.003661,-72.774265 +-60.690269,-73.166179 +-60.827367,-73.695242 +-61.375809,-74.106742 +-61.96337,-74.439848 +-63.295201,-74.576997 +-63.74569,-74.92974 +-64.352836,-75.262847 +-65.860987,-75.635124 +-67.192818,-75.79191 +-68.446282,-76.007452 +-69.797724,-76.222995 +-70.600724,-76.634494 +-72.206776,-76.673665 +-73.969536,-76.634494 +-75.555977,-76.712887 +-77.24037,-76.712887 +-76.926979,-77.104802 +-75.399294,-77.28107 +-74.282876,-77.55542 +-73.656119,-77.908112 +-74.772536,-78.221633 +-76.4961,-78.123654 +-77.925858,-78.378419 +-77.984666,-78.789918 +-78.023785,-79.181833 +-76.848637,-79.514939 +-76.633224,-79.887216 +-75.360097,-80.259545 +-73.244852,-80.416331 +-71.442946,-80.69063 +-70.013163,-81.004151 +-68.191646,-81.317672 +-65.704279,-81.474458 +-63.25603,-81.748757 +-61.552026,-82.042692 +-59.691416,-82.37585 +-58.712121,-82.846106 +-58.222487,-83.218434 +-57.008117,-82.865691 +-55.362894,-82.571755 +-53.619771,-82.258235 +-51.543644,-82.003521 +-49.76135,-81.729171 +-47.273931,-81.709586 +-44.825708,-81.846735 +-42.808363,-82.081915 +-42.16202,-81.65083 +-40.771433,-81.356894 +-38.244818,-81.337309 +-36.26667,-81.121715 +-34.386397,-80.906172 +-32.310296,-80.769023 +-30.097098,-80.592651 +-28.549802,-80.337938 +-29.254901,-79.985195 +-29.685805,-79.632503 +-29.685805,-79.260226 +-31.624808,-79.299397 +-33.681324,-79.456132 +-35.639912,-79.456132 +-35.914107,-79.083855 +-35.77701,-78.339248 +-35.326546,-78.123654 +-33.896763,-77.888526 +-32.212369,-77.65345 +-30.998051,-77.359515 +-29.783732,-77.065579 +-28.882779,-76.673665 +-27.511752,-76.497345 +-26.160336,-76.360144 +-25.474822,-76.281803 +-23.927552,-76.24258 +-22.458598,-76.105431 +-21.224694,-75.909474 +-20.010375,-75.674346 +-18.913543,-75.439218 +-17.522982,-75.125698 +-16.641589,-74.79254 +-15.701491,-74.498604 +-15.40771,-74.106742 +-16.46532,-73.871614 +-16.112784,-73.460114 +-15.446855,-73.146542 +-14.408805,-72.950585 +-13.311973,-72.715457 +-12.293508,-72.401936 +-11.510067,-72.010074 +-11.020433,-71.539767 +-10.295774,-71.265416 +-9.101015,-71.324224 +-8.611381,-71.65733 +-7.416622,-71.696501 +-7.377451,-71.324224 +-6.868232,-70.93231 +-5.790985,-71.030289 +-5.536375,-71.402617 +-4.341667,-71.461373 +-3.048981,-71.285053 +-1.795492,-71.167438 +-0.659489,-71.226246 +-0.228637,-71.637745 +0.868195,-71.304639 +1.886686,-71.128267 +3.022638,-70.991118 +4.139055,-70.853917 +5.157546,-70.618789 +6.273912,-70.462055 +7.13572,-70.246512 +7.742866,-69.893769 +8.48711,-70.148534 +9.525135,-70.011333 +10.249845,-70.48164 +10.817821,-70.834332 +11.953824,-70.638375 +12.404287,-70.246512 +13.422778,-69.972162 +14.734998,-70.030918 +15.126757,-70.403247 +15.949342,-70.030918 +17.026589,-69.913354 +18.201711,-69.874183 +19.259373,-69.893769 +20.375739,-70.011333 +21.452985,-70.07014 +21.923034,-70.403247 +22.569403,-70.697182 +23.666184,-70.520811 +24.841357,-70.48164 +25.977309,-70.48164 +27.093726,-70.462055 +28.09258,-70.324854 +29.150242,-70.20729 +30.031583,-69.93294 +30.971733,-69.75662 +31.990172,-69.658641 +32.754053,-69.384291 +33.302443,-68.835642 +33.870419,-68.502588 +34.908495,-68.659271 +35.300202,-69.012014 +36.16201,-69.247142 +37.200035,-69.168748 +37.905108,-69.52144 +38.649404,-69.776205 +39.667894,-69.541077 +40.020431,-69.109941 +40.921358,-68.933621 +41.959434,-68.600514 +42.938702,-68.463313 +44.113876,-68.267408 +44.897291,-68.051866 +45.719928,-67.816738 +46.503343,-67.601196 +47.44344,-67.718759 +48.344419,-67.366068 +48.990736,-67.091718 +49.930885,-67.111303 +50.753471,-66.876175 +50.949325,-66.523484 +51.791547,-66.249133 +52.614133,-66.053176 +53.613038,-65.89639 +54.53355,-65.818049 +55.414943,-65.876805 +56.355041,-65.974783 +57.158093,-66.249133 +57.255968,-66.680218 +58.137361,-67.013324 +58.744508,-67.287675 +59.939318,-67.405239 +60.605221,-67.679589 +61.427806,-67.953887 +62.387489,-68.012695 +63.19049,-67.816738 +64.052349,-67.405239 +64.992447,-67.620729 +65.971715,-67.738345 +66.911864,-67.855909 +67.891133,-67.934302 +68.890038,-67.934302 +69.712624,-68.972791 +69.673453,-69.227556 +69.555941,-69.678226 +68.596258,-69.93294 +67.81274,-70.305268 +67.949889,-70.697182 +69.066307,-70.677545 +68.929157,-71.069459 +68.419989,-71.441788 +67.949889,-71.853287 +68.71377,-72.166808 +69.869307,-72.264787 +71.024895,-72.088415 +71.573285,-71.696501 +71.906288,-71.324224 +72.454627,-71.010703 +73.08141,-70.716768 +73.33602,-70.364024 +73.864877,-69.874183 +74.491557,-69.776205 +75.62756,-69.737034 +76.626465,-69.619419 +77.644904,-69.462684 +78.134539,-69.07077 +78.428371,-68.698441 +79.113859,-68.326216 +80.093127,-68.071503 +80.93535,-67.875546 +81.483792,-67.542388 +82.051767,-67.366068 +82.776426,-67.209282 +83.775331,-67.30726 +84.676206,-67.209282 +85.655527,-67.091718 +86.752359,-67.150474 +87.477017,-66.876175 +87.986289,-66.209911 +88.358411,-66.484261 +88.828408,-66.954568 +89.67063,-67.150474 +90.630365,-67.228867 +91.5901,-67.111303 +92.608539,-67.189696 +93.548637,-67.209282 +94.17542,-67.111303 +95.017591,-67.170111 +95.781472,-67.385653 +96.682399,-67.248504 +97.759646,-67.248504 +98.68021,-67.111303 +99.718182,-67.248504 +100.384188,-66.915346 +100.893356,-66.58224 +101.578896,-66.30789 +102.832411,-65.563284 +103.478676,-65.700485 +104.242557,-65.974783 +104.90846,-66.327527 +106.181561,-66.934931 +107.160881,-66.954568 +108.081393,-66.954568 +109.15864,-66.837004 +110.235835,-66.699804 +111.058472,-66.425505 +111.74396,-66.13157 +112.860378,-66.092347 +113.604673,-65.876805 +114.388088,-66.072762 +114.897308,-66.386283 +115.602381,-66.699804 +116.699161,-66.660633 +117.384701,-66.915346 +118.57946,-67.170111 +119.832924,-67.268089 +120.871,-67.189696 +121.654415,-66.876175 +122.320369,-66.562654 +123.221296,-66.484261 +124.122274,-66.621462 +125.160247,-66.719389 +126.100396,-66.562654 +127.001427,-66.562654 +127.882768,-66.660633 +128.80328,-66.758611 +129.704259,-66.58224 +130.781454,-66.425505 +131.799945,-66.386283 +132.935896,-66.386283 +133.85646,-66.288304 +134.757387,-66.209963 +135.031582,-65.72007 +135.070753,-65.308571 +135.697485,-65.582869 +135.873805,-66.033591 +136.206705,-66.44509 +136.618049,-66.778197 +137.460271,-66.954568 +138.596223,-66.895761 +139.908442,-66.876175 +140.809421,-66.817367 +142.121692,-66.817367 +143.061842,-66.797782 +144.374061,-66.837004 +145.490427,-66.915346 +146.195552,-67.228867 +145.999699,-67.601196 +146.646067,-67.895131 +147.723263,-68.130259 +148.839629,-68.385024 +150.132314,-68.561292 +151.483705,-68.71813 +152.502247,-68.874813 +153.638199,-68.894502 +154.284567,-68.561292 +155.165857,-68.835642 +155.92979,-69.149215 +156.811132,-69.384291 +158.025528,-69.482269 +159.181013,-69.599833 +159.670699,-69.991747 +160.80665,-70.226875 +161.570479,-70.579618 +162.686897,-70.736353 +163.842434,-70.716768 +164.919681,-70.775524 +166.11444,-70.755938 +167.309095,-70.834332 +168.425616,-70.971481 +169.463589,-71.20666 +170.501665,-71.402617 +171.20679,-71.696501 +171.089227,-72.088415 +170.560422,-72.441159 +170.109958,-72.891829 +169.75737,-73.24452 +169.287321,-73.65602 +167.975101,-73.812806 +167.387489,-74.165498 +166.094803,-74.38104 +165.644391,-74.772954 +164.958851,-75.145283 +164.234193,-75.458804 +163.822797,-75.870303 +163.568239,-76.24258 +163.47026,-76.693302 +163.489897,-77.065579 +164.057873,-77.457442 +164.273363,-77.82977 +164.743464,-78.182514 +166.604126,-78.319611 +166.995781,-78.750748 +165.193876,-78.907483 +163.666217,-79.123025 +161.766385,-79.162248 +160.924162,-79.730482 +160.747894,-80.200737 +160.316964,-80.573066 +159.788211,-80.945395 +161.120016,-81.278501 +161.629287,-81.690001 +162.490992,-82.062278 +163.705336,-82.395435 +165.095949,-82.708956 +166.604126,-83.022477 +168.895665,-83.335998 +169.404782,-83.825891 +172.283934,-84.041433 +172.477049,-84.117914 +173.224083,-84.41371 +175.985672,-84.158997 +178.277212,-84.472518 +180.0,-84.71338 +-179.942499,-84.721443 +-179.058677,-84.139412 +-177.256772,-84.452933 +-177.140807,-84.417941 +-176.084673,-84.099259 +-175.947235,-84.110449 +-175.829882,-84.117914 +-174.382503,-84.534323 +-173.116559,-84.117914 +-172.889106,-84.061019 +-169.951223,-83.884647 +-168.999989,-84.117914 +-168.530199,-84.23739 +-167.022099,-84.570497 +-164.182144,-84.82521 +-161.929775,-85.138731 +-158.07138,-85.37391 +-155.192253,-85.09956 +-150.942099,-85.295517 +-148.533073,-85.609038 +-145.888918,-85.315102 +-143.107718,-85.040752 +-142.892279,-84.570497 +-146.829068,-84.531274 +-150.060732,-84.296146 +-150.902928,-83.904232 +-153.586201,-83.68869 +-153.409907,-83.23802 +-153.037759,-82.82652 +-152.665637,-82.454192 +-152.861517,-82.042692 +-154.526299,-81.768394 +-155.29018,-81.41565 +-156.83745,-81.102129 +-154.408787,-81.160937 +-152.097662,-81.004151 +-150.648293,-81.337309 +-148.865998,-81.043373 +-147.22075,-80.671045 +-146.417749,-80.337938 +-146.770286,-79.926439 +-148.062947,-79.652089 +-149.531901,-79.358205 +-151.588416,-79.299397 +-153.390322,-79.162248 +-155.329376,-79.064269 +-155.975668,-78.69194 +-157.268302,-78.378419 +-158.051768,-78.025676 +-158.365134,-76.889207 +-157.875474,-76.987238 +-156.974573,-77.300759 +-155.329376,-77.202728 +-153.742832,-77.065579 +-152.920247,-77.496664 +-151.33378,-77.398737 +-150.00195,-77.183143 +-148.748486,-76.908845 +-147.612483,-76.575738 +-146.104409,-76.47776 +-146.143528,-76.105431 +-146.496091,-75.733154 +-146.20231,-75.380411 +-144.909624,-75.204039 +-144.322037,-75.537197 +-142.794353,-75.34124 +-141.638764,-75.086475 +-140.209007,-75.06689 +-138.85759,-74.968911 +-137.5062,-74.733783 +-136.428901,-74.518241 +-135.214583,-74.302699 +-134.431194,-74.361455 +-133.745654,-74.439848 +-132.257168,-74.302699 +-130.925311,-74.479019 +-129.554284,-74.459433 +-128.242038,-74.322284 +-126.890622,-74.420263 +-125.402082,-74.518241 +-124.011496,-74.479019 +-122.562152,-74.498604 +-121.073613,-74.518241 +-119.70256,-74.479019 +-118.684145,-74.185083 +-117.469801,-74.028348 +-116.216312,-74.243891 +-115.021552,-74.067519 +-113.944331,-73.714828 +-113.297988,-74.028348 +-112.945452,-74.38104 +-112.299083,-74.714198 +-111.261059,-74.420263 +-110.066325,-74.79254 +-108.714909,-74.910103 +-107.559346,-75.184454 +-106.149148,-75.125698 +-104.876074,-74.949326 +-103.367949,-74.988497 +-102.016507,-75.125698 +-100.645531,-75.302018 +-100.1167,-74.870933 +-100.763043,-74.537826 +-101.252703,-74.185083 +-102.545337,-74.106742 +-103.113313,-73.734413 +-103.328752,-73.362084 +-103.681289,-72.61753 +-102.917485,-72.754679 +-101.60524,-72.813436 +-100.312528,-72.754679 +-99.13738,-72.911414 +-98.118889,-73.20535 +-97.688037,-73.558041 +-96.336595,-73.616849 +-95.043961,-73.4797 +-93.672907,-73.283743 +-92.439003,-73.166179 +-91.420564,-73.401307 +-90.088733,-73.322914 +-89.226951,-72.558722 +-88.423951,-73.009393 +-87.268337,-73.185764 +-86.014822,-73.087786 +-85.192236,-73.4797 +-83.879991,-73.518871 +-82.665646,-73.636434 +-81.470913,-73.851977 +-80.687447,-73.4797 +-80.295791,-73.126956 +-79.296886,-73.518871 +-77.925858,-73.420892 +-76.907367,-73.636434 +-76.221879,-73.969541 +-74.890049,-73.871614 +-73.852024,-73.65602 +-72.833533,-73.401307 +-71.619215,-73.264157 +-70.209042,-73.146542 +-68.935916,-73.009393 +-67.956622,-72.79385 +-67.369061,-72.480329 +-67.134036,-72.049244 +-67.251548,-71.637745 +-67.56494,-71.245831 +-67.917477,-70.853917 +-68.230843,-70.462055 +-68.485452,-70.109311 +-68.544209,-69.717397 +-68.446282,-69.325535 +-67.976233,-68.953206 +-67.5845,-68.541707 +-67.427843,-68.149844 +-67.62367,-67.718759 +-67.741183,-67.326845 +-67.251548,-66.876175 +-66.703184,-66.58224 +-66.056815,-66.209963 +-65.371327,-65.89639 +-64.568276,-65.602506 +-64.176542,-65.171423 +-63.628152,-64.897073 +-63.001394,-64.642308 +-62.041686,-64.583552 +-61.414928,-64.270031 +-60.709855,-64.074074 +-59.887269,-63.95651 +-59.162585,-63.701745 +-58.594557,-63.388224 +-57.811143,-63.27066 +-57.223582,-63.525425 +-57.59573,-63.858532 -58.614143,-64.152467 - + #defaultStyle French Southern and Antarctic Lands - + - 68.935,-48.625 -69.58,-48.94 -70.525,-49.065 -70.56,-49.255 -70.28,-49.71 -68.745,-49.775 -68.72,-49.2425 -68.8675,-48.83 + 68.935,-48.625 +69.58,-48.94 +70.525,-49.065 +70.56,-49.255 +70.28,-49.71 +68.745,-49.775 +68.72,-49.2425 +68.8675,-48.83 68.935,-48.625 - + #defaultStyle Bangladesh - - - - - - - 92.672721,22.041239 -92.652257,21.324048 -92.303234,21.475485 -92.368554,20.670883 -92.082886,21.192195 -92.025215,21.70157 -91.834891,22.182936 -91.417087,22.765019 -90.496006,22.805017 -90.586957,22.392794 -90.272971,21.836368 -89.847467,22.039146 -89.70205,21.857116 -89.418863,21.966179 -89.031961,22.055708 -88.876312,22.879146 -88.52977,23.631142 -88.69994,24.233715 -88.084422,24.501657 -88.306373,24.866079 -88.931554,25.238692 -88.209789,25.768066 -88.563049,26.446526 -89.355094,26.014407 -89.832481,25.965082 -89.920693,25.26975 -90.872211,25.132601 -91.799596,25.147432 -92.376202,24.976693 -91.915093,24.130414 -91.46773,24.072639 -91.158963,23.503527 -91.706475,22.985264 -91.869928,23.624346 -92.146035,23.627499 + + + + + + + 92.672721,22.041239 +92.652257,21.324048 +92.303234,21.475485 +92.368554,20.670883 +92.082886,21.192195 +92.025215,21.70157 +91.834891,22.182936 +91.417087,22.765019 +90.496006,22.805017 +90.586957,22.392794 +90.272971,21.836368 +89.847467,22.039146 +89.70205,21.857116 +89.418863,21.966179 +89.031961,22.055708 +88.876312,22.879146 +88.52977,23.631142 +88.69994,24.233715 +88.084422,24.501657 +88.306373,24.866079 +88.931554,25.238692 +88.209789,25.768066 +88.563049,26.446526 +89.355094,26.014407 +89.832481,25.965082 +89.920693,25.26975 +90.872211,25.132601 +91.799596,25.147432 +92.376202,24.976693 +91.915093,24.130414 +91.46773,24.072639 +91.158963,23.503527 +91.706475,22.985264 +91.869928,23.624346 +92.146035,23.627499 92.672721,22.041239 - + #defaultStyle Bulgaria - - - - - - - 22.65715,44.234923 -22.944832,43.823785 -23.332302,43.897011 -24.100679,43.741051 -25.569272,43.688445 -26.065159,43.943494 -27.2424,44.175986 -27.970107,43.812468 -28.558081,43.707462 -28.039095,43.293172 -27.673898,42.577892 -27.99672,42.007359 -27.135739,42.141485 -26.117042,41.826905 -26.106138,41.328899 -25.197201,41.234486 -24.492645,41.583896 -23.692074,41.309081 -22.952377,41.337994 -22.881374,41.999297 -22.380526,42.32026 -22.545012,42.461362 -22.436595,42.580321 -22.604801,42.898519 -22.986019,43.211161 -22.500157,43.642814 -22.410446,44.008063 + + + + + + + 22.65715,44.234923 +22.944832,43.823785 +23.332302,43.897011 +24.100679,43.741051 +25.569272,43.688445 +26.065159,43.943494 +27.2424,44.175986 +27.970107,43.812468 +28.558081,43.707462 +28.039095,43.293172 +27.673898,42.577892 +27.99672,42.007359 +27.135739,42.141485 +26.117042,41.826905 +26.106138,41.328899 +25.197201,41.234486 +24.492645,41.583896 +23.692074,41.309081 +22.952377,41.337994 +22.881374,41.999297 +22.380526,42.32026 +22.545012,42.461362 +22.436595,42.580321 +22.604801,42.898519 +22.986019,43.211161 +22.500157,43.642814 +22.410446,44.008063 22.65715,44.234923 - + #defaultStyle Bosnia and Herzegovina - - - - - - - 19.005486,44.860234 -19.36803,44.863 -19.11761,44.42307 -19.59976,44.03847 -19.454,43.5681 -19.21852,43.52384 -19.03165,43.43253 -18.70648,43.20011 -18.56,42.65 -17.674922,43.028563 -17.297373,43.446341 -16.916156,43.667722 -16.456443,44.04124 -16.23966,44.351143 -15.750026,44.818712 -15.959367,45.233777 -16.318157,45.004127 -16.534939,45.211608 -17.002146,45.233777 -17.861783,45.06774 -18.553214,45.08159 + + + + + + + 19.005486,44.860234 +19.36803,44.863 +19.11761,44.42307 +19.59976,44.03847 +19.454,43.5681 +19.21852,43.52384 +19.03165,43.43253 +18.70648,43.20011 +18.56,42.65 +17.674922,43.028563 +17.297373,43.446341 +16.916156,43.667722 +16.456443,44.04124 +16.23966,44.351143 +15.750026,44.818712 +15.959367,45.233777 +16.318157,45.004127 +16.534939,45.211608 +17.002146,45.233777 +17.861783,45.06774 +18.553214,45.08159 19.005486,44.860234 - + #defaultStyle Australia - + - 145.397978,-40.792549 -146.364121,-41.137695 -146.908584,-41.000546 -147.689259,-40.808258 -148.289068,-40.875438 -148.359865,-42.062445 -148.017301,-42.407024 -147.914052,-43.211522 -147.564564,-42.937689 -146.870343,-43.634597 -146.663327,-43.580854 -146.048378,-43.549745 -145.43193,-42.693776 -145.29509,-42.03361 -144.718071,-41.162552 -144.743755,-40.703975 + 145.397978,-40.792549 +146.364121,-41.137695 +146.908584,-41.000546 +147.689259,-40.808258 +148.289068,-40.875438 +148.359865,-42.062445 +148.017301,-42.407024 +147.914052,-43.211522 +147.564564,-42.937689 +146.870343,-43.634597 +146.663327,-43.580854 +146.048378,-43.549745 +145.43193,-42.693776 +145.29509,-42.03361 +144.718071,-41.162552 +144.743755,-40.703975 145.397978,-40.792549 - + - 143.561811,-13.763656 -143.922099,-14.548311 -144.563714,-14.171176 -144.894908,-14.594458 -145.374724,-14.984976 -145.271991,-15.428205 -145.48526,-16.285672 -145.637033,-16.784918 -145.888904,-16.906926 -146.160309,-17.761655 -146.063674,-18.280073 -146.387478,-18.958274 -147.471082,-19.480723 -148.177602,-19.955939 -148.848414,-20.39121 -148.717465,-20.633469 -149.28942,-21.260511 -149.678337,-22.342512 -150.077382,-22.122784 -150.482939,-22.556142 -150.727265,-22.402405 -150.899554,-23.462237 -151.609175,-24.076256 -152.07354,-24.457887 -152.855197,-25.267501 -153.136162,-26.071173 -153.161949,-26.641319 -153.092909,-27.2603 -153.569469,-28.110067 -153.512108,-28.995077 -153.339095,-29.458202 -153.069241,-30.35024 -153.089602,-30.923642 -152.891578,-31.640446 -152.450002,-32.550003 -151.709117,-33.041342 -151.343972,-33.816023 -151.010555,-34.31036 -150.714139,-35.17346 -150.32822,-35.671879 -150.075212,-36.420206 -149.946124,-37.109052 -149.997284,-37.425261 -149.423882,-37.772681 -148.304622,-37.809061 -147.381733,-38.219217 -146.922123,-38.606532 -146.317922,-39.035757 -145.489652,-38.593768 -144.876976,-38.417448 -145.032212,-37.896188 -144.485682,-38.085324 -143.609974,-38.809465 -142.745427,-38.538268 -142.17833,-38.380034 -141.606582,-38.308514 -140.638579,-38.019333 -139.992158,-37.402936 -139.806588,-36.643603 -139.574148,-36.138362 -139.082808,-35.732754 -138.120748,-35.612296 -138.449462,-35.127261 -138.207564,-34.384723 -137.71917,-35.076825 -136.829406,-35.260535 -137.352371,-34.707339 -137.503886,-34.130268 -137.890116,-33.640479 -137.810328,-32.900007 -136.996837,-33.752771 -136.372069,-34.094766 -135.989043,-34.890118 -135.208213,-34.47867 -135.239218,-33.947953 -134.613417,-33.222778 -134.085904,-32.848072 -134.273903,-32.617234 -132.990777,-32.011224 -132.288081,-31.982647 -131.326331,-31.495803 -129.535794,-31.590423 -128.240938,-31.948489 -127.102867,-32.282267 -126.148714,-32.215966 -125.088623,-32.728751 -124.221648,-32.959487 -124.028947,-33.483847 -123.659667,-33.890179 -122.811036,-33.914467 -122.183064,-34.003402 -121.299191,-33.821036 -120.580268,-33.930177 -119.893695,-33.976065 -119.298899,-34.509366 -119.007341,-34.464149 -118.505718,-34.746819 -118.024972,-35.064733 -117.295507,-35.025459 -116.625109,-35.025097 -115.564347,-34.386428 -115.026809,-34.196517 -115.048616,-33.623425 -115.545123,-33.487258 -115.714674,-33.259572 -115.679379,-32.900369 -115.801645,-32.205062 -115.689611,-31.612437 -115.160909,-30.601594 -114.997043,-30.030725 -115.040038,-29.461095 -114.641974,-28.810231 -114.616498,-28.516399 -114.173579,-28.118077 -114.048884,-27.334765 -113.477498,-26.543134 -113.338953,-26.116545 -113.778358,-26.549025 -113.440962,-25.621278 -113.936901,-25.911235 -114.232852,-26.298446 -114.216161,-25.786281 -113.721255,-24.998939 -113.625344,-24.683971 -113.393523,-24.384764 -113.502044,-23.80635 -113.706993,-23.560215 -113.843418,-23.059987 -113.736552,-22.475475 -114.149756,-21.755881 -114.225307,-22.517488 -114.647762,-21.82952 -115.460167,-21.495173 -115.947373,-21.068688 -116.711615,-20.701682 -117.166316,-20.623599 -117.441545,-20.746899 -118.229559,-20.374208 -118.836085,-20.263311 -118.987807,-20.044203 -119.252494,-19.952942 -119.805225,-19.976506 -120.85622,-19.683708 -121.399856,-19.239756 -121.655138,-18.705318 -122.241665,-18.197649 -122.286624,-17.798603 -122.312772,-17.254967 -123.012574,-16.4052 -123.433789,-17.268558 -123.859345,-17.069035 -123.503242,-16.596506 -123.817073,-16.111316 -124.258287,-16.327944 -124.379726,-15.56706 -124.926153,-15.0751 -125.167275,-14.680396 -125.670087,-14.51007 -125.685796,-14.230656 -126.125149,-14.347341 -126.142823,-14.095987 -126.582589,-13.952791 -127.065867,-13.817968 -127.804633,-14.276906 -128.35969,-14.86917 -128.985543,-14.875991 -129.621473,-14.969784 -129.4096,-14.42067 -129.888641,-13.618703 -130.339466,-13.357376 -130.183506,-13.10752 -130.617795,-12.536392 -131.223495,-12.183649 -131.735091,-12.302453 -132.575298,-12.114041 -132.557212,-11.603012 -131.824698,-11.273782 -132.357224,-11.128519 -133.019561,-11.376411 -133.550846,-11.786515 -134.393068,-12.042365 -134.678632,-11.941183 -135.298491,-12.248606 -135.882693,-11.962267 -136.258381,-12.049342 -136.492475,-11.857209 -136.95162,-12.351959 -136.685125,-12.887223 -136.305407,-13.29123 -135.961758,-13.324509 -136.077617,-13.724278 -135.783836,-14.223989 -135.428664,-14.715432 -135.500184,-14.997741 -136.295175,-15.550265 -137.06536,-15.870762 -137.580471,-16.215082 -138.303217,-16.807604 -138.585164,-16.806622 -139.108543,-17.062679 -139.260575,-17.371601 -140.215245,-17.710805 -140.875463,-17.369069 -141.07111,-16.832047 -141.274095,-16.38887 -141.398222,-15.840532 -141.702183,-15.044921 -141.56338,-14.561333 -141.63552,-14.270395 -141.519869,-13.698078 -141.65092,-12.944688 -141.842691,-12.741548 -141.68699,-12.407614 -141.928629,-11.877466 -142.118488,-11.328042 -142.143706,-11.042737 -142.51526,-10.668186 -142.79731,-11.157355 -142.866763,-11.784707 -143.115947,-11.90563 -143.158632,-12.325656 -143.522124,-12.834358 -143.597158,-13.400422 + 143.561811,-13.763656 +143.922099,-14.548311 +144.563714,-14.171176 +144.894908,-14.594458 +145.374724,-14.984976 +145.271991,-15.428205 +145.48526,-16.285672 +145.637033,-16.784918 +145.888904,-16.906926 +146.160309,-17.761655 +146.063674,-18.280073 +146.387478,-18.958274 +147.471082,-19.480723 +148.177602,-19.955939 +148.848414,-20.39121 +148.717465,-20.633469 +149.28942,-21.260511 +149.678337,-22.342512 +150.077382,-22.122784 +150.482939,-22.556142 +150.727265,-22.402405 +150.899554,-23.462237 +151.609175,-24.076256 +152.07354,-24.457887 +152.855197,-25.267501 +153.136162,-26.071173 +153.161949,-26.641319 +153.092909,-27.2603 +153.569469,-28.110067 +153.512108,-28.995077 +153.339095,-29.458202 +153.069241,-30.35024 +153.089602,-30.923642 +152.891578,-31.640446 +152.450002,-32.550003 +151.709117,-33.041342 +151.343972,-33.816023 +151.010555,-34.31036 +150.714139,-35.17346 +150.32822,-35.671879 +150.075212,-36.420206 +149.946124,-37.109052 +149.997284,-37.425261 +149.423882,-37.772681 +148.304622,-37.809061 +147.381733,-38.219217 +146.922123,-38.606532 +146.317922,-39.035757 +145.489652,-38.593768 +144.876976,-38.417448 +145.032212,-37.896188 +144.485682,-38.085324 +143.609974,-38.809465 +142.745427,-38.538268 +142.17833,-38.380034 +141.606582,-38.308514 +140.638579,-38.019333 +139.992158,-37.402936 +139.806588,-36.643603 +139.574148,-36.138362 +139.082808,-35.732754 +138.120748,-35.612296 +138.449462,-35.127261 +138.207564,-34.384723 +137.71917,-35.076825 +136.829406,-35.260535 +137.352371,-34.707339 +137.503886,-34.130268 +137.890116,-33.640479 +137.810328,-32.900007 +136.996837,-33.752771 +136.372069,-34.094766 +135.989043,-34.890118 +135.208213,-34.47867 +135.239218,-33.947953 +134.613417,-33.222778 +134.085904,-32.848072 +134.273903,-32.617234 +132.990777,-32.011224 +132.288081,-31.982647 +131.326331,-31.495803 +129.535794,-31.590423 +128.240938,-31.948489 +127.102867,-32.282267 +126.148714,-32.215966 +125.088623,-32.728751 +124.221648,-32.959487 +124.028947,-33.483847 +123.659667,-33.890179 +122.811036,-33.914467 +122.183064,-34.003402 +121.299191,-33.821036 +120.580268,-33.930177 +119.893695,-33.976065 +119.298899,-34.509366 +119.007341,-34.464149 +118.505718,-34.746819 +118.024972,-35.064733 +117.295507,-35.025459 +116.625109,-35.025097 +115.564347,-34.386428 +115.026809,-34.196517 +115.048616,-33.623425 +115.545123,-33.487258 +115.714674,-33.259572 +115.679379,-32.900369 +115.801645,-32.205062 +115.689611,-31.612437 +115.160909,-30.601594 +114.997043,-30.030725 +115.040038,-29.461095 +114.641974,-28.810231 +114.616498,-28.516399 +114.173579,-28.118077 +114.048884,-27.334765 +113.477498,-26.543134 +113.338953,-26.116545 +113.778358,-26.549025 +113.440962,-25.621278 +113.936901,-25.911235 +114.232852,-26.298446 +114.216161,-25.786281 +113.721255,-24.998939 +113.625344,-24.683971 +113.393523,-24.384764 +113.502044,-23.80635 +113.706993,-23.560215 +113.843418,-23.059987 +113.736552,-22.475475 +114.149756,-21.755881 +114.225307,-22.517488 +114.647762,-21.82952 +115.460167,-21.495173 +115.947373,-21.068688 +116.711615,-20.701682 +117.166316,-20.623599 +117.441545,-20.746899 +118.229559,-20.374208 +118.836085,-20.263311 +118.987807,-20.044203 +119.252494,-19.952942 +119.805225,-19.976506 +120.85622,-19.683708 +121.399856,-19.239756 +121.655138,-18.705318 +122.241665,-18.197649 +122.286624,-17.798603 +122.312772,-17.254967 +123.012574,-16.4052 +123.433789,-17.268558 +123.859345,-17.069035 +123.503242,-16.596506 +123.817073,-16.111316 +124.258287,-16.327944 +124.379726,-15.56706 +124.926153,-15.0751 +125.167275,-14.680396 +125.670087,-14.51007 +125.685796,-14.230656 +126.125149,-14.347341 +126.142823,-14.095987 +126.582589,-13.952791 +127.065867,-13.817968 +127.804633,-14.276906 +128.35969,-14.86917 +128.985543,-14.875991 +129.621473,-14.969784 +129.4096,-14.42067 +129.888641,-13.618703 +130.339466,-13.357376 +130.183506,-13.10752 +130.617795,-12.536392 +131.223495,-12.183649 +131.735091,-12.302453 +132.575298,-12.114041 +132.557212,-11.603012 +131.824698,-11.273782 +132.357224,-11.128519 +133.019561,-11.376411 +133.550846,-11.786515 +134.393068,-12.042365 +134.678632,-11.941183 +135.298491,-12.248606 +135.882693,-11.962267 +136.258381,-12.049342 +136.492475,-11.857209 +136.95162,-12.351959 +136.685125,-12.887223 +136.305407,-13.29123 +135.961758,-13.324509 +136.077617,-13.724278 +135.783836,-14.223989 +135.428664,-14.715432 +135.500184,-14.997741 +136.295175,-15.550265 +137.06536,-15.870762 +137.580471,-16.215082 +138.303217,-16.807604 +138.585164,-16.806622 +139.108543,-17.062679 +139.260575,-17.371601 +140.215245,-17.710805 +140.875463,-17.369069 +141.07111,-16.832047 +141.274095,-16.38887 +141.398222,-15.840532 +141.702183,-15.044921 +141.56338,-14.561333 +141.63552,-14.270395 +141.519869,-13.698078 +141.65092,-12.944688 +141.842691,-12.741548 +141.68699,-12.407614 +141.928629,-11.877466 +142.118488,-11.328042 +142.143706,-11.042737 +142.51526,-10.668186 +142.79731,-11.157355 +142.866763,-11.784707 +143.115947,-11.90563 +143.158632,-12.325656 +143.522124,-12.834358 +143.597158,-13.400422 143.561811,-13.763656 - + #defaultStyle Austria - - - - - - - 16.979667,48.123497 -16.903754,47.714866 -16.340584,47.712902 -16.534268,47.496171 -16.202298,46.852386 -16.011664,46.683611 -15.137092,46.658703 -14.632472,46.431817 -13.806475,46.509306 -12.376485,46.767559 -12.153088,47.115393 -11.164828,46.941579 -11.048556,46.751359 -10.442701,46.893546 -9.932448,46.920728 -9.47997,47.10281 -9.632932,47.347601 -9.594226,47.525058 -9.896068,47.580197 -10.402084,47.302488 -10.544504,47.566399 -11.426414,47.523766 -12.141357,47.703083 -12.62076,47.672388 -12.932627,47.467646 -13.025851,47.637584 -12.884103,48.289146 -13.243357,48.416115 -13.595946,48.877172 -14.338898,48.555305 -14.901447,48.964402 -15.253416,49.039074 -16.029647,48.733899 -16.499283,48.785808 -16.960288,48.596982 -16.879983,48.470013 + + + + + + + 16.979667,48.123497 +16.903754,47.714866 +16.340584,47.712902 +16.534268,47.496171 +16.202298,46.852386 +16.011664,46.683611 +15.137092,46.658703 +14.632472,46.431817 +13.806475,46.509306 +12.376485,46.767559 +12.153088,47.115393 +11.164828,46.941579 +11.048556,46.751359 +10.442701,46.893546 +9.932448,46.920728 +9.47997,47.10281 +9.632932,47.347601 +9.594226,47.525058 +9.896068,47.580197 +10.402084,47.302488 +10.544504,47.566399 +11.426414,47.523766 +12.141357,47.703083 +12.62076,47.672388 +12.932627,47.467646 +13.025851,47.637584 +12.884103,48.289146 +13.243357,48.416115 +13.595946,48.877172 +14.338898,48.555305 +14.901447,48.964402 +15.253416,49.039074 +16.029647,48.733899 +16.499283,48.785808 +16.960288,48.596982 +16.879983,48.470013 16.979667,48.123497 - + #defaultStyle Azerbaijan - + - 45.001987,39.740004 -45.298145,39.471751 -45.739978,39.473999 -45.735379,39.319719 -46.143623,38.741201 -45.457722,38.874139 -44.952688,39.335765 -44.79399,39.713003 + 45.001987,39.740004 +45.298145,39.471751 +45.739978,39.473999 +45.735379,39.319719 +46.143623,38.741201 +45.457722,38.874139 +44.952688,39.335765 +44.79399,39.713003 45.001987,39.740004 - + - 47.373315,41.219732 -47.815666,41.151416 -47.987283,41.405819 -48.584353,41.80887 -49.110264,41.282287 -49.618915,40.572924 -50.08483,40.526157 -50.392821,40.256561 -49.569202,40.176101 -49.395259,39.399482 -49.223228,39.049219 -48.856532,38.815486 -48.883249,38.320245 -48.634375,38.270378 -48.010744,38.794015 -48.355529,39.288765 -48.060095,39.582235 -47.685079,39.508364 -46.50572,38.770605 -46.483499,39.464155 -46.034534,39.628021 -45.610012,39.899994 -45.891907,40.218476 -45.359175,40.561504 -45.560351,40.81229 -45.179496,40.985354 -44.97248,41.248129 -45.217426,41.411452 -45.962601,41.123873 -46.501637,41.064445 -46.637908,41.181673 -46.145432,41.722802 -46.404951,41.860675 -46.686071,41.827137 + 47.373315,41.219732 +47.815666,41.151416 +47.987283,41.405819 +48.584353,41.80887 +49.110264,41.282287 +49.618915,40.572924 +50.08483,40.526157 +50.392821,40.256561 +49.569202,40.176101 +49.395259,39.399482 +49.223228,39.049219 +48.856532,38.815486 +48.883249,38.320245 +48.634375,38.270378 +48.010744,38.794015 +48.355529,39.288765 +48.060095,39.582235 +47.685079,39.508364 +46.50572,38.770605 +46.483499,39.464155 +46.034534,39.628021 +45.610012,39.899994 +45.891907,40.218476 +45.359175,40.561504 +45.560351,40.81229 +45.179496,40.985354 +44.97248,41.248129 +45.217426,41.411452 +45.962601,41.123873 +46.501637,41.064445 +46.637908,41.181673 +46.145432,41.722802 +46.404951,41.860675 +46.686071,41.827137 47.373315,41.219732 - + #defaultStyle Burundi - + - 29.339998,-4.499983 -29.276384,-3.293907 -29.024926,-2.839258 -29.632176,-2.917858 -29.938359,-2.348487 -30.469696,-2.413858 -30.527677,-2.807632 -30.743013,-3.034285 -30.752263,-3.35933 -30.50556,-3.568567 -30.116333,-4.090138 -29.753512,-4.452389 + 29.339998,-4.499983 +29.276384,-3.293907 +29.024926,-2.839258 +29.632176,-2.917858 +29.938359,-2.348487 +30.469696,-2.413858 +30.527677,-2.807632 +30.743013,-3.034285 +30.752263,-3.35933 +30.50556,-3.568567 +30.116333,-4.090138 +29.753512,-4.452389 29.339998,-4.499983 - + #defaultStyle Belgium - + - 3.314971,51.345781 -4.047071,51.267259 -4.973991,51.475024 -5.606976,51.037298 -6.156658,50.803721 -6.043073,50.128052 -5.782417,50.090328 -5.674052,49.529484 -4.799222,49.985373 -4.286023,49.907497 -3.588184,50.378992 -3.123252,50.780363 -2.658422,50.796848 -2.513573,51.148506 + 3.314971,51.345781 +4.047071,51.267259 +4.973991,51.475024 +5.606976,51.037298 +6.156658,50.803721 +6.043073,50.128052 +5.782417,50.090328 +5.674052,49.529484 +4.799222,49.985373 +4.286023,49.907497 +3.588184,50.378992 +3.123252,50.780363 +2.658422,50.796848 +2.513573,51.148506 3.314971,51.345781 - + #defaultStyle Benin - - - - - - - 2.691702,6.258817 -1.865241,6.142158 -1.618951,6.832038 -1.664478,9.12859 -1.463043,9.334624 -1.425061,9.825395 -1.077795,10.175607 -0.772336,10.470808 -0.899563,10.997339 -1.24347,11.110511 -1.447178,11.547719 -1.935986,11.64115 -2.154474,11.94015 -2.490164,12.233052 -2.848643,12.235636 -3.61118,11.660167 -3.572216,11.327939 -3.797112,10.734746 -3.60007,10.332186 -3.705438,10.06321 -3.220352,9.444153 -2.912308,9.137608 -2.723793,8.506845 -2.749063,7.870734 + + + + + + + 2.691702,6.258817 +1.865241,6.142158 +1.618951,6.832038 +1.664478,9.12859 +1.463043,9.334624 +1.425061,9.825395 +1.077795,10.175607 +0.772336,10.470808 +0.899563,10.997339 +1.24347,11.110511 +1.447178,11.547719 +1.935986,11.64115 +2.154474,11.94015 +2.490164,12.233052 +2.848643,12.235636 +3.61118,11.660167 +3.572216,11.327939 +3.797112,10.734746 +3.60007,10.332186 +3.705438,10.06321 +3.220352,9.444153 +2.912308,9.137608 +2.723793,8.506845 +2.749063,7.870734 2.691702,6.258817 - + #defaultStyle Burkina Faso - - - - - - - -2.827496,9.642461 --3.511899,9.900326 --3.980449,9.862344 --4.330247,9.610835 --4.779884,9.821985 --4.954653,10.152714 --5.404342,10.370737 --5.470565,10.95127 --5.197843,11.375146 --5.220942,11.713859 --4.427166,12.542646 --4.280405,13.228444 --4.006391,13.472485 --3.522803,13.337662 --3.103707,13.541267 --2.967694,13.79815 --2.191825,14.246418 --2.001035,14.559008 --1.066363,14.973815 --0.515854,15.116158 --0.266257,14.924309 -0.374892,14.928908 -0.295646,14.444235 -0.429928,13.988733 -0.993046,13.33575 -1.024103,12.851826 -2.177108,12.625018 -2.154474,11.94015 -1.935986,11.64115 -1.447178,11.547719 -1.24347,11.110511 -0.899563,10.997339 -0.023803,11.018682 --0.438702,11.098341 --0.761576,10.93693 --1.203358,11.009819 --2.940409,10.96269 --2.963896,10.395335 + + + + + + + -2.827496,9.642461 +-3.511899,9.900326 +-3.980449,9.862344 +-4.330247,9.610835 +-4.779884,9.821985 +-4.954653,10.152714 +-5.404342,10.370737 +-5.470565,10.95127 +-5.197843,11.375146 +-5.220942,11.713859 +-4.427166,12.542646 +-4.280405,13.228444 +-4.006391,13.472485 +-3.522803,13.337662 +-3.103707,13.541267 +-2.967694,13.79815 +-2.191825,14.246418 +-2.001035,14.559008 +-1.066363,14.973815 +-0.515854,15.116158 +-0.266257,14.924309 +0.374892,14.928908 +0.295646,14.444235 +0.429928,13.988733 +0.993046,13.33575 +1.024103,12.851826 +2.177108,12.625018 +2.154474,11.94015 +1.935986,11.64115 +1.447178,11.547719 +1.24347,11.110511 +0.899563,10.997339 +0.023803,11.018682 +-0.438702,11.098341 +-0.761576,10.93693 +-1.203358,11.009819 +-2.940409,10.96269 +-2.963896,10.395335 -2.827496,9.642461 - + #defaultStyle The Bahamas - + - -77.53466,23.75975 --77.78,23.71 --78.03405,24.28615 --78.40848,24.57564 --78.19087,25.2103 --77.89,25.17 --77.54,24.34 + -77.53466,23.75975 +-77.78,23.71 +-78.03405,24.28615 +-78.40848,24.57564 +-78.19087,25.2103 +-77.89,25.17 +-77.54,24.34 -77.53466,23.75975 - + - -77.82,26.58 --78.91,26.42 --78.98,26.79 --78.51,26.87 --77.85,26.84 + -77.82,26.58 +-78.91,26.42 +-78.98,26.79 +-78.51,26.87 +-77.85,26.84 -77.82,26.58 - + - -77.0,26.59 --77.17255,25.87918 --77.35641,26.00735 --77.34,26.53 --77.78802,26.92516 --77.79,27.04 + -77.0,26.59 +-77.17255,25.87918 +-77.35641,26.00735 +-77.34,26.53 +-77.78802,26.92516 +-77.79,27.04 -77.0,26.59 - + #defaultStyle Belarus - - - - - - - 23.484128,53.912498 -24.450684,53.905702 -25.536354,54.282423 -25.768433,54.846963 -26.588279,55.167176 -26.494331,55.615107 -27.10246,55.783314 -28.176709,56.16913 -29.229513,55.918344 -29.371572,55.670091 -29.896294,55.789463 -30.873909,55.550976 -30.971836,55.081548 -30.757534,54.811771 -31.384472,54.157056 -31.791424,53.974639 -31.731273,53.794029 -32.405599,53.618045 -32.693643,53.351421 -32.304519,53.132726 -31.497644,53.167427 -31.305201,53.073996 -31.540018,52.742052 -31.785998,52.101678 -30.927549,52.042353 -30.619454,51.822806 -30.555117,51.319503 -30.157364,51.416138 -29.254938,51.368234 -28.992835,51.602044 -28.617613,51.427714 -28.241615,51.572227 -27.454066,51.592303 -26.337959,51.832289 -25.327788,51.910656 -24.553106,51.888461 -24.005078,51.617444 -23.527071,51.578454 -23.508002,52.023647 -23.199494,52.486977 -23.799199,52.691099 -23.804935,53.089731 -23.527536,53.470122 + + + + + + + 23.484128,53.912498 +24.450684,53.905702 +25.536354,54.282423 +25.768433,54.846963 +26.588279,55.167176 +26.494331,55.615107 +27.10246,55.783314 +28.176709,56.16913 +29.229513,55.918344 +29.371572,55.670091 +29.896294,55.789463 +30.873909,55.550976 +30.971836,55.081548 +30.757534,54.811771 +31.384472,54.157056 +31.791424,53.974639 +31.731273,53.794029 +32.405599,53.618045 +32.693643,53.351421 +32.304519,53.132726 +31.497644,53.167427 +31.305201,53.073996 +31.540018,52.742052 +31.785998,52.101678 +30.927549,52.042353 +30.619454,51.822806 +30.555117,51.319503 +30.157364,51.416138 +29.254938,51.368234 +28.992835,51.602044 +28.617613,51.427714 +28.241615,51.572227 +27.454066,51.592303 +26.337959,51.832289 +25.327788,51.910656 +24.553106,51.888461 +24.005078,51.617444 +23.527071,51.578454 +23.508002,52.023647 +23.199494,52.486977 +23.799199,52.691099 +23.804935,53.089731 +23.527536,53.470122 23.484128,53.912498 - + #defaultStyle Belize - + - -89.14308,17.808319 --89.150909,17.955468 --89.029857,18.001511 --88.848344,17.883198 --88.490123,18.486831 --88.300031,18.499982 --88.296336,18.353273 --88.106813,18.348674 --88.123479,18.076675 --88.285355,17.644143 --88.197867,17.489475 --88.302641,17.131694 --88.239518,17.036066 --88.355428,16.530774 --88.551825,16.265467 --88.732434,16.233635 --88.930613,15.887273 --89.229122,15.886938 --89.150806,17.015577 + -89.14308,17.808319 +-89.150909,17.955468 +-89.029857,18.001511 +-88.848344,17.883198 +-88.490123,18.486831 +-88.300031,18.499982 +-88.296336,18.353273 +-88.106813,18.348674 +-88.123479,18.076675 +-88.285355,17.644143 +-88.197867,17.489475 +-88.302641,17.131694 +-88.239518,17.036066 +-88.355428,16.530774 +-88.551825,16.265467 +-88.732434,16.233635 +-88.930613,15.887273 +-89.229122,15.886938 +-89.150806,17.015577 -89.14308,17.808319 - + #defaultStyle Bermuda - - - - - - - -64.7799734332998,32.3072000581802 --64.768738200563,32.3088369816572 --64.7566773739613,32.3130509130175 --64.7462482354281,32.318538611581 --64.7423019216485,32.323311561213 --64.7411729976333,32.3311790864627 --64.7383521549094,32.3407216514918 --64.734962460882,32.3442819830499 --64.7270616067222,32.3466461715475 --64.7214189847314,32.3518830231342 --64.7185967666669,32.3552239212394 --64.7149301576112,32.3552188753513 --64.706732854446,32.3429010723036 --64.6887090648183,32.3342439408053 --64.6737331235384,32.3390281851635 --64.6598811264978,32.3497625771755 --64.6672170444687,32.3597751617473 --64.6801998697415,32.3631199096979 --64.6835876652835,32.3626447677968 --64.6824635995504,32.3540628176846 --64.6864150099255,32.3547797587266 --64.6895164826082,32.3633598579866 --64.6940348033967,32.3640708659835 --64.700531552697,32.3590601356818 --64.7061764744404,32.3600110593559 --64.7117569982798,32.368132600249 --64.70438689565,32.3704254760469 --64.6954617672714,32.3763221285869 --64.6834270548595,32.3854968316788 --64.6690666074397,32.388444543924 --64.6613227512832,32.3763135008721 --64.6462011670816,32.36975169749 --64.65125819471,32.3615600906466 --64.6605720384429,32.3589423487763 --64.6532168823499,32.3494356627941 --64.6567136927777,32.3451776458469 --64.6768921127452,32.3324095397555 --64.6962778813133,32.3275029115532 --64.7117851247134,32.3176823360806 --64.7206991797682,32.3137542201258 --64.7423982971436,32.2996734994024 --64.7551803442276,32.2908326702531 --64.7644742436426,32.2855931353214 --64.7745415970416,32.2718413023427 --64.7900177727338,32.2659446936992 --64.8105087275579,32.2561208974156 --64.8205697556026,32.2531698880328 --64.8256389530584,32.2472637398594 --64.8334002575532,32.2462714714698 --64.8388242605209,32.2475773472534 --64.842311980074,32.2492123317296 --64.8519819555722,32.2485519134663 --64.863194968877,32.2465799485801 --64.8783230004629,32.2613001418681 --64.8849776658292,32.2819261366004 --64.8780046844321,32.2907757831692 --64.8779667328485,32.3038632800462 --64.8721354593415,32.3041908606301 --64.8686668994079,32.30910745083 --64.8635922932573,32.3048469433363 --64.8520298651164,32.3110911879954 --64.8338690593672,32.3294587561557 --64.8350242329311,32.3242161760006 --64.8439233486717,32.3140553852543 --64.8597429072279,32.3015842021933 --64.8559068764437,32.2960321186471 --64.8671422127295,32.2930760547989 --64.8717752856644,32.2819371582026 --64.8748651338951,32.2757120264753 --64.8628241459563,32.2724481933959 --64.8682296789446,32.2616393614322 --64.8566090462354,32.2547740387514 --64.8399924854972,32.254782282336 --64.8306732143498,32.2583944840235 --64.8287548840306,32.2669075473817 --64.8225587873683,32.2669111289395 --64.8066707691087,32.2747767569465 --64.7997025513437,32.2796896417328 --64.7815086336978,32.2868973114514 --64.7962291465484,32.2934409732427 --64.8101968029642,32.3022833180511 --64.8167896352437,32.3058845718466 --64.8094297981283,32.3098175728414 --64.7946942710173,32.3032682700388 --64.7873319183061,32.3039237143428 + + + + + + + -64.7799734332998,32.3072000581802 +-64.768738200563,32.3088369816572 +-64.7566773739613,32.3130509130175 +-64.7462482354281,32.318538611581 +-64.7423019216485,32.323311561213 +-64.7411729976333,32.3311790864627 +-64.7383521549094,32.3407216514918 +-64.734962460882,32.3442819830499 +-64.7270616067222,32.3466461715475 +-64.7214189847314,32.3518830231342 +-64.7185967666669,32.3552239212394 +-64.7149301576112,32.3552188753513 +-64.706732854446,32.3429010723036 +-64.6887090648183,32.3342439408053 +-64.6737331235384,32.3390281851635 +-64.6598811264978,32.3497625771755 +-64.6672170444687,32.3597751617473 +-64.6801998697415,32.3631199096979 +-64.6835876652835,32.3626447677968 +-64.6824635995504,32.3540628176846 +-64.6864150099255,32.3547797587266 +-64.6895164826082,32.3633598579866 +-64.6940348033967,32.3640708659835 +-64.700531552697,32.3590601356818 +-64.7061764744404,32.3600110593559 +-64.7117569982798,32.368132600249 +-64.70438689565,32.3704254760469 +-64.6954617672714,32.3763221285869 +-64.6834270548595,32.3854968316788 +-64.6690666074397,32.388444543924 +-64.6613227512832,32.3763135008721 +-64.6462011670816,32.36975169749 +-64.65125819471,32.3615600906466 +-64.6605720384429,32.3589423487763 +-64.6532168823499,32.3494356627941 +-64.6567136927777,32.3451776458469 +-64.6768921127452,32.3324095397555 +-64.6962778813133,32.3275029115532 +-64.7117851247134,32.3176823360806 +-64.7206991797682,32.3137542201258 +-64.7423982971436,32.2996734994024 +-64.7551803442276,32.2908326702531 +-64.7644742436426,32.2855931353214 +-64.7745415970416,32.2718413023427 +-64.7900177727338,32.2659446936992 +-64.8105087275579,32.2561208974156 +-64.8205697556026,32.2531698880328 +-64.8256389530584,32.2472637398594 +-64.8334002575532,32.2462714714698 +-64.8388242605209,32.2475773472534 +-64.842311980074,32.2492123317296 +-64.8519819555722,32.2485519134663 +-64.863194968877,32.2465799485801 +-64.8783230004629,32.2613001418681 +-64.8849776658292,32.2819261366004 +-64.8780046844321,32.2907757831692 +-64.8779667328485,32.3038632800462 +-64.8721354593415,32.3041908606301 +-64.8686668994079,32.30910745083 +-64.8635922932573,32.3048469433363 +-64.8520298651164,32.3110911879954 +-64.8338690593672,32.3294587561557 +-64.8350242329311,32.3242161760006 +-64.8439233486717,32.3140553852543 +-64.8597429072279,32.3015842021933 +-64.8559068764437,32.2960321186471 +-64.8671422127295,32.2930760547989 +-64.8717752856644,32.2819371582026 +-64.8748651338951,32.2757120264753 +-64.8628241459563,32.2724481933959 +-64.8682296789446,32.2616393614322 +-64.8566090462354,32.2547740387514 +-64.8399924854972,32.254782282336 +-64.8306732143498,32.2583944840235 +-64.8287548840306,32.2669075473817 +-64.8225587873683,32.2669111289395 +-64.8066707691087,32.2747767569465 +-64.7997025513437,32.2796896417328 +-64.7815086336978,32.2868973114514 +-64.7962291465484,32.2934409732427 +-64.8101968029642,32.3022833180511 +-64.8167896352437,32.3058845718466 +-64.8094297981283,32.3098175728414 +-64.7946942710173,32.3032682700388 +-64.7873319183061,32.3039237143428 -64.7799734332998,32.3072000581802 - + #defaultStyle Bolivia - - - - - - - -62.846468,-22.034985 --63.986838,-21.993644 --64.377021,-22.798091 --64.964892,-22.075862 --66.273339,-21.83231 --67.106674,-22.735925 --67.82818,-22.872919 --68.219913,-21.494347 --68.757167,-20.372658 --68.442225,-19.405068 --68.966818,-18.981683 --69.100247,-18.260125 --69.590424,-17.580012 --68.959635,-16.500698 --69.389764,-15.660129 --69.160347,-15.323974 --69.339535,-14.953195 --68.948887,-14.453639 --68.929224,-13.602684 --68.88008,-12.899729 --68.66508,-12.5613 --69.529678,-10.951734 --68.786158,-11.03638 --68.271254,-11.014521 --68.048192,-10.712059 --67.173801,-10.306812 --66.646908,-9.931331 --65.338435,-9.761988 --65.444837,-10.511451 --65.321899,-10.895872 --65.402281,-11.56627 --64.316353,-12.461978 --63.196499,-12.627033 --62.80306,-13.000653 --62.127081,-13.198781 --61.713204,-13.489202 --61.084121,-13.479384 --60.503304,-13.775955 --60.459198,-14.354007 --60.264326,-14.645979 --60.251149,-15.077219 --60.542966,-15.09391 --60.15839,-16.258284 --58.24122,-16.299573 --58.388058,-16.877109 --58.280804,-17.27171 --57.734558,-17.552468 --57.498371,-18.174188 --57.676009,-18.96184 --57.949997,-19.400004 --57.853802,-19.969995 --58.166392,-20.176701 --58.183471,-19.868399 --59.115042,-19.356906 --60.043565,-19.342747 --61.786326,-19.633737 --62.265961,-20.513735 --62.291179,-21.051635 --62.685057,-22.249029 + + + + + + + -62.846468,-22.034985 +-63.986838,-21.993644 +-64.377021,-22.798091 +-64.964892,-22.075862 +-66.273339,-21.83231 +-67.106674,-22.735925 +-67.82818,-22.872919 +-68.219913,-21.494347 +-68.757167,-20.372658 +-68.442225,-19.405068 +-68.966818,-18.981683 +-69.100247,-18.260125 +-69.590424,-17.580012 +-68.959635,-16.500698 +-69.389764,-15.660129 +-69.160347,-15.323974 +-69.339535,-14.953195 +-68.948887,-14.453639 +-68.929224,-13.602684 +-68.88008,-12.899729 +-68.66508,-12.5613 +-69.529678,-10.951734 +-68.786158,-11.03638 +-68.271254,-11.014521 +-68.048192,-10.712059 +-67.173801,-10.306812 +-66.646908,-9.931331 +-65.338435,-9.761988 +-65.444837,-10.511451 +-65.321899,-10.895872 +-65.402281,-11.56627 +-64.316353,-12.461978 +-63.196499,-12.627033 +-62.80306,-13.000653 +-62.127081,-13.198781 +-61.713204,-13.489202 +-61.084121,-13.479384 +-60.503304,-13.775955 +-60.459198,-14.354007 +-60.264326,-14.645979 +-60.251149,-15.077219 +-60.542966,-15.09391 +-60.15839,-16.258284 +-58.24122,-16.299573 +-58.388058,-16.877109 +-58.280804,-17.27171 +-57.734558,-17.552468 +-57.498371,-18.174188 +-57.676009,-18.96184 +-57.949997,-19.400004 +-57.853802,-19.969995 +-58.166392,-20.176701 +-58.183471,-19.868399 +-59.115042,-19.356906 +-60.043565,-19.342747 +-61.786326,-19.633737 +-62.265961,-20.513735 +-62.291179,-21.051635 +-62.685057,-22.249029 -62.846468,-22.034985 - + #defaultStyle Brazil - - - - - - - -57.625133,-30.216295 --56.2909,-28.852761 --55.162286,-27.881915 --54.490725,-27.474757 --53.648735,-26.923473 --53.628349,-26.124865 --54.13005,-25.547639 --54.625291,-25.739255 --54.428946,-25.162185 --54.293476,-24.5708 --54.29296,-24.021014 --54.652834,-23.839578 --55.027902,-24.001274 --55.400747,-23.956935 --55.517639,-23.571998 --55.610683,-22.655619 --55.797958,-22.35693 --56.473317,-22.0863 --56.88151,-22.282154 --57.937156,-22.090176 --57.870674,-20.732688 --58.166392,-20.176701 --57.853802,-19.969995 --57.949997,-19.400004 --57.676009,-18.96184 --57.498371,-18.174188 --57.734558,-17.552468 --58.280804,-17.27171 --58.388058,-16.877109 --58.24122,-16.299573 --60.15839,-16.258284 --60.542966,-15.09391 --60.251149,-15.077219 --60.264326,-14.645979 --60.459198,-14.354007 --60.503304,-13.775955 --61.084121,-13.479384 --61.713204,-13.489202 --62.127081,-13.198781 --62.80306,-13.000653 --63.196499,-12.627033 --64.316353,-12.461978 --65.402281,-11.56627 --65.321899,-10.895872 --65.444837,-10.511451 --65.338435,-9.761988 --66.646908,-9.931331 --67.173801,-10.306812 --68.048192,-10.712059 --68.271254,-11.014521 --68.786158,-11.03638 --69.529678,-10.951734 --70.093752,-11.123972 --70.548686,-11.009147 --70.481894,-9.490118 --71.302412,-10.079436 --72.184891,-10.053598 --72.563033,-9.520194 --73.226713,-9.462213 --73.015383,-9.032833 --73.571059,-8.424447 --73.987235,-7.52383 --73.723401,-7.340999 --73.724487,-6.918595 --73.120027,-6.629931 --73.219711,-6.089189 --72.964507,-5.741251 --72.891928,-5.274561 --71.748406,-4.593983 --70.928843,-4.401591 --70.794769,-4.251265 --69.893635,-4.298187 --69.444102,-1.556287 --69.420486,-1.122619 --69.577065,-0.549992 --70.020656,-0.185156 --70.015566,0.541414 --69.452396,0.706159 --69.252434,0.602651 --69.218638,0.985677 --69.804597,1.089081 --69.816973,1.714805 --67.868565,1.692455 --67.53781,2.037163 --67.259998,1.719999 --67.065048,1.130112 --66.876326,1.253361 --66.325765,0.724452 --65.548267,0.789254 --65.354713,1.095282 --64.611012,1.328731 --64.199306,1.492855 --64.083085,1.916369 --63.368788,2.2009 --63.422867,2.411068 --64.269999,2.497006 --64.408828,3.126786 --64.368494,3.79721 --64.816064,4.056445 --64.628659,4.148481 --63.888343,4.02053 --63.093198,3.770571 --62.804533,4.006965 --62.08543,4.162124 --60.966893,4.536468 --60.601179,4.918098 --60.733574,5.200277 --60.213683,5.244486 --59.980959,5.014061 --60.111002,4.574967 --59.767406,4.423503 --59.53804,3.958803 --59.815413,3.606499 --59.974525,2.755233 --59.718546,2.24963 --59.646044,1.786894 --59.030862,1.317698 --58.540013,1.268088 --58.429477,1.463942 --58.11345,1.507195 --57.660971,1.682585 --57.335823,1.948538 --56.782704,1.863711 --56.539386,1.899523 --55.995698,1.817667 --55.9056,2.021996 --56.073342,2.220795 --55.973322,2.510364 --55.569755,2.421506 --55.097587,2.523748 --54.524754,2.311849 --54.088063,2.105557 --53.778521,2.376703 --53.554839,2.334897 --53.418465,2.053389 --52.939657,2.124858 --52.556425,2.504705 --52.249338,3.241094 --51.657797,4.156232 --51.317146,4.203491 --51.069771,3.650398 --50.508875,1.901564 --49.974076,1.736483 --49.947101,1.04619 --50.699251,0.222984 --50.388211,-0.078445 --48.620567,-0.235489 --48.584497,-1.237805 --47.824956,-0.581618 --46.566584,-0.941028 --44.905703,-1.55174 --44.417619,-2.13775 --44.581589,-2.691308 --43.418791,-2.38311 --41.472657,-2.912018 --39.978665,-2.873054 --38.500383,-3.700652 --37.223252,-4.820946 --36.452937,-5.109404 --35.597796,-5.149504 --35.235389,-5.464937 --34.89603,-6.738193 --34.729993,-7.343221 --35.128212,-8.996401 --35.636967,-9.649282 --37.046519,-11.040721 --37.683612,-12.171195 --38.423877,-13.038119 --38.673887,-13.057652 --38.953276,-13.79337 --38.882298,-15.667054 --39.161092,-17.208407 --39.267339,-17.867746 --39.583521,-18.262296 --39.760823,-19.599113 --40.774741,-20.904512 --40.944756,-21.937317 --41.754164,-22.370676 --41.988284,-22.97007 --43.074704,-22.967693 --44.647812,-23.351959 --45.352136,-23.796842 --46.472093,-24.088969 --47.648972,-24.885199 --48.495458,-25.877025 --48.641005,-26.623698 --48.474736,-27.175912 --48.66152,-28.186135 --48.888457,-28.674115 --49.587329,-29.224469 --50.696874,-30.984465 --51.576226,-31.777698 --52.256081,-32.24537 --52.7121,-33.196578 --53.373662,-33.768378 --53.650544,-33.202004 --53.209589,-32.727666 --53.787952,-32.047243 --54.572452,-31.494511 --55.60151,-30.853879 --55.973245,-30.883076 --56.976026,-30.109686 + + + + + + + -57.625133,-30.216295 +-56.2909,-28.852761 +-55.162286,-27.881915 +-54.490725,-27.474757 +-53.648735,-26.923473 +-53.628349,-26.124865 +-54.13005,-25.547639 +-54.625291,-25.739255 +-54.428946,-25.162185 +-54.293476,-24.5708 +-54.29296,-24.021014 +-54.652834,-23.839578 +-55.027902,-24.001274 +-55.400747,-23.956935 +-55.517639,-23.571998 +-55.610683,-22.655619 +-55.797958,-22.35693 +-56.473317,-22.0863 +-56.88151,-22.282154 +-57.937156,-22.090176 +-57.870674,-20.732688 +-58.166392,-20.176701 +-57.853802,-19.969995 +-57.949997,-19.400004 +-57.676009,-18.96184 +-57.498371,-18.174188 +-57.734558,-17.552468 +-58.280804,-17.27171 +-58.388058,-16.877109 +-58.24122,-16.299573 +-60.15839,-16.258284 +-60.542966,-15.09391 +-60.251149,-15.077219 +-60.264326,-14.645979 +-60.459198,-14.354007 +-60.503304,-13.775955 +-61.084121,-13.479384 +-61.713204,-13.489202 +-62.127081,-13.198781 +-62.80306,-13.000653 +-63.196499,-12.627033 +-64.316353,-12.461978 +-65.402281,-11.56627 +-65.321899,-10.895872 +-65.444837,-10.511451 +-65.338435,-9.761988 +-66.646908,-9.931331 +-67.173801,-10.306812 +-68.048192,-10.712059 +-68.271254,-11.014521 +-68.786158,-11.03638 +-69.529678,-10.951734 +-70.093752,-11.123972 +-70.548686,-11.009147 +-70.481894,-9.490118 +-71.302412,-10.079436 +-72.184891,-10.053598 +-72.563033,-9.520194 +-73.226713,-9.462213 +-73.015383,-9.032833 +-73.571059,-8.424447 +-73.987235,-7.52383 +-73.723401,-7.340999 +-73.724487,-6.918595 +-73.120027,-6.629931 +-73.219711,-6.089189 +-72.964507,-5.741251 +-72.891928,-5.274561 +-71.748406,-4.593983 +-70.928843,-4.401591 +-70.794769,-4.251265 +-69.893635,-4.298187 +-69.444102,-1.556287 +-69.420486,-1.122619 +-69.577065,-0.549992 +-70.020656,-0.185156 +-70.015566,0.541414 +-69.452396,0.706159 +-69.252434,0.602651 +-69.218638,0.985677 +-69.804597,1.089081 +-69.816973,1.714805 +-67.868565,1.692455 +-67.53781,2.037163 +-67.259998,1.719999 +-67.065048,1.130112 +-66.876326,1.253361 +-66.325765,0.724452 +-65.548267,0.789254 +-65.354713,1.095282 +-64.611012,1.328731 +-64.199306,1.492855 +-64.083085,1.916369 +-63.368788,2.2009 +-63.422867,2.411068 +-64.269999,2.497006 +-64.408828,3.126786 +-64.368494,3.79721 +-64.816064,4.056445 +-64.628659,4.148481 +-63.888343,4.02053 +-63.093198,3.770571 +-62.804533,4.006965 +-62.08543,4.162124 +-60.966893,4.536468 +-60.601179,4.918098 +-60.733574,5.200277 +-60.213683,5.244486 +-59.980959,5.014061 +-60.111002,4.574967 +-59.767406,4.423503 +-59.53804,3.958803 +-59.815413,3.606499 +-59.974525,2.755233 +-59.718546,2.24963 +-59.646044,1.786894 +-59.030862,1.317698 +-58.540013,1.268088 +-58.429477,1.463942 +-58.11345,1.507195 +-57.660971,1.682585 +-57.335823,1.948538 +-56.782704,1.863711 +-56.539386,1.899523 +-55.995698,1.817667 +-55.9056,2.021996 +-56.073342,2.220795 +-55.973322,2.510364 +-55.569755,2.421506 +-55.097587,2.523748 +-54.524754,2.311849 +-54.088063,2.105557 +-53.778521,2.376703 +-53.554839,2.334897 +-53.418465,2.053389 +-52.939657,2.124858 +-52.556425,2.504705 +-52.249338,3.241094 +-51.657797,4.156232 +-51.317146,4.203491 +-51.069771,3.650398 +-50.508875,1.901564 +-49.974076,1.736483 +-49.947101,1.04619 +-50.699251,0.222984 +-50.388211,-0.078445 +-48.620567,-0.235489 +-48.584497,-1.237805 +-47.824956,-0.581618 +-46.566584,-0.941028 +-44.905703,-1.55174 +-44.417619,-2.13775 +-44.581589,-2.691308 +-43.418791,-2.38311 +-41.472657,-2.912018 +-39.978665,-2.873054 +-38.500383,-3.700652 +-37.223252,-4.820946 +-36.452937,-5.109404 +-35.597796,-5.149504 +-35.235389,-5.464937 +-34.89603,-6.738193 +-34.729993,-7.343221 +-35.128212,-8.996401 +-35.636967,-9.649282 +-37.046519,-11.040721 +-37.683612,-12.171195 +-38.423877,-13.038119 +-38.673887,-13.057652 +-38.953276,-13.79337 +-38.882298,-15.667054 +-39.161092,-17.208407 +-39.267339,-17.867746 +-39.583521,-18.262296 +-39.760823,-19.599113 +-40.774741,-20.904512 +-40.944756,-21.937317 +-41.754164,-22.370676 +-41.988284,-22.97007 +-43.074704,-22.967693 +-44.647812,-23.351959 +-45.352136,-23.796842 +-46.472093,-24.088969 +-47.648972,-24.885199 +-48.495458,-25.877025 +-48.641005,-26.623698 +-48.474736,-27.175912 +-48.66152,-28.186135 +-48.888457,-28.674115 +-49.587329,-29.224469 +-50.696874,-30.984465 +-51.576226,-31.777698 +-52.256081,-32.24537 +-52.7121,-33.196578 +-53.373662,-33.768378 +-53.650544,-33.202004 +-53.209589,-32.727666 +-53.787952,-32.047243 +-54.572452,-31.494511 +-55.60151,-30.853879 +-55.973245,-30.883076 +-56.976026,-30.109686 -57.625133,-30.216295 - + #defaultStyle Brunei - + - 114.204017,4.525874 -114.599961,4.900011 -115.45071,5.44773 -115.4057,4.955228 -115.347461,4.316636 -114.869557,4.348314 -114.659596,4.007637 + 114.204017,4.525874 +114.599961,4.900011 +115.45071,5.44773 +115.4057,4.955228 +115.347461,4.316636 +114.869557,4.348314 +114.659596,4.007637 114.204017,4.525874 - + #defaultStyle Bhutan - + - 91.696657,27.771742 -92.103712,27.452614 -92.033484,26.83831 -91.217513,26.808648 -90.373275,26.875724 -89.744528,26.719403 -88.835643,27.098966 -88.814248,27.299316 -89.47581,28.042759 -90.015829,28.296439 -90.730514,28.064954 -91.258854,28.040614 + 91.696657,27.771742 +92.103712,27.452614 +92.033484,26.83831 +91.217513,26.808648 +90.373275,26.875724 +89.744528,26.719403 +88.835643,27.098966 +88.814248,27.299316 +89.47581,28.042759 +90.015829,28.296439 +90.730514,28.064954 +91.258854,28.040614 91.696657,27.771742 - + #defaultStyle Botswana - - - - - - - 25.649163,-18.536026 -25.850391,-18.714413 -26.164791,-19.293086 -27.296505,-20.39152 -27.724747,-20.499059 -27.727228,-20.851802 -28.02137,-21.485975 -28.794656,-21.639454 -29.432188,-22.091313 -28.017236,-22.827754 -27.11941,-23.574323 -26.786407,-24.240691 -26.485753,-24.616327 -25.941652,-24.696373 -25.765849,-25.174845 -25.664666,-25.486816 -25.025171,-25.71967 -24.211267,-25.670216 -23.73357,-25.390129 -23.312097,-25.26869 -22.824271,-25.500459 -22.579532,-25.979448 -22.105969,-26.280256 -21.605896,-26.726534 -20.889609,-26.828543 -20.66647,-26.477453 -20.758609,-25.868136 -20.165726,-24.917962 -19.895768,-24.76779 -19.895458,-21.849157 -20.881134,-21.814327 -20.910641,-18.252219 -21.65504,-18.219146 -23.196858,-17.869038 -23.579006,-18.281261 -24.217365,-17.889347 -24.520705,-17.887125 -25.084443,-17.661816 -25.264226,-17.73654 + + + + + + + 25.649163,-18.536026 +25.850391,-18.714413 +26.164791,-19.293086 +27.296505,-20.39152 +27.724747,-20.499059 +27.727228,-20.851802 +28.02137,-21.485975 +28.794656,-21.639454 +29.432188,-22.091313 +28.017236,-22.827754 +27.11941,-23.574323 +26.786407,-24.240691 +26.485753,-24.616327 +25.941652,-24.696373 +25.765849,-25.174845 +25.664666,-25.486816 +25.025171,-25.71967 +24.211267,-25.670216 +23.73357,-25.390129 +23.312097,-25.26869 +22.824271,-25.500459 +22.579532,-25.979448 +22.105969,-26.280256 +21.605896,-26.726534 +20.889609,-26.828543 +20.66647,-26.477453 +20.758609,-25.868136 +20.165726,-24.917962 +19.895768,-24.76779 +19.895458,-21.849157 +20.881134,-21.814327 +20.910641,-18.252219 +21.65504,-18.219146 +23.196858,-17.869038 +23.579006,-18.281261 +24.217365,-17.889347 +24.520705,-17.887125 +25.084443,-17.661816 +25.264226,-17.73654 25.649163,-18.536026 - + #defaultStyle Central African Republic - - - - - - - 15.27946,7.421925 -16.106232,7.497088 -16.290562,7.754307 -16.456185,7.734774 -16.705988,7.508328 -17.96493,7.890914 -18.389555,8.281304 -18.911022,8.630895 -18.81201,8.982915 -19.094008,9.074847 -20.059685,9.012706 -21.000868,9.475985 -21.723822,10.567056 -22.231129,10.971889 -22.864165,11.142395 -22.977544,10.714463 -23.554304,10.089255 -23.55725,9.681218 -23.394779,9.265068 -23.459013,8.954286 -23.805813,8.666319 -24.567369,8.229188 -25.114932,7.825104 -25.124131,7.500085 -25.796648,6.979316 -26.213418,6.546603 -26.465909,5.946717 -27.213409,5.550953 -27.374226,5.233944 -27.044065,5.127853 -26.402761,5.150875 -25.650455,5.256088 -25.278798,5.170408 -25.128833,4.927245 -24.805029,4.897247 -24.410531,5.108784 -23.297214,4.609693 -22.84148,4.710126 -22.704124,4.633051 -22.405124,4.02916 -21.659123,4.224342 -20.927591,4.322786 -20.290679,4.691678 -19.467784,5.031528 -18.932312,4.709506 -18.542982,4.201785 -18.453065,3.504386 -17.8099,3.560196 -17.133042,3.728197 -16.537058,3.198255 -16.012852,2.26764 -15.907381,2.557389 -15.862732,3.013537 -15.405396,3.335301 -15.03622,3.851367 -14.950953,4.210389 -14.478372,4.732605 -14.558936,5.030598 -14.459407,5.451761 -14.53656,6.226959 -14.776545,6.408498 + + + + + + + 15.27946,7.421925 +16.106232,7.497088 +16.290562,7.754307 +16.456185,7.734774 +16.705988,7.508328 +17.96493,7.890914 +18.389555,8.281304 +18.911022,8.630895 +18.81201,8.982915 +19.094008,9.074847 +20.059685,9.012706 +21.000868,9.475985 +21.723822,10.567056 +22.231129,10.971889 +22.864165,11.142395 +22.977544,10.714463 +23.554304,10.089255 +23.55725,9.681218 +23.394779,9.265068 +23.459013,8.954286 +23.805813,8.666319 +24.567369,8.229188 +25.114932,7.825104 +25.124131,7.500085 +25.796648,6.979316 +26.213418,6.546603 +26.465909,5.946717 +27.213409,5.550953 +27.374226,5.233944 +27.044065,5.127853 +26.402761,5.150875 +25.650455,5.256088 +25.278798,5.170408 +25.128833,4.927245 +24.805029,4.897247 +24.410531,5.108784 +23.297214,4.609693 +22.84148,4.710126 +22.704124,4.633051 +22.405124,4.02916 +21.659123,4.224342 +20.927591,4.322786 +20.290679,4.691678 +19.467784,5.031528 +18.932312,4.709506 +18.542982,4.201785 +18.453065,3.504386 +17.8099,3.560196 +17.133042,3.728197 +16.537058,3.198255 +16.012852,2.26764 +15.907381,2.557389 +15.862732,3.013537 +15.405396,3.335301 +15.03622,3.851367 +14.950953,4.210389 +14.478372,4.732605 +14.558936,5.030598 +14.459407,5.451761 +14.53656,6.226959 +14.776545,6.408498 15.27946,7.421925 - + #defaultStyle Canada - + - -63.6645,46.55001 --62.9393,46.41587 --62.01208,46.44314 --62.50391,46.03339 --62.87433,45.96818 --64.1428,46.39265 --64.39261,46.72747 --64.01486,47.03601 + -63.6645,46.55001 +-62.9393,46.41587 +-62.01208,46.44314 +-62.50391,46.03339 +-62.87433,45.96818 +-64.1428,46.39265 +-64.39261,46.72747 +-64.01486,47.03601 -63.6645,46.55001 - + - -61.806305,49.10506 --62.29318,49.08717 --63.58926,49.40069 --64.51912,49.87304 --64.17322,49.95718 --62.85829,49.70641 --61.835585,49.28855 + -61.806305,49.10506 +-62.29318,49.08717 +-63.58926,49.40069 +-64.51912,49.87304 +-64.17322,49.95718 +-62.85829,49.70641 +-61.835585,49.28855 -61.806305,49.10506 - + - -123.510002,48.510011 --124.012891,48.370846 --125.655013,48.825005 --125.954994,49.179996 --126.850004,49.53 --127.029993,49.814996 --128.059336,49.994959 --128.444584,50.539138 --128.358414,50.770648 --127.308581,50.552574 --126.695001,50.400903 --125.755007,50.295018 --125.415002,49.950001 --124.920768,49.475275 --123.922509,49.062484 + -123.510002,48.510011 +-124.012891,48.370846 +-125.655013,48.825005 +-125.954994,49.179996 +-126.850004,49.53 +-127.029993,49.814996 +-128.059336,49.994959 +-128.444584,50.539138 +-128.358414,50.770648 +-127.308581,50.552574 +-126.695001,50.400903 +-125.755007,50.295018 +-125.415002,49.950001 +-124.920768,49.475275 +-123.922509,49.062484 -123.510002,48.510011 - + - -56.134036,50.68701 --56.795882,49.812309 --56.143105,50.150117 --55.471492,49.935815 --55.822401,49.587129 --54.935143,49.313011 --54.473775,49.556691 --53.476549,49.249139 --53.786014,48.516781 --53.086134,48.687804 --52.958648,48.157164 --52.648099,47.535548 --53.069158,46.655499 --53.521456,46.618292 --54.178936,46.807066 --53.961869,47.625207 --54.240482,47.752279 --55.400773,46.884994 --55.997481,46.91972 --55.291219,47.389562 --56.250799,47.632545 --57.325229,47.572807 --59.266015,47.603348 --59.419494,47.899454 --58.796586,48.251525 --59.231625,48.523188 --58.391805,49.125581 --57.35869,50.718274 --56.73865,51.287438 --55.870977,51.632094 --55.406974,51.588273 --55.600218,51.317075 + -56.134036,50.68701 +-56.795882,49.812309 +-56.143105,50.150117 +-55.471492,49.935815 +-55.822401,49.587129 +-54.935143,49.313011 +-54.473775,49.556691 +-53.476549,49.249139 +-53.786014,48.516781 +-53.086134,48.687804 +-52.958648,48.157164 +-52.648099,47.535548 +-53.069158,46.655499 +-53.521456,46.618292 +-54.178936,46.807066 +-53.961869,47.625207 +-54.240482,47.752279 +-55.400773,46.884994 +-55.997481,46.91972 +-55.291219,47.389562 +-56.250799,47.632545 +-57.325229,47.572807 +-59.266015,47.603348 +-59.419494,47.899454 +-58.796586,48.251525 +-59.231625,48.523188 +-58.391805,49.125581 +-57.35869,50.718274 +-56.73865,51.287438 +-55.870977,51.632094 +-55.406974,51.588273 +-55.600218,51.317075 -56.134036,50.68701 - + - -132.710008,54.040009 --131.74999,54.120004 --132.04948,52.984621 --131.179043,52.180433 --131.57783,52.182371 --132.180428,52.639707 --132.549992,53.100015 --133.054611,53.411469 --133.239664,53.85108 --133.180004,54.169975 + -132.710008,54.040009 +-131.74999,54.120004 +-132.04948,52.984621 +-131.179043,52.180433 +-131.57783,52.182371 +-132.180428,52.639707 +-132.549992,53.100015 +-133.054611,53.411469 +-133.239664,53.85108 +-133.180004,54.169975 -132.710008,54.040009 - + - -79.26582,62.158675 --79.65752,61.63308 --80.09956,61.7181 --80.36215,62.01649 --80.315395,62.085565 --79.92939,62.3856 --79.52002,62.36371 + -79.26582,62.158675 +-79.65752,61.63308 +-80.09956,61.7181 +-80.36215,62.01649 +-80.315395,62.085565 +-79.92939,62.3856 +-79.52002,62.36371 -79.26582,62.158675 - + - -81.89825,62.7108 --83.06857,62.15922 --83.77462,62.18231 --83.99367,62.4528 --83.25048,62.91409 --81.87699,62.90458 + -81.89825,62.7108 +-83.06857,62.15922 +-83.77462,62.18231 +-83.99367,62.4528 +-83.25048,62.91409 +-81.87699,62.90458 -81.89825,62.7108 - + - -85.161308,65.657285 --84.975764,65.217518 --84.464012,65.371772 --83.882626,65.109618 --82.787577,64.766693 --81.642014,64.455136 --81.55344,63.979609 --80.817361,64.057486 --80.103451,63.725981 --80.99102,63.411246 --82.547178,63.651722 --83.108798,64.101876 --84.100417,63.569712 --85.523405,63.052379 --85.866769,63.637253 --87.221983,63.541238 --86.35276,64.035833 --86.224886,64.822917 --85.883848,65.738778 + -85.161308,65.657285 +-84.975764,65.217518 +-84.464012,65.371772 +-83.882626,65.109618 +-82.787577,64.766693 +-81.642014,64.455136 +-81.55344,63.979609 +-80.817361,64.057486 +-80.103451,63.725981 +-80.99102,63.411246 +-82.547178,63.651722 +-83.108798,64.101876 +-84.100417,63.569712 +-85.523405,63.052379 +-85.866769,63.637253 +-87.221983,63.541238 +-86.35276,64.035833 +-86.224886,64.822917 +-85.883848,65.738778 -85.161308,65.657285 - + - -75.86588,67.14886 --76.98687,67.09873 --77.2364,67.58809 --76.81166,68.14856 --75.89521,68.28721 --75.1145,68.01036 --75.10333,67.58202 --75.21597,67.44425 + -75.86588,67.14886 +-76.98687,67.09873 +-77.2364,67.58809 +-76.81166,68.14856 +-75.89521,68.28721 +-75.1145,68.01036 +-75.10333,67.58202 +-75.21597,67.44425 -75.86588,67.14886 - + - -95.647681,69.10769 --96.269521,68.75704 --97.617401,69.06003 --98.431801,68.9507 --99.797401,69.40003 --98.917401,69.71003 --98.218261,70.14354 --97.157401,69.86003 --96.557401,69.68003 --96.257401,69.49003 + -95.647681,69.10769 +-96.269521,68.75704 +-97.617401,69.06003 +-98.431801,68.9507 +-99.797401,69.40003 +-98.917401,69.71003 +-98.218261,70.14354 +-97.157401,69.86003 +-96.557401,69.68003 +-96.257401,69.49003 -95.647681,69.10769 - + - -90.5471,69.49766 --90.55151,68.47499 --89.21515,69.25873 --88.01966,68.61508 --88.31749,67.87338 --87.35017,67.19872 --86.30607,67.92146 --85.57664,68.78456 --85.52197,69.88211 --84.10081,69.80539 --82.62258,69.65826 --81.28043,69.16202 --81.2202,68.66567 --81.96436,68.13253 --81.25928,67.59716 --81.38653,67.11078 --83.34456,66.41154 --84.73542,66.2573 --85.76943,66.55833 --86.0676,66.05625 --87.03143,65.21297 --87.32324,64.77563 --88.48296,64.09897 --89.91444,64.03273 --90.70398,63.61017 --90.77004,62.96021 --91.93342,62.83508 --93.15698,62.02469 --94.24153,60.89865 --94.62931,60.11021 --94.6846,58.94882 --93.21502,58.78212 --92.76462,57.84571 --92.29703,57.08709 --90.89769,57.28468 --89.03953,56.85172 --88.03978,56.47162 --87.32421,55.99914 --86.07121,55.72383 --85.01181,55.3026 --83.36055,55.24489 --82.27285,55.14832 --82.4362,54.28227 --82.12502,53.27703 --81.40075,52.15788 --79.91289,51.20842 --79.14301,51.53393 --78.60191,52.56208 --79.12421,54.14145 --79.82958,54.66772 --78.22874,55.13645 --77.0956,55.83741 --76.54137,56.53423 --76.62319,57.20263 --77.30226,58.05209 --78.51688,58.80458 --77.33676,59.85261 --77.77272,60.75788 --78.10687,62.31964 --77.41067,62.55053 --75.69621,62.2784 --74.6682,62.18111 --73.83988,62.4438 --72.90853,62.10507 --71.67708,61.52535 --71.37369,61.13717 --69.59042,61.06141 --69.62033,60.22125 --69.2879,58.95736 --68.37455,58.80106 --67.64976,58.21206 --66.20178,58.76731 --65.24517,59.87071 --64.58352,60.33558 --63.80475,59.4426 --62.50236,58.16708 --61.39655,56.96745 --61.79866,56.33945 --60.46853,55.77548 --59.56962,55.20407 --57.97508,54.94549 --57.3332,54.6265 --56.93689,53.78032 --56.15811,53.64749 --55.75632,53.27036 --55.68338,52.14664 --56.40916,51.7707 --57.12691,51.41972 --58.77482,51.0643 --60.03309,50.24277 --61.72366,50.08046 --63.86251,50.29099 --65.36331,50.2982 --66.39905,50.22897 --67.23631,49.51156 --68.51114,49.06836 --69.95362,47.74488 --71.10458,46.82171 --70.25522,46.98606 --68.65,48.3 --66.55243,49.1331 --65.05626,49.23278 --64.17099,48.74248 --65.11545,48.07085 --64.79854,46.99297 --64.47219,46.23849 --63.17329,45.73902 --61.52072,45.88377 --60.51815,47.00793 --60.4486,46.28264 --59.80287,45.9204 --61.03988,45.26525 --63.25471,44.67014 --64.24656,44.26553 --65.36406,43.54523 --66.1234,43.61867 --66.16173,44.46512 --64.42549,45.29204 --66.02605,45.25931 --67.13741,45.13753 --67.79134,45.70281 --67.79046,47.06636 --68.23444,47.35486 --68.905,47.185 --69.237216,47.447781 --69.99997,46.69307 --70.305,45.915 --70.66,45.46 --71.08482,45.30524 --71.405,45.255 --71.50506,45.0082 --73.34783,45.00738 --74.867,45.00048 --75.31821,44.81645 --76.375,44.09631 --76.5,44.018459 --76.820034,43.628784 --77.737885,43.629056 --78.72028,43.625089 --79.171674,43.466339 --79.01,43.27 --78.92,42.965 --78.939362,42.863611 --80.247448,42.3662 --81.277747,42.209026 --82.439278,41.675105 --82.690089,41.675105 --83.02981,41.832796 --83.142,41.975681 --83.12,42.08 --82.9,42.43 --82.43,42.98 --82.137642,43.571088 --82.337763,44.44 --82.550925,45.347517 --83.592851,45.816894 --83.469551,45.994686 --83.616131,46.116927 --83.890765,46.116927 --84.091851,46.275419 --84.14212,46.512226 --84.3367,46.40877 --84.6049,46.4396 --84.543749,46.538684 --84.779238,46.637102 --84.87608,46.900083 --85.652363,47.220219 --86.461991,47.553338 --87.439793,47.94 --88.378114,48.302918 --89.272917,48.019808 --89.6,48.01 --90.83,48.27 --91.64,48.14 --92.61,48.45 --93.63087,48.60926 --94.32914,48.67074 --94.64,48.84 --94.81758,49.38905 --95.15609,49.38425 --95.15907,49.0 --97.22872,49.0007 --100.65,49.0 --104.04826,48.99986 --107.05,49.0 --110.05,49.0 --113.0,49.0 --116.04818,49.0 --117.03121,49.0 --120.0,49.0 --122.84,49.0 --122.97421,49.002538 --124.91024,49.98456 --125.62461,50.41656 --127.43561,50.83061 --127.99276,51.71583 --127.85032,52.32961 --129.12979,52.75538 --129.30523,53.56159 --130.51497,54.28757 --130.53611,54.80278 --129.98,55.285 --130.00778,55.91583 --131.70781,56.55212 --132.73042,57.69289 --133.35556,58.41028 --134.27111,58.86111 --134.945,59.27056 --135.47583,59.78778 --136.47972,59.46389 --137.4525,58.905 --138.34089,59.56211 --139.039,60.0 --140.013,60.27682 --140.99778,60.30639 --140.9925,66.00003 --140.986,69.712 --139.12052,69.47102 --137.54636,68.99002 --136.50358,68.89804 --135.62576,69.31512 --134.41464,69.62743 --132.92925,69.50534 --131.43136,69.94451 --129.79471,70.19369 --129.10773,69.77927 --128.36156,70.01286 --128.13817,70.48384 --127.44712,70.37721 --125.75632,69.48058 --124.42483,70.1584 --124.28968,69.39969 --123.06108,69.56372 --122.6835,69.85553 --121.47226,69.79778 --119.94288,69.37786 --117.60268,69.01128 --116.22643,68.84151 --115.2469,68.90591 --113.89794,68.3989 --115.30489,67.90261 --113.49727,67.68815 --110.798,67.80612 --109.94619,67.98104 --108.8802,67.38144 --107.79239,67.88736 --108.81299,68.31164 --108.16721,68.65392 --106.95,68.7 --106.15,68.8 --105.34282,68.56122 --104.33791,68.018 --103.22115,68.09775 --101.45433,67.64689 --99.90195,67.80566 --98.4432,67.78165 --98.5586,68.40394 --97.66948,68.57864 --96.11991,68.23939 --96.12588,67.29338 --95.48943,68.0907 --94.685,68.06383 --94.23282,69.06903 --95.30408,69.68571 --96.47131,70.08976 --96.39115,71.19482 --95.2088,71.92053 --93.88997,71.76015 --92.87818,71.31869 --91.51964,70.19129 --92.40692,69.69997 + -90.5471,69.49766 +-90.55151,68.47499 +-89.21515,69.25873 +-88.01966,68.61508 +-88.31749,67.87338 +-87.35017,67.19872 +-86.30607,67.92146 +-85.57664,68.78456 +-85.52197,69.88211 +-84.10081,69.80539 +-82.62258,69.65826 +-81.28043,69.16202 +-81.2202,68.66567 +-81.96436,68.13253 +-81.25928,67.59716 +-81.38653,67.11078 +-83.34456,66.41154 +-84.73542,66.2573 +-85.76943,66.55833 +-86.0676,66.05625 +-87.03143,65.21297 +-87.32324,64.77563 +-88.48296,64.09897 +-89.91444,64.03273 +-90.70398,63.61017 +-90.77004,62.96021 +-91.93342,62.83508 +-93.15698,62.02469 +-94.24153,60.89865 +-94.62931,60.11021 +-94.6846,58.94882 +-93.21502,58.78212 +-92.76462,57.84571 +-92.29703,57.08709 +-90.89769,57.28468 +-89.03953,56.85172 +-88.03978,56.47162 +-87.32421,55.99914 +-86.07121,55.72383 +-85.01181,55.3026 +-83.36055,55.24489 +-82.27285,55.14832 +-82.4362,54.28227 +-82.12502,53.27703 +-81.40075,52.15788 +-79.91289,51.20842 +-79.14301,51.53393 +-78.60191,52.56208 +-79.12421,54.14145 +-79.82958,54.66772 +-78.22874,55.13645 +-77.0956,55.83741 +-76.54137,56.53423 +-76.62319,57.20263 +-77.30226,58.05209 +-78.51688,58.80458 +-77.33676,59.85261 +-77.77272,60.75788 +-78.10687,62.31964 +-77.41067,62.55053 +-75.69621,62.2784 +-74.6682,62.18111 +-73.83988,62.4438 +-72.90853,62.10507 +-71.67708,61.52535 +-71.37369,61.13717 +-69.59042,61.06141 +-69.62033,60.22125 +-69.2879,58.95736 +-68.37455,58.80106 +-67.64976,58.21206 +-66.20178,58.76731 +-65.24517,59.87071 +-64.58352,60.33558 +-63.80475,59.4426 +-62.50236,58.16708 +-61.39655,56.96745 +-61.79866,56.33945 +-60.46853,55.77548 +-59.56962,55.20407 +-57.97508,54.94549 +-57.3332,54.6265 +-56.93689,53.78032 +-56.15811,53.64749 +-55.75632,53.27036 +-55.68338,52.14664 +-56.40916,51.7707 +-57.12691,51.41972 +-58.77482,51.0643 +-60.03309,50.24277 +-61.72366,50.08046 +-63.86251,50.29099 +-65.36331,50.2982 +-66.39905,50.22897 +-67.23631,49.51156 +-68.51114,49.06836 +-69.95362,47.74488 +-71.10458,46.82171 +-70.25522,46.98606 +-68.65,48.3 +-66.55243,49.1331 +-65.05626,49.23278 +-64.17099,48.74248 +-65.11545,48.07085 +-64.79854,46.99297 +-64.47219,46.23849 +-63.17329,45.73902 +-61.52072,45.88377 +-60.51815,47.00793 +-60.4486,46.28264 +-59.80287,45.9204 +-61.03988,45.26525 +-63.25471,44.67014 +-64.24656,44.26553 +-65.36406,43.54523 +-66.1234,43.61867 +-66.16173,44.46512 +-64.42549,45.29204 +-66.02605,45.25931 +-67.13741,45.13753 +-67.79134,45.70281 +-67.79046,47.06636 +-68.23444,47.35486 +-68.905,47.185 +-69.237216,47.447781 +-69.99997,46.69307 +-70.305,45.915 +-70.66,45.46 +-71.08482,45.30524 +-71.405,45.255 +-71.50506,45.0082 +-73.34783,45.00738 +-74.867,45.00048 +-75.31821,44.81645 +-76.375,44.09631 +-76.5,44.018459 +-76.820034,43.628784 +-77.737885,43.629056 +-78.72028,43.625089 +-79.171674,43.466339 +-79.01,43.27 +-78.92,42.965 +-78.939362,42.863611 +-80.247448,42.3662 +-81.277747,42.209026 +-82.439278,41.675105 +-82.690089,41.675105 +-83.02981,41.832796 +-83.142,41.975681 +-83.12,42.08 +-82.9,42.43 +-82.43,42.98 +-82.137642,43.571088 +-82.337763,44.44 +-82.550925,45.347517 +-83.592851,45.816894 +-83.469551,45.994686 +-83.616131,46.116927 +-83.890765,46.116927 +-84.091851,46.275419 +-84.14212,46.512226 +-84.3367,46.40877 +-84.6049,46.4396 +-84.543749,46.538684 +-84.779238,46.637102 +-84.87608,46.900083 +-85.652363,47.220219 +-86.461991,47.553338 +-87.439793,47.94 +-88.378114,48.302918 +-89.272917,48.019808 +-89.6,48.01 +-90.83,48.27 +-91.64,48.14 +-92.61,48.45 +-93.63087,48.60926 +-94.32914,48.67074 +-94.64,48.84 +-94.81758,49.38905 +-95.15609,49.38425 +-95.15907,49.0 +-97.22872,49.0007 +-100.65,49.0 +-104.04826,48.99986 +-107.05,49.0 +-110.05,49.0 +-113.0,49.0 +-116.04818,49.0 +-117.03121,49.0 +-120.0,49.0 +-122.84,49.0 +-122.97421,49.002538 +-124.91024,49.98456 +-125.62461,50.41656 +-127.43561,50.83061 +-127.99276,51.71583 +-127.85032,52.32961 +-129.12979,52.75538 +-129.30523,53.56159 +-130.51497,54.28757 +-130.53611,54.80278 +-129.98,55.285 +-130.00778,55.91583 +-131.70781,56.55212 +-132.73042,57.69289 +-133.35556,58.41028 +-134.27111,58.86111 +-134.945,59.27056 +-135.47583,59.78778 +-136.47972,59.46389 +-137.4525,58.905 +-138.34089,59.56211 +-139.039,60.0 +-140.013,60.27682 +-140.99778,60.30639 +-140.9925,66.00003 +-140.986,69.712 +-139.12052,69.47102 +-137.54636,68.99002 +-136.50358,68.89804 +-135.62576,69.31512 +-134.41464,69.62743 +-132.92925,69.50534 +-131.43136,69.94451 +-129.79471,70.19369 +-129.10773,69.77927 +-128.36156,70.01286 +-128.13817,70.48384 +-127.44712,70.37721 +-125.75632,69.48058 +-124.42483,70.1584 +-124.28968,69.39969 +-123.06108,69.56372 +-122.6835,69.85553 +-121.47226,69.79778 +-119.94288,69.37786 +-117.60268,69.01128 +-116.22643,68.84151 +-115.2469,68.90591 +-113.89794,68.3989 +-115.30489,67.90261 +-113.49727,67.68815 +-110.798,67.80612 +-109.94619,67.98104 +-108.8802,67.38144 +-107.79239,67.88736 +-108.81299,68.31164 +-108.16721,68.65392 +-106.95,68.7 +-106.15,68.8 +-105.34282,68.56122 +-104.33791,68.018 +-103.22115,68.09775 +-101.45433,67.64689 +-99.90195,67.80566 +-98.4432,67.78165 +-98.5586,68.40394 +-97.66948,68.57864 +-96.11991,68.23939 +-96.12588,67.29338 +-95.48943,68.0907 +-94.685,68.06383 +-94.23282,69.06903 +-95.30408,69.68571 +-96.47131,70.08976 +-96.39115,71.19482 +-95.2088,71.92053 +-93.88997,71.76015 +-92.87818,71.31869 +-91.51964,70.19129 +-92.40692,69.69997 -90.5471,69.49766 - + - -114.16717,73.12145 --114.66634,72.65277 --112.44102,72.9554 --111.05039,72.4504 --109.92035,72.96113 --109.00654,72.63335 --108.18835,71.65089 --107.68599,72.06548 --108.39639,73.08953 --107.51645,73.23598 --106.52259,73.07601 --105.40246,72.67259 --104.77484,71.6984 --104.46476,70.99297 --102.78537,70.49776 --100.98078,70.02432 --101.08929,69.58447 --102.73116,69.50402 --102.09329,69.11962 --102.43024,68.75282 --104.24,68.91 --105.96,69.18 --107.12254,69.11922 --109.0,68.78 --111.534149,68.630059 --113.3132,68.53554 --113.85496,69.00744 --115.22,69.28 --116.10794,69.16821 --117.34,69.96 --116.67473,70.06655 --115.13112,70.2373 --113.72141,70.19237 --112.4161,70.36638 --114.35,70.6 --116.48684,70.52045 --117.9048,70.54056 --118.43238,70.9092 --116.11311,71.30918 --117.65568,71.2952 --119.40199,71.55859 --118.56267,72.30785 --117.86642,72.70594 --115.18909,73.31459 + -114.16717,73.12145 +-114.66634,72.65277 +-112.44102,72.9554 +-111.05039,72.4504 +-109.92035,72.96113 +-109.00654,72.63335 +-108.18835,71.65089 +-107.68599,72.06548 +-108.39639,73.08953 +-107.51645,73.23598 +-106.52259,73.07601 +-105.40246,72.67259 +-104.77484,71.6984 +-104.46476,70.99297 +-102.78537,70.49776 +-100.98078,70.02432 +-101.08929,69.58447 +-102.73116,69.50402 +-102.09329,69.11962 +-102.43024,68.75282 +-104.24,68.91 +-105.96,69.18 +-107.12254,69.11922 +-109.0,68.78 +-111.534149,68.630059 +-113.3132,68.53554 +-113.85496,69.00744 +-115.22,69.28 +-116.10794,69.16821 +-117.34,69.96 +-116.67473,70.06655 +-115.13112,70.2373 +-113.72141,70.19237 +-112.4161,70.36638 +-114.35,70.6 +-116.48684,70.52045 +-117.9048,70.54056 +-118.43238,70.9092 +-116.11311,71.30918 +-117.65568,71.2952 +-119.40199,71.55859 +-118.56267,72.30785 +-117.86642,72.70594 +-115.18909,73.31459 -114.16717,73.12145 - + - -104.5,73.42 --105.38,72.76 --106.94,73.46 --106.6,73.6 --105.26,73.64 + -104.5,73.42 +-105.38,72.76 +-106.94,73.46 +-106.6,73.6 +-105.26,73.64 -104.5,73.42 - + - -76.34,73.102685 --76.251404,72.826385 --77.314438,72.855545 --78.39167,72.876656 --79.486252,72.742203 --79.775833,72.802902 --80.876099,73.333183 --80.833885,73.693184 --80.353058,73.75972 --78.064438,73.651932 + -76.34,73.102685 +-76.251404,72.826385 +-77.314438,72.855545 +-78.39167,72.876656 +-79.486252,72.742203 +-79.775833,72.802902 +-80.876099,73.333183 +-80.833885,73.693184 +-80.353058,73.75972 +-78.064438,73.651932 -76.34,73.102685 - + - -86.562179,73.157447 --85.774371,72.534126 --84.850112,73.340278 --82.31559,73.750951 --80.600088,72.716544 --80.748942,72.061907 --78.770639,72.352173 --77.824624,72.749617 --75.605845,72.243678 --74.228616,71.767144 --74.099141,71.33084 --72.242226,71.556925 --71.200015,70.920013 --68.786054,70.525024 --67.91497,70.121948 --66.969033,69.186087 --68.805123,68.720198 --66.449866,68.067163 --64.862314,67.847539 --63.424934,66.928473 --61.851981,66.862121 --62.163177,66.160251 --63.918444,64.998669 --65.14886,65.426033 --66.721219,66.388041 --68.015016,66.262726 --68.141287,65.689789 --67.089646,65.108455 --65.73208,64.648406 --65.320168,64.382737 --64.669406,63.392927 --65.013804,62.674185 --66.275045,62.945099 --68.783186,63.74567 --67.369681,62.883966 --66.328297,62.280075 --66.165568,61.930897 --68.877367,62.330149 --71.023437,62.910708 --72.235379,63.397836 --71.886278,63.679989 --73.378306,64.193963 --74.834419,64.679076 --74.818503,64.389093 --77.70998,64.229542 --78.555949,64.572906 --77.897281,65.309192 --76.018274,65.326969 --73.959795,65.454765 --74.293883,65.811771 --73.944912,66.310578 --72.651167,67.284576 --72.92606,67.726926 --73.311618,68.069437 --74.843307,68.554627 --76.869101,68.894736 --76.228649,69.147769 --77.28737,69.76954 --78.168634,69.826488 --78.957242,70.16688 --79.492455,69.871808 --81.305471,69.743185 --84.944706,69.966634 --87.060003,70.260001 --88.681713,70.410741 --89.51342,70.762038 --88.467721,71.218186 --89.888151,71.222552 --90.20516,72.235074 --89.436577,73.129464 --88.408242,73.537889 --85.826151,73.803816 + -86.562179,73.157447 +-85.774371,72.534126 +-84.850112,73.340278 +-82.31559,73.750951 +-80.600088,72.716544 +-80.748942,72.061907 +-78.770639,72.352173 +-77.824624,72.749617 +-75.605845,72.243678 +-74.228616,71.767144 +-74.099141,71.33084 +-72.242226,71.556925 +-71.200015,70.920013 +-68.786054,70.525024 +-67.91497,70.121948 +-66.969033,69.186087 +-68.805123,68.720198 +-66.449866,68.067163 +-64.862314,67.847539 +-63.424934,66.928473 +-61.851981,66.862121 +-62.163177,66.160251 +-63.918444,64.998669 +-65.14886,65.426033 +-66.721219,66.388041 +-68.015016,66.262726 +-68.141287,65.689789 +-67.089646,65.108455 +-65.73208,64.648406 +-65.320168,64.382737 +-64.669406,63.392927 +-65.013804,62.674185 +-66.275045,62.945099 +-68.783186,63.74567 +-67.369681,62.883966 +-66.328297,62.280075 +-66.165568,61.930897 +-68.877367,62.330149 +-71.023437,62.910708 +-72.235379,63.397836 +-71.886278,63.679989 +-73.378306,64.193963 +-74.834419,64.679076 +-74.818503,64.389093 +-77.70998,64.229542 +-78.555949,64.572906 +-77.897281,65.309192 +-76.018274,65.326969 +-73.959795,65.454765 +-74.293883,65.811771 +-73.944912,66.310578 +-72.651167,67.284576 +-72.92606,67.726926 +-73.311618,68.069437 +-74.843307,68.554627 +-76.869101,68.894736 +-76.228649,69.147769 +-77.28737,69.76954 +-78.168634,69.826488 +-78.957242,70.16688 +-79.492455,69.871808 +-81.305471,69.743185 +-84.944706,69.966634 +-87.060003,70.260001 +-88.681713,70.410741 +-89.51342,70.762038 +-88.467721,71.218186 +-89.888151,71.222552 +-90.20516,72.235074 +-89.436577,73.129464 +-88.408242,73.537889 +-85.826151,73.803816 -86.562179,73.157447 - + - -100.35642,73.84389 --99.16387,73.63339 --97.38,73.76 --97.12,73.47 --98.05359,72.99052 --96.54,72.56 --96.72,71.66 --98.35966,71.27285 --99.32286,71.35639 --100.01482,71.73827 --102.5,72.51 --102.48,72.83 --100.43836,72.70588 --101.54,73.36 + -100.35642,73.84389 +-99.16387,73.63339 +-97.38,73.76 +-97.12,73.47 +-98.05359,72.99052 +-96.54,72.56 +-96.72,71.66 +-98.35966,71.27285 +-99.32286,71.35639 +-100.01482,71.73827 +-102.5,72.51 +-102.48,72.83 +-100.43836,72.70588 +-101.54,73.36 -100.35642,73.84389 - + - -93.196296,72.771992 --94.269047,72.024596 --95.409856,72.061881 --96.033745,72.940277 --96.018268,73.43743 --95.495793,73.862417 --94.503658,74.134907 --92.420012,74.100025 --90.509793,73.856732 --92.003965,72.966244 + -93.196296,72.771992 +-94.269047,72.024596 +-95.409856,72.061881 +-96.033745,72.940277 +-96.018268,73.43743 +-95.495793,73.862417 +-94.503658,74.134907 +-92.420012,74.100025 +-90.509793,73.856732 +-92.003965,72.966244 -93.196296,72.771992 - + - -120.46,71.383602 --123.09219,70.90164 --123.62,71.34 --125.928949,71.868688 --125.5,72.292261 --124.80729,73.02256 --123.94,73.68 --124.91775,74.29275 --121.53788,74.44893 --120.10978,74.24135 --117.55564,74.18577 --116.58442,73.89607 --115.51081,73.47519 --116.76794,73.22292 --119.22,72.52 --120.46,71.82 + -120.46,71.383602 +-123.09219,70.90164 +-123.62,71.34 +-125.928949,71.868688 +-125.5,72.292261 +-124.80729,73.02256 +-123.94,73.68 +-124.91775,74.29275 +-121.53788,74.44893 +-120.10978,74.24135 +-117.55564,74.18577 +-116.58442,73.89607 +-115.51081,73.47519 +-116.76794,73.22292 +-119.22,72.52 +-120.46,71.82 -120.46,71.383602 - + - -93.612756,74.979997 --94.156909,74.592347 --95.608681,74.666864 --96.820932,74.927623 --96.288587,75.377828 --94.85082,75.647218 --93.977747,75.29649 + -93.612756,74.979997 +-94.156909,74.592347 +-95.608681,74.666864 +-96.820932,74.927623 +-96.288587,75.377828 +-94.85082,75.647218 +-93.977747,75.29649 -93.612756,74.979997 - + - -98.5,76.72 --97.735585,76.25656 --97.704415,75.74344 --98.16,75.0 --99.80874,74.89744 --100.88366,75.05736 --100.86292,75.64075 --102.50209,75.5638 --102.56552,76.3366 --101.48973,76.30537 --99.98349,76.64634 --98.57699,76.58859 + -98.5,76.72 +-97.735585,76.25656 +-97.704415,75.74344 +-98.16,75.0 +-99.80874,74.89744 +-100.88366,75.05736 +-100.86292,75.64075 +-102.50209,75.5638 +-102.56552,76.3366 +-101.48973,76.30537 +-99.98349,76.64634 +-98.57699,76.58859 -98.5,76.72 - + - -108.21141,76.20168 --107.81943,75.84552 --106.92893,76.01282 --105.881,75.9694 --105.70498,75.47951 --106.31347,75.00527 --109.7,74.85 --112.22307,74.41696 --113.74381,74.39427 --113.87135,74.72029 --111.79421,75.1625 --116.31221,75.04343 --117.7104,75.2222 --116.34602,76.19903 --115.40487,76.47887 --112.59056,76.14134 --110.81422,75.54919 --109.0671,75.47321 --110.49726,76.42982 --109.5811,76.79417 --108.54859,76.67832 + -108.21141,76.20168 +-107.81943,75.84552 +-106.92893,76.01282 +-105.881,75.9694 +-105.70498,75.47951 +-106.31347,75.00527 +-109.7,74.85 +-112.22307,74.41696 +-113.74381,74.39427 +-113.87135,74.72029 +-111.79421,75.1625 +-116.31221,75.04343 +-117.7104,75.2222 +-116.34602,76.19903 +-115.40487,76.47887 +-112.59056,76.14134 +-110.81422,75.54919 +-109.0671,75.47321 +-110.49726,76.42982 +-109.5811,76.79417 +-108.54859,76.67832 -108.21141,76.20168 - + - -94.684086,77.097878 --93.573921,76.776296 --91.605023,76.778518 --90.741846,76.449597 --90.969661,76.074013 --89.822238,75.847774 --89.187083,75.610166 --87.838276,75.566189 --86.379192,75.482421 --84.789625,75.699204 --82.753445,75.784315 --81.128531,75.713983 --80.057511,75.336849 --79.833933,74.923127 --80.457771,74.657304 --81.948843,74.442459 --83.228894,74.564028 --86.097452,74.410032 --88.15035,74.392307 --89.764722,74.515555 --92.422441,74.837758 --92.768285,75.38682 --92.889906,75.882655 --93.893824,76.319244 --95.962457,76.441381 --97.121379,76.751078 --96.745123,77.161389 + -94.684086,77.097878 +-93.573921,76.776296 +-91.605023,76.778518 +-90.741846,76.449597 +-90.969661,76.074013 +-89.822238,75.847774 +-89.187083,75.610166 +-87.838276,75.566189 +-86.379192,75.482421 +-84.789625,75.699204 +-82.753445,75.784315 +-81.128531,75.713983 +-80.057511,75.336849 +-79.833933,74.923127 +-80.457771,74.657304 +-81.948843,74.442459 +-83.228894,74.564028 +-86.097452,74.410032 +-88.15035,74.392307 +-89.764722,74.515555 +-92.422441,74.837758 +-92.768285,75.38682 +-92.889906,75.882655 +-93.893824,76.319244 +-95.962457,76.441381 +-97.121379,76.751078 +-96.745123,77.161389 -94.684086,77.097878 - + - -116.198587,77.645287 --116.335813,76.876962 --117.106051,76.530032 --118.040412,76.481172 --119.899318,76.053213 --121.499995,75.900019 --122.854924,76.116543 --122.854925,76.116543 --121.157535,76.864508 --119.103939,77.51222 --117.570131,77.498319 + -116.198587,77.645287 +-116.335813,76.876962 +-117.106051,76.530032 +-118.040412,76.481172 +-119.899318,76.053213 +-121.499995,75.900019 +-122.854924,76.116543 +-122.854925,76.116543 +-121.157535,76.864508 +-119.103939,77.51222 +-117.570131,77.498319 -116.198587,77.645287 - + - -93.840003,77.519997 --94.295608,77.491343 --96.169654,77.555111 --96.436304,77.834629 --94.422577,77.820005 --93.720656,77.634331 + -93.840003,77.519997 +-94.295608,77.491343 +-96.169654,77.555111 +-96.436304,77.834629 +-94.422577,77.820005 +-93.720656,77.634331 -93.840003,77.519997 - + - -110.186938,77.697015 --112.051191,77.409229 --113.534279,77.732207 --112.724587,78.05105 --111.264443,78.152956 --109.854452,77.996325 + -110.186938,77.697015 +-112.051191,77.409229 +-113.534279,77.732207 +-112.724587,78.05105 +-111.264443,78.152956 +-109.854452,77.996325 -110.186938,77.697015 - + - -109.663146,78.601973 --110.881314,78.40692 --112.542091,78.407902 --112.525891,78.550555 --111.50001,78.849994 --110.963661,78.804441 + -109.663146,78.601973 +-110.881314,78.40692 +-112.542091,78.407902 +-112.525891,78.550555 +-111.50001,78.849994 +-110.963661,78.804441 -109.663146,78.601973 - + - -95.830295,78.056941 --97.309843,77.850597 --98.124289,78.082857 --98.552868,78.458105 --98.631984,78.87193 --97.337231,78.831984 --96.754399,78.765813 --95.559278,78.418315 + -95.830295,78.056941 +-97.309843,77.850597 +-98.124289,78.082857 +-98.552868,78.458105 +-98.631984,78.87193 +-97.337231,78.831984 +-96.754399,78.765813 +-95.559278,78.418315 -95.830295,78.056941 - + - -100.060192,78.324754 --99.670939,77.907545 --101.30394,78.018985 --102.949809,78.343229 --105.176133,78.380332 --104.210429,78.67742 --105.41958,78.918336 --105.492289,79.301594 --103.529282,79.165349 --100.825158,78.800462 + -100.060192,78.324754 +-99.670939,77.907545 +-101.30394,78.018985 +-102.949809,78.343229 +-105.176133,78.380332 +-104.210429,78.67742 +-105.41958,78.918336 +-105.492289,79.301594 +-103.529282,79.165349 +-100.825158,78.800462 -100.060192,78.324754 - + - -87.02,79.66 --85.81435,79.3369 --87.18756,79.0393 --89.03535,78.28723 --90.80436,78.21533 --92.87669,78.34333 --93.95116,78.75099 --93.93574,79.11373 --93.14524,79.3801 --94.974,79.37248 --96.07614,79.70502 --96.70972,80.15777 --96.01644,80.60233 --95.32345,80.90729 --94.29843,80.97727 --94.73542,81.20646 --92.40984,81.25739 --91.13289,80.72345 --89.45,80.509322 --87.81,80.32 + -87.02,79.66 +-85.81435,79.3369 +-87.18756,79.0393 +-89.03535,78.28723 +-90.80436,78.21533 +-92.87669,78.34333 +-93.95116,78.75099 +-93.93574,79.11373 +-93.14524,79.3801 +-94.974,79.37248 +-96.07614,79.70502 +-96.70972,80.15777 +-96.01644,80.60233 +-95.32345,80.90729 +-94.29843,80.97727 +-94.73542,81.20646 +-92.40984,81.25739 +-91.13289,80.72345 +-89.45,80.509322 +-87.81,80.32 -87.02,79.66 - + - -68.5,83.106322 --65.82735,83.02801 --63.68,82.9 --61.85,82.6286 --61.89388,82.36165 --64.334,81.92775 --66.75342,81.72527 --67.65755,81.50141 --65.48031,81.50657 --67.84,80.9 --69.4697,80.61683 --71.18,79.8 --73.2428,79.63415 --73.88,79.430162 --76.90773,79.32309 --75.52924,79.19766 --76.22046,79.01907 --75.39345,78.52581 --76.34354,78.18296 --77.88851,77.89991 --78.36269,77.50859 --79.75951,77.20968 --79.61965,76.98336 --77.91089,77.022045 --77.88911,76.777955 --80.56125,76.17812 --83.17439,76.45403 --86.11184,76.29901 --87.6,76.42 --89.49068,76.47239 --89.6161,76.95213 --87.76739,77.17833 --88.26,77.9 --87.65,77.970222 --84.97634,77.53873 --86.34,78.18 --87.96192,78.37181 --87.15198,78.75867 --85.37868,78.9969 --85.09495,79.34543 --86.50734,79.73624 --86.93179,80.25145 --84.19844,80.20836 --83.408696,80.1 --81.84823,80.46442 --84.1,80.58 --87.59895,80.51627 --89.36663,80.85569 --90.2,81.26 --91.36786,81.5531 --91.58702,81.89429 --90.1,82.085 --88.93227,82.11751 --86.97024,82.27961 --85.5,82.652273 --84.260005,82.6 --83.18,82.32 --82.42,82.86 --81.1,83.02 --79.30664,83.13056 --76.25,83.172059 --75.71878,83.06404 --72.83153,83.23324 --70.665765,83.169781 + -68.5,83.106322 +-65.82735,83.02801 +-63.68,82.9 +-61.85,82.6286 +-61.89388,82.36165 +-64.334,81.92775 +-66.75342,81.72527 +-67.65755,81.50141 +-65.48031,81.50657 +-67.84,80.9 +-69.4697,80.61683 +-71.18,79.8 +-73.2428,79.63415 +-73.88,79.430162 +-76.90773,79.32309 +-75.52924,79.19766 +-76.22046,79.01907 +-75.39345,78.52581 +-76.34354,78.18296 +-77.88851,77.89991 +-78.36269,77.50859 +-79.75951,77.20968 +-79.61965,76.98336 +-77.91089,77.022045 +-77.88911,76.777955 +-80.56125,76.17812 +-83.17439,76.45403 +-86.11184,76.29901 +-87.6,76.42 +-89.49068,76.47239 +-89.6161,76.95213 +-87.76739,77.17833 +-88.26,77.9 +-87.65,77.970222 +-84.97634,77.53873 +-86.34,78.18 +-87.96192,78.37181 +-87.15198,78.75867 +-85.37868,78.9969 +-85.09495,79.34543 +-86.50734,79.73624 +-86.93179,80.25145 +-84.19844,80.20836 +-83.408696,80.1 +-81.84823,80.46442 +-84.1,80.58 +-87.59895,80.51627 +-89.36663,80.85569 +-90.2,81.26 +-91.36786,81.5531 +-91.58702,81.89429 +-90.1,82.085 +-88.93227,82.11751 +-86.97024,82.27961 +-85.5,82.652273 +-84.260005,82.6 +-83.18,82.32 +-82.42,82.86 +-81.1,83.02 +-79.30664,83.13056 +-76.25,83.172059 +-75.71878,83.06404 +-72.83153,83.23324 +-70.665765,83.169781 -68.5,83.106322 - + #defaultStyle Switzerland - - - - - - - 9.594226,47.525058 -9.632932,47.347601 -9.47997,47.10281 -9.932448,46.920728 -10.442701,46.893546 -10.363378,46.483571 -9.922837,46.314899 -9.182882,46.440215 -8.966306,46.036932 -8.489952,46.005151 -8.31663,46.163642 -7.755992,45.82449 -7.273851,45.776948 -6.843593,45.991147 -6.5001,46.429673 -6.022609,46.27299 -6.037389,46.725779 -6.768714,47.287708 -6.736571,47.541801 -7.192202,47.449766 -7.466759,47.620582 -8.317301,47.61358 -8.522612,47.830828 + + + + + + + 9.594226,47.525058 +9.632932,47.347601 +9.47997,47.10281 +9.932448,46.920728 +10.442701,46.893546 +10.363378,46.483571 +9.922837,46.314899 +9.182882,46.440215 +8.966306,46.036932 +8.489952,46.005151 +8.31663,46.163642 +7.755992,45.82449 +7.273851,45.776948 +6.843593,45.991147 +6.5001,46.429673 +6.022609,46.27299 +6.037389,46.725779 +6.768714,47.287708 +6.736571,47.541801 +7.192202,47.449766 +7.466759,47.620582 +8.317301,47.61358 +8.522612,47.830828 9.594226,47.525058 - + #defaultStyle Chile - + - -68.63401,-52.63637 --68.63335,-54.8695 --67.56244,-54.87001 --66.95992,-54.89681 --67.29103,-55.30124 --68.14863,-55.61183 --68.639991,-55.580018 --69.2321,-55.49906 --69.95809,-55.19843 --71.00568,-55.05383 --72.2639,-54.49514 --73.2852,-53.95752 --74.66253,-52.83749 --73.8381,-53.04743 --72.43418,-53.7154 --71.10773,-54.07433 --70.59178,-53.61583 --70.26748,-52.93123 --69.34565,-52.5183 + -68.63401,-52.63637 +-68.63335,-54.8695 +-67.56244,-54.87001 +-66.95992,-54.89681 +-67.29103,-55.30124 +-68.14863,-55.61183 +-68.639991,-55.580018 +-69.2321,-55.49906 +-69.95809,-55.19843 +-71.00568,-55.05383 +-72.2639,-54.49514 +-73.2852,-53.95752 +-74.66253,-52.83749 +-73.8381,-53.04743 +-72.43418,-53.7154 +-71.10773,-54.07433 +-70.59178,-53.61583 +-70.26748,-52.93123 +-69.34565,-52.5183 -68.63401,-52.63637 - + - -68.219913,-21.494347 --67.82818,-22.872919 --67.106674,-22.735925 --66.985234,-22.986349 --67.328443,-24.025303 --68.417653,-24.518555 --68.386001,-26.185016 --68.5948,-26.506909 --68.295542,-26.89934 --69.001235,-27.521214 --69.65613,-28.459141 --70.01355,-29.367923 --69.919008,-30.336339 --70.535069,-31.36501 --70.074399,-33.09121 --69.814777,-33.273886 --69.817309,-34.193571 --70.388049,-35.169688 --70.364769,-36.005089 --71.121881,-36.658124 --71.118625,-37.576827 --70.814664,-38.552995 --71.413517,-38.916022 --71.680761,-39.808164 --71.915734,-40.832339 --71.746804,-42.051386 --72.148898,-42.254888 --71.915424,-43.408565 --71.464056,-43.787611 --71.793623,-44.207172 --71.329801,-44.407522 --71.222779,-44.784243 --71.659316,-44.973689 --71.552009,-45.560733 --71.917258,-46.884838 --72.447355,-47.738533 --72.331161,-48.244238 --72.648247,-48.878618 --73.415436,-49.318436 --73.328051,-50.378785 --72.975747,-50.74145 --72.309974,-50.67701 --72.329404,-51.425956 --71.914804,-52.009022 --69.498362,-52.142761 --68.571545,-52.299444 --69.461284,-52.291951 --69.94278,-52.537931 --70.845102,-52.899201 --71.006332,-53.833252 --71.429795,-53.856455 --72.557943,-53.53141 --73.702757,-52.835069 --73.702757,-52.83507 --74.946763,-52.262754 --75.260026,-51.629355 --74.976632,-51.043396 --75.479754,-50.378372 --75.608015,-48.673773 --75.18277,-47.711919 --74.126581,-46.939253 --75.644395,-46.647643 --74.692154,-45.763976 --74.351709,-44.103044 --73.240356,-44.454961 --72.717804,-42.383356 --73.3889,-42.117532 --73.701336,-43.365776 --74.331943,-43.224958 --74.017957,-41.794813 --73.677099,-39.942213 --73.217593,-39.258689 --73.505559,-38.282883 --73.588061,-37.156285 --73.166717,-37.12378 --72.553137,-35.50884 --71.861732,-33.909093 --71.43845,-32.418899 --71.668721,-30.920645 --71.370083,-30.095682 --71.489894,-28.861442 --70.905124,-27.64038 --70.724954,-25.705924 --70.403966,-23.628997 --70.091246,-21.393319 --70.16442,-19.756468 --70.372572,-18.347975 --69.858444,-18.092694 --69.590424,-17.580012 --69.100247,-18.260125 --68.966818,-18.981683 --68.442225,-19.405068 --68.757167,-20.372658 + -68.219913,-21.494347 +-67.82818,-22.872919 +-67.106674,-22.735925 +-66.985234,-22.986349 +-67.328443,-24.025303 +-68.417653,-24.518555 +-68.386001,-26.185016 +-68.5948,-26.506909 +-68.295542,-26.89934 +-69.001235,-27.521214 +-69.65613,-28.459141 +-70.01355,-29.367923 +-69.919008,-30.336339 +-70.535069,-31.36501 +-70.074399,-33.09121 +-69.814777,-33.273886 +-69.817309,-34.193571 +-70.388049,-35.169688 +-70.364769,-36.005089 +-71.121881,-36.658124 +-71.118625,-37.576827 +-70.814664,-38.552995 +-71.413517,-38.916022 +-71.680761,-39.808164 +-71.915734,-40.832339 +-71.746804,-42.051386 +-72.148898,-42.254888 +-71.915424,-43.408565 +-71.464056,-43.787611 +-71.793623,-44.207172 +-71.329801,-44.407522 +-71.222779,-44.784243 +-71.659316,-44.973689 +-71.552009,-45.560733 +-71.917258,-46.884838 +-72.447355,-47.738533 +-72.331161,-48.244238 +-72.648247,-48.878618 +-73.415436,-49.318436 +-73.328051,-50.378785 +-72.975747,-50.74145 +-72.309974,-50.67701 +-72.329404,-51.425956 +-71.914804,-52.009022 +-69.498362,-52.142761 +-68.571545,-52.299444 +-69.461284,-52.291951 +-69.94278,-52.537931 +-70.845102,-52.899201 +-71.006332,-53.833252 +-71.429795,-53.856455 +-72.557943,-53.53141 +-73.702757,-52.835069 +-73.702757,-52.83507 +-74.946763,-52.262754 +-75.260026,-51.629355 +-74.976632,-51.043396 +-75.479754,-50.378372 +-75.608015,-48.673773 +-75.18277,-47.711919 +-74.126581,-46.939253 +-75.644395,-46.647643 +-74.692154,-45.763976 +-74.351709,-44.103044 +-73.240356,-44.454961 +-72.717804,-42.383356 +-73.3889,-42.117532 +-73.701336,-43.365776 +-74.331943,-43.224958 +-74.017957,-41.794813 +-73.677099,-39.942213 +-73.217593,-39.258689 +-73.505559,-38.282883 +-73.588061,-37.156285 +-73.166717,-37.12378 +-72.553137,-35.50884 +-71.861732,-33.909093 +-71.43845,-32.418899 +-71.668721,-30.920645 +-71.370083,-30.095682 +-71.489894,-28.861442 +-70.905124,-27.64038 +-70.724954,-25.705924 +-70.403966,-23.628997 +-70.091246,-21.393319 +-70.16442,-19.756468 +-70.372572,-18.347975 +-69.858444,-18.092694 +-69.590424,-17.580012 +-69.100247,-18.260125 +-68.966818,-18.981683 +-68.442225,-19.405068 +-68.757167,-20.372658 -68.219913,-21.494347 - + #defaultStyle Republic of the Congo - - - - - - - 12.995517,-4.781103 -12.62076,-4.438023 -12.318608,-4.60623 -11.914963,-5.037987 -11.093773,-3.978827 -11.855122,-3.426871 -11.478039,-2.765619 -11.820964,-2.514161 -12.495703,-2.391688 -12.575284,-1.948511 -13.109619,-2.42874 -13.992407,-2.470805 -14.29921,-1.998276 -14.425456,-1.333407 -14.316418,-0.552627 -13.843321,0.038758 -14.276266,1.19693 -14.026669,1.395677 -13.282631,1.314184 -13.003114,1.830896 -13.075822,2.267097 -14.337813,2.227875 -15.146342,1.964015 -15.940919,1.727673 -16.012852,2.26764 -16.537058,3.198255 -17.133042,3.728197 -17.8099,3.560196 -18.453065,3.504386 -18.393792,2.900443 -18.094276,2.365722 -17.898835,1.741832 -17.774192,0.855659 -17.82654,0.288923 -17.663553,-0.058084 -17.638645,-0.424832 -17.523716,-0.74383 -16.865307,-1.225816 -16.407092,-1.740927 -15.972803,-2.712392 -16.00629,-3.535133 -15.75354,-3.855165 -15.170992,-4.343507 -14.582604,-4.970239 -14.209035,-4.793092 -14.144956,-4.510009 -13.600235,-4.500138 -13.25824,-4.882957 + + + + + + + 12.995517,-4.781103 +12.62076,-4.438023 +12.318608,-4.60623 +11.914963,-5.037987 +11.093773,-3.978827 +11.855122,-3.426871 +11.478039,-2.765619 +11.820964,-2.514161 +12.495703,-2.391688 +12.575284,-1.948511 +13.109619,-2.42874 +13.992407,-2.470805 +14.29921,-1.998276 +14.425456,-1.333407 +14.316418,-0.552627 +13.843321,0.038758 +14.276266,1.19693 +14.026669,1.395677 +13.282631,1.314184 +13.003114,1.830896 +13.075822,2.267097 +14.337813,2.227875 +15.146342,1.964015 +15.940919,1.727673 +16.012852,2.26764 +16.537058,3.198255 +17.133042,3.728197 +17.8099,3.560196 +18.453065,3.504386 +18.393792,2.900443 +18.094276,2.365722 +17.898835,1.741832 +17.774192,0.855659 +17.82654,0.288923 +17.663553,-0.058084 +17.638645,-0.424832 +17.523716,-0.74383 +16.865307,-1.225816 +16.407092,-1.740927 +15.972803,-2.712392 +16.00629,-3.535133 +15.75354,-3.855165 +15.170992,-4.343507 +14.582604,-4.970239 +14.209035,-4.793092 +14.144956,-4.510009 +13.600235,-4.500138 +13.25824,-4.882957 12.995517,-4.781103 - + #defaultStyle Colombia - - - - - - - -75.373223,-0.152032 --75.801466,0.084801 --76.292314,0.416047 --76.57638,0.256936 --77.424984,0.395687 --77.668613,0.825893 --77.855061,0.809925 --78.855259,1.380924 --78.990935,1.69137 --78.617831,1.766404 --78.662118,2.267355 --78.42761,2.629556 --77.931543,2.696606 --77.510431,3.325017 --77.12769,3.849636 --77.496272,4.087606 --77.307601,4.667984 --77.533221,5.582812 --77.318815,5.845354 --77.476661,6.691116 --77.881571,7.223771 --77.753414,7.70984 --77.431108,7.638061 --77.242566,7.935278 --77.474723,8.524286 --77.353361,8.670505 --76.836674,8.638749 --76.086384,9.336821 --75.6746,9.443248 --75.664704,9.774003 --75.480426,10.61899 --74.906895,11.083045 --74.276753,11.102036 --74.197223,11.310473 --73.414764,11.227015 --72.627835,11.731972 --72.238195,11.95555 --71.75409,12.437303 --71.399822,12.376041 --71.137461,12.112982 --71.331584,11.776284 --71.973922,11.608672 --72.227575,11.108702 --72.614658,10.821975 --72.905286,10.450344 --73.027604,9.73677 --73.304952,9.152 --72.78873,9.085027 --72.660495,8.625288 --72.439862,8.405275 --72.360901,8.002638 --72.479679,7.632506 --72.444487,7.423785 --72.198352,7.340431 --71.960176,6.991615 --70.674234,7.087785 --70.093313,6.960376 --69.38948,6.099861 --68.985319,6.206805 --68.265052,6.153268 --67.695087,6.267318 --67.34144,6.095468 --67.521532,5.55687 --67.744697,5.221129 --67.823012,4.503937 --67.621836,3.839482 --67.337564,3.542342 --67.303173,3.318454 --67.809938,2.820655 --67.447092,2.600281 --67.181294,2.250638 --66.876326,1.253361 --67.065048,1.130112 --67.259998,1.719999 --67.53781,2.037163 --67.868565,1.692455 --69.816973,1.714805 --69.804597,1.089081 --69.218638,0.985677 --69.252434,0.602651 --69.452396,0.706159 --70.015566,0.541414 --70.020656,-0.185156 --69.577065,-0.549992 --69.420486,-1.122619 --69.444102,-1.556287 --69.893635,-4.298187 --70.394044,-3.766591 --70.692682,-3.742872 --70.047709,-2.725156 --70.813476,-2.256865 --71.413646,-2.342802 --71.774761,-2.16979 --72.325787,-2.434218 --73.070392,-2.308954 --73.659504,-1.260491 --74.122395,-1.002833 --74.441601,-0.53082 --75.106625,-0.057205 + + + + + + + -75.373223,-0.152032 +-75.801466,0.084801 +-76.292314,0.416047 +-76.57638,0.256936 +-77.424984,0.395687 +-77.668613,0.825893 +-77.855061,0.809925 +-78.855259,1.380924 +-78.990935,1.69137 +-78.617831,1.766404 +-78.662118,2.267355 +-78.42761,2.629556 +-77.931543,2.696606 +-77.510431,3.325017 +-77.12769,3.849636 +-77.496272,4.087606 +-77.307601,4.667984 +-77.533221,5.582812 +-77.318815,5.845354 +-77.476661,6.691116 +-77.881571,7.223771 +-77.753414,7.70984 +-77.431108,7.638061 +-77.242566,7.935278 +-77.474723,8.524286 +-77.353361,8.670505 +-76.836674,8.638749 +-76.086384,9.336821 +-75.6746,9.443248 +-75.664704,9.774003 +-75.480426,10.61899 +-74.906895,11.083045 +-74.276753,11.102036 +-74.197223,11.310473 +-73.414764,11.227015 +-72.627835,11.731972 +-72.238195,11.95555 +-71.75409,12.437303 +-71.399822,12.376041 +-71.137461,12.112982 +-71.331584,11.776284 +-71.973922,11.608672 +-72.227575,11.108702 +-72.614658,10.821975 +-72.905286,10.450344 +-73.027604,9.73677 +-73.304952,9.152 +-72.78873,9.085027 +-72.660495,8.625288 +-72.439862,8.405275 +-72.360901,8.002638 +-72.479679,7.632506 +-72.444487,7.423785 +-72.198352,7.340431 +-71.960176,6.991615 +-70.674234,7.087785 +-70.093313,6.960376 +-69.38948,6.099861 +-68.985319,6.206805 +-68.265052,6.153268 +-67.695087,6.267318 +-67.34144,6.095468 +-67.521532,5.55687 +-67.744697,5.221129 +-67.823012,4.503937 +-67.621836,3.839482 +-67.337564,3.542342 +-67.303173,3.318454 +-67.809938,2.820655 +-67.447092,2.600281 +-67.181294,2.250638 +-66.876326,1.253361 +-67.065048,1.130112 +-67.259998,1.719999 +-67.53781,2.037163 +-67.868565,1.692455 +-69.816973,1.714805 +-69.804597,1.089081 +-69.218638,0.985677 +-69.252434,0.602651 +-69.452396,0.706159 +-70.015566,0.541414 +-70.020656,-0.185156 +-69.577065,-0.549992 +-69.420486,-1.122619 +-69.444102,-1.556287 +-69.893635,-4.298187 +-70.394044,-3.766591 +-70.692682,-3.742872 +-70.047709,-2.725156 +-70.813476,-2.256865 +-71.413646,-2.342802 +-71.774761,-2.16979 +-72.325787,-2.434218 +-73.070392,-2.308954 +-73.659504,-1.260491 +-74.122395,-1.002833 +-74.441601,-0.53082 +-75.106625,-0.057205 -75.373223,-0.152032 - + #defaultStyle Costa Rica - - - - - - - -82.965783,8.225028 --83.508437,8.446927 --83.711474,8.656836 --83.596313,8.830443 --83.632642,9.051386 --83.909886,9.290803 --84.303402,9.487354 --84.647644,9.615537 --84.713351,9.908052 --84.97566,10.086723 --84.911375,9.795992 --85.110923,9.55704 --85.339488,9.834542 --85.660787,9.933347 --85.797445,10.134886 --85.791709,10.439337 --85.659314,10.754331 --85.941725,10.895278 --85.71254,11.088445 --85.561852,11.217119 --84.903003,10.952303 --84.673069,11.082657 --84.355931,10.999226 --84.190179,10.79345 --83.895054,10.726839 --83.655612,10.938764 --83.40232,10.395438 --83.015677,9.992982 --82.546196,9.566135 --82.932891,9.476812 --82.927155,9.07433 --82.719183,8.925709 --82.868657,8.807266 --82.829771,8.626295 --82.913176,8.423517 + + + + + + + -82.965783,8.225028 +-83.508437,8.446927 +-83.711474,8.656836 +-83.596313,8.830443 +-83.632642,9.051386 +-83.909886,9.290803 +-84.303402,9.487354 +-84.647644,9.615537 +-84.713351,9.908052 +-84.97566,10.086723 +-84.911375,9.795992 +-85.110923,9.55704 +-85.339488,9.834542 +-85.660787,9.933347 +-85.797445,10.134886 +-85.791709,10.439337 +-85.659314,10.754331 +-85.941725,10.895278 +-85.71254,11.088445 +-85.561852,11.217119 +-84.903003,10.952303 +-84.673069,11.082657 +-84.355931,10.999226 +-84.190179,10.79345 +-83.895054,10.726839 +-83.655612,10.938764 +-83.40232,10.395438 +-83.015677,9.992982 +-82.546196,9.566135 +-82.932891,9.476812 +-82.927155,9.07433 +-82.719183,8.925709 +-82.868657,8.807266 +-82.829771,8.626295 +-82.913176,8.423517 -82.965783,8.225028 - + #defaultStyle China - + - 110.339188,18.678395 -109.47521,18.197701 -108.655208,18.507682 -108.626217,19.367888 -109.119056,19.821039 -110.211599,20.101254 -110.786551,20.077534 -111.010051,19.69593 -110.570647,19.255879 + 110.339188,18.678395 +109.47521,18.197701 +108.655208,18.507682 +108.626217,19.367888 +109.119056,19.821039 +110.211599,20.101254 +110.786551,20.077534 +111.010051,19.69593 +110.570647,19.255879 110.339188,18.678395 - + - 127.657407,49.76027 -129.397818,49.4406 -130.582293,48.729687 -130.987282,47.790132 -132.506672,47.78897 -133.373596,48.183442 -135.026311,48.47823 -134.500814,47.57844 -134.112362,47.212467 -133.769644,46.116927 -133.097127,45.144066 -131.883454,45.321162 -131.025212,44.967953 -131.288555,44.11152 -131.144688,42.92999 -130.633866,42.903015 -130.640016,42.395009 -129.994267,42.985387 -129.596669,42.424982 -128.052215,41.994285 -128.208433,41.466772 -127.343783,41.503152 -126.869083,41.816569 -126.182045,41.107336 -125.079942,40.569824 -124.265625,39.928493 -122.86757,39.637788 -122.131388,39.170452 -121.054554,38.897471 -121.585995,39.360854 -121.376757,39.750261 -122.168595,40.422443 -121.640359,40.94639 -120.768629,40.593388 -119.639602,39.898056 -119.023464,39.252333 -118.042749,39.204274 -117.532702,38.737636 -118.059699,38.061476 -118.87815,37.897325 -118.911636,37.448464 -119.702802,37.156389 -120.823457,37.870428 -121.711259,37.481123 -122.357937,37.454484 -122.519995,36.930614 -121.104164,36.651329 -120.637009,36.11144 -119.664562,35.609791 -119.151208,34.909859 -120.227525,34.360332 -120.620369,33.376723 -121.229014,32.460319 -121.908146,31.692174 -121.891919,30.949352 -121.264257,30.676267 -121.503519,30.142915 -122.092114,29.83252 -121.938428,29.018022 -121.684439,28.225513 -121.125661,28.135673 -120.395473,27.053207 -119.585497,25.740781 -118.656871,24.547391 -117.281606,23.624501 -115.890735,22.782873 -114.763827,22.668074 -114.152547,22.22376 -113.80678,22.54834 -113.241078,22.051367 -111.843592,21.550494 -110.785466,21.397144 -110.444039,20.341033 -109.889861,20.282457 -109.627655,21.008227 -109.864488,21.395051 -108.522813,21.715212 -108.05018,21.55238 -107.04342,21.811899 -106.567273,22.218205 -106.725403,22.794268 -105.811247,22.976892 -105.329209,23.352063 -104.476858,22.81915 -103.504515,22.703757 -102.706992,22.708795 -102.170436,22.464753 -101.652018,22.318199 -101.80312,21.174367 -101.270026,21.201652 -101.180005,21.436573 -101.150033,21.849984 -100.416538,21.558839 -99.983489,21.742937 -99.240899,22.118314 -99.531992,22.949039 -98.898749,23.142722 -98.660262,24.063286 -97.60472,23.897405 -97.724609,25.083637 -98.671838,25.918703 -98.712094,26.743536 -98.68269,27.508812 -98.246231,27.747221 -97.911988,28.335945 -97.327114,28.261583 -96.248833,28.411031 -96.586591,28.83098 -96.117679,29.452802 -95.404802,29.031717 -94.56599,29.277438 -93.413348,28.640629 -92.503119,27.896876 -91.696657,27.771742 -91.258854,28.040614 -90.730514,28.064954 -90.015829,28.296439 -89.47581,28.042759 -88.814248,27.299316 -88.730326,28.086865 -88.120441,27.876542 -86.954517,27.974262 -85.82332,28.203576 -85.011638,28.642774 -84.23458,28.839894 -83.898993,29.320226 -83.337115,29.463732 -82.327513,30.115268 -81.525804,30.422717 -81.111256,30.183481 -79.721367,30.882715 -78.738894,31.515906 -78.458446,32.618164 -79.176129,32.48378 -79.208892,32.994395 -78.811086,33.506198 -78.912269,34.321936 -77.837451,35.49401 -76.192848,35.898403 -75.896897,36.666806 -75.158028,37.133031 -74.980002,37.41999 -74.829986,37.990007 -74.864816,38.378846 -74.257514,38.606507 -73.928852,38.505815 -73.675379,39.431237 -73.960013,39.660008 -73.822244,39.893973 -74.776862,40.366425 -75.467828,40.562072 -76.526368,40.427946 -76.904484,41.066486 -78.187197,41.185316 -78.543661,41.582243 -80.11943,42.123941 -80.25999,42.349999 -80.18015,42.920068 -80.866206,43.180362 -79.966106,44.917517 -81.947071,45.317027 -82.458926,45.53965 -83.180484,47.330031 -85.16429,47.000956 -85.720484,47.452969 -85.768233,48.455751 -86.598776,48.549182 -87.35997,49.214981 -87.751264,49.297198 -88.013832,48.599463 -88.854298,48.069082 -90.280826,47.693549 -90.970809,46.888146 -90.585768,45.719716 -90.94554,45.286073 -92.133891,45.115076 -93.480734,44.975472 -94.688929,44.352332 -95.306875,44.241331 -95.762455,43.319449 -96.349396,42.725635 -97.451757,42.74889 -99.515817,42.524691 -100.845866,42.663804 -101.83304,42.514873 -103.312278,41.907468 -104.522282,41.908347 -104.964994,41.59741 -106.129316,42.134328 -107.744773,42.481516 -109.243596,42.519446 -110.412103,42.871234 -111.129682,43.406834 -111.829588,43.743118 -111.667737,44.073176 -111.348377,44.457442 -111.873306,45.102079 -112.436062,45.011646 -113.463907,44.808893 -114.460332,45.339817 -115.985096,45.727235 -116.717868,46.388202 -117.421701,46.672733 -118.874326,46.805412 -119.66327,46.69268 -119.772824,47.048059 -118.866574,47.74706 -118.064143,48.06673 -117.295507,47.697709 -116.308953,47.85341 -115.742837,47.726545 -115.485282,48.135383 -116.191802,49.134598 -116.678801,49.888531 -117.879244,49.510983 -119.288461,50.142883 -119.279366,50.582908 -120.18205,51.643566 -120.738191,51.964115 -120.725789,52.516226 -120.177089,52.753886 -121.003085,53.251401 -122.245748,53.431726 -123.571507,53.458804 -125.068211,53.161045 -125.946349,52.792799 -126.564399,51.784255 -126.939157,51.353894 -127.287456,50.739797 + 127.657407,49.76027 +129.397818,49.4406 +130.582293,48.729687 +130.987282,47.790132 +132.506672,47.78897 +133.373596,48.183442 +135.026311,48.47823 +134.500814,47.57844 +134.112362,47.212467 +133.769644,46.116927 +133.097127,45.144066 +131.883454,45.321162 +131.025212,44.967953 +131.288555,44.11152 +131.144688,42.92999 +130.633866,42.903015 +130.640016,42.395009 +129.994267,42.985387 +129.596669,42.424982 +128.052215,41.994285 +128.208433,41.466772 +127.343783,41.503152 +126.869083,41.816569 +126.182045,41.107336 +125.079942,40.569824 +124.265625,39.928493 +122.86757,39.637788 +122.131388,39.170452 +121.054554,38.897471 +121.585995,39.360854 +121.376757,39.750261 +122.168595,40.422443 +121.640359,40.94639 +120.768629,40.593388 +119.639602,39.898056 +119.023464,39.252333 +118.042749,39.204274 +117.532702,38.737636 +118.059699,38.061476 +118.87815,37.897325 +118.911636,37.448464 +119.702802,37.156389 +120.823457,37.870428 +121.711259,37.481123 +122.357937,37.454484 +122.519995,36.930614 +121.104164,36.651329 +120.637009,36.11144 +119.664562,35.609791 +119.151208,34.909859 +120.227525,34.360332 +120.620369,33.376723 +121.229014,32.460319 +121.908146,31.692174 +121.891919,30.949352 +121.264257,30.676267 +121.503519,30.142915 +122.092114,29.83252 +121.938428,29.018022 +121.684439,28.225513 +121.125661,28.135673 +120.395473,27.053207 +119.585497,25.740781 +118.656871,24.547391 +117.281606,23.624501 +115.890735,22.782873 +114.763827,22.668074 +114.152547,22.22376 +113.80678,22.54834 +113.241078,22.051367 +111.843592,21.550494 +110.785466,21.397144 +110.444039,20.341033 +109.889861,20.282457 +109.627655,21.008227 +109.864488,21.395051 +108.522813,21.715212 +108.05018,21.55238 +107.04342,21.811899 +106.567273,22.218205 +106.725403,22.794268 +105.811247,22.976892 +105.329209,23.352063 +104.476858,22.81915 +103.504515,22.703757 +102.706992,22.708795 +102.170436,22.464753 +101.652018,22.318199 +101.80312,21.174367 +101.270026,21.201652 +101.180005,21.436573 +101.150033,21.849984 +100.416538,21.558839 +99.983489,21.742937 +99.240899,22.118314 +99.531992,22.949039 +98.898749,23.142722 +98.660262,24.063286 +97.60472,23.897405 +97.724609,25.083637 +98.671838,25.918703 +98.712094,26.743536 +98.68269,27.508812 +98.246231,27.747221 +97.911988,28.335945 +97.327114,28.261583 +96.248833,28.411031 +96.586591,28.83098 +96.117679,29.452802 +95.404802,29.031717 +94.56599,29.277438 +93.413348,28.640629 +92.503119,27.896876 +91.696657,27.771742 +91.258854,28.040614 +90.730514,28.064954 +90.015829,28.296439 +89.47581,28.042759 +88.814248,27.299316 +88.730326,28.086865 +88.120441,27.876542 +86.954517,27.974262 +85.82332,28.203576 +85.011638,28.642774 +84.23458,28.839894 +83.898993,29.320226 +83.337115,29.463732 +82.327513,30.115268 +81.525804,30.422717 +81.111256,30.183481 +79.721367,30.882715 +78.738894,31.515906 +78.458446,32.618164 +79.176129,32.48378 +79.208892,32.994395 +78.811086,33.506198 +78.912269,34.321936 +77.837451,35.49401 +76.192848,35.898403 +75.896897,36.666806 +75.158028,37.133031 +74.980002,37.41999 +74.829986,37.990007 +74.864816,38.378846 +74.257514,38.606507 +73.928852,38.505815 +73.675379,39.431237 +73.960013,39.660008 +73.822244,39.893973 +74.776862,40.366425 +75.467828,40.562072 +76.526368,40.427946 +76.904484,41.066486 +78.187197,41.185316 +78.543661,41.582243 +80.11943,42.123941 +80.25999,42.349999 +80.18015,42.920068 +80.866206,43.180362 +79.966106,44.917517 +81.947071,45.317027 +82.458926,45.53965 +83.180484,47.330031 +85.16429,47.000956 +85.720484,47.452969 +85.768233,48.455751 +86.598776,48.549182 +87.35997,49.214981 +87.751264,49.297198 +88.013832,48.599463 +88.854298,48.069082 +90.280826,47.693549 +90.970809,46.888146 +90.585768,45.719716 +90.94554,45.286073 +92.133891,45.115076 +93.480734,44.975472 +94.688929,44.352332 +95.306875,44.241331 +95.762455,43.319449 +96.349396,42.725635 +97.451757,42.74889 +99.515817,42.524691 +100.845866,42.663804 +101.83304,42.514873 +103.312278,41.907468 +104.522282,41.908347 +104.964994,41.59741 +106.129316,42.134328 +107.744773,42.481516 +109.243596,42.519446 +110.412103,42.871234 +111.129682,43.406834 +111.829588,43.743118 +111.667737,44.073176 +111.348377,44.457442 +111.873306,45.102079 +112.436062,45.011646 +113.463907,44.808893 +114.460332,45.339817 +115.985096,45.727235 +116.717868,46.388202 +117.421701,46.672733 +118.874326,46.805412 +119.66327,46.69268 +119.772824,47.048059 +118.866574,47.74706 +118.064143,48.06673 +117.295507,47.697709 +116.308953,47.85341 +115.742837,47.726545 +115.485282,48.135383 +116.191802,49.134598 +116.678801,49.888531 +117.879244,49.510983 +119.288461,50.142883 +119.279366,50.582908 +120.18205,51.643566 +120.738191,51.964115 +120.725789,52.516226 +120.177089,52.753886 +121.003085,53.251401 +122.245748,53.431726 +123.571507,53.458804 +125.068211,53.161045 +125.946349,52.792799 +126.564399,51.784255 +126.939157,51.353894 +127.287456,50.739797 127.657407,49.76027 - + #defaultStyle Ivory Coast - - - - - - - -2.856125,4.994476 --3.311084,4.984296 --4.00882,5.179813 --4.649917,5.168264 --5.834496,4.993701 --6.528769,4.705088 --7.518941,4.338288 --7.712159,4.364566 --7.635368,5.188159 --7.539715,5.313345 --7.570153,5.707352 --7.993693,6.12619 --8.311348,6.193033 --8.60288,6.467564 --8.385452,6.911801 --8.485446,7.395208 --8.439298,7.686043 --8.280703,7.68718 --8.221792,8.123329 --8.299049,8.316444 --8.203499,8.455453 --7.8321,8.575704 --8.079114,9.376224 --8.309616,9.789532 --8.229337,10.12902 --8.029944,10.206535 --7.89959,10.297382 --7.622759,10.147236 --6.850507,10.138994 --6.666461,10.430811 --6.493965,10.411303 --6.205223,10.524061 --6.050452,10.096361 --5.816926,10.222555 --5.404342,10.370737 --4.954653,10.152714 --4.779884,9.821985 --4.330247,9.610835 --3.980449,9.862344 --3.511899,9.900326 --2.827496,9.642461 --2.56219,8.219628 --2.983585,7.379705 --3.24437,6.250472 --2.810701,5.389051 + + + + + + + -2.856125,4.994476 +-3.311084,4.984296 +-4.00882,5.179813 +-4.649917,5.168264 +-5.834496,4.993701 +-6.528769,4.705088 +-7.518941,4.338288 +-7.712159,4.364566 +-7.635368,5.188159 +-7.539715,5.313345 +-7.570153,5.707352 +-7.993693,6.12619 +-8.311348,6.193033 +-8.60288,6.467564 +-8.385452,6.911801 +-8.485446,7.395208 +-8.439298,7.686043 +-8.280703,7.68718 +-8.221792,8.123329 +-8.299049,8.316444 +-8.203499,8.455453 +-7.8321,8.575704 +-8.079114,9.376224 +-8.309616,9.789532 +-8.229337,10.12902 +-8.029944,10.206535 +-7.89959,10.297382 +-7.622759,10.147236 +-6.850507,10.138994 +-6.666461,10.430811 +-6.493965,10.411303 +-6.205223,10.524061 +-6.050452,10.096361 +-5.816926,10.222555 +-5.404342,10.370737 +-4.954653,10.152714 +-4.779884,9.821985 +-4.330247,9.610835 +-3.980449,9.862344 +-3.511899,9.900326 +-2.827496,9.642461 +-2.56219,8.219628 +-2.983585,7.379705 +-3.24437,6.250472 +-2.810701,5.389051 -2.856125,4.994476 - + #defaultStyle Cameroon - - - - - - - 13.075822,2.267097 -12.951334,2.321616 -12.35938,2.192812 -11.751665,2.326758 -11.276449,2.261051 -9.649158,2.283866 -9.795196,3.073404 -9.404367,3.734527 -8.948116,3.904129 -8.744924,4.352215 -8.488816,4.495617 -8.500288,4.771983 -8.757533,5.479666 -9.233163,6.444491 -9.522706,6.453482 -10.118277,7.03877 -10.497375,7.055358 -11.058788,6.644427 -11.745774,6.981383 -11.839309,7.397042 -12.063946,7.799808 -12.218872,8.305824 -12.753672,8.717763 -12.955468,9.417772 -13.1676,9.640626 -13.308676,10.160362 -13.57295,10.798566 -14.415379,11.572369 -14.468192,11.904752 -14.577178,12.085361 -14.181336,12.483657 -14.213531,12.802035 -14.495787,12.859396 -14.893386,12.219048 -14.960152,11.555574 -14.923565,10.891325 -15.467873,9.982337 -14.909354,9.992129 -14.627201,9.920919 -14.171466,10.021378 -13.954218,9.549495 -14.544467,8.965861 -14.979996,8.796104 -15.120866,8.38215 -15.436092,7.692812 -15.27946,7.421925 -14.776545,6.408498 -14.53656,6.226959 -14.459407,5.451761 -14.558936,5.030598 -14.478372,4.732605 -14.950953,4.210389 -15.03622,3.851367 -15.405396,3.335301 -15.862732,3.013537 -15.907381,2.557389 -16.012852,2.26764 -15.940919,1.727673 -15.146342,1.964015 -14.337813,2.227875 + + + + + + + 13.075822,2.267097 +12.951334,2.321616 +12.35938,2.192812 +11.751665,2.326758 +11.276449,2.261051 +9.649158,2.283866 +9.795196,3.073404 +9.404367,3.734527 +8.948116,3.904129 +8.744924,4.352215 +8.488816,4.495617 +8.500288,4.771983 +8.757533,5.479666 +9.233163,6.444491 +9.522706,6.453482 +10.118277,7.03877 +10.497375,7.055358 +11.058788,6.644427 +11.745774,6.981383 +11.839309,7.397042 +12.063946,7.799808 +12.218872,8.305824 +12.753672,8.717763 +12.955468,9.417772 +13.1676,9.640626 +13.308676,10.160362 +13.57295,10.798566 +14.415379,11.572369 +14.468192,11.904752 +14.577178,12.085361 +14.181336,12.483657 +14.213531,12.802035 +14.495787,12.859396 +14.893386,12.219048 +14.960152,11.555574 +14.923565,10.891325 +15.467873,9.982337 +14.909354,9.992129 +14.627201,9.920919 +14.171466,10.021378 +13.954218,9.549495 +14.544467,8.965861 +14.979996,8.796104 +15.120866,8.38215 +15.436092,7.692812 +15.27946,7.421925 +14.776545,6.408498 +14.53656,6.226959 +14.459407,5.451761 +14.558936,5.030598 +14.478372,4.732605 +14.950953,4.210389 +15.03622,3.851367 +15.405396,3.335301 +15.862732,3.013537 +15.907381,2.557389 +16.012852,2.26764 +15.940919,1.727673 +15.146342,1.964015 +14.337813,2.227875 13.075822,2.267097 - + #defaultStyle Democratic Republic of the Congo - - - - - - - 30.83386,3.509166 -30.773347,2.339883 -31.174149,2.204465 -30.85267,1.849396 -30.468508,1.583805 -30.086154,1.062313 -29.875779,0.59738 -29.819503,-0.20531 -29.587838,-0.587406 -29.579466,-1.341313 -29.291887,-1.620056 -29.254835,-2.21511 -29.117479,-2.292211 -29.024926,-2.839258 -29.276384,-3.293907 -29.339998,-4.499983 -29.519987,-5.419979 -29.419993,-5.939999 -29.620032,-6.520015 -30.199997,-7.079981 -30.740015,-8.340007 -30.346086,-8.238257 -29.002912,-8.407032 -28.734867,-8.526559 -28.449871,-9.164918 -28.673682,-9.605925 -28.49607,-10.789884 -28.372253,-11.793647 -28.642417,-11.971569 -29.341548,-12.360744 -29.616001,-12.178895 -29.699614,-13.257227 -28.934286,-13.248958 -28.523562,-12.698604 -28.155109,-12.272481 -27.388799,-12.132747 -27.16442,-11.608748 -26.553088,-11.92444 -25.75231,-11.784965 -25.418118,-11.330936 -24.78317,-11.238694 -24.314516,-11.262826 -24.257155,-10.951993 -23.912215,-10.926826 -23.456791,-10.867863 -22.837345,-11.017622 -22.402798,-10.993075 -22.155268,-11.084801 -22.208753,-9.894796 -21.875182,-9.523708 -21.801801,-8.908707 -21.949131,-8.305901 -21.746456,-7.920085 -21.728111,-7.290872 -20.514748,-7.299606 -20.601823,-6.939318 -20.091622,-6.94309 -20.037723,-7.116361 -19.417502,-7.155429 -19.166613,-7.738184 -19.016752,-7.988246 -18.464176,-7.847014 -18.134222,-7.987678 -17.47297,-8.068551 -17.089996,-7.545689 -16.860191,-7.222298 -16.57318,-6.622645 -16.326528,-5.87747 -13.375597,-5.864241 -13.024869,-5.984389 -12.735171,-5.965682 -12.322432,-6.100092 -12.182337,-5.789931 -12.436688,-5.684304 -12.468004,-5.248362 -12.631612,-4.991271 -12.995517,-4.781103 -13.25824,-4.882957 -13.600235,-4.500138 -14.144956,-4.510009 -14.209035,-4.793092 -14.582604,-4.970239 -15.170992,-4.343507 -15.75354,-3.855165 -16.00629,-3.535133 -15.972803,-2.712392 -16.407092,-1.740927 -16.865307,-1.225816 -17.523716,-0.74383 -17.638645,-0.424832 -17.663553,-0.058084 -17.82654,0.288923 -17.774192,0.855659 -17.898835,1.741832 -18.094276,2.365722 -18.393792,2.900443 -18.453065,3.504386 -18.542982,4.201785 -18.932312,4.709506 -19.467784,5.031528 -20.290679,4.691678 -20.927591,4.322786 -21.659123,4.224342 -22.405124,4.02916 -22.704124,4.633051 -22.84148,4.710126 -23.297214,4.609693 -24.410531,5.108784 -24.805029,4.897247 -25.128833,4.927245 -25.278798,5.170408 -25.650455,5.256088 -26.402761,5.150875 -27.044065,5.127853 -27.374226,5.233944 -27.979977,4.408413 -28.428994,4.287155 -28.696678,4.455077 -29.159078,4.389267 -29.715995,4.600805 -29.9535,4.173699 + + + + + + + 30.83386,3.509166 +30.773347,2.339883 +31.174149,2.204465 +30.85267,1.849396 +30.468508,1.583805 +30.086154,1.062313 +29.875779,0.59738 +29.819503,-0.20531 +29.587838,-0.587406 +29.579466,-1.341313 +29.291887,-1.620056 +29.254835,-2.21511 +29.117479,-2.292211 +29.024926,-2.839258 +29.276384,-3.293907 +29.339998,-4.499983 +29.519987,-5.419979 +29.419993,-5.939999 +29.620032,-6.520015 +30.199997,-7.079981 +30.740015,-8.340007 +30.346086,-8.238257 +29.002912,-8.407032 +28.734867,-8.526559 +28.449871,-9.164918 +28.673682,-9.605925 +28.49607,-10.789884 +28.372253,-11.793647 +28.642417,-11.971569 +29.341548,-12.360744 +29.616001,-12.178895 +29.699614,-13.257227 +28.934286,-13.248958 +28.523562,-12.698604 +28.155109,-12.272481 +27.388799,-12.132747 +27.16442,-11.608748 +26.553088,-11.92444 +25.75231,-11.784965 +25.418118,-11.330936 +24.78317,-11.238694 +24.314516,-11.262826 +24.257155,-10.951993 +23.912215,-10.926826 +23.456791,-10.867863 +22.837345,-11.017622 +22.402798,-10.993075 +22.155268,-11.084801 +22.208753,-9.894796 +21.875182,-9.523708 +21.801801,-8.908707 +21.949131,-8.305901 +21.746456,-7.920085 +21.728111,-7.290872 +20.514748,-7.299606 +20.601823,-6.939318 +20.091622,-6.94309 +20.037723,-7.116361 +19.417502,-7.155429 +19.166613,-7.738184 +19.016752,-7.988246 +18.464176,-7.847014 +18.134222,-7.987678 +17.47297,-8.068551 +17.089996,-7.545689 +16.860191,-7.222298 +16.57318,-6.622645 +16.326528,-5.87747 +13.375597,-5.864241 +13.024869,-5.984389 +12.735171,-5.965682 +12.322432,-6.100092 +12.182337,-5.789931 +12.436688,-5.684304 +12.468004,-5.248362 +12.631612,-4.991271 +12.995517,-4.781103 +13.25824,-4.882957 +13.600235,-4.500138 +14.144956,-4.510009 +14.209035,-4.793092 +14.582604,-4.970239 +15.170992,-4.343507 +15.75354,-3.855165 +16.00629,-3.535133 +15.972803,-2.712392 +16.407092,-1.740927 +16.865307,-1.225816 +17.523716,-0.74383 +17.638645,-0.424832 +17.663553,-0.058084 +17.82654,0.288923 +17.774192,0.855659 +17.898835,1.741832 +18.094276,2.365722 +18.393792,2.900443 +18.453065,3.504386 +18.542982,4.201785 +18.932312,4.709506 +19.467784,5.031528 +20.290679,4.691678 +20.927591,4.322786 +21.659123,4.224342 +22.405124,4.02916 +22.704124,4.633051 +22.84148,4.710126 +23.297214,4.609693 +24.410531,5.108784 +24.805029,4.897247 +25.128833,4.927245 +25.278798,5.170408 +25.650455,5.256088 +26.402761,5.150875 +27.044065,5.127853 +27.374226,5.233944 +27.979977,4.408413 +28.428994,4.287155 +28.696678,4.455077 +29.159078,4.389267 +29.715995,4.600805 +29.9535,4.173699 30.83386,3.509166 - + #defaultStyle Cuba - - - - - - - -82.268151,23.188611 --81.404457,23.117271 --80.618769,23.10598 --79.679524,22.765303 --79.281486,22.399202 --78.347434,22.512166 --77.993296,22.277194 --77.146422,21.657851 --76.523825,21.20682 --76.19462,21.220565 --75.598222,21.016624 --75.67106,20.735091 --74.933896,20.693905 --74.178025,20.284628 --74.296648,20.050379 --74.961595,19.923435 --75.63468,19.873774 --76.323656,19.952891 --77.755481,19.855481 --77.085108,20.413354 --77.492655,20.673105 --78.137292,20.739949 --78.482827,21.028613 --78.719867,21.598114 --79.285,21.559175 --80.217475,21.827324 --80.517535,22.037079 --81.820943,22.192057 --82.169992,22.387109 --81.795002,22.636965 --82.775898,22.68815 --83.494459,22.168518 --83.9088,22.154565 --84.052151,21.910575 --84.54703,21.801228 --84.974911,21.896028 --84.447062,22.20495 --84.230357,22.565755 --83.77824,22.788118 --83.267548,22.983042 --82.510436,23.078747 + + + + + + + -82.268151,23.188611 +-81.404457,23.117271 +-80.618769,23.10598 +-79.679524,22.765303 +-79.281486,22.399202 +-78.347434,22.512166 +-77.993296,22.277194 +-77.146422,21.657851 +-76.523825,21.20682 +-76.19462,21.220565 +-75.598222,21.016624 +-75.67106,20.735091 +-74.933896,20.693905 +-74.178025,20.284628 +-74.296648,20.050379 +-74.961595,19.923435 +-75.63468,19.873774 +-76.323656,19.952891 +-77.755481,19.855481 +-77.085108,20.413354 +-77.492655,20.673105 +-78.137292,20.739949 +-78.482827,21.028613 +-78.719867,21.598114 +-79.285,21.559175 +-80.217475,21.827324 +-80.517535,22.037079 +-81.820943,22.192057 +-82.169992,22.387109 +-81.795002,22.636965 +-82.775898,22.68815 +-83.494459,22.168518 +-83.9088,22.154565 +-84.052151,21.910575 +-84.54703,21.801228 +-84.974911,21.896028 +-84.447062,22.20495 +-84.230357,22.565755 +-83.77824,22.788118 +-83.267548,22.983042 +-82.510436,23.078747 -82.268151,23.188611 - + #defaultStyle Northern Cyprus - + - 32.73178,35.140026 -32.802474,35.145504 -32.946961,35.386703 -33.667227,35.373216 -34.576474,35.671596 -33.900804,35.245756 -33.973617,35.058506 -33.86644,35.093595 -33.675392,35.017863 -33.525685,35.038688 -33.475817,35.000345 -33.455922,35.101424 -33.383833,35.162712 -33.190977,35.173125 -32.919572,35.087833 + 32.73178,35.140026 +32.802474,35.145504 +32.946961,35.386703 +33.667227,35.373216 +34.576474,35.671596 +33.900804,35.245756 +33.973617,35.058506 +33.86644,35.093595 +33.675392,35.017863 +33.525685,35.038688 +33.475817,35.000345 +33.455922,35.101424 +33.383833,35.162712 +33.190977,35.173125 +32.919572,35.087833 32.73178,35.140026 - + #defaultStyle Cyprus - + - 33.973617,35.058506 -34.004881,34.978098 -32.979827,34.571869 -32.490296,34.701655 -32.256667,35.103232 -32.73178,35.140026 -32.919572,35.087833 -33.190977,35.173125 -33.383833,35.162712 -33.455922,35.101424 -33.475817,35.000345 -33.525685,35.038688 -33.675392,35.017863 -33.86644,35.093595 + 33.973617,35.058506 +34.004881,34.978098 +32.979827,34.571869 +32.490296,34.701655 +32.256667,35.103232 +32.73178,35.140026 +32.919572,35.087833 +33.190977,35.173125 +33.383833,35.162712 +33.455922,35.101424 +33.475817,35.000345 +33.525685,35.038688 +33.675392,35.017863 +33.86644,35.093595 33.973617,35.058506 - + #defaultStyle Czech Republic - - - - - - - 16.960288,48.596982 -16.499283,48.785808 -16.029647,48.733899 -15.253416,49.039074 -14.901447,48.964402 -14.338898,48.555305 -13.595946,48.877172 -13.031329,49.307068 -12.521024,49.547415 -12.415191,49.969121 -12.240111,50.266338 -12.966837,50.484076 -13.338132,50.733234 -14.056228,50.926918 -14.307013,51.117268 -14.570718,51.002339 -15.016996,51.106674 -15.490972,50.78473 -16.238627,50.697733 -16.176253,50.422607 -16.719476,50.215747 -16.868769,50.473974 -17.554567,50.362146 -17.649445,50.049038 -18.392914,49.988629 -18.853144,49.49623 -18.554971,49.495015 -18.399994,49.315001 -18.170498,49.271515 -18.104973,49.043983 -17.913512,48.996493 -17.886485,48.903475 -17.545007,48.800019 -17.101985,48.816969 + + + + + + + 16.960288,48.596982 +16.499283,48.785808 +16.029647,48.733899 +15.253416,49.039074 +14.901447,48.964402 +14.338898,48.555305 +13.595946,48.877172 +13.031329,49.307068 +12.521024,49.547415 +12.415191,49.969121 +12.240111,50.266338 +12.966837,50.484076 +13.338132,50.733234 +14.056228,50.926918 +14.307013,51.117268 +14.570718,51.002339 +15.016996,51.106674 +15.490972,50.78473 +16.238627,50.697733 +16.176253,50.422607 +16.719476,50.215747 +16.868769,50.473974 +17.554567,50.362146 +17.649445,50.049038 +18.392914,49.988629 +18.853144,49.49623 +18.554971,49.495015 +18.399994,49.315001 +18.170498,49.271515 +18.104973,49.043983 +17.913512,48.996493 +17.886485,48.903475 +17.545007,48.800019 +17.101985,48.816969 16.960288,48.596982 - + #defaultStyle Germany - - - - - - - 9.921906,54.983104 -9.93958,54.596642 -10.950112,54.363607 -10.939467,54.008693 -11.956252,54.196486 -12.51844,54.470371 -13.647467,54.075511 -14.119686,53.757029 -14.353315,53.248171 -14.074521,52.981263 -14.4376,52.62485 -14.685026,52.089947 -14.607098,51.745188 -15.016996,51.106674 -14.570718,51.002339 -14.307013,51.117268 -14.056228,50.926918 -13.338132,50.733234 -12.966837,50.484076 -12.240111,50.266338 -12.415191,49.969121 -12.521024,49.547415 -13.031329,49.307068 -13.595946,48.877172 -13.243357,48.416115 -12.884103,48.289146 -13.025851,47.637584 -12.932627,47.467646 -12.62076,47.672388 -12.141357,47.703083 -11.426414,47.523766 -10.544504,47.566399 -10.402084,47.302488 -9.896068,47.580197 -9.594226,47.525058 -8.522612,47.830828 -8.317301,47.61358 -7.466759,47.620582 -7.593676,48.333019 -8.099279,49.017784 -6.65823,49.201958 -6.18632,49.463803 -6.242751,49.902226 -6.043073,50.128052 -6.156658,50.803721 -5.988658,51.851616 -6.589397,51.852029 -6.84287,52.22844 -7.092053,53.144043 -6.90514,53.482162 -7.100425,53.693932 -7.936239,53.748296 -8.121706,53.527792 -8.800734,54.020786 -8.572118,54.395646 -8.526229,54.962744 -9.282049,54.830865 + + + + + + + 9.921906,54.983104 +9.93958,54.596642 +10.950112,54.363607 +10.939467,54.008693 +11.956252,54.196486 +12.51844,54.470371 +13.647467,54.075511 +14.119686,53.757029 +14.353315,53.248171 +14.074521,52.981263 +14.4376,52.62485 +14.685026,52.089947 +14.607098,51.745188 +15.016996,51.106674 +14.570718,51.002339 +14.307013,51.117268 +14.056228,50.926918 +13.338132,50.733234 +12.966837,50.484076 +12.240111,50.266338 +12.415191,49.969121 +12.521024,49.547415 +13.031329,49.307068 +13.595946,48.877172 +13.243357,48.416115 +12.884103,48.289146 +13.025851,47.637584 +12.932627,47.467646 +12.62076,47.672388 +12.141357,47.703083 +11.426414,47.523766 +10.544504,47.566399 +10.402084,47.302488 +9.896068,47.580197 +9.594226,47.525058 +8.522612,47.830828 +8.317301,47.61358 +7.466759,47.620582 +7.593676,48.333019 +8.099279,49.017784 +6.65823,49.201958 +6.18632,49.463803 +6.242751,49.902226 +6.043073,50.128052 +6.156658,50.803721 +5.988658,51.851616 +6.589397,51.852029 +6.84287,52.22844 +7.092053,53.144043 +6.90514,53.482162 +7.100425,53.693932 +7.936239,53.748296 +8.121706,53.527792 +8.800734,54.020786 +8.572118,54.395646 +8.526229,54.962744 +9.282049,54.830865 9.921906,54.983104 - + #defaultStyle Djibouti - + - 43.081226,12.699639 -43.317852,12.390148 -43.286381,11.974928 -42.715874,11.735641 -43.145305,11.46204 -42.776852,10.926879 -42.55493,11.10511 -42.31414,11.0342 -41.75557,11.05091 -41.73959,11.35511 -41.66176,11.6312 -42.0,12.1 -42.35156,12.54223 -42.779642,12.455416 + 43.081226,12.699639 +43.317852,12.390148 +43.286381,11.974928 +42.715874,11.735641 +43.145305,11.46204 +42.776852,10.926879 +42.55493,11.10511 +42.31414,11.0342 +41.75557,11.05091 +41.73959,11.35511 +41.66176,11.6312 +42.0,12.1 +42.35156,12.54223 +42.779642,12.455416 43.081226,12.699639 - + #defaultStyle Denmark - + - 12.690006,55.609991 -12.089991,54.800015 -11.043543,55.364864 -10.903914,55.779955 -12.370904,56.111407 + 12.690006,55.609991 +12.089991,54.800015 +11.043543,55.364864 +10.903914,55.779955 +12.370904,56.111407 12.690006,55.609991 - + - 10.912182,56.458621 -10.667804,56.081383 -10.369993,56.190007 -9.649985,55.469999 -9.921906,54.983104 -9.282049,54.830865 -8.526229,54.962744 -8.120311,55.517723 -8.089977,56.540012 -8.256582,56.809969 -8.543438,57.110003 -9.424469,57.172066 -9.775559,57.447941 -10.580006,57.730017 -10.546106,57.215733 -10.25,56.890016 -10.369993,56.609982 + 10.912182,56.458621 +10.667804,56.081383 +10.369993,56.190007 +9.649985,55.469999 +9.921906,54.983104 +9.282049,54.830865 +8.526229,54.962744 +8.120311,55.517723 +8.089977,56.540012 +8.256582,56.809969 +8.543438,57.110003 +9.424469,57.172066 +9.775559,57.447941 +10.580006,57.730017 +10.546106,57.215733 +10.25,56.890016 +10.369993,56.609982 10.912182,56.458621 - + #defaultStyle Dominican Republic - - - - - - - -71.712361,19.714456 --71.587304,19.884911 --70.806706,19.880286 --70.214365,19.622885 --69.950815,19.648 --69.76925,19.293267 --69.222126,19.313214 --69.254346,19.015196 --68.809412,18.979074 --68.317943,18.612198 --68.689316,18.205142 --69.164946,18.422648 --69.623988,18.380713 --69.952934,18.428307 --70.133233,18.245915 --70.517137,18.184291 --70.669298,18.426886 --70.99995,18.283329 --71.40021,17.598564 --71.657662,17.757573 --71.708305,18.044997 --71.687738,18.31666 --71.945112,18.6169 --71.701303,18.785417 --71.624873,19.169838 + + + + + + + -71.712361,19.714456 +-71.587304,19.884911 +-70.806706,19.880286 +-70.214365,19.622885 +-69.950815,19.648 +-69.76925,19.293267 +-69.222126,19.313214 +-69.254346,19.015196 +-68.809412,18.979074 +-68.317943,18.612198 +-68.689316,18.205142 +-69.164946,18.422648 +-69.623988,18.380713 +-69.952934,18.428307 +-70.133233,18.245915 +-70.517137,18.184291 +-70.669298,18.426886 +-70.99995,18.283329 +-71.40021,17.598564 +-71.657662,17.757573 +-71.708305,18.044997 +-71.687738,18.31666 +-71.945112,18.6169 +-71.701303,18.785417 +-71.624873,19.169838 -71.712361,19.714456 - + #defaultStyle Algeria - - - - - - - 11.999506,23.471668 -8.572893,21.565661 -5.677566,19.601207 -4.267419,19.155265 -3.158133,19.057364 -3.146661,19.693579 -2.683588,19.85623 -2.060991,20.142233 -1.823228,20.610809 --1.550055,22.792666 --4.923337,24.974574 --8.6844,27.395744 --8.665124,27.589479 --8.66559,27.656426 --8.674116,28.841289 --7.059228,29.579228 --6.060632,29.7317 --5.242129,30.000443 --4.859646,30.501188 --3.690441,30.896952 --3.647498,31.637294 --3.06898,31.724498 --2.616605,32.094346 --1.307899,32.262889 --1.124551,32.651522 --1.388049,32.864015 --1.733455,33.919713 --1.792986,34.527919 --2.169914,35.168396 --1.208603,35.714849 --0.127454,35.888662 -0.503877,36.301273 -1.466919,36.605647 -3.161699,36.783905 -4.815758,36.865037 -5.32012,36.716519 -6.26182,37.110655 -7.330385,37.118381 -7.737078,36.885708 -8.420964,36.946427 -8.217824,36.433177 -8.376368,35.479876 -8.140981,34.655146 -7.524482,34.097376 -7.612642,33.344115 -8.430473,32.748337 -8.439103,32.506285 -9.055603,32.102692 -9.48214,30.307556 -9.805634,29.424638 -9.859998,28.95999 -9.683885,28.144174 -9.756128,27.688259 -9.629056,27.140953 -9.716286,26.512206 -9.319411,26.094325 -9.910693,25.365455 -9.948261,24.936954 -10.303847,24.379313 -10.771364,24.562532 -11.560669,24.097909 + + + + + + + 11.999506,23.471668 +8.572893,21.565661 +5.677566,19.601207 +4.267419,19.155265 +3.158133,19.057364 +3.146661,19.693579 +2.683588,19.85623 +2.060991,20.142233 +1.823228,20.610809 +-1.550055,22.792666 +-4.923337,24.974574 +-8.6844,27.395744 +-8.665124,27.589479 +-8.66559,27.656426 +-8.674116,28.841289 +-7.059228,29.579228 +-6.060632,29.7317 +-5.242129,30.000443 +-4.859646,30.501188 +-3.690441,30.896952 +-3.647498,31.637294 +-3.06898,31.724498 +-2.616605,32.094346 +-1.307899,32.262889 +-1.124551,32.651522 +-1.388049,32.864015 +-1.733455,33.919713 +-1.792986,34.527919 +-2.169914,35.168396 +-1.208603,35.714849 +-0.127454,35.888662 +0.503877,36.301273 +1.466919,36.605647 +3.161699,36.783905 +4.815758,36.865037 +5.32012,36.716519 +6.26182,37.110655 +7.330385,37.118381 +7.737078,36.885708 +8.420964,36.946427 +8.217824,36.433177 +8.376368,35.479876 +8.140981,34.655146 +7.524482,34.097376 +7.612642,33.344115 +8.430473,32.748337 +8.439103,32.506285 +9.055603,32.102692 +9.48214,30.307556 +9.805634,29.424638 +9.859998,28.95999 +9.683885,28.144174 +9.756128,27.688259 +9.629056,27.140953 +9.716286,26.512206 +9.319411,26.094325 +9.910693,25.365455 +9.948261,24.936954 +10.303847,24.379313 +10.771364,24.562532 +11.560669,24.097909 11.999506,23.471668 - + #defaultStyle Ecuador - - - - - - - -80.302561,-3.404856 --79.770293,-2.657512 --79.986559,-2.220794 --80.368784,-2.685159 --80.967765,-2.246943 --80.764806,-1.965048 --80.933659,-1.057455 --80.58337,-0.906663 --80.399325,-0.283703 --80.020898,0.36034 --80.09061,0.768429 --79.542762,0.982938 --78.855259,1.380924 --77.855061,0.809925 --77.668613,0.825893 --77.424984,0.395687 --76.57638,0.256936 --76.292314,0.416047 --75.801466,0.084801 --75.373223,-0.152032 --75.233723,-0.911417 --75.544996,-1.56161 --76.635394,-2.608678 --77.837905,-3.003021 --78.450684,-3.873097 --78.639897,-4.547784 --79.205289,-4.959129 --79.624979,-4.454198 --80.028908,-4.346091 --80.442242,-4.425724 --80.469295,-4.059287 --80.184015,-3.821162 + + + + + + + -80.302561,-3.404856 +-79.770293,-2.657512 +-79.986559,-2.220794 +-80.368784,-2.685159 +-80.967765,-2.246943 +-80.764806,-1.965048 +-80.933659,-1.057455 +-80.58337,-0.906663 +-80.399325,-0.283703 +-80.020898,0.36034 +-80.09061,0.768429 +-79.542762,0.982938 +-78.855259,1.380924 +-77.855061,0.809925 +-77.668613,0.825893 +-77.424984,0.395687 +-76.57638,0.256936 +-76.292314,0.416047 +-75.801466,0.084801 +-75.373223,-0.152032 +-75.233723,-0.911417 +-75.544996,-1.56161 +-76.635394,-2.608678 +-77.837905,-3.003021 +-78.450684,-3.873097 +-78.639897,-4.547784 +-79.205289,-4.959129 +-79.624979,-4.454198 +-80.028908,-4.346091 +-80.442242,-4.425724 +-80.469295,-4.059287 +-80.184015,-3.821162 -80.302561,-3.404856 - + #defaultStyle Egypt - - - - - - - 34.9226,29.50133 -34.64174,29.09942 -34.42655,28.34399 -34.15451,27.8233 -33.92136,27.6487 -33.58811,27.97136 -33.13676,28.41765 -32.42323,29.85108 -32.32046,29.76043 -32.73482,28.70523 -33.34876,27.69989 -34.10455,26.14227 -34.47387,25.59856 -34.79507,25.03375 -35.69241,23.92671 -35.49372,23.75237 -35.52598,23.10244 -36.69069,22.20485 -36.86623,22.0 -32.9,22.0 -29.02,22.0 -25.0,22.0 -25.0,25.6825 -25.0,29.238655 -24.70007,30.04419 -24.95762,30.6616 -24.80287,31.08929 -25.16482,31.56915 -26.49533,31.58568 -27.45762,31.32126 -28.45048,31.02577 -28.91353,30.87005 -29.68342,31.18686 -30.09503,31.4734 -30.97693,31.55586 -31.68796,31.4296 -31.96041,30.9336 -32.19247,31.26034 -32.99392,31.02407 -33.7734,30.96746 -34.26544,31.21936 + + + + + + + 34.9226,29.50133 +34.64174,29.09942 +34.42655,28.34399 +34.15451,27.8233 +33.92136,27.6487 +33.58811,27.97136 +33.13676,28.41765 +32.42323,29.85108 +32.32046,29.76043 +32.73482,28.70523 +33.34876,27.69989 +34.10455,26.14227 +34.47387,25.59856 +34.79507,25.03375 +35.69241,23.92671 +35.49372,23.75237 +35.52598,23.10244 +36.69069,22.20485 +36.86623,22.0 +32.9,22.0 +29.02,22.0 +25.0,22.0 +25.0,25.6825 +25.0,29.238655 +24.70007,30.04419 +24.95762,30.6616 +24.80287,31.08929 +25.16482,31.56915 +26.49533,31.58568 +27.45762,31.32126 +28.45048,31.02577 +28.91353,30.87005 +29.68342,31.18686 +30.09503,31.4734 +30.97693,31.55586 +31.68796,31.4296 +31.96041,30.9336 +32.19247,31.26034 +32.99392,31.02407 +33.7734,30.96746 +34.26544,31.21936 34.9226,29.50133 - + #defaultStyle Eritrea - - - - - - - 42.35156,12.54223 -42.00975,12.86582 -41.59856,13.45209 -41.155194,13.77332 -40.8966,14.11864 -40.026219,14.519579 -39.34061,14.53155 -39.0994,14.74064 -38.51295,14.50547 -37.90607,14.95943 -37.59377,14.2131 -36.42951,14.42211 -36.323189,14.822481 -36.75386,16.291874 -36.85253,16.95655 -37.16747,17.26314 -37.904,17.42754 -38.41009,17.998307 -38.990623,16.840626 -39.26611,15.922723 -39.814294,15.435647 -41.179275,14.49108 -41.734952,13.921037 -42.276831,13.343992 -42.589576,13.000421 -43.081226,12.699639 -42.779642,12.455416 + + + + + + + 42.35156,12.54223 +42.00975,12.86582 +41.59856,13.45209 +41.155194,13.77332 +40.8966,14.11864 +40.026219,14.519579 +39.34061,14.53155 +39.0994,14.74064 +38.51295,14.50547 +37.90607,14.95943 +37.59377,14.2131 +36.42951,14.42211 +36.323189,14.822481 +36.75386,16.291874 +36.85253,16.95655 +37.16747,17.26314 +37.904,17.42754 +38.41009,17.998307 +38.990623,16.840626 +39.26611,15.922723 +39.814294,15.435647 +41.179275,14.49108 +41.734952,13.921037 +42.276831,13.343992 +42.589576,13.000421 +43.081226,12.699639 +42.779642,12.455416 42.35156,12.54223 - + #defaultStyle Spain - - - - - - - -9.034818,41.880571 --8.984433,42.592775 --9.392884,43.026625 --7.97819,43.748338 --6.754492,43.567909 --5.411886,43.57424 --4.347843,43.403449 --3.517532,43.455901 --1.901351,43.422802 --1.502771,43.034014 -0.338047,42.579546 -0.701591,42.795734 -1.826793,42.343385 -2.985999,42.473015 -3.039484,41.89212 -2.091842,41.226089 -0.810525,41.014732 -0.721331,40.678318 -0.106692,40.123934 --0.278711,39.309978 -0.111291,38.738514 --0.467124,38.292366 --0.683389,37.642354 --1.438382,37.443064 --2.146453,36.674144 --3.415781,36.6589 --4.368901,36.677839 --4.995219,36.324708 --5.37716,35.94685 --5.866432,36.029817 --6.236694,36.367677 --6.520191,36.942913 --7.453726,37.097788 --7.537105,37.428904 --7.166508,37.803894 --7.029281,38.075764 --7.374092,38.373059 --7.098037,39.030073 --7.498632,39.629571 --7.066592,39.711892 --7.026413,40.184524 --6.86402,40.330872 --6.851127,41.111083 --6.389088,41.381815 --6.668606,41.883387 --7.251309,41.918346 --7.422513,41.792075 --8.013175,41.790886 --8.263857,42.280469 --8.671946,42.134689 + + + + + + + -9.034818,41.880571 +-8.984433,42.592775 +-9.392884,43.026625 +-7.97819,43.748338 +-6.754492,43.567909 +-5.411886,43.57424 +-4.347843,43.403449 +-3.517532,43.455901 +-1.901351,43.422802 +-1.502771,43.034014 +0.338047,42.579546 +0.701591,42.795734 +1.826793,42.343385 +2.985999,42.473015 +3.039484,41.89212 +2.091842,41.226089 +0.810525,41.014732 +0.721331,40.678318 +0.106692,40.123934 +-0.278711,39.309978 +0.111291,38.738514 +-0.467124,38.292366 +-0.683389,37.642354 +-1.438382,37.443064 +-2.146453,36.674144 +-3.415781,36.6589 +-4.368901,36.677839 +-4.995219,36.324708 +-5.37716,35.94685 +-5.866432,36.029817 +-6.236694,36.367677 +-6.520191,36.942913 +-7.453726,37.097788 +-7.537105,37.428904 +-7.166508,37.803894 +-7.029281,38.075764 +-7.374092,38.373059 +-7.098037,39.030073 +-7.498632,39.629571 +-7.066592,39.711892 +-7.026413,40.184524 +-6.86402,40.330872 +-6.851127,41.111083 +-6.389088,41.381815 +-6.668606,41.883387 +-7.251309,41.918346 +-7.422513,41.792075 +-8.013175,41.790886 +-8.263857,42.280469 +-8.671946,42.134689 -9.034818,41.880571 - + #defaultStyle Estonia - + - 24.312863,57.793424 -24.428928,58.383413 -24.061198,58.257375 -23.42656,58.612753 -23.339795,59.18724 -24.604214,59.465854 -25.864189,59.61109 -26.949136,59.445803 -27.981114,59.475388 -28.131699,59.300825 -27.420166,58.724581 -27.716686,57.791899 -27.288185,57.474528 -26.463532,57.476389 -25.60281,57.847529 -25.164594,57.970157 + 24.312863,57.793424 +24.428928,58.383413 +24.061198,58.257375 +23.42656,58.612753 +23.339795,59.18724 +24.604214,59.465854 +25.864189,59.61109 +26.949136,59.445803 +27.981114,59.475388 +28.131699,59.300825 +27.420166,58.724581 +27.716686,57.791899 +27.288185,57.474528 +26.463532,57.476389 +25.60281,57.847529 +25.164594,57.970157 24.312863,57.793424 - + #defaultStyle Ethiopia - - - - - - - 37.90607,14.95943 -38.51295,14.50547 -39.0994,14.74064 -39.34061,14.53155 -40.02625,14.51959 -40.8966,14.11864 -41.1552,13.77333 -41.59856,13.45209 -42.00975,12.86582 -42.35156,12.54223 -42.0,12.1 -41.66176,11.6312 -41.73959,11.35511 -41.75557,11.05091 -42.31414,11.0342 -42.55493,11.10511 -42.776852,10.926879 -42.55876,10.57258 -42.92812,10.02194 -43.29699,9.54048 -43.67875,9.18358 -46.94834,7.99688 -47.78942,8.003 -44.9636,5.00162 -43.66087,4.95755 -42.76967,4.25259 -42.12861,4.23413 -41.855083,3.918912 -41.1718,3.91909 -40.76848,4.25702 -39.85494,3.83879 -39.559384,3.42206 -38.89251,3.50074 -38.67114,3.61607 -38.43697,3.58851 -38.120915,3.598605 -36.855093,4.447864 -36.159079,4.447864 -35.817448,4.776966 -35.817448,5.338232 -35.298007,5.506 -34.70702,6.59422 -34.25032,6.82607 -34.0751,7.22595 -33.56829,7.71334 -32.95418,7.78497 -33.2948,8.35458 -33.8255,8.37916 -33.97498,8.68456 -33.96162,9.58358 -34.25745,10.63009 -34.73115,10.91017 -34.83163,11.31896 -35.26049,12.08286 -35.86363,12.57828 -36.27022,13.56333 -36.42951,14.42211 -37.59377,14.2131 + + + + + + + 37.90607,14.95943 +38.51295,14.50547 +39.0994,14.74064 +39.34061,14.53155 +40.02625,14.51959 +40.8966,14.11864 +41.1552,13.77333 +41.59856,13.45209 +42.00975,12.86582 +42.35156,12.54223 +42.0,12.1 +41.66176,11.6312 +41.73959,11.35511 +41.75557,11.05091 +42.31414,11.0342 +42.55493,11.10511 +42.776852,10.926879 +42.55876,10.57258 +42.92812,10.02194 +43.29699,9.54048 +43.67875,9.18358 +46.94834,7.99688 +47.78942,8.003 +44.9636,5.00162 +43.66087,4.95755 +42.76967,4.25259 +42.12861,4.23413 +41.855083,3.918912 +41.1718,3.91909 +40.76848,4.25702 +39.85494,3.83879 +39.559384,3.42206 +38.89251,3.50074 +38.67114,3.61607 +38.43697,3.58851 +38.120915,3.598605 +36.855093,4.447864 +36.159079,4.447864 +35.817448,4.776966 +35.817448,5.338232 +35.298007,5.506 +34.70702,6.59422 +34.25032,6.82607 +34.0751,7.22595 +33.56829,7.71334 +32.95418,7.78497 +33.2948,8.35458 +33.8255,8.37916 +33.97498,8.68456 +33.96162,9.58358 +34.25745,10.63009 +34.73115,10.91017 +34.83163,11.31896 +35.26049,12.08286 +35.86363,12.57828 +36.27022,13.56333 +36.42951,14.42211 +37.59377,14.2131 37.90607,14.95943 - + #defaultStyle Finland - - - - - - - 28.59193,69.064777 -28.445944,68.364613 -29.977426,67.698297 -29.054589,66.944286 -30.21765,65.80598 -29.54443,64.948672 -30.444685,64.204453 -30.035872,63.552814 -31.516092,62.867687 -31.139991,62.357693 -30.211107,61.780028 -28.069998,60.503517 -26.255173,60.423961 -24.496624,60.057316 -22.869695,59.846373 -22.290764,60.391921 -21.322244,60.72017 -21.544866,61.705329 -21.059211,62.607393 -21.536029,63.189735 -22.442744,63.81781 -24.730512,64.902344 -25.398068,65.111427 -25.294043,65.534346 -23.903379,66.006927 -23.56588,66.396051 -23.539473,67.936009 -21.978535,68.616846 -20.645593,69.106247 -21.244936,69.370443 -22.356238,68.841741 -23.66205,68.891247 -24.735679,68.649557 -25.689213,69.092114 -26.179622,69.825299 -27.732292,70.164193 -29.015573,69.766491 + + + + + + + 28.59193,69.064777 +28.445944,68.364613 +29.977426,67.698297 +29.054589,66.944286 +30.21765,65.80598 +29.54443,64.948672 +30.444685,64.204453 +30.035872,63.552814 +31.516092,62.867687 +31.139991,62.357693 +30.211107,61.780028 +28.069998,60.503517 +26.255173,60.423961 +24.496624,60.057316 +22.869695,59.846373 +22.290764,60.391921 +21.322244,60.72017 +21.544866,61.705329 +21.059211,62.607393 +21.536029,63.189735 +22.442744,63.81781 +24.730512,64.902344 +25.398068,65.111427 +25.294043,65.534346 +23.903379,66.006927 +23.56588,66.396051 +23.539473,67.936009 +21.978535,68.616846 +20.645593,69.106247 +21.244936,69.370443 +22.356238,68.841741 +23.66205,68.891247 +24.735679,68.649557 +25.689213,69.092114 +26.179622,69.825299 +27.732292,70.164193 +29.015573,69.766491 28.59193,69.064777 - + #defaultStyle Fiji - + - 178.3736,-17.33992 -178.71806,-17.62846 -178.55271,-18.15059 -177.93266,-18.28799 -177.38146,-18.16432 -177.28504,-17.72465 -177.67087,-17.38114 -178.12557,-17.50481 + 178.3736,-17.33992 +178.71806,-17.62846 +178.55271,-18.15059 +177.93266,-18.28799 +177.38146,-18.16432 +177.28504,-17.72465 +177.67087,-17.38114 +178.12557,-17.50481 178.3736,-17.33992 - + - 179.364143,-16.801354 -178.725059,-17.012042 -178.596839,-16.63915 -179.096609,-16.433984 -179.413509,-16.379054 -180.0,-16.067133 -180.0,-16.555217 + 179.364143,-16.801354 +178.725059,-17.012042 +178.596839,-16.63915 +179.096609,-16.433984 +179.413509,-16.379054 +180.0,-16.067133 +180.0,-16.555217 179.364143,-16.801354 - + - -179.917369,-16.501783 --180.0,-16.555217 --180.0,-16.067133 --179.79332,-16.020882 + -179.917369,-16.501783 +-180.0,-16.555217 +-180.0,-16.067133 +-179.79332,-16.020882 -179.917369,-16.501783 - + #defaultStyle Falkland Islands - + - -61.2,-51.85 --60.0,-51.25 --59.15,-51.5 --58.55,-51.1 --57.75,-51.55 --58.05,-51.9 --59.4,-52.2 --59.85,-51.85 --60.7,-52.3 + -61.2,-51.85 +-60.0,-51.25 +-59.15,-51.5 +-58.55,-51.1 +-57.75,-51.55 +-58.05,-51.9 +-59.4,-52.2 +-59.85,-51.85 +-60.7,-52.3 -61.2,-51.85 - + #defaultStyle France - + - 9.560016,42.152492 -9.229752,41.380007 -8.775723,41.583612 -8.544213,42.256517 -8.746009,42.628122 -9.390001,43.009985 + 9.560016,42.152492 +9.229752,41.380007 +8.775723,41.583612 +8.544213,42.256517 +8.746009,42.628122 +9.390001,43.009985 9.560016,42.152492 - + - 3.588184,50.378992 -4.286023,49.907497 -4.799222,49.985373 -5.674052,49.529484 -5.897759,49.442667 -6.18632,49.463803 -6.65823,49.201958 -8.099279,49.017784 -7.593676,48.333019 -7.466759,47.620582 -7.192202,47.449766 -6.736571,47.541801 -6.768714,47.287708 -6.037389,46.725779 -6.022609,46.27299 -6.5001,46.429673 -6.843593,45.991147 -6.802355,45.70858 -7.096652,45.333099 -6.749955,45.028518 -7.007562,44.254767 -7.549596,44.127901 -7.435185,43.693845 -6.529245,43.128892 -4.556963,43.399651 -3.100411,43.075201 -2.985999,42.473015 -1.826793,42.343385 -0.701591,42.795734 -0.338047,42.579546 --1.502771,43.034014 --1.901351,43.422802 --1.384225,44.02261 --1.193798,46.014918 --2.225724,47.064363 --2.963276,47.570327 --4.491555,47.954954 --4.59235,48.68416 --3.295814,48.901692 --1.616511,48.644421 --1.933494,49.776342 --0.989469,49.347376 -1.338761,50.127173 -1.639001,50.946606 -2.513573,51.148506 -2.658422,50.796848 -3.123252,50.780363 + 3.588184,50.378992 +4.286023,49.907497 +4.799222,49.985373 +5.674052,49.529484 +5.897759,49.442667 +6.18632,49.463803 +6.65823,49.201958 +8.099279,49.017784 +7.593676,48.333019 +7.466759,47.620582 +7.192202,47.449766 +6.736571,47.541801 +6.768714,47.287708 +6.037389,46.725779 +6.022609,46.27299 +6.5001,46.429673 +6.843593,45.991147 +6.802355,45.70858 +7.096652,45.333099 +6.749955,45.028518 +7.007562,44.254767 +7.549596,44.127901 +7.435185,43.693845 +6.529245,43.128892 +4.556963,43.399651 +3.100411,43.075201 +2.985999,42.473015 +1.826793,42.343385 +0.701591,42.795734 +0.338047,42.579546 +-1.502771,43.034014 +-1.901351,43.422802 +-1.384225,44.02261 +-1.193798,46.014918 +-2.225724,47.064363 +-2.963276,47.570327 +-4.491555,47.954954 +-4.59235,48.68416 +-3.295814,48.901692 +-1.616511,48.644421 +-1.933494,49.776342 +-0.989469,49.347376 +1.338761,50.127173 +1.639001,50.946606 +2.513573,51.148506 +2.658422,50.796848 +3.123252,50.780363 3.588184,50.378992 - + #defaultStyle Gabon - - - - - - - 11.093773,-3.978827 -10.066135,-2.969483 -9.405245,-2.144313 -8.797996,-1.111301 -8.830087,-0.779074 -9.04842,-0.459351 -9.291351,0.268666 -9.492889,1.01012 -9.830284,1.067894 -11.285079,1.057662 -11.276449,2.261051 -11.751665,2.326758 -12.35938,2.192812 -12.951334,2.321616 -13.075822,2.267097 -13.003114,1.830896 -13.282631,1.314184 -14.026669,1.395677 -14.276266,1.19693 -13.843321,0.038758 -14.316418,-0.552627 -14.425456,-1.333407 -14.29921,-1.998276 -13.992407,-2.470805 -13.109619,-2.42874 -12.575284,-1.948511 -12.495703,-2.391688 -11.820964,-2.514161 -11.478039,-2.765619 -11.855122,-3.426871 + + + + + + + 11.093773,-3.978827 +10.066135,-2.969483 +9.405245,-2.144313 +8.797996,-1.111301 +8.830087,-0.779074 +9.04842,-0.459351 +9.291351,0.268666 +9.492889,1.01012 +9.830284,1.067894 +11.285079,1.057662 +11.276449,2.261051 +11.751665,2.326758 +12.35938,2.192812 +12.951334,2.321616 +13.075822,2.267097 +13.003114,1.830896 +13.282631,1.314184 +14.026669,1.395677 +14.276266,1.19693 +13.843321,0.038758 +14.316418,-0.552627 +14.425456,-1.333407 +14.29921,-1.998276 +13.992407,-2.470805 +13.109619,-2.42874 +12.575284,-1.948511 +12.495703,-2.391688 +11.820964,-2.514161 +11.478039,-2.765619 +11.855122,-3.426871 11.093773,-3.978827 - + #defaultStyle United Kingdom - + - -5.661949,54.554603 --6.197885,53.867565 --6.95373,54.073702 --7.572168,54.059956 --7.366031,54.595841 --7.572168,55.131622 --6.733847,55.17286 + -5.661949,54.554603 +-6.197885,53.867565 +-6.95373,54.073702 +-7.572168,54.059956 +-7.366031,54.595841 +-7.572168,55.131622 +-6.733847,55.17286 -5.661949,54.554603 - + - -3.005005,58.635 --4.073828,57.553025 --3.055002,57.690019 --1.959281,57.6848 --2.219988,56.870017 --3.119003,55.973793 --2.085009,55.909998 --2.005676,55.804903 --1.114991,54.624986 --0.430485,54.464376 -0.184981,53.325014 -0.469977,52.929999 -1.681531,52.73952 -1.559988,52.099998 -1.050562,51.806761 -1.449865,51.289428 -0.550334,50.765739 --0.787517,50.774989 --2.489998,50.500019 --2.956274,50.69688 --3.617448,50.228356 --4.542508,50.341837 --5.245023,49.96 --5.776567,50.159678 --4.30999,51.210001 --3.414851,51.426009 --3.422719,51.426848 --4.984367,51.593466 --5.267296,51.9914 --4.222347,52.301356 --4.770013,52.840005 --4.579999,53.495004 --3.093831,53.404547 --3.09208,53.404441 --2.945009,53.985 --3.614701,54.600937 --3.630005,54.615013 --4.844169,54.790971 --5.082527,55.061601 --4.719112,55.508473 --5.047981,55.783986 --5.586398,55.311146 --5.644999,56.275015 --6.149981,56.78501 --5.786825,57.818848 --5.009999,58.630013 --4.211495,58.550845 + -3.005005,58.635 +-4.073828,57.553025 +-3.055002,57.690019 +-1.959281,57.6848 +-2.219988,56.870017 +-3.119003,55.973793 +-2.085009,55.909998 +-2.005676,55.804903 +-1.114991,54.624986 +-0.430485,54.464376 +0.184981,53.325014 +0.469977,52.929999 +1.681531,52.73952 +1.559988,52.099998 +1.050562,51.806761 +1.449865,51.289428 +0.550334,50.765739 +-0.787517,50.774989 +-2.489998,50.500019 +-2.956274,50.69688 +-3.617448,50.228356 +-4.542508,50.341837 +-5.245023,49.96 +-5.776567,50.159678 +-4.30999,51.210001 +-3.414851,51.426009 +-3.422719,51.426848 +-4.984367,51.593466 +-5.267296,51.9914 +-4.222347,52.301356 +-4.770013,52.840005 +-4.579999,53.495004 +-3.093831,53.404547 +-3.09208,53.404441 +-2.945009,53.985 +-3.614701,54.600937 +-3.630005,54.615013 +-4.844169,54.790971 +-5.082527,55.061601 +-4.719112,55.508473 +-5.047981,55.783986 +-5.586398,55.311146 +-5.644999,56.275015 +-6.149981,56.78501 +-5.786825,57.818848 +-5.009999,58.630013 +-4.211495,58.550845 -3.005005,58.635 - + #defaultStyle Georgia - - - - - - - 41.554084,41.535656 -41.703171,41.962943 -41.45347,42.645123 -40.875469,43.013628 -40.321394,43.128634 -39.955009,43.434998 -40.076965,43.553104 -40.922185,43.382159 -42.394395,43.220308 -43.756017,42.740828 -43.9312,42.554974 -44.537623,42.711993 -45.470279,42.502781 -45.77641,42.092444 -46.404951,41.860675 -46.145432,41.722802 -46.637908,41.181673 -46.501637,41.064445 -45.962601,41.123873 -45.217426,41.411452 -44.97248,41.248129 -43.582746,41.092143 -42.619549,41.583173 + + + + + + + 41.554084,41.535656 +41.703171,41.962943 +41.45347,42.645123 +40.875469,43.013628 +40.321394,43.128634 +39.955009,43.434998 +40.076965,43.553104 +40.922185,43.382159 +42.394395,43.220308 +43.756017,42.740828 +43.9312,42.554974 +44.537623,42.711993 +45.470279,42.502781 +45.77641,42.092444 +46.404951,41.860675 +46.145432,41.722802 +46.637908,41.181673 +46.501637,41.064445 +45.962601,41.123873 +45.217426,41.411452 +44.97248,41.248129 +43.582746,41.092143 +42.619549,41.583173 41.554084,41.535656 - + #defaultStyle Ghana - - - - - - - 1.060122,5.928837 --0.507638,5.343473 --1.063625,5.000548 --1.964707,4.710462 --2.856125,4.994476 --2.810701,5.389051 --3.24437,6.250472 --2.983585,7.379705 --2.56219,8.219628 --2.827496,9.642461 --2.963896,10.395335 --2.940409,10.96269 --1.203358,11.009819 --0.761576,10.93693 --0.438702,11.098341 -0.023803,11.018682 --0.049785,10.706918 -0.36758,10.191213 -0.365901,9.465004 -0.461192,8.677223 -0.712029,8.312465 -0.490957,7.411744 -0.570384,6.914359 -0.836931,6.279979 + + + + + + + 1.060122,5.928837 +-0.507638,5.343473 +-1.063625,5.000548 +-1.964707,4.710462 +-2.856125,4.994476 +-2.810701,5.389051 +-3.24437,6.250472 +-2.983585,7.379705 +-2.56219,8.219628 +-2.827496,9.642461 +-2.963896,10.395335 +-2.940409,10.96269 +-1.203358,11.009819 +-0.761576,10.93693 +-0.438702,11.098341 +0.023803,11.018682 +-0.049785,10.706918 +0.36758,10.191213 +0.365901,9.465004 +0.461192,8.677223 +0.712029,8.312465 +0.490957,7.411744 +0.570384,6.914359 +0.836931,6.279979 1.060122,5.928837 - + #defaultStyle Macedonia - + - 20.59023,41.85541 -20.71731,41.84711 -20.76216,42.05186 -21.3527,42.2068 -21.576636,42.245224 -21.91708,42.30364 -22.380526,42.32026 -22.881374,41.999297 -22.952377,41.337994 -22.76177,41.3048 -22.597308,41.130487 -22.055378,41.149866 -21.674161,40.931275 -21.02004,40.842727 -20.60518,41.08622 -20.46315,41.51509 + 20.59023,41.85541 +20.71731,41.84711 +20.76216,42.05186 +21.3527,42.2068 +21.576636,42.245224 +21.91708,42.30364 +22.380526,42.32026 +22.881374,41.999297 +22.952377,41.337994 +22.76177,41.3048 +22.597308,41.130487 +22.055378,41.149866 +21.674161,40.931275 +21.02004,40.842727 +20.60518,41.08622 +20.46315,41.51509 20.59023,41.85541 - + #defaultStyle Guinea - - - - - - - -8.439298,7.686043 --8.722124,7.711674 --8.926065,7.309037 --9.208786,7.313921 --9.403348,7.526905 --9.33728,7.928534 --9.755342,8.541055 --10.016567,8.428504 --10.230094,8.406206 --10.505477,8.348896 --10.494315,8.715541 --10.65477,8.977178 --10.622395,9.26791 --10.839152,9.688246 --11.117481,10.045873 --11.917277,10.046984 --12.150338,9.858572 --12.425929,9.835834 --12.596719,9.620188 --12.711958,9.342712 --13.24655,8.903049 --13.685154,9.494744 --14.074045,9.886167 --14.330076,10.01572 --14.579699,10.214467 --14.693232,10.656301 --14.839554,10.876572 --15.130311,11.040412 --14.685687,11.527824 --14.382192,11.509272 --14.121406,11.677117 --13.9008,11.678719 --13.743161,11.811269 --13.828272,12.142644 --13.718744,12.247186 --13.700476,12.586183 --13.217818,12.575874 --12.499051,12.33209 --12.278599,12.35444 --12.203565,12.465648 --11.658301,12.386583 --11.513943,12.442988 --11.456169,12.076834 --11.297574,12.077971 --11.036556,12.211245 --10.87083,12.177887 --10.593224,11.923975 --10.165214,11.844084 --9.890993,12.060479 --9.567912,12.194243 --9.327616,12.334286 --9.127474,12.30806 --8.905265,12.088358 --8.786099,11.812561 --8.376305,11.393646 --8.581305,11.136246 --8.620321,10.810891 --8.407311,10.909257 --8.282357,10.792597 --8.335377,10.494812 --8.029944,10.206535 --8.229337,10.12902 --8.309616,9.789532 --8.079114,9.376224 --7.8321,8.575704 --8.203499,8.455453 --8.299049,8.316444 --8.221792,8.123329 --8.280703,7.68718 + + + + + + + -8.439298,7.686043 +-8.722124,7.711674 +-8.926065,7.309037 +-9.208786,7.313921 +-9.403348,7.526905 +-9.33728,7.928534 +-9.755342,8.541055 +-10.016567,8.428504 +-10.230094,8.406206 +-10.505477,8.348896 +-10.494315,8.715541 +-10.65477,8.977178 +-10.622395,9.26791 +-10.839152,9.688246 +-11.117481,10.045873 +-11.917277,10.046984 +-12.150338,9.858572 +-12.425929,9.835834 +-12.596719,9.620188 +-12.711958,9.342712 +-13.24655,8.903049 +-13.685154,9.494744 +-14.074045,9.886167 +-14.330076,10.01572 +-14.579699,10.214467 +-14.693232,10.656301 +-14.839554,10.876572 +-15.130311,11.040412 +-14.685687,11.527824 +-14.382192,11.509272 +-14.121406,11.677117 +-13.9008,11.678719 +-13.743161,11.811269 +-13.828272,12.142644 +-13.718744,12.247186 +-13.700476,12.586183 +-13.217818,12.575874 +-12.499051,12.33209 +-12.278599,12.35444 +-12.203565,12.465648 +-11.658301,12.386583 +-11.513943,12.442988 +-11.456169,12.076834 +-11.297574,12.077971 +-11.036556,12.211245 +-10.87083,12.177887 +-10.593224,11.923975 +-10.165214,11.844084 +-9.890993,12.060479 +-9.567912,12.194243 +-9.327616,12.334286 +-9.127474,12.30806 +-8.905265,12.088358 +-8.786099,11.812561 +-8.376305,11.393646 +-8.581305,11.136246 +-8.620321,10.810891 +-8.407311,10.909257 +-8.282357,10.792597 +-8.335377,10.494812 +-8.029944,10.206535 +-8.229337,10.12902 +-8.309616,9.789532 +-8.079114,9.376224 +-7.8321,8.575704 +-8.203499,8.455453 +-8.299049,8.316444 +-8.221792,8.123329 +-8.280703,7.68718 -8.439298,7.686043 - + #defaultStyle Gambia - + - -16.841525,13.151394 --16.713729,13.594959 --15.624596,13.623587 --15.39877,13.860369 --15.081735,13.876492 --14.687031,13.630357 --14.376714,13.62568 --14.046992,13.794068 --13.844963,13.505042 --14.277702,13.280585 --14.712197,13.298207 --15.141163,13.509512 --15.511813,13.27857 --15.691001,13.270353 --15.931296,13.130284 + -16.841525,13.151394 +-16.713729,13.594959 +-15.624596,13.623587 +-15.39877,13.860369 +-15.081735,13.876492 +-14.687031,13.630357 +-14.376714,13.62568 +-14.046992,13.794068 +-13.844963,13.505042 +-14.277702,13.280585 +-14.712197,13.298207 +-15.141163,13.509512 +-15.511813,13.27857 +-15.691001,13.270353 +-15.931296,13.130284 -16.841525,13.151394 - + #defaultStyle Guinea Bissau - + - -15.130311,11.040412 --15.66418,11.458474 --16.085214,11.524594 --16.314787,11.806515 --16.308947,11.958702 --16.613838,12.170911 --16.677452,12.384852 --16.147717,12.547762 --15.816574,12.515567 --15.548477,12.62817 --13.700476,12.586183 --13.718744,12.247186 --13.828272,12.142644 --13.743161,11.811269 --13.9008,11.678719 --14.121406,11.677117 --14.382192,11.509272 --14.685687,11.527824 + -15.130311,11.040412 +-15.66418,11.458474 +-16.085214,11.524594 +-16.314787,11.806515 +-16.308947,11.958702 +-16.613838,12.170911 +-16.677452,12.384852 +-16.147717,12.547762 +-15.816574,12.515567 +-15.548477,12.62817 +-13.700476,12.586183 +-13.718744,12.247186 +-13.828272,12.142644 +-13.743161,11.811269 +-13.9008,11.678719 +-14.121406,11.677117 +-14.382192,11.509272 +-14.685687,11.527824 -15.130311,11.040412 - + #defaultStyle Equatorial Guinea - + - 9.492889,1.01012 -9.305613,1.160911 -9.649158,2.283866 -11.276449,2.261051 -11.285079,1.057662 -9.830284,1.067894 + 9.492889,1.01012 +9.305613,1.160911 +9.649158,2.283866 +11.276449,2.261051 +11.285079,1.057662 +9.830284,1.067894 9.492889,1.01012 - + #defaultStyle Greece - + - 23.69998,35.705004 -24.246665,35.368022 -25.025015,35.424996 -25.769208,35.354018 -25.745023,35.179998 -26.290003,35.29999 -26.164998,35.004995 -24.724982,34.919988 -24.735007,35.084991 -23.514978,35.279992 + 23.69998,35.705004 +24.246665,35.368022 +25.025015,35.424996 +25.769208,35.354018 +25.745023,35.179998 +26.290003,35.29999 +26.164998,35.004995 +24.724982,34.919988 +24.735007,35.084991 +23.514978,35.279992 23.69998,35.705004 - + - 26.604196,41.562115 -26.294602,40.936261 -26.056942,40.824123 -25.447677,40.852545 -24.925848,40.947062 -23.714811,40.687129 -24.407999,40.124993 -23.899968,39.962006 -23.342999,39.960998 -22.813988,40.476005 -22.626299,40.256561 -22.849748,39.659311 -23.350027,39.190011 -22.973099,38.970903 -23.530016,38.510001 -24.025025,38.219993 -24.040011,37.655015 -23.115003,37.920011 -23.409972,37.409991 -22.774972,37.30501 -23.154225,36.422506 -22.490028,36.41 -21.670026,36.844986 -21.295011,37.644989 -21.120034,38.310323 -20.730032,38.769985 -20.217712,39.340235 -20.150016,39.624998 -20.615,40.110007 -20.674997,40.435 -20.99999,40.580004 -21.02004,40.842727 -21.674161,40.931275 -22.055378,41.149866 -22.597308,41.130487 -22.76177,41.3048 -22.952377,41.337994 -23.692074,41.309081 -24.492645,41.583896 -25.197201,41.234486 -26.106138,41.328899 -26.117042,41.826905 + 26.604196,41.562115 +26.294602,40.936261 +26.056942,40.824123 +25.447677,40.852545 +24.925848,40.947062 +23.714811,40.687129 +24.407999,40.124993 +23.899968,39.962006 +23.342999,39.960998 +22.813988,40.476005 +22.626299,40.256561 +22.849748,39.659311 +23.350027,39.190011 +22.973099,38.970903 +23.530016,38.510001 +24.025025,38.219993 +24.040011,37.655015 +23.115003,37.920011 +23.409972,37.409991 +22.774972,37.30501 +23.154225,36.422506 +22.490028,36.41 +21.670026,36.844986 +21.295011,37.644989 +21.120034,38.310323 +20.730032,38.769985 +20.217712,39.340235 +20.150016,39.624998 +20.615,40.110007 +20.674997,40.435 +20.99999,40.580004 +21.02004,40.842727 +21.674161,40.931275 +22.055378,41.149866 +22.597308,41.130487 +22.76177,41.3048 +22.952377,41.337994 +23.692074,41.309081 +24.492645,41.583896 +25.197201,41.234486 +26.106138,41.328899 +26.117042,41.826905 26.604196,41.562115 - + #defaultStyle Greenland - - - - - - - -46.76379,82.62796 --43.40644,83.22516 --39.89753,83.18018 --38.62214,83.54905 --35.08787,83.64513 --27.10046,83.51966 --20.84539,82.72669 --22.69182,82.34165 --26.51753,82.29765 --31.9,82.2 --31.39646,82.02154 --27.85666,82.13178 --24.84448,81.78697 --22.90328,82.09317 --22.07175,81.73449 --23.16961,81.15271 --20.62363,81.52462 --15.76818,81.91245 --12.77018,81.71885 --12.20855,81.29154 --16.28533,80.58004 --16.85,80.35 --20.04624,80.17708 --17.73035,80.12912 --18.9,79.4 --19.70499,78.75128 --19.67353,77.63859 --18.47285,76.98565 --20.03503,76.94434 --21.67944,76.62795 --19.83407,76.09808 --19.59896,75.24838 --20.66818,75.15585 --19.37281,74.29561 --21.59422,74.22382 --20.43454,73.81713 --20.76234,73.46436 --22.17221,73.30955 --23.56593,73.30663 --22.31311,72.62928 --22.29954,72.18409 --24.27834,72.59788 --24.79296,72.3302 --23.44296,72.08016 --22.13281,71.46898 --21.75356,70.66369 --23.53603,70.471 --24.30702,70.85649 --25.54341,71.43094 --25.20135,70.75226 --26.36276,70.22646 --23.72742,70.18401 --22.34902,70.12946 --25.02927,69.2588 --27.74737,68.47046 --30.67371,68.12503 --31.77665,68.12078 --32.81105,67.73547 --34.20196,66.67974 --36.35284,65.9789 --37.04378,65.93768 --38.37505,65.69213 --39.81222,65.45848 --40.66899,64.83997 --40.68281,64.13902 --41.1887,63.48246 --42.81938,62.68233 --42.41666,61.90093 --42.86619,61.07404 --43.3784,60.09772 --44.7875,60.03676 --46.26364,60.85328 --48.26294,60.85843 --49.23308,61.40681 --49.90039,62.38336 --51.63325,63.62691 --52.14014,64.27842 --52.27659,65.1767 --53.66166,66.09957 --53.30161,66.8365 --53.96911,67.18899 --52.9804,68.35759 --51.47536,68.72958 --51.08041,69.14781 --50.87122,69.9291 --52.013585,69.574925 --52.55792,69.42616 --53.45629,69.283625 --54.68336,69.61003 --54.75001,70.28932 --54.35884,70.821315 --53.431315,70.835755 --51.39014,70.56978 --53.10937,71.20485 --54.00422,71.54719 --55.0,71.406537 --55.83468,71.65444 --54.71819,72.58625 --55.32634,72.95861 --56.12003,73.64977 --57.32363,74.71026 --58.59679,75.09861 --58.58516,75.51727 --61.26861,76.10238 --63.39165,76.1752 --66.06427,76.13486 --68.50438,76.06141 --69.66485,76.37975 --71.40257,77.00857 --68.77671,77.32312 --66.76397,77.37595 --71.04293,77.63595 --73.297,78.04419 --73.15938,78.43271 --69.37345,78.91388 --65.7107,79.39436 --65.3239,79.75814 --68.02298,80.11721 --67.15129,80.51582 --63.68925,81.21396 --62.23444,81.3211 --62.65116,81.77042 --60.28249,82.03363 --57.20744,82.19074 --54.13442,82.19962 --53.04328,81.88833 --50.39061,82.43883 --48.00386,82.06481 --46.59984,81.985945 --44.523,81.6607 --46.9007,82.19979 + + + + + + + -46.76379,82.62796 +-43.40644,83.22516 +-39.89753,83.18018 +-38.62214,83.54905 +-35.08787,83.64513 +-27.10046,83.51966 +-20.84539,82.72669 +-22.69182,82.34165 +-26.51753,82.29765 +-31.9,82.2 +-31.39646,82.02154 +-27.85666,82.13178 +-24.84448,81.78697 +-22.90328,82.09317 +-22.07175,81.73449 +-23.16961,81.15271 +-20.62363,81.52462 +-15.76818,81.91245 +-12.77018,81.71885 +-12.20855,81.29154 +-16.28533,80.58004 +-16.85,80.35 +-20.04624,80.17708 +-17.73035,80.12912 +-18.9,79.4 +-19.70499,78.75128 +-19.67353,77.63859 +-18.47285,76.98565 +-20.03503,76.94434 +-21.67944,76.62795 +-19.83407,76.09808 +-19.59896,75.24838 +-20.66818,75.15585 +-19.37281,74.29561 +-21.59422,74.22382 +-20.43454,73.81713 +-20.76234,73.46436 +-22.17221,73.30955 +-23.56593,73.30663 +-22.31311,72.62928 +-22.29954,72.18409 +-24.27834,72.59788 +-24.79296,72.3302 +-23.44296,72.08016 +-22.13281,71.46898 +-21.75356,70.66369 +-23.53603,70.471 +-24.30702,70.85649 +-25.54341,71.43094 +-25.20135,70.75226 +-26.36276,70.22646 +-23.72742,70.18401 +-22.34902,70.12946 +-25.02927,69.2588 +-27.74737,68.47046 +-30.67371,68.12503 +-31.77665,68.12078 +-32.81105,67.73547 +-34.20196,66.67974 +-36.35284,65.9789 +-37.04378,65.93768 +-38.37505,65.69213 +-39.81222,65.45848 +-40.66899,64.83997 +-40.68281,64.13902 +-41.1887,63.48246 +-42.81938,62.68233 +-42.41666,61.90093 +-42.86619,61.07404 +-43.3784,60.09772 +-44.7875,60.03676 +-46.26364,60.85328 +-48.26294,60.85843 +-49.23308,61.40681 +-49.90039,62.38336 +-51.63325,63.62691 +-52.14014,64.27842 +-52.27659,65.1767 +-53.66166,66.09957 +-53.30161,66.8365 +-53.96911,67.18899 +-52.9804,68.35759 +-51.47536,68.72958 +-51.08041,69.14781 +-50.87122,69.9291 +-52.013585,69.574925 +-52.55792,69.42616 +-53.45629,69.283625 +-54.68336,69.61003 +-54.75001,70.28932 +-54.35884,70.821315 +-53.431315,70.835755 +-51.39014,70.56978 +-53.10937,71.20485 +-54.00422,71.54719 +-55.0,71.406537 +-55.83468,71.65444 +-54.71819,72.58625 +-55.32634,72.95861 +-56.12003,73.64977 +-57.32363,74.71026 +-58.59679,75.09861 +-58.58516,75.51727 +-61.26861,76.10238 +-63.39165,76.1752 +-66.06427,76.13486 +-68.50438,76.06141 +-69.66485,76.37975 +-71.40257,77.00857 +-68.77671,77.32312 +-66.76397,77.37595 +-71.04293,77.63595 +-73.297,78.04419 +-73.15938,78.43271 +-69.37345,78.91388 +-65.7107,79.39436 +-65.3239,79.75814 +-68.02298,80.11721 +-67.15129,80.51582 +-63.68925,81.21396 +-62.23444,81.3211 +-62.65116,81.77042 +-60.28249,82.03363 +-57.20744,82.19074 +-54.13442,82.19962 +-53.04328,81.88833 +-50.39061,82.43883 +-48.00386,82.06481 +-46.59984,81.985945 +-44.523,81.6607 +-46.9007,82.19979 -46.76379,82.62796 - + #defaultStyle Guatemala - - - - - - - -90.095555,13.735338 --90.608624,13.909771 --91.23241,13.927832 --91.689747,14.126218 --92.22775,14.538829 --92.20323,14.830103 --92.087216,15.064585 --92.229249,15.251447 --91.74796,16.066565 --90.464473,16.069562 --90.438867,16.41011 --90.600847,16.470778 --90.711822,16.687483 --91.08167,16.918477 --91.453921,17.252177 --91.002269,17.254658 --91.00152,17.817595 --90.067934,17.819326 --89.14308,17.808319 --89.150806,17.015577 --89.229122,15.886938 --88.930613,15.887273 --88.604586,15.70638 --88.518364,15.855389 --88.225023,15.727722 --88.68068,15.346247 --89.154811,15.066419 --89.22522,14.874286 --89.145535,14.678019 --89.353326,14.424133 --89.587343,14.362586 --89.534219,14.244816 --89.721934,14.134228 --90.064678,13.88197 + + + + + + + -90.095555,13.735338 +-90.608624,13.909771 +-91.23241,13.927832 +-91.689747,14.126218 +-92.22775,14.538829 +-92.20323,14.830103 +-92.087216,15.064585 +-92.229249,15.251447 +-91.74796,16.066565 +-90.464473,16.069562 +-90.438867,16.41011 +-90.600847,16.470778 +-90.711822,16.687483 +-91.08167,16.918477 +-91.453921,17.252177 +-91.002269,17.254658 +-91.00152,17.817595 +-90.067934,17.819326 +-89.14308,17.808319 +-89.150806,17.015577 +-89.229122,15.886938 +-88.930613,15.887273 +-88.604586,15.70638 +-88.518364,15.855389 +-88.225023,15.727722 +-88.68068,15.346247 +-89.154811,15.066419 +-89.22522,14.874286 +-89.145535,14.678019 +-89.353326,14.424133 +-89.587343,14.362586 +-89.534219,14.244816 +-89.721934,14.134228 +-90.064678,13.88197 -90.095555,13.735338 - + #defaultStyle French Guiana - + - -52.556425,2.504705 --52.939657,2.124858 --53.418465,2.053389 --53.554839,2.334897 --53.778521,2.376703 --54.088063,2.105557 --54.524754,2.311849 --54.27123,2.738748 --54.184284,3.194172 --54.011504,3.62257 --54.399542,4.212611 --54.478633,4.896756 --53.958045,5.756548 --53.618453,5.646529 --52.882141,5.409851 --51.823343,4.565768 --51.657797,4.156232 --52.249338,3.241094 + -52.556425,2.504705 +-52.939657,2.124858 +-53.418465,2.053389 +-53.554839,2.334897 +-53.778521,2.376703 +-54.088063,2.105557 +-54.524754,2.311849 +-54.27123,2.738748 +-54.184284,3.194172 +-54.011504,3.62257 +-54.399542,4.212611 +-54.478633,4.896756 +-53.958045,5.756548 +-53.618453,5.646529 +-52.882141,5.409851 +-51.823343,4.565768 +-51.657797,4.156232 +-52.249338,3.241094 -52.556425,2.504705 - + #defaultStyle Guyana - - - - - - - -59.758285,8.367035 --59.101684,7.999202 --58.482962,7.347691 --58.454876,6.832787 --58.078103,6.809094 --57.542219,6.321268 --57.147436,5.97315 --57.307246,5.073567 --57.914289,4.812626 --57.86021,4.576801 --58.044694,4.060864 --57.601569,3.334655 --57.281433,3.333492 --57.150098,2.768927 --56.539386,1.899523 --56.782704,1.863711 --57.335823,1.948538 --57.660971,1.682585 --58.11345,1.507195 --58.429477,1.463942 --58.540013,1.268088 --59.030862,1.317698 --59.646044,1.786894 --59.718546,2.24963 --59.974525,2.755233 --59.815413,3.606499 --59.53804,3.958803 --59.767406,4.423503 --60.111002,4.574967 --59.980959,5.014061 --60.213683,5.244486 --60.733574,5.200277 --61.410303,5.959068 --61.139415,6.234297 --61.159336,6.696077 --60.543999,6.856584 --60.295668,7.043911 --60.637973,7.415 --60.550588,7.779603 + + + + + + + -59.758285,8.367035 +-59.101684,7.999202 +-58.482962,7.347691 +-58.454876,6.832787 +-58.078103,6.809094 +-57.542219,6.321268 +-57.147436,5.97315 +-57.307246,5.073567 +-57.914289,4.812626 +-57.86021,4.576801 +-58.044694,4.060864 +-57.601569,3.334655 +-57.281433,3.333492 +-57.150098,2.768927 +-56.539386,1.899523 +-56.782704,1.863711 +-57.335823,1.948538 +-57.660971,1.682585 +-58.11345,1.507195 +-58.429477,1.463942 +-58.540013,1.268088 +-59.030862,1.317698 +-59.646044,1.786894 +-59.718546,2.24963 +-59.974525,2.755233 +-59.815413,3.606499 +-59.53804,3.958803 +-59.767406,4.423503 +-60.111002,4.574967 +-59.980959,5.014061 +-60.213683,5.244486 +-60.733574,5.200277 +-61.410303,5.959068 +-61.139415,6.234297 +-61.159336,6.696077 +-60.543999,6.856584 +-60.295668,7.043911 +-60.637973,7.415 +-60.550588,7.779603 -59.758285,8.367035 - + #defaultStyle Slovakia - - - - - - - 18.853144,49.49623 -18.909575,49.435846 -19.320713,49.571574 -19.825023,49.217125 -20.415839,49.431453 -20.887955,49.328772 -21.607808,49.470107 -22.558138,49.085738 -22.280842,48.825392 -22.085608,48.422264 -21.872236,48.319971 -20.801294,48.623854 -20.473562,48.56285 -20.239054,48.327567 -19.769471,48.202691 -19.661364,48.266615 -19.174365,48.111379 -18.777025,48.081768 -18.696513,47.880954 -17.857133,47.758429 -17.488473,47.867466 -16.979667,48.123497 -16.879983,48.470013 -16.960288,48.596982 -17.101985,48.816969 -17.545007,48.800019 -17.886485,48.903475 -17.913512,48.996493 -18.104973,49.043983 -18.170498,49.271515 -18.399994,49.315001 -18.554971,49.495015 + + + + + + + 18.853144,49.49623 +18.909575,49.435846 +19.320713,49.571574 +19.825023,49.217125 +20.415839,49.431453 +20.887955,49.328772 +21.607808,49.470107 +22.558138,49.085738 +22.280842,48.825392 +22.085608,48.422264 +21.872236,48.319971 +20.801294,48.623854 +20.473562,48.56285 +20.239054,48.327567 +19.769471,48.202691 +19.661364,48.266615 +19.174365,48.111379 +18.777025,48.081768 +18.696513,47.880954 +17.857133,47.758429 +17.488473,47.867466 +16.979667,48.123497 +16.879983,48.470013 +16.960288,48.596982 +17.101985,48.816969 +17.545007,48.800019 +17.886485,48.903475 +17.913512,48.996493 +18.104973,49.043983 +18.170498,49.271515 +18.399994,49.315001 +18.554971,49.495015 18.853144,49.49623 - + #defaultStyle Honduras - - - - - - - -87.316654,12.984686 --87.489409,13.297535 --87.793111,13.38448 --87.723503,13.78505 --87.859515,13.893312 --88.065343,13.964626 --88.503998,13.845486 --88.541231,13.980155 --88.843073,14.140507 --89.058512,14.340029 --89.353326,14.424133 --89.145535,14.678019 --89.22522,14.874286 --89.154811,15.066419 --88.68068,15.346247 --88.225023,15.727722 --88.121153,15.688655 --87.901813,15.864458 --87.61568,15.878799 --87.522921,15.797279 --87.367762,15.84694 --86.903191,15.756713 --86.440946,15.782835 --86.119234,15.893449 --86.001954,16.005406 --85.683317,15.953652 --85.444004,15.885749 --85.182444,15.909158 --84.983722,15.995923 --84.52698,15.857224 --84.368256,15.835158 --84.063055,15.648244 --83.773977,15.424072 --83.410381,15.270903 --83.147219,14.995829 --83.489989,15.016267 --83.628585,14.880074 --83.975721,14.749436 --84.228342,14.748764 --84.449336,14.621614 --84.649582,14.666805 --84.820037,14.819587 --84.924501,14.790493 --85.052787,14.551541 --85.148751,14.560197 --85.165365,14.35437 --85.514413,14.079012 --85.698665,13.960078 --85.801295,13.836055 --86.096264,14.038187 --86.312142,13.771356 --86.520708,13.778487 --86.755087,13.754845 --86.733822,13.263093 --86.880557,13.254204 --87.005769,13.025794 + + + + + + + -87.316654,12.984686 +-87.489409,13.297535 +-87.793111,13.38448 +-87.723503,13.78505 +-87.859515,13.893312 +-88.065343,13.964626 +-88.503998,13.845486 +-88.541231,13.980155 +-88.843073,14.140507 +-89.058512,14.340029 +-89.353326,14.424133 +-89.145535,14.678019 +-89.22522,14.874286 +-89.154811,15.066419 +-88.68068,15.346247 +-88.225023,15.727722 +-88.121153,15.688655 +-87.901813,15.864458 +-87.61568,15.878799 +-87.522921,15.797279 +-87.367762,15.84694 +-86.903191,15.756713 +-86.440946,15.782835 +-86.119234,15.893449 +-86.001954,16.005406 +-85.683317,15.953652 +-85.444004,15.885749 +-85.182444,15.909158 +-84.983722,15.995923 +-84.52698,15.857224 +-84.368256,15.835158 +-84.063055,15.648244 +-83.773977,15.424072 +-83.410381,15.270903 +-83.147219,14.995829 +-83.489989,15.016267 +-83.628585,14.880074 +-83.975721,14.749436 +-84.228342,14.748764 +-84.449336,14.621614 +-84.649582,14.666805 +-84.820037,14.819587 +-84.924501,14.790493 +-85.052787,14.551541 +-85.148751,14.560197 +-85.165365,14.35437 +-85.514413,14.079012 +-85.698665,13.960078 +-85.801295,13.836055 +-86.096264,14.038187 +-86.312142,13.771356 +-86.520708,13.778487 +-86.755087,13.754845 +-86.733822,13.263093 +-86.880557,13.254204 +-87.005769,13.025794 -87.316654,12.984686 - + #defaultStyle Croatia - - - - - - - 18.829838,45.908878 -19.072769,45.521511 -19.390476,45.236516 -19.005486,44.860234 -18.553214,45.08159 -17.861783,45.06774 -17.002146,45.233777 -16.534939,45.211608 -16.318157,45.004127 -15.959367,45.233777 -15.750026,44.818712 -16.23966,44.351143 -16.456443,44.04124 -16.916156,43.667722 -17.297373,43.446341 -17.674922,43.028563 -18.56,42.65 -18.450016,42.479991 -17.50997,42.849995 -16.930006,43.209998 -16.015385,43.507215 -15.174454,44.243191 -15.37625,44.317915 -14.920309,44.738484 -14.901602,45.07606 -14.258748,45.233777 -13.952255,44.802124 -13.656976,45.136935 -13.679403,45.484149 -13.71506,45.500324 -14.411968,45.466166 -14.595109,45.634941 -14.935244,45.471695 -15.327675,45.452316 -15.323954,45.731783 -15.67153,45.834154 -15.768733,46.238108 -16.564808,46.503751 -16.882515,46.380632 -17.630066,45.951769 -18.456062,45.759481 + + + + + + + 18.829838,45.908878 +19.072769,45.521511 +19.390476,45.236516 +19.005486,44.860234 +18.553214,45.08159 +17.861783,45.06774 +17.002146,45.233777 +16.534939,45.211608 +16.318157,45.004127 +15.959367,45.233777 +15.750026,44.818712 +16.23966,44.351143 +16.456443,44.04124 +16.916156,43.667722 +17.297373,43.446341 +17.674922,43.028563 +18.56,42.65 +18.450016,42.479991 +17.50997,42.849995 +16.930006,43.209998 +16.015385,43.507215 +15.174454,44.243191 +15.37625,44.317915 +14.920309,44.738484 +14.901602,45.07606 +14.258748,45.233777 +13.952255,44.802124 +13.656976,45.136935 +13.679403,45.484149 +13.71506,45.500324 +14.411968,45.466166 +14.595109,45.634941 +14.935244,45.471695 +15.327675,45.452316 +15.323954,45.731783 +15.67153,45.834154 +15.768733,46.238108 +16.564808,46.503751 +16.882515,46.380632 +17.630066,45.951769 +18.456062,45.759481 18.829838,45.908878 - + #defaultStyle Haiti - + - -73.189791,19.915684 --72.579673,19.871501 --71.712361,19.714456 --71.624873,19.169838 --71.701303,18.785417 --71.945112,18.6169 --71.687738,18.31666 --71.708305,18.044997 --72.372476,18.214961 --72.844411,18.145611 --73.454555,18.217906 --73.922433,18.030993 --74.458034,18.34255 --74.369925,18.664908 --73.449542,18.526053 --72.694937,18.445799 --72.334882,18.668422 --72.79165,19.101625 --72.784105,19.483591 --73.415022,19.639551 + -73.189791,19.915684 +-72.579673,19.871501 +-71.712361,19.714456 +-71.624873,19.169838 +-71.701303,18.785417 +-71.945112,18.6169 +-71.687738,18.31666 +-71.708305,18.044997 +-72.372476,18.214961 +-72.844411,18.145611 +-73.454555,18.217906 +-73.922433,18.030993 +-74.458034,18.34255 +-74.369925,18.664908 +-73.449542,18.526053 +-72.694937,18.445799 +-72.334882,18.668422 +-72.79165,19.101625 +-72.784105,19.483591 +-73.415022,19.639551 -73.189791,19.915684 - + #defaultStyle Hungary - - - - - - - 16.202298,46.852386 -16.534268,47.496171 -16.340584,47.712902 -16.903754,47.714866 -16.979667,48.123497 -17.488473,47.867466 -17.857133,47.758429 -18.696513,47.880954 -18.777025,48.081768 -19.174365,48.111379 -19.661364,48.266615 -19.769471,48.202691 -20.239054,48.327567 -20.473562,48.56285 -20.801294,48.623854 -21.872236,48.319971 -22.085608,48.422264 -22.64082,48.15024 -22.710531,47.882194 -22.099768,47.672439 -21.626515,46.994238 -21.021952,46.316088 -20.220192,46.127469 -19.596045,46.17173 -18.829838,45.908878 -18.456062,45.759481 -17.630066,45.951769 -16.882515,46.380632 -16.564808,46.503751 -16.370505,46.841327 + + + + + + + 16.202298,46.852386 +16.534268,47.496171 +16.340584,47.712902 +16.903754,47.714866 +16.979667,48.123497 +17.488473,47.867466 +17.857133,47.758429 +18.696513,47.880954 +18.777025,48.081768 +19.174365,48.111379 +19.661364,48.266615 +19.769471,48.202691 +20.239054,48.327567 +20.473562,48.56285 +20.801294,48.623854 +21.872236,48.319971 +22.085608,48.422264 +22.64082,48.15024 +22.710531,47.882194 +22.099768,47.672439 +21.626515,46.994238 +21.021952,46.316088 +20.220192,46.127469 +19.596045,46.17173 +18.829838,45.908878 +18.456062,45.759481 +17.630066,45.951769 +16.882515,46.380632 +16.564808,46.503751 +16.370505,46.841327 16.202298,46.852386 - + #defaultStyle Indonesia - + - 120.715609,-10.239581 -120.295014,-10.25865 -118.967808,-9.557969 -119.90031,-9.36134 -120.425756,-9.665921 -120.775502,-9.969675 + 120.715609,-10.239581 +120.295014,-10.25865 +118.967808,-9.557969 +119.90031,-9.36134 +120.425756,-9.665921 +120.775502,-9.969675 120.715609,-10.239581 - + - 124.43595,-10.140001 -123.579982,-10.359987 -123.459989,-10.239995 -123.550009,-9.900016 -123.980009,-9.290027 -124.968682,-8.89279 -125.07002,-9.089987 -125.08852,-9.393173 + 124.43595,-10.140001 +123.579982,-10.359987 +123.459989,-10.239995 +123.550009,-9.900016 +123.980009,-9.290027 +124.968682,-8.89279 +125.07002,-9.089987 +125.08852,-9.393173 124.43595,-10.140001 - + - 117.900018,-8.095681 -118.260616,-8.362383 -118.87846,-8.280683 -119.126507,-8.705825 -117.970402,-8.906639 -117.277731,-9.040895 -116.740141,-9.032937 -117.083737,-8.457158 -117.632024,-8.449303 + 117.900018,-8.095681 +118.260616,-8.362383 +118.87846,-8.280683 +119.126507,-8.705825 +117.970402,-8.906639 +117.277731,-9.040895 +116.740141,-9.032937 +117.083737,-8.457158 +117.632024,-8.449303 117.900018,-8.095681 - + - 122.903537,-8.094234 -122.756983,-8.649808 -121.254491,-8.933666 -119.924391,-8.810418 -119.920929,-8.444859 -120.715092,-8.236965 -121.341669,-8.53674 -122.007365,-8.46062 + 122.903537,-8.094234 +122.756983,-8.649808 +121.254491,-8.933666 +119.924391,-8.810418 +119.920929,-8.444859 +120.715092,-8.236965 +121.341669,-8.53674 +122.007365,-8.46062 122.903537,-8.094234 - + - 108.623479,-6.777674 -110.539227,-6.877358 -110.759576,-6.465186 -112.614811,-6.946036 -112.978768,-7.594213 -114.478935,-7.776528 -115.705527,-8.370807 -114.564511,-8.751817 -113.464734,-8.348947 -112.559672,-8.376181 -111.522061,-8.302129 -110.58615,-8.122605 -109.427667,-7.740664 -108.693655,-7.6416 -108.277763,-7.766657 -106.454102,-7.3549 -106.280624,-6.9249 -105.365486,-6.851416 -106.051646,-5.895919 -107.265009,-5.954985 -108.072091,-6.345762 -108.486846,-6.421985 + 108.623479,-6.777674 +110.539227,-6.877358 +110.759576,-6.465186 +112.614811,-6.946036 +112.978768,-7.594213 +114.478935,-7.776528 +115.705527,-8.370807 +114.564511,-8.751817 +113.464734,-8.348947 +112.559672,-8.376181 +111.522061,-8.302129 +110.58615,-8.122605 +109.427667,-7.740664 +108.693655,-7.6416 +108.277763,-7.766657 +106.454102,-7.3549 +106.280624,-6.9249 +105.365486,-6.851416 +106.051646,-5.895919 +107.265009,-5.954985 +108.072091,-6.345762 +108.486846,-6.421985 108.623479,-6.777674 - + - 134.724624,-6.214401 -134.210134,-6.895238 -134.112776,-6.142467 -134.290336,-5.783058 -134.499625,-5.445042 -134.727002,-5.737582 + 134.724624,-6.214401 +134.210134,-6.895238 +134.112776,-6.142467 +134.290336,-5.783058 +134.499625,-5.445042 +134.727002,-5.737582 134.724624,-6.214401 - + - 127.249215,-3.459065 -126.874923,-3.790983 -126.183802,-3.607376 -125.989034,-3.177273 -127.000651,-3.129318 + 127.249215,-3.459065 +126.874923,-3.790983 +126.183802,-3.607376 +125.989034,-3.177273 +127.000651,-3.129318 127.249215,-3.459065 - + - 130.471344,-3.093764 -130.834836,-3.858472 -129.990547,-3.446301 -129.155249,-3.362637 -128.590684,-3.428679 -127.898891,-3.393436 -128.135879,-2.84365 -129.370998,-2.802154 + 130.471344,-3.093764 +130.834836,-3.858472 +129.990547,-3.446301 +129.155249,-3.362637 +128.590684,-3.428679 +127.898891,-3.393436 +128.135879,-2.84365 +129.370998,-2.802154 130.471344,-3.093764 - + - 134.143368,-1.151867 -134.422627,-2.769185 -135.457603,-3.367753 -136.293314,-2.307042 -137.440738,-1.703513 -138.329727,-1.702686 -139.184921,-2.051296 -139.926684,-2.409052 -141.00021,-2.600151 -141.017057,-5.859022 -141.033852,-9.117893 -140.143415,-8.297168 -139.127767,-8.096043 -138.881477,-8.380935 -137.614474,-8.411683 -138.039099,-7.597882 -138.668621,-7.320225 -138.407914,-6.232849 -137.92784,-5.393366 -135.98925,-4.546544 -135.164598,-4.462931 -133.66288,-3.538853 -133.367705,-4.024819 -132.983956,-4.112979 -132.756941,-3.746283 -132.753789,-3.311787 -131.989804,-2.820551 -133.066845,-2.460418 -133.780031,-2.479848 -133.696212,-2.214542 -132.232373,-2.212526 -131.836222,-1.617162 -130.94284,-1.432522 -130.519558,-0.93772 -131.867538,-0.695461 -132.380116,-0.369538 -133.985548,-0.78021 + 134.143368,-1.151867 +134.422627,-2.769185 +135.457603,-3.367753 +136.293314,-2.307042 +137.440738,-1.703513 +138.329727,-1.702686 +139.184921,-2.051296 +139.926684,-2.409052 +141.00021,-2.600151 +141.017057,-5.859022 +141.033852,-9.117893 +140.143415,-8.297168 +139.127767,-8.096043 +138.881477,-8.380935 +137.614474,-8.411683 +138.039099,-7.597882 +138.668621,-7.320225 +138.407914,-6.232849 +137.92784,-5.393366 +135.98925,-4.546544 +135.164598,-4.462931 +133.66288,-3.538853 +133.367705,-4.024819 +132.983956,-4.112979 +132.756941,-3.746283 +132.753789,-3.311787 +131.989804,-2.820551 +133.066845,-2.460418 +133.780031,-2.479848 +133.696212,-2.214542 +132.232373,-2.212526 +131.836222,-1.617162 +130.94284,-1.432522 +130.519558,-0.93772 +131.867538,-0.695461 +132.380116,-0.369538 +133.985548,-0.78021 134.143368,-1.151867 - + - 125.240501,1.419836 -124.437035,0.427881 -123.685505,0.235593 -122.723083,0.431137 -121.056725,0.381217 -120.183083,0.237247 -120.04087,-0.519658 -120.935905,-1.408906 -121.475821,-0.955962 -123.340565,-0.615673 -123.258399,-1.076213 -122.822715,-0.930951 -122.38853,-1.516858 -121.508274,-1.904483 -122.454572,-3.186058 -122.271896,-3.5295 -123.170963,-4.683693 -123.162333,-5.340604 -122.628515,-5.634591 -122.236394,-5.282933 -122.719569,-4.464172 -121.738234,-4.851331 -121.489463,-4.574553 -121.619171,-4.188478 -120.898182,-3.602105 -120.972389,-2.627643 -120.305453,-2.931604 -120.390047,-4.097579 -120.430717,-5.528241 -119.796543,-5.6734 -119.366906,-5.379878 -119.653606,-4.459417 -119.498835,-3.494412 -119.078344,-3.487022 -118.767769,-2.801999 -119.180974,-2.147104 -119.323394,-1.353147 -119.825999,0.154254 -120.035702,0.566477 -120.885779,1.309223 -121.666817,1.013944 -122.927567,0.875192 -124.077522,0.917102 -125.065989,1.643259 + 125.240501,1.419836 +124.437035,0.427881 +123.685505,0.235593 +122.723083,0.431137 +121.056725,0.381217 +120.183083,0.237247 +120.04087,-0.519658 +120.935905,-1.408906 +121.475821,-0.955962 +123.340565,-0.615673 +123.258399,-1.076213 +122.822715,-0.930951 +122.38853,-1.516858 +121.508274,-1.904483 +122.454572,-3.186058 +122.271896,-3.5295 +123.170963,-4.683693 +123.162333,-5.340604 +122.628515,-5.634591 +122.236394,-5.282933 +122.719569,-4.464172 +121.738234,-4.851331 +121.489463,-4.574553 +121.619171,-4.188478 +120.898182,-3.602105 +120.972389,-2.627643 +120.305453,-2.931604 +120.390047,-4.097579 +120.430717,-5.528241 +119.796543,-5.6734 +119.366906,-5.379878 +119.653606,-4.459417 +119.498835,-3.494412 +119.078344,-3.487022 +118.767769,-2.801999 +119.180974,-2.147104 +119.323394,-1.353147 +119.825999,0.154254 +120.035702,0.566477 +120.885779,1.309223 +121.666817,1.013944 +122.927567,0.875192 +124.077522,0.917102 +125.065989,1.643259 125.240501,1.419836 - + - 128.688249,1.132386 -128.635952,0.258486 -128.12017,0.356413 -127.968034,-0.252077 -128.379999,-0.780004 -128.100016,-0.899996 -127.696475,-0.266598 -127.39949,1.011722 -127.600512,1.810691 -127.932378,2.174596 -128.004156,1.628531 -128.594559,1.540811 + 128.688249,1.132386 +128.635952,0.258486 +128.12017,0.356413 +127.968034,-0.252077 +128.379999,-0.780004 +128.100016,-0.899996 +127.696475,-0.266598 +127.39949,1.011722 +127.600512,1.810691 +127.932378,2.174596 +128.004156,1.628531 +128.594559,1.540811 128.688249,1.132386 - + - 117.875627,1.827641 -118.996747,0.902219 -117.811858,0.784242 -117.478339,0.102475 -117.521644,-0.803723 -116.560048,-1.487661 -116.533797,-2.483517 -116.148084,-4.012726 -116.000858,-3.657037 -114.864803,-4.106984 -114.468652,-3.495704 -113.755672,-3.43917 -113.256994,-3.118776 -112.068126,-3.478392 -111.703291,-2.994442 -111.04824,-3.049426 -110.223846,-2.934032 -110.070936,-1.592874 -109.571948,-1.314907 -109.091874,-0.459507 -108.952658,0.415375 -109.069136,1.341934 -109.66326,2.006467 -109.830227,1.338136 -110.514061,0.773131 -111.159138,0.976478 -111.797548,0.904441 -112.380252,1.410121 -112.859809,1.49779 -113.80585,1.217549 -114.621355,1.430688 -115.134037,2.821482 -115.519078,3.169238 -115.865517,4.306559 -117.015214,4.306094 -117.882035,4.137551 -117.313232,3.234428 -118.04833,2.28769 + 117.875627,1.827641 +118.996747,0.902219 +117.811858,0.784242 +117.478339,0.102475 +117.521644,-0.803723 +116.560048,-1.487661 +116.533797,-2.483517 +116.148084,-4.012726 +116.000858,-3.657037 +114.864803,-4.106984 +114.468652,-3.495704 +113.755672,-3.43917 +113.256994,-3.118776 +112.068126,-3.478392 +111.703291,-2.994442 +111.04824,-3.049426 +110.223846,-2.934032 +110.070936,-1.592874 +109.571948,-1.314907 +109.091874,-0.459507 +108.952658,0.415375 +109.069136,1.341934 +109.66326,2.006467 +109.830227,1.338136 +110.514061,0.773131 +111.159138,0.976478 +111.797548,0.904441 +112.380252,1.410121 +112.859809,1.49779 +113.80585,1.217549 +114.621355,1.430688 +115.134037,2.821482 +115.519078,3.169238 +115.865517,4.306559 +117.015214,4.306094 +117.882035,4.137551 +117.313232,3.234428 +118.04833,2.28769 117.875627,1.827641 - + - 105.817655,-5.852356 -104.710384,-5.873285 -103.868213,-5.037315 -102.584261,-4.220259 -102.156173,-3.614146 -101.399113,-2.799777 -100.902503,-2.050262 -100.141981,-0.650348 -99.26374,0.183142 -98.970011,1.042882 -98.601351,1.823507 -97.699598,2.453184 -97.176942,3.308791 -96.424017,3.86886 -95.380876,4.970782 -95.293026,5.479821 -95.936863,5.439513 -97.484882,5.246321 -98.369169,4.26837 -99.142559,3.59035 -99.693998,3.174329 -100.641434,2.099381 -101.658012,2.083697 -102.498271,1.3987 -103.07684,0.561361 -103.838396,0.104542 -103.437645,-0.711946 -104.010789,-1.059212 -104.369991,-1.084843 -104.53949,-1.782372 -104.887893,-2.340425 -105.622111,-2.428844 -106.108593,-3.061777 -105.857446,-4.305525 + 105.817655,-5.852356 +104.710384,-5.873285 +103.868213,-5.037315 +102.584261,-4.220259 +102.156173,-3.614146 +101.399113,-2.799777 +100.902503,-2.050262 +100.141981,-0.650348 +99.26374,0.183142 +98.970011,1.042882 +98.601351,1.823507 +97.699598,2.453184 +97.176942,3.308791 +96.424017,3.86886 +95.380876,4.970782 +95.293026,5.479821 +95.936863,5.439513 +97.484882,5.246321 +98.369169,4.26837 +99.142559,3.59035 +99.693998,3.174329 +100.641434,2.099381 +101.658012,2.083697 +102.498271,1.3987 +103.07684,0.561361 +103.838396,0.104542 +103.437645,-0.711946 +104.010789,-1.059212 +104.369991,-1.084843 +104.53949,-1.782372 +104.887893,-2.340425 +105.622111,-2.428844 +106.108593,-3.061777 +105.857446,-4.305525 105.817655,-5.852356 - + #defaultStyle Slovenia - + - 13.806475,46.509306 -14.632472,46.431817 -15.137092,46.658703 -16.011664,46.683611 -16.202298,46.852386 -16.370505,46.841327 -16.564808,46.503751 -15.768733,46.238108 -15.67153,45.834154 -15.323954,45.731783 -15.327675,45.452316 -14.935244,45.471695 -14.595109,45.634941 -14.411968,45.466166 -13.71506,45.500324 -13.93763,45.591016 -13.69811,46.016778 + 13.806475,46.509306 +14.632472,46.431817 +15.137092,46.658703 +16.011664,46.683611 +16.202298,46.852386 +16.370505,46.841327 +16.564808,46.503751 +15.768733,46.238108 +15.67153,45.834154 +15.323954,45.731783 +15.327675,45.452316 +14.935244,45.471695 +14.595109,45.634941 +14.411968,45.466166 +13.71506,45.500324 +13.93763,45.591016 +13.69811,46.016778 13.806475,46.509306 - + #defaultStyle India - - - - - - - 77.837451,35.49401 -78.912269,34.321936 -78.811086,33.506198 -79.208892,32.994395 -79.176129,32.48378 -78.458446,32.618164 -78.738894,31.515906 -79.721367,30.882715 -81.111256,30.183481 -80.476721,29.729865 -80.088425,28.79447 -81.057203,28.416095 -81.999987,27.925479 -83.304249,27.364506 -84.675018,27.234901 -85.251779,26.726198 -86.024393,26.630985 -87.227472,26.397898 -88.060238,26.414615 -88.174804,26.810405 -88.043133,27.445819 -88.120441,27.876542 -88.730326,28.086865 -88.814248,27.299316 -88.835643,27.098966 -89.744528,26.719403 -90.373275,26.875724 -91.217513,26.808648 -92.033484,26.83831 -92.103712,27.452614 -91.696657,27.771742 -92.503119,27.896876 -93.413348,28.640629 -94.56599,29.277438 -95.404802,29.031717 -96.117679,29.452802 -96.586591,28.83098 -96.248833,28.411031 -97.327114,28.261583 -97.402561,27.882536 -97.051989,27.699059 -97.133999,27.083774 -96.419366,27.264589 -95.124768,26.573572 -95.155153,26.001307 -94.603249,25.162495 -94.552658,24.675238 -94.106742,23.850741 -93.325188,24.078556 -93.286327,23.043658 -93.060294,22.703111 -93.166128,22.27846 -92.672721,22.041239 -92.146035,23.627499 -91.869928,23.624346 -91.706475,22.985264 -91.158963,23.503527 -91.46773,24.072639 -91.915093,24.130414 -92.376202,24.976693 -91.799596,25.147432 -90.872211,25.132601 -89.920693,25.26975 -89.832481,25.965082 -89.355094,26.014407 -88.563049,26.446526 -88.209789,25.768066 -88.931554,25.238692 -88.306373,24.866079 -88.084422,24.501657 -88.69994,24.233715 -88.52977,23.631142 -88.876312,22.879146 -89.031961,22.055708 -88.888766,21.690588 -88.208497,21.703172 -86.975704,21.495562 -87.033169,20.743308 -86.499351,20.151638 -85.060266,19.478579 -83.941006,18.30201 -83.189217,17.671221 -82.192792,17.016636 -82.191242,16.556664 -81.692719,16.310219 -80.791999,15.951972 -80.324896,15.899185 -80.025069,15.136415 -80.233274,13.835771 -80.286294,13.006261 -79.862547,12.056215 -79.857999,10.357275 -79.340512,10.308854 -78.885345,9.546136 -79.18972,9.216544 -78.277941,8.933047 -77.941165,8.252959 -77.539898,7.965535 -76.592979,8.899276 -76.130061,10.29963 -75.746467,11.308251 -75.396101,11.781245 -74.864816,12.741936 -74.616717,13.992583 -74.443859,14.617222 -73.534199,15.990652 -73.119909,17.92857 -72.820909,19.208234 -72.824475,20.419503 -72.630533,21.356009 -71.175273,20.757441 -70.470459,20.877331 -69.16413,22.089298 -69.644928,22.450775 -69.349597,22.84318 -68.176645,23.691965 -68.842599,24.359134 -71.04324,24.356524 -70.844699,25.215102 -70.282873,25.722229 -70.168927,26.491872 -69.514393,26.940966 -70.616496,27.989196 -71.777666,27.91318 -72.823752,28.961592 -73.450638,29.976413 -74.42138,30.979815 -74.405929,31.692639 -75.258642,32.271105 -74.451559,32.7649 -74.104294,33.441473 -73.749948,34.317699 -74.240203,34.748887 -75.757061,34.504923 -76.871722,34.653544 + + + + + + + 77.837451,35.49401 +78.912269,34.321936 +78.811086,33.506198 +79.208892,32.994395 +79.176129,32.48378 +78.458446,32.618164 +78.738894,31.515906 +79.721367,30.882715 +81.111256,30.183481 +80.476721,29.729865 +80.088425,28.79447 +81.057203,28.416095 +81.999987,27.925479 +83.304249,27.364506 +84.675018,27.234901 +85.251779,26.726198 +86.024393,26.630985 +87.227472,26.397898 +88.060238,26.414615 +88.174804,26.810405 +88.043133,27.445819 +88.120441,27.876542 +88.730326,28.086865 +88.814248,27.299316 +88.835643,27.098966 +89.744528,26.719403 +90.373275,26.875724 +91.217513,26.808648 +92.033484,26.83831 +92.103712,27.452614 +91.696657,27.771742 +92.503119,27.896876 +93.413348,28.640629 +94.56599,29.277438 +95.404802,29.031717 +96.117679,29.452802 +96.586591,28.83098 +96.248833,28.411031 +97.327114,28.261583 +97.402561,27.882536 +97.051989,27.699059 +97.133999,27.083774 +96.419366,27.264589 +95.124768,26.573572 +95.155153,26.001307 +94.603249,25.162495 +94.552658,24.675238 +94.106742,23.850741 +93.325188,24.078556 +93.286327,23.043658 +93.060294,22.703111 +93.166128,22.27846 +92.672721,22.041239 +92.146035,23.627499 +91.869928,23.624346 +91.706475,22.985264 +91.158963,23.503527 +91.46773,24.072639 +91.915093,24.130414 +92.376202,24.976693 +91.799596,25.147432 +90.872211,25.132601 +89.920693,25.26975 +89.832481,25.965082 +89.355094,26.014407 +88.563049,26.446526 +88.209789,25.768066 +88.931554,25.238692 +88.306373,24.866079 +88.084422,24.501657 +88.69994,24.233715 +88.52977,23.631142 +88.876312,22.879146 +89.031961,22.055708 +88.888766,21.690588 +88.208497,21.703172 +86.975704,21.495562 +87.033169,20.743308 +86.499351,20.151638 +85.060266,19.478579 +83.941006,18.30201 +83.189217,17.671221 +82.192792,17.016636 +82.191242,16.556664 +81.692719,16.310219 +80.791999,15.951972 +80.324896,15.899185 +80.025069,15.136415 +80.233274,13.835771 +80.286294,13.006261 +79.862547,12.056215 +79.857999,10.357275 +79.340512,10.308854 +78.885345,9.546136 +79.18972,9.216544 +78.277941,8.933047 +77.941165,8.252959 +77.539898,7.965535 +76.592979,8.899276 +76.130061,10.29963 +75.746467,11.308251 +75.396101,11.781245 +74.864816,12.741936 +74.616717,13.992583 +74.443859,14.617222 +73.534199,15.990652 +73.119909,17.92857 +72.820909,19.208234 +72.824475,20.419503 +72.630533,21.356009 +71.175273,20.757441 +70.470459,20.877331 +69.16413,22.089298 +69.644928,22.450775 +69.349597,22.84318 +68.176645,23.691965 +68.842599,24.359134 +71.04324,24.356524 +70.844699,25.215102 +70.282873,25.722229 +70.168927,26.491872 +69.514393,26.940966 +70.616496,27.989196 +71.777666,27.91318 +72.823752,28.961592 +73.450638,29.976413 +74.42138,30.979815 +74.405929,31.692639 +75.258642,32.271105 +74.451559,32.7649 +74.104294,33.441473 +73.749948,34.317699 +74.240203,34.748887 +75.757061,34.504923 +76.871722,34.653544 77.837451,35.49401 - + #defaultStyle Ireland - + - -6.197885,53.867565 --6.032985,53.153164 --6.788857,52.260118 --8.561617,51.669301 --9.977086,51.820455 --9.166283,52.864629 --9.688525,53.881363 --8.327987,54.664519 --7.572168,55.131622 --7.366031,54.595841 --7.572168,54.059956 --6.95373,54.073702 + -6.197885,53.867565 +-6.032985,53.153164 +-6.788857,52.260118 +-8.561617,51.669301 +-9.977086,51.820455 +-9.166283,52.864629 +-9.688525,53.881363 +-8.327987,54.664519 +-7.572168,55.131622 +-7.366031,54.595841 +-7.572168,54.059956 +-6.95373,54.073702 -6.197885,53.867565 - + #defaultStyle Iran - - - - - - - 53.921598,37.198918 -54.800304,37.392421 -55.511578,37.964117 -56.180375,37.935127 -56.619366,38.121394 -57.330434,38.029229 -58.436154,37.522309 -59.234762,37.412988 -60.377638,36.527383 -61.123071,36.491597 -61.210817,35.650072 -60.803193,34.404102 -60.52843,33.676446 -60.9637,33.528832 -60.536078,32.981269 -60.863655,32.18292 -60.941945,31.548075 -61.699314,31.379506 -61.781222,30.73585 -60.874248,29.829239 -61.369309,29.303276 -61.771868,28.699334 -62.72783,28.259645 -62.755426,27.378923 -63.233898,27.217047 -63.316632,26.756532 -61.874187,26.239975 -61.497363,25.078237 -59.616134,25.380157 -58.525761,25.609962 -57.397251,25.739902 -56.970766,26.966106 -56.492139,27.143305 -55.72371,26.964633 -54.71509,26.480658 -53.493097,26.812369 -52.483598,27.580849 -51.520763,27.86569 -50.852948,28.814521 -50.115009,30.147773 -49.57685,29.985715 -48.941333,30.31709 -48.567971,29.926778 -48.014568,30.452457 -48.004698,30.985137 -47.685286,30.984853 -47.849204,31.709176 -47.334661,32.469155 -46.109362,33.017287 -45.416691,33.967798 -45.64846,34.748138 -46.151788,35.093259 -46.07634,35.677383 -45.420618,35.977546 -44.77267,37.17045 -44.225756,37.971584 -44.421403,38.281281 -44.109225,39.428136 -44.79399,39.713003 -44.952688,39.335765 -45.457722,38.874139 -46.143623,38.741201 -46.50572,38.770605 -47.685079,39.508364 -48.060095,39.582235 -48.355529,39.288765 -48.010744,38.794015 -48.634375,38.270378 -48.883249,38.320245 -49.199612,37.582874 -50.147771,37.374567 -50.842354,36.872814 -52.264025,36.700422 -53.82579,36.965031 + + + + + + + 53.921598,37.198918 +54.800304,37.392421 +55.511578,37.964117 +56.180375,37.935127 +56.619366,38.121394 +57.330434,38.029229 +58.436154,37.522309 +59.234762,37.412988 +60.377638,36.527383 +61.123071,36.491597 +61.210817,35.650072 +60.803193,34.404102 +60.52843,33.676446 +60.9637,33.528832 +60.536078,32.981269 +60.863655,32.18292 +60.941945,31.548075 +61.699314,31.379506 +61.781222,30.73585 +60.874248,29.829239 +61.369309,29.303276 +61.771868,28.699334 +62.72783,28.259645 +62.755426,27.378923 +63.233898,27.217047 +63.316632,26.756532 +61.874187,26.239975 +61.497363,25.078237 +59.616134,25.380157 +58.525761,25.609962 +57.397251,25.739902 +56.970766,26.966106 +56.492139,27.143305 +55.72371,26.964633 +54.71509,26.480658 +53.493097,26.812369 +52.483598,27.580849 +51.520763,27.86569 +50.852948,28.814521 +50.115009,30.147773 +49.57685,29.985715 +48.941333,30.31709 +48.567971,29.926778 +48.014568,30.452457 +48.004698,30.985137 +47.685286,30.984853 +47.849204,31.709176 +47.334661,32.469155 +46.109362,33.017287 +45.416691,33.967798 +45.64846,34.748138 +46.151788,35.093259 +46.07634,35.677383 +45.420618,35.977546 +44.77267,37.17045 +44.225756,37.971584 +44.421403,38.281281 +44.109225,39.428136 +44.79399,39.713003 +44.952688,39.335765 +45.457722,38.874139 +46.143623,38.741201 +46.50572,38.770605 +47.685079,39.508364 +48.060095,39.582235 +48.355529,39.288765 +48.010744,38.794015 +48.634375,38.270378 +48.883249,38.320245 +49.199612,37.582874 +50.147771,37.374567 +50.842354,36.872814 +52.264025,36.700422 +53.82579,36.965031 53.921598,37.198918 - + #defaultStyle Iraq - - - - - - - 45.420618,35.977546 -46.07634,35.677383 -46.151788,35.093259 -45.64846,34.748138 -45.416691,33.967798 -46.109362,33.017287 -47.334661,32.469155 -47.849204,31.709176 -47.685286,30.984853 -48.004698,30.985137 -48.014568,30.452457 -48.567971,29.926778 -47.974519,29.975819 -47.302622,30.05907 -46.568713,29.099025 -44.709499,29.178891 -41.889981,31.190009 -40.399994,31.889992 -39.195468,32.161009 -38.792341,33.378686 -41.006159,34.419372 -41.383965,35.628317 -41.289707,36.358815 -41.837064,36.605854 -42.349591,37.229873 -42.779126,37.385264 -43.942259,37.256228 -44.293452,37.001514 -44.772699,37.170445 + + + + + + + 45.420618,35.977546 +46.07634,35.677383 +46.151788,35.093259 +45.64846,34.748138 +45.416691,33.967798 +46.109362,33.017287 +47.334661,32.469155 +47.849204,31.709176 +47.685286,30.984853 +48.004698,30.985137 +48.014568,30.452457 +48.567971,29.926778 +47.974519,29.975819 +47.302622,30.05907 +46.568713,29.099025 +44.709499,29.178891 +41.889981,31.190009 +40.399994,31.889992 +39.195468,32.161009 +38.792341,33.378686 +41.006159,34.419372 +41.383965,35.628317 +41.289707,36.358815 +41.837064,36.605854 +42.349591,37.229873 +42.779126,37.385264 +43.942259,37.256228 +44.293452,37.001514 +44.772699,37.170445 45.420618,35.977546 - + #defaultStyle Iceland - + - -14.508695,66.455892 --14.739637,65.808748 --13.609732,65.126671 --14.909834,64.364082 --17.794438,63.678749 --18.656246,63.496383 --19.972755,63.643635 --22.762972,63.960179 --21.778484,64.402116 --23.955044,64.89113 --22.184403,65.084968 --22.227423,65.378594 --24.326184,65.611189 --23.650515,66.262519 --22.134922,66.410469 --20.576284,65.732112 --19.056842,66.276601 --17.798624,65.993853 --16.167819,66.526792 + -14.508695,66.455892 +-14.739637,65.808748 +-13.609732,65.126671 +-14.909834,64.364082 +-17.794438,63.678749 +-18.656246,63.496383 +-19.972755,63.643635 +-22.762972,63.960179 +-21.778484,64.402116 +-23.955044,64.89113 +-22.184403,65.084968 +-22.227423,65.378594 +-24.326184,65.611189 +-23.650515,66.262519 +-22.134922,66.410469 +-20.576284,65.732112 +-19.056842,66.276601 +-17.798624,65.993853 +-16.167819,66.526792 -14.508695,66.455892 - + #defaultStyle Israel - - - - - - - 35.719918,32.709192 -35.545665,32.393992 -35.18393,32.532511 -34.974641,31.866582 -35.225892,31.754341 -34.970507,31.616778 -34.927408,31.353435 -35.397561,31.489086 -35.420918,31.100066 -34.922603,29.501326 -34.265433,31.219361 -34.556372,31.548824 -34.488107,31.605539 -34.752587,32.072926 -34.955417,32.827376 -35.098457,33.080539 -35.126053,33.0909 -35.460709,33.08904 -35.552797,33.264275 -35.821101,33.277426 -35.836397,32.868123 -35.700798,32.716014 + + + + + + + 35.719918,32.709192 +35.545665,32.393992 +35.18393,32.532511 +34.974641,31.866582 +35.225892,31.754341 +34.970507,31.616778 +34.927408,31.353435 +35.397561,31.489086 +35.420918,31.100066 +34.922603,29.501326 +34.265433,31.219361 +34.556372,31.548824 +34.488107,31.605539 +34.752587,32.072926 +34.955417,32.827376 +35.098457,33.080539 +35.126053,33.0909 +35.460709,33.08904 +35.552797,33.264275 +35.821101,33.277426 +35.836397,32.868123 +35.700798,32.716014 35.719918,32.709192 - + #defaultStyle Italy - + - 15.520376,38.231155 -15.160243,37.444046 -15.309898,37.134219 -15.099988,36.619987 -14.335229,36.996631 -13.826733,37.104531 -12.431004,37.61295 -12.570944,38.126381 -13.741156,38.034966 -14.761249,38.143874 + 15.520376,38.231155 +15.160243,37.444046 +15.309898,37.134219 +15.099988,36.619987 +14.335229,36.996631 +13.826733,37.104531 +12.431004,37.61295 +12.570944,38.126381 +13.741156,38.034966 +14.761249,38.143874 15.520376,38.231155 - + - 9.210012,41.209991 -9.809975,40.500009 -9.669519,39.177376 -9.214818,39.240473 -8.806936,38.906618 -8.428302,39.171847 -8.388253,40.378311 -8.159998,40.950007 -8.709991,40.899984 + 9.210012,41.209991 +9.809975,40.500009 +9.669519,39.177376 +9.214818,39.240473 +8.806936,38.906618 +8.428302,39.171847 +8.388253,40.378311 +8.159998,40.950007 +8.709991,40.899984 9.210012,41.209991 - + - 12.376485,46.767559 -13.806475,46.509306 -13.69811,46.016778 -13.93763,45.591016 -13.141606,45.736692 -12.328581,45.381778 -12.383875,44.885374 -12.261453,44.600482 -12.589237,44.091366 -13.526906,43.587727 -14.029821,42.761008 -15.14257,41.95514 -15.926191,41.961315 -16.169897,41.740295 -15.889346,41.541082 -16.785002,41.179606 -17.519169,40.877143 -18.376687,40.355625 -18.480247,40.168866 -18.293385,39.810774 -17.73838,40.277671 -16.869596,40.442235 -16.448743,39.795401 -17.17149,39.4247 -17.052841,38.902871 -16.635088,38.843572 -16.100961,37.985899 -15.684087,37.908849 -15.687963,38.214593 -15.891981,38.750942 -16.109332,38.964547 -15.718814,39.544072 -15.413613,40.048357 -14.998496,40.172949 -14.703268,40.60455 -14.060672,40.786348 -13.627985,41.188287 -12.888082,41.25309 -12.106683,41.704535 -11.191906,42.355425 -10.511948,42.931463 -10.200029,43.920007 -9.702488,44.036279 -8.888946,44.366336 -8.428561,44.231228 -7.850767,43.767148 -7.435185,43.693845 -7.549596,44.127901 -7.007562,44.254767 -6.749955,45.028518 -7.096652,45.333099 -6.802355,45.70858 -6.843593,45.991147 -7.273851,45.776948 -7.755992,45.82449 -8.31663,46.163642 -8.489952,46.005151 -8.966306,46.036932 -9.182882,46.440215 -9.922837,46.314899 -10.363378,46.483571 -10.442701,46.893546 -11.048556,46.751359 -11.164828,46.941579 -12.153088,47.115393 + 12.376485,46.767559 +13.806475,46.509306 +13.69811,46.016778 +13.93763,45.591016 +13.141606,45.736692 +12.328581,45.381778 +12.383875,44.885374 +12.261453,44.600482 +12.589237,44.091366 +13.526906,43.587727 +14.029821,42.761008 +15.14257,41.95514 +15.926191,41.961315 +16.169897,41.740295 +15.889346,41.541082 +16.785002,41.179606 +17.519169,40.877143 +18.376687,40.355625 +18.480247,40.168866 +18.293385,39.810774 +17.73838,40.277671 +16.869596,40.442235 +16.448743,39.795401 +17.17149,39.4247 +17.052841,38.902871 +16.635088,38.843572 +16.100961,37.985899 +15.684087,37.908849 +15.687963,38.214593 +15.891981,38.750942 +16.109332,38.964547 +15.718814,39.544072 +15.413613,40.048357 +14.998496,40.172949 +14.703268,40.60455 +14.060672,40.786348 +13.627985,41.188287 +12.888082,41.25309 +12.106683,41.704535 +11.191906,42.355425 +10.511948,42.931463 +10.200029,43.920007 +9.702488,44.036279 +8.888946,44.366336 +8.428561,44.231228 +7.850767,43.767148 +7.435185,43.693845 +7.549596,44.127901 +7.007562,44.254767 +6.749955,45.028518 +7.096652,45.333099 +6.802355,45.70858 +6.843593,45.991147 +7.273851,45.776948 +7.755992,45.82449 +8.31663,46.163642 +8.489952,46.005151 +8.966306,46.036932 +9.182882,46.440215 +9.922837,46.314899 +10.363378,46.483571 +10.442701,46.893546 +11.048556,46.751359 +11.164828,46.941579 +12.153088,47.115393 12.376485,46.767559 - + #defaultStyle Jamaica - + - -77.569601,18.490525 --76.896619,18.400867 --76.365359,18.160701 --76.199659,17.886867 --76.902561,17.868238 --77.206341,17.701116 --77.766023,17.861597 --78.337719,18.225968 --78.217727,18.454533 --77.797365,18.524218 + -77.569601,18.490525 +-76.896619,18.400867 +-76.365359,18.160701 +-76.199659,17.886867 +-76.902561,17.868238 +-77.206341,17.701116 +-77.766023,17.861597 +-78.337719,18.225968 +-78.217727,18.454533 +-77.797365,18.524218 -77.569601,18.490525 - + #defaultStyle Jordan - + - 35.545665,32.393992 -35.719918,32.709192 -36.834062,32.312938 -38.792341,33.378686 -39.195468,32.161009 -39.004886,32.010217 -37.002166,31.508413 -37.998849,30.5085 -37.66812,30.338665 -37.503582,30.003776 -36.740528,29.865283 -36.501214,29.505254 -36.068941,29.197495 -34.956037,29.356555 -34.922603,29.501326 -35.420918,31.100066 -35.397561,31.489086 -35.545252,31.782505 + 35.545665,32.393992 +35.719918,32.709192 +36.834062,32.312938 +38.792341,33.378686 +39.195468,32.161009 +39.004886,32.010217 +37.002166,31.508413 +37.998849,30.5085 +37.66812,30.338665 +37.503582,30.003776 +36.740528,29.865283 +36.501214,29.505254 +36.068941,29.197495 +34.956037,29.356555 +34.922603,29.501326 +35.420918,31.100066 +35.397561,31.489086 +35.545252,31.782505 35.545665,32.393992 - + #defaultStyle Japan - + - 134.638428,34.149234 -134.766379,33.806335 -134.203416,33.201178 -133.79295,33.521985 -133.280268,33.28957 -133.014858,32.704567 -132.363115,32.989382 -132.371176,33.463642 -132.924373,34.060299 -133.492968,33.944621 -133.904106,34.364931 + 134.638428,34.149234 +134.766379,33.806335 +134.203416,33.201178 +133.79295,33.521985 +133.280268,33.28957 +133.014858,32.704567 +132.363115,32.989382 +132.371176,33.463642 +132.924373,34.060299 +133.492968,33.944621 +133.904106,34.364931 134.638428,34.149234 - + - 140.976388,37.142074 -140.59977,36.343983 -140.774074,35.842877 -140.253279,35.138114 -138.975528,34.6676 -137.217599,34.606286 -135.792983,33.464805 -135.120983,33.849071 -135.079435,34.596545 -133.340316,34.375938 -132.156771,33.904933 -130.986145,33.885761 -132.000036,33.149992 -131.33279,31.450355 -130.686318,31.029579 -130.20242,31.418238 -130.447676,32.319475 -129.814692,32.61031 -129.408463,33.296056 -130.353935,33.604151 -130.878451,34.232743 -131.884229,34.749714 -132.617673,35.433393 -134.608301,35.731618 -135.677538,35.527134 -136.723831,37.304984 -137.390612,36.827391 -138.857602,37.827485 -139.426405,38.215962 -140.05479,39.438807 -139.883379,40.563312 -140.305783,41.195005 -141.368973,41.37856 -141.914263,39.991616 -141.884601,39.180865 -140.959489,38.174001 + 140.976388,37.142074 +140.59977,36.343983 +140.774074,35.842877 +140.253279,35.138114 +138.975528,34.6676 +137.217599,34.606286 +135.792983,33.464805 +135.120983,33.849071 +135.079435,34.596545 +133.340316,34.375938 +132.156771,33.904933 +130.986145,33.885761 +132.000036,33.149992 +131.33279,31.450355 +130.686318,31.029579 +130.20242,31.418238 +130.447676,32.319475 +129.814692,32.61031 +129.408463,33.296056 +130.353935,33.604151 +130.878451,34.232743 +131.884229,34.749714 +132.617673,35.433393 +134.608301,35.731618 +135.677538,35.527134 +136.723831,37.304984 +137.390612,36.827391 +138.857602,37.827485 +139.426405,38.215962 +140.05479,39.438807 +139.883379,40.563312 +140.305783,41.195005 +141.368973,41.37856 +141.914263,39.991616 +141.884601,39.180865 +140.959489,38.174001 140.976388,37.142074 - + - 143.910162,44.1741 -144.613427,43.960883 -145.320825,44.384733 -145.543137,43.262088 -144.059662,42.988358 -143.18385,41.995215 -141.611491,42.678791 -141.067286,41.584594 -139.955106,41.569556 -139.817544,42.563759 -140.312087,43.333273 -141.380549,43.388825 -141.671952,44.772125 -141.967645,45.551483 -143.14287,44.510358 + 143.910162,44.1741 +144.613427,43.960883 +145.320825,44.384733 +145.543137,43.262088 +144.059662,42.988358 +143.18385,41.995215 +141.611491,42.678791 +141.067286,41.584594 +139.955106,41.569556 +139.817544,42.563759 +140.312087,43.333273 +141.380549,43.388825 +141.671952,44.772125 +141.967645,45.551483 +143.14287,44.510358 143.910162,44.1741 - + #defaultStyle Kazakhstan - - - - - - - 70.962315,42.266154 -70.388965,42.081308 -69.070027,41.384244 -68.632483,40.668681 -68.259896,40.662325 -67.985856,41.135991 -66.714047,41.168444 -66.510649,41.987644 -66.023392,41.994646 -66.098012,42.99766 -64.900824,43.728081 -63.185787,43.650075 -62.0133,43.504477 -61.05832,44.405817 -60.239972,44.784037 -58.689989,45.500014 -58.503127,45.586804 -55.928917,44.995858 -55.968191,41.308642 -55.455251,41.259859 -54.755345,42.043971 -54.079418,42.324109 -52.944293,42.116034 -52.50246,41.783316 -52.446339,42.027151 -52.692112,42.443895 -52.501426,42.792298 -51.342427,43.132975 -50.891292,44.031034 -50.339129,44.284016 -50.305643,44.609836 -51.278503,44.514854 -51.316899,45.245998 -52.16739,45.408391 -53.040876,45.259047 -53.220866,46.234646 -53.042737,46.853006 -52.042023,46.804637 -51.191945,47.048705 -50.034083,46.60899 -49.10116,46.39933 -48.593241,46.561034 -48.694734,47.075628 -48.057253,47.743753 -47.315231,47.715847 -46.466446,48.394152 -47.043672,49.152039 -46.751596,49.356006 -47.54948,50.454698 -48.577841,49.87476 -48.702382,50.605128 -50.766648,51.692762 -52.328724,51.718652 -54.532878,51.02624 -55.716941,50.621717 -56.777961,51.043551 -58.363291,51.063653 -59.642282,50.545442 -59.932807,50.842194 -61.337424,50.79907 -61.588003,51.272659 -59.967534,51.96042 -60.927269,52.447548 -60.739993,52.719986 -61.699986,52.979996 -60.978066,53.664993 -61.436591,54.006265 -65.178534,54.354228 -65.666876,54.601267 -68.1691,54.970392 -69.068167,55.38525 -70.865267,55.169734 -71.180131,54.133285 -72.22415,54.376655 -73.508516,54.035617 -73.425679,53.48981 -74.384845,53.546861 -76.8911,54.490524 -76.525179,54.177003 -77.800916,53.404415 -80.03556,50.864751 -80.568447,51.388336 -81.945986,50.812196 -83.383004,51.069183 -83.935115,50.889246 -84.416377,50.3114 -85.11556,50.117303 -85.54127,49.692859 -86.829357,49.826675 -87.35997,49.214981 -86.598776,48.549182 -85.768233,48.455751 -85.720484,47.452969 -85.16429,47.000956 -83.180484,47.330031 -82.458926,45.53965 -81.947071,45.317027 -79.966106,44.917517 -80.866206,43.180362 -80.18015,42.920068 -80.25999,42.349999 -79.643645,42.496683 -79.142177,42.856092 -77.658392,42.960686 -76.000354,42.988022 -75.636965,42.8779 -74.212866,43.298339 -73.645304,43.091272 -73.489758,42.500894 -71.844638,42.845395 -71.186281,42.704293 + + + + + + + 70.962315,42.266154 +70.388965,42.081308 +69.070027,41.384244 +68.632483,40.668681 +68.259896,40.662325 +67.985856,41.135991 +66.714047,41.168444 +66.510649,41.987644 +66.023392,41.994646 +66.098012,42.99766 +64.900824,43.728081 +63.185787,43.650075 +62.0133,43.504477 +61.05832,44.405817 +60.239972,44.784037 +58.689989,45.500014 +58.503127,45.586804 +55.928917,44.995858 +55.968191,41.308642 +55.455251,41.259859 +54.755345,42.043971 +54.079418,42.324109 +52.944293,42.116034 +52.50246,41.783316 +52.446339,42.027151 +52.692112,42.443895 +52.501426,42.792298 +51.342427,43.132975 +50.891292,44.031034 +50.339129,44.284016 +50.305643,44.609836 +51.278503,44.514854 +51.316899,45.245998 +52.16739,45.408391 +53.040876,45.259047 +53.220866,46.234646 +53.042737,46.853006 +52.042023,46.804637 +51.191945,47.048705 +50.034083,46.60899 +49.10116,46.39933 +48.593241,46.561034 +48.694734,47.075628 +48.057253,47.743753 +47.315231,47.715847 +46.466446,48.394152 +47.043672,49.152039 +46.751596,49.356006 +47.54948,50.454698 +48.577841,49.87476 +48.702382,50.605128 +50.766648,51.692762 +52.328724,51.718652 +54.532878,51.02624 +55.716941,50.621717 +56.777961,51.043551 +58.363291,51.063653 +59.642282,50.545442 +59.932807,50.842194 +61.337424,50.79907 +61.588003,51.272659 +59.967534,51.96042 +60.927269,52.447548 +60.739993,52.719986 +61.699986,52.979996 +60.978066,53.664993 +61.436591,54.006265 +65.178534,54.354228 +65.666876,54.601267 +68.1691,54.970392 +69.068167,55.38525 +70.865267,55.169734 +71.180131,54.133285 +72.22415,54.376655 +73.508516,54.035617 +73.425679,53.48981 +74.384845,53.546861 +76.8911,54.490524 +76.525179,54.177003 +77.800916,53.404415 +80.03556,50.864751 +80.568447,51.388336 +81.945986,50.812196 +83.383004,51.069183 +83.935115,50.889246 +84.416377,50.3114 +85.11556,50.117303 +85.54127,49.692859 +86.829357,49.826675 +87.35997,49.214981 +86.598776,48.549182 +85.768233,48.455751 +85.720484,47.452969 +85.16429,47.000956 +83.180484,47.330031 +82.458926,45.53965 +81.947071,45.317027 +79.966106,44.917517 +80.866206,43.180362 +80.18015,42.920068 +80.25999,42.349999 +79.643645,42.496683 +79.142177,42.856092 +77.658392,42.960686 +76.000354,42.988022 +75.636965,42.8779 +74.212866,43.298339 +73.645304,43.091272 +73.489758,42.500894 +71.844638,42.845395 +71.186281,42.704293 70.962315,42.266154 - + #defaultStyle Kenya - - - - - - - 40.993,-0.85829 -41.58513,-1.68325 -40.88477,-2.08255 -40.63785,-2.49979 -40.26304,-2.57309 -40.12119,-3.27768 -39.80006,-3.68116 -39.60489,-4.34653 -39.20222,-4.67677 -37.7669,-3.67712 -37.69869,-3.09699 -34.07262,-1.05982 -33.903711,-0.95 -33.893569,0.109814 -34.18,0.515 -34.6721,1.17694 -35.03599,1.90584 -34.59607,3.05374 -34.47913,3.5556 -34.005,4.249885 -34.620196,4.847123 -35.298007,5.506 -35.817448,5.338232 -35.817448,4.776966 -36.159079,4.447864 -36.855093,4.447864 -38.120915,3.598605 -38.43697,3.58851 -38.67114,3.61607 -38.89251,3.50074 -39.559384,3.42206 -39.85494,3.83879 -40.76848,4.25702 -41.1718,3.91909 -41.855083,3.918912 -40.98105,2.78452 + + + + + + + 40.993,-0.85829 +41.58513,-1.68325 +40.88477,-2.08255 +40.63785,-2.49979 +40.26304,-2.57309 +40.12119,-3.27768 +39.80006,-3.68116 +39.60489,-4.34653 +39.20222,-4.67677 +37.7669,-3.67712 +37.69869,-3.09699 +34.07262,-1.05982 +33.903711,-0.95 +33.893569,0.109814 +34.18,0.515 +34.6721,1.17694 +35.03599,1.90584 +34.59607,3.05374 +34.47913,3.5556 +34.005,4.249885 +34.620196,4.847123 +35.298007,5.506 +35.817448,5.338232 +35.817448,4.776966 +36.159079,4.447864 +36.855093,4.447864 +38.120915,3.598605 +38.43697,3.58851 +38.67114,3.61607 +38.89251,3.50074 +39.559384,3.42206 +39.85494,3.83879 +40.76848,4.25702 +41.1718,3.91909 +41.855083,3.918912 +40.98105,2.78452 40.993,-0.85829 - + #defaultStyle Kyrgyzstan - - - - - - - 70.962315,42.266154 -71.186281,42.704293 -71.844638,42.845395 -73.489758,42.500894 -73.645304,43.091272 -74.212866,43.298339 -75.636965,42.8779 -76.000354,42.988022 -77.658392,42.960686 -79.142177,42.856092 -79.643645,42.496683 -80.25999,42.349999 -80.11943,42.123941 -78.543661,41.582243 -78.187197,41.185316 -76.904484,41.066486 -76.526368,40.427946 -75.467828,40.562072 -74.776862,40.366425 -73.822244,39.893973 -73.960013,39.660008 -73.675379,39.431237 -71.784694,39.279463 -70.549162,39.604198 -69.464887,39.526683 -69.55961,40.103211 -70.648019,39.935754 -71.014198,40.244366 -71.774875,40.145844 -73.055417,40.866033 -71.870115,41.3929 -71.157859,41.143587 -70.420022,41.519998 -71.259248,42.167711 + + + + + + + 70.962315,42.266154 +71.186281,42.704293 +71.844638,42.845395 +73.489758,42.500894 +73.645304,43.091272 +74.212866,43.298339 +75.636965,42.8779 +76.000354,42.988022 +77.658392,42.960686 +79.142177,42.856092 +79.643645,42.496683 +80.25999,42.349999 +80.11943,42.123941 +78.543661,41.582243 +78.187197,41.185316 +76.904484,41.066486 +76.526368,40.427946 +75.467828,40.562072 +74.776862,40.366425 +73.822244,39.893973 +73.960013,39.660008 +73.675379,39.431237 +71.784694,39.279463 +70.549162,39.604198 +69.464887,39.526683 +69.55961,40.103211 +70.648019,39.935754 +71.014198,40.244366 +71.774875,40.145844 +73.055417,40.866033 +71.870115,41.3929 +71.157859,41.143587 +70.420022,41.519998 +71.259248,42.167711 70.962315,42.266154 - + #defaultStyle Cambodia - + - 103.49728,10.632555 -103.09069,11.153661 -102.584932,12.186595 -102.348099,13.394247 -102.988422,14.225721 -104.281418,14.416743 -105.218777,14.273212 -106.043946,13.881091 -106.496373,14.570584 -107.382727,14.202441 -107.614548,13.535531 -107.491403,12.337206 -105.810524,11.567615 -106.24967,10.961812 -105.199915,10.88931 -104.334335,10.486544 + 103.49728,10.632555 +103.09069,11.153661 +102.584932,12.186595 +102.348099,13.394247 +102.988422,14.225721 +104.281418,14.416743 +105.218777,14.273212 +106.043946,13.881091 +106.496373,14.570584 +107.382727,14.202441 +107.614548,13.535531 +107.491403,12.337206 +105.810524,11.567615 +106.24967,10.961812 +105.199915,10.88931 +104.334335,10.486544 103.49728,10.632555 - + #defaultStyle South Korea - + - 128.349716,38.612243 -129.21292,37.432392 -129.46045,36.784189 -129.468304,35.632141 -129.091377,35.082484 -128.18585,34.890377 -127.386519,34.475674 -126.485748,34.390046 -126.37392,34.93456 -126.559231,35.684541 -126.117398,36.725485 -126.860143,36.893924 -126.174759,37.749686 -126.237339,37.840378 -126.68372,37.804773 -127.073309,38.256115 -127.780035,38.304536 -128.205746,38.370397 + 128.349716,38.612243 +129.21292,37.432392 +129.46045,36.784189 +129.468304,35.632141 +129.091377,35.082484 +128.18585,34.890377 +127.386519,34.475674 +126.485748,34.390046 +126.37392,34.93456 +126.559231,35.684541 +126.117398,36.725485 +126.860143,36.893924 +126.174759,37.749686 +126.237339,37.840378 +126.68372,37.804773 +127.073309,38.256115 +127.780035,38.304536 +128.205746,38.370397 128.349716,38.612243 - + #defaultStyle Kosovo - + - 20.76216,42.05186 -20.71731,41.84711 -20.59023,41.85541 -20.52295,42.21787 -20.28374,42.32025 -20.0707,42.58863 -20.25758,42.81275 -20.49679,42.88469 -20.63508,43.21671 -20.81448,43.27205 -20.95651,43.13094 -21.143395,43.068685 -21.27421,42.90959 -21.43866,42.86255 -21.63302,42.67717 -21.77505,42.6827 -21.66292,42.43922 -21.54332,42.32025 -21.576636,42.245224 -21.3527,42.2068 + 20.76216,42.05186 +20.71731,41.84711 +20.59023,41.85541 +20.52295,42.21787 +20.28374,42.32025 +20.0707,42.58863 +20.25758,42.81275 +20.49679,42.88469 +20.63508,43.21671 +20.81448,43.27205 +20.95651,43.13094 +21.143395,43.068685 +21.27421,42.90959 +21.43866,42.86255 +21.63302,42.67717 +21.77505,42.6827 +21.66292,42.43922 +21.54332,42.32025 +21.576636,42.245224 +21.3527,42.2068 20.76216,42.05186 - + #defaultStyle Kuwait - + - 47.974519,29.975819 -48.183189,29.534477 -48.093943,29.306299 -48.416094,28.552004 -47.708851,28.526063 -47.459822,29.002519 -46.568713,29.099025 -47.302622,30.05907 + 47.974519,29.975819 +48.183189,29.534477 +48.093943,29.306299 +48.416094,28.552004 +47.708851,28.526063 +47.459822,29.002519 +46.568713,29.099025 +47.302622,30.05907 47.974519,29.975819 - + #defaultStyle Laos - - - - - - - 105.218777,14.273212 -105.544338,14.723934 -105.589039,15.570316 -104.779321,16.441865 -104.716947,17.428859 -103.956477,18.240954 -103.200192,18.309632 -102.998706,17.961695 -102.413005,17.932782 -102.113592,18.109102 -101.059548,17.512497 -101.035931,18.408928 -101.282015,19.462585 -100.606294,19.508344 -100.548881,20.109238 -100.115988,20.41785 -100.329101,20.786122 -101.180005,21.436573 -101.270026,21.201652 -101.80312,21.174367 -101.652018,22.318199 -102.170436,22.464753 -102.754896,21.675137 -103.203861,20.766562 -104.435,20.758733 -104.822574,19.886642 -104.183388,19.624668 -103.896532,19.265181 -105.094598,18.666975 -105.925762,17.485315 -106.556008,16.604284 -107.312706,15.908538 -107.564525,15.202173 -107.382727,14.202441 -106.496373,14.570584 -106.043946,13.881091 + + + + + + + 105.218777,14.273212 +105.544338,14.723934 +105.589039,15.570316 +104.779321,16.441865 +104.716947,17.428859 +103.956477,18.240954 +103.200192,18.309632 +102.998706,17.961695 +102.413005,17.932782 +102.113592,18.109102 +101.059548,17.512497 +101.035931,18.408928 +101.282015,19.462585 +100.606294,19.508344 +100.548881,20.109238 +100.115988,20.41785 +100.329101,20.786122 +101.180005,21.436573 +101.270026,21.201652 +101.80312,21.174367 +101.652018,22.318199 +102.170436,22.464753 +102.754896,21.675137 +103.203861,20.766562 +104.435,20.758733 +104.822574,19.886642 +104.183388,19.624668 +103.896532,19.265181 +105.094598,18.666975 +105.925762,17.485315 +106.556008,16.604284 +107.312706,15.908538 +107.564525,15.202173 +107.382727,14.202441 +106.496373,14.570584 +106.043946,13.881091 105.218777,14.273212 - + #defaultStyle Lebanon - + - 35.821101,33.277426 -35.552797,33.264275 -35.460709,33.08904 -35.126053,33.0909 -35.482207,33.90545 -35.979592,34.610058 -35.998403,34.644914 -36.448194,34.593935 -36.61175,34.201789 -36.06646,33.824912 + 35.821101,33.277426 +35.552797,33.264275 +35.460709,33.08904 +35.126053,33.0909 +35.482207,33.90545 +35.979592,34.610058 +35.998403,34.644914 +36.448194,34.593935 +36.61175,34.201789 +36.06646,33.824912 35.821101,33.277426 - + #defaultStyle Liberia - - - - - - - -7.712159,4.364566 --7.974107,4.355755 --9.004794,4.832419 --9.91342,5.593561 --10.765384,6.140711 --11.438779,6.785917 --11.199802,7.105846 --11.146704,7.396706 --10.695595,7.939464 --10.230094,8.406206 --10.016567,8.428504 --9.755342,8.541055 --9.33728,7.928534 --9.403348,7.526905 --9.208786,7.313921 --8.926065,7.309037 --8.722124,7.711674 --8.439298,7.686043 --8.485446,7.395208 --8.385452,6.911801 --8.60288,6.467564 --8.311348,6.193033 --7.993693,6.12619 --7.570153,5.707352 --7.539715,5.313345 --7.635368,5.188159 + + + + + + + -7.712159,4.364566 +-7.974107,4.355755 +-9.004794,4.832419 +-9.91342,5.593561 +-10.765384,6.140711 +-11.438779,6.785917 +-11.199802,7.105846 +-11.146704,7.396706 +-10.695595,7.939464 +-10.230094,8.406206 +-10.016567,8.428504 +-9.755342,8.541055 +-9.33728,7.928534 +-9.403348,7.526905 +-9.208786,7.313921 +-8.926065,7.309037 +-8.722124,7.711674 +-8.439298,7.686043 +-8.485446,7.395208 +-8.385452,6.911801 +-8.60288,6.467564 +-8.311348,6.193033 +-7.993693,6.12619 +-7.570153,5.707352 +-7.539715,5.313345 +-7.635368,5.188159 -7.712159,4.364566 - + #defaultStyle Libya - - - - - - - 14.8513,22.86295 -14.143871,22.491289 -13.581425,23.040506 -11.999506,23.471668 -11.560669,24.097909 -10.771364,24.562532 -10.303847,24.379313 -9.948261,24.936954 -9.910693,25.365455 -9.319411,26.094325 -9.716286,26.512206 -9.629056,27.140953 -9.756128,27.688259 -9.683885,28.144174 -9.859998,28.95999 -9.805634,29.424638 -9.48214,30.307556 -9.970017,30.539325 -10.056575,30.961831 -9.950225,31.37607 -10.636901,31.761421 -10.94479,32.081815 -11.432253,32.368903 -11.488787,33.136996 -12.66331,32.79278 -13.08326,32.87882 -13.91868,32.71196 -15.24563,32.26508 -15.71394,31.37626 -16.61162,31.18218 -18.02109,30.76357 -19.08641,30.26639 -19.57404,30.52582 -20.05335,30.98576 -19.82033,31.75179 -20.13397,32.2382 -20.85452,32.7068 -21.54298,32.8432 -22.89576,32.63858 -23.2368,32.19149 -23.60913,32.18726 -23.9275,32.01667 -24.92114,31.89936 -25.16482,31.56915 -24.80287,31.08929 -24.95762,30.6616 -24.70007,30.04419 -25.0,29.238655 -25.0,25.6825 -25.0,22.0 -25.0,20.00304 -23.85,20.0 -23.83766,19.58047 -19.84926,21.49509 -15.86085,23.40972 + + + + + + + 14.8513,22.86295 +14.143871,22.491289 +13.581425,23.040506 +11.999506,23.471668 +11.560669,24.097909 +10.771364,24.562532 +10.303847,24.379313 +9.948261,24.936954 +9.910693,25.365455 +9.319411,26.094325 +9.716286,26.512206 +9.629056,27.140953 +9.756128,27.688259 +9.683885,28.144174 +9.859998,28.95999 +9.805634,29.424638 +9.48214,30.307556 +9.970017,30.539325 +10.056575,30.961831 +9.950225,31.37607 +10.636901,31.761421 +10.94479,32.081815 +11.432253,32.368903 +11.488787,33.136996 +12.66331,32.79278 +13.08326,32.87882 +13.91868,32.71196 +15.24563,32.26508 +15.71394,31.37626 +16.61162,31.18218 +18.02109,30.76357 +19.08641,30.26639 +19.57404,30.52582 +20.05335,30.98576 +19.82033,31.75179 +20.13397,32.2382 +20.85452,32.7068 +21.54298,32.8432 +22.89576,32.63858 +23.2368,32.19149 +23.60913,32.18726 +23.9275,32.01667 +24.92114,31.89936 +25.16482,31.56915 +24.80287,31.08929 +24.95762,30.6616 +24.70007,30.04419 +25.0,29.238655 +25.0,25.6825 +25.0,22.0 +25.0,20.00304 +23.85,20.0 +23.83766,19.58047 +19.84926,21.49509 +15.86085,23.40972 14.8513,22.86295 - + #defaultStyle Sri Lanka - + - 81.787959,7.523055 -81.637322,6.481775 -81.21802,6.197141 -80.348357,5.96837 -79.872469,6.763463 -79.695167,8.200843 -80.147801,9.824078 -80.838818,9.268427 -81.304319,8.564206 + 81.787959,7.523055 +81.637322,6.481775 +81.21802,6.197141 +80.348357,5.96837 +79.872469,6.763463 +79.695167,8.200843 +80.147801,9.824078 +80.838818,9.268427 +81.304319,8.564206 81.787959,7.523055 - + #defaultStyle Lesotho - + - 28.978263,-28.955597 -29.325166,-29.257387 -29.018415,-29.743766 -28.8484,-30.070051 -28.291069,-30.226217 -28.107205,-30.545732 -27.749397,-30.645106 -26.999262,-29.875954 -27.532511,-29.242711 -28.074338,-28.851469 -28.5417,-28.647502 + 28.978263,-28.955597 +29.325166,-29.257387 +29.018415,-29.743766 +28.8484,-30.070051 +28.291069,-30.226217 +28.107205,-30.545732 +27.749397,-30.645106 +26.999262,-29.875954 +27.532511,-29.242711 +28.074338,-28.851469 +28.5417,-28.647502 28.978263,-28.955597 - + #defaultStyle Lithuania - + - 22.731099,54.327537 -22.651052,54.582741 -22.757764,54.856574 -22.315724,55.015299 -21.268449,55.190482 -21.0558,56.031076 -22.201157,56.337802 -23.878264,56.273671 -24.860684,56.372528 -25.000934,56.164531 -25.533047,56.100297 -26.494331,55.615107 -26.588279,55.167176 -25.768433,54.846963 -25.536354,54.282423 -24.450684,53.905702 -23.484128,53.912498 -23.243987,54.220567 + 22.731099,54.327537 +22.651052,54.582741 +22.757764,54.856574 +22.315724,55.015299 +21.268449,55.190482 +21.0558,56.031076 +22.201157,56.337802 +23.878264,56.273671 +24.860684,56.372528 +25.000934,56.164531 +25.533047,56.100297 +26.494331,55.615107 +26.588279,55.167176 +25.768433,54.846963 +25.536354,54.282423 +24.450684,53.905702 +23.484128,53.912498 +23.243987,54.220567 22.731099,54.327537 - + #defaultStyle Luxembourg - + - 6.043073,50.128052 -6.242751,49.902226 -6.18632,49.463803 -5.897759,49.442667 -5.674052,49.529484 -5.782417,50.090328 + 6.043073,50.128052 +6.242751,49.902226 +6.18632,49.463803 +5.897759,49.442667 +5.674052,49.529484 +5.782417,50.090328 6.043073,50.128052 - + #defaultStyle Latvia - - - - - - - 21.0558,56.031076 -21.090424,56.783873 -21.581866,57.411871 -22.524341,57.753374 -23.318453,57.006236 -24.12073,57.025693 -24.312863,57.793424 -25.164594,57.970157 -25.60281,57.847529 -26.463532,57.476389 -27.288185,57.474528 -27.770016,57.244258 -27.855282,56.759326 -28.176709,56.16913 -27.10246,55.783314 -26.494331,55.615107 -25.533047,56.100297 -25.000934,56.164531 -24.860684,56.372528 -23.878264,56.273671 -22.201157,56.337802 + + + + + + + 21.0558,56.031076 +21.090424,56.783873 +21.581866,57.411871 +22.524341,57.753374 +23.318453,57.006236 +24.12073,57.025693 +24.312863,57.793424 +25.164594,57.970157 +25.60281,57.847529 +26.463532,57.476389 +27.288185,57.474528 +27.770016,57.244258 +27.855282,56.759326 +28.176709,56.16913 +27.10246,55.783314 +26.494331,55.615107 +25.533047,56.100297 +25.000934,56.164531 +24.860684,56.372528 +23.878264,56.273671 +22.201157,56.337802 21.0558,56.031076 - + #defaultStyle Morocco - - - - - - - -5.193863,35.755182 --4.591006,35.330712 --3.640057,35.399855 --2.604306,35.179093 --2.169914,35.168396 --1.792986,34.527919 --1.733455,33.919713 --1.388049,32.864015 --1.124551,32.651522 --1.307899,32.262889 --2.616605,32.094346 --3.06898,31.724498 --3.647498,31.637294 --3.690441,30.896952 --4.859646,30.501188 --5.242129,30.000443 --6.060632,29.7317 --7.059228,29.579228 --8.674116,28.841289 --8.66559,27.656426 --8.817809,27.656426 --8.817828,27.656426 --8.794884,27.120696 --9.413037,27.088476 --9.735343,26.860945 --10.189424,26.860945 --10.551263,26.990808 --11.392555,26.883424 --11.71822,26.104092 --12.030759,26.030866 --12.500963,24.770116 --13.89111,23.691009 --14.221168,22.310163 --14.630833,21.86094 --14.750955,21.5006 --17.002962,21.420734 --17.020428,21.42231 --16.973248,21.885745 --16.589137,22.158234 --16.261922,22.67934 --16.326414,23.017768 --15.982611,23.723358 --15.426004,24.359134 --15.089332,24.520261 --14.824645,25.103533 --14.800926,25.636265 --14.43994,26.254418 --13.773805,26.618892 --13.139942,27.640148 --13.121613,27.654148 --12.618837,28.038186 --11.688919,28.148644 --10.900957,28.832142 --10.399592,29.098586 --9.564811,29.933574 --9.814718,31.177736 --9.434793,32.038096 --9.300693,32.564679 --8.657476,33.240245 --7.654178,33.697065 --6.912544,34.110476 --6.244342,35.145865 --5.929994,35.759988 + + + + + + + -5.193863,35.755182 +-4.591006,35.330712 +-3.640057,35.399855 +-2.604306,35.179093 +-2.169914,35.168396 +-1.792986,34.527919 +-1.733455,33.919713 +-1.388049,32.864015 +-1.124551,32.651522 +-1.307899,32.262889 +-2.616605,32.094346 +-3.06898,31.724498 +-3.647498,31.637294 +-3.690441,30.896952 +-4.859646,30.501188 +-5.242129,30.000443 +-6.060632,29.7317 +-7.059228,29.579228 +-8.674116,28.841289 +-8.66559,27.656426 +-8.817809,27.656426 +-8.817828,27.656426 +-8.794884,27.120696 +-9.413037,27.088476 +-9.735343,26.860945 +-10.189424,26.860945 +-10.551263,26.990808 +-11.392555,26.883424 +-11.71822,26.104092 +-12.030759,26.030866 +-12.500963,24.770116 +-13.89111,23.691009 +-14.221168,22.310163 +-14.630833,21.86094 +-14.750955,21.5006 +-17.002962,21.420734 +-17.020428,21.42231 +-16.973248,21.885745 +-16.589137,22.158234 +-16.261922,22.67934 +-16.326414,23.017768 +-15.982611,23.723358 +-15.426004,24.359134 +-15.089332,24.520261 +-14.824645,25.103533 +-14.800926,25.636265 +-14.43994,26.254418 +-13.773805,26.618892 +-13.139942,27.640148 +-13.121613,27.654148 +-12.618837,28.038186 +-11.688919,28.148644 +-10.900957,28.832142 +-10.399592,29.098586 +-9.564811,29.933574 +-9.814718,31.177736 +-9.434793,32.038096 +-9.300693,32.564679 +-8.657476,33.240245 +-7.654178,33.697065 +-6.912544,34.110476 +-6.244342,35.145865 +-5.929994,35.759988 -5.193863,35.755182 - + #defaultStyle Moldova - - - - - - - 26.619337,48.220726 -26.857824,48.368211 -27.522537,48.467119 -28.259547,48.155562 -28.670891,48.118149 -29.122698,47.849095 -29.050868,47.510227 -29.415135,47.346645 -29.559674,46.928583 -29.908852,46.674361 -29.83821,46.525326 -30.024659,46.423937 -29.759972,46.349988 -29.170654,46.379262 -29.072107,46.517678 -28.862972,46.437889 -28.933717,46.25883 -28.659987,45.939987 -28.485269,45.596907 -28.233554,45.488283 -28.054443,45.944586 -28.160018,46.371563 -28.12803,46.810476 -27.551166,47.405117 -27.233873,47.826771 -26.924176,48.123264 + + + + + + + 26.619337,48.220726 +26.857824,48.368211 +27.522537,48.467119 +28.259547,48.155562 +28.670891,48.118149 +29.122698,47.849095 +29.050868,47.510227 +29.415135,47.346645 +29.559674,46.928583 +29.908852,46.674361 +29.83821,46.525326 +30.024659,46.423937 +29.759972,46.349988 +29.170654,46.379262 +29.072107,46.517678 +28.862972,46.437889 +28.933717,46.25883 +28.659987,45.939987 +28.485269,45.596907 +28.233554,45.488283 +28.054443,45.944586 +28.160018,46.371563 +28.12803,46.810476 +27.551166,47.405117 +27.233873,47.826771 +26.924176,48.123264 26.619337,48.220726 - + #defaultStyle Madagascar - - - - - - - 49.543519,-12.469833 -49.808981,-12.895285 -50.056511,-13.555761 -50.217431,-14.758789 -50.476537,-15.226512 -50.377111,-15.706069 -50.200275,-16.000263 -49.860606,-15.414253 -49.672607,-15.710204 -49.863344,-16.451037 -49.774564,-16.875042 -49.498612,-17.106036 -49.435619,-17.953064 -49.041792,-19.118781 -48.548541,-20.496888 -47.930749,-22.391501 -47.547723,-23.781959 -47.095761,-24.94163 -46.282478,-25.178463 -45.409508,-25.601434 -44.833574,-25.346101 -44.03972,-24.988345 -43.763768,-24.460677 -43.697778,-23.574116 -43.345654,-22.776904 -43.254187,-22.057413 -43.433298,-21.336475 -43.893683,-21.163307 -43.89637,-20.830459 -44.374325,-20.072366 -44.464397,-19.435454 -44.232422,-18.961995 -44.042976,-18.331387 -43.963084,-17.409945 -44.312469,-16.850496 -44.446517,-16.216219 -44.944937,-16.179374 -45.502732,-15.974373 -45.872994,-15.793454 -46.312243,-15.780018 -46.882183,-15.210182 -47.70513,-14.594303 -48.005215,-14.091233 -47.869047,-13.663869 -48.293828,-13.784068 -48.84506,-13.089175 -48.863509,-12.487868 -49.194651,-12.040557 + + + + + + + 49.543519,-12.469833 +49.808981,-12.895285 +50.056511,-13.555761 +50.217431,-14.758789 +50.476537,-15.226512 +50.377111,-15.706069 +50.200275,-16.000263 +49.860606,-15.414253 +49.672607,-15.710204 +49.863344,-16.451037 +49.774564,-16.875042 +49.498612,-17.106036 +49.435619,-17.953064 +49.041792,-19.118781 +48.548541,-20.496888 +47.930749,-22.391501 +47.547723,-23.781959 +47.095761,-24.94163 +46.282478,-25.178463 +45.409508,-25.601434 +44.833574,-25.346101 +44.03972,-24.988345 +43.763768,-24.460677 +43.697778,-23.574116 +43.345654,-22.776904 +43.254187,-22.057413 +43.433298,-21.336475 +43.893683,-21.163307 +43.89637,-20.830459 +44.374325,-20.072366 +44.464397,-19.435454 +44.232422,-18.961995 +44.042976,-18.331387 +43.963084,-17.409945 +44.312469,-16.850496 +44.446517,-16.216219 +44.944937,-16.179374 +45.502732,-15.974373 +45.872994,-15.793454 +46.312243,-15.780018 +46.882183,-15.210182 +47.70513,-14.594303 +48.005215,-14.091233 +47.869047,-13.663869 +48.293828,-13.784068 +48.84506,-13.089175 +48.863509,-12.487868 +49.194651,-12.040557 49.543519,-12.469833 - + #defaultStyle Mexico - - - - - - - -97.140008,25.869997 --97.528072,24.992144 --97.702946,24.272343 --97.776042,22.93258 --97.872367,22.444212 --97.699044,21.898689 --97.38896,21.411019 --97.189333,20.635433 --96.525576,19.890931 --96.292127,19.320371 --95.900885,18.828024 --94.839063,18.562717 --94.42573,18.144371 --93.548651,18.423837 --92.786114,18.524839 --92.037348,18.704569 --91.407903,18.876083 --90.77187,19.28412 --90.53359,19.867418 --90.451476,20.707522 --90.278618,20.999855 --89.601321,21.261726 --88.543866,21.493675 --87.658417,21.458846 --87.05189,21.543543 --86.811982,21.331515 --86.845908,20.849865 --87.383291,20.255405 --87.621054,19.646553 --87.43675,19.472403 --87.58656,19.04013 --87.837191,18.259816 --88.090664,18.516648 --88.300031,18.499982 --88.490123,18.486831 --88.848344,17.883198 --89.029857,18.001511 --89.150909,17.955468 --89.14308,17.808319 --90.067934,17.819326 --91.00152,17.817595 --91.002269,17.254658 --91.453921,17.252177 --91.08167,16.918477 --90.711822,16.687483 --90.600847,16.470778 --90.438867,16.41011 --90.464473,16.069562 --91.74796,16.066565 --92.229249,15.251447 --92.087216,15.064585 --92.20323,14.830103 --92.22775,14.538829 --93.359464,15.61543 --93.875169,15.940164 --94.691656,16.200975 --95.250227,16.128318 --96.053382,15.752088 --96.557434,15.653515 --97.263592,15.917065 --98.01303,16.107312 --98.947676,16.566043 --99.697397,16.706164 --100.829499,17.171071 --101.666089,17.649026 --101.918528,17.91609 --102.478132,17.975751 --103.50099,18.292295 --103.917527,18.748572 --104.99201,19.316134 --105.493038,19.946767 --105.731396,20.434102 --105.397773,20.531719 --105.500661,20.816895 --105.270752,21.076285 --105.265817,21.422104 --105.603161,21.871146 --105.693414,22.26908 --106.028716,22.773752 --106.90998,23.767774 --107.915449,24.548915 --108.401905,25.172314 --109.260199,25.580609 --109.444089,25.824884 --109.291644,26.442934 --109.801458,26.676176 --110.391732,27.162115 --110.641019,27.859876 --111.178919,27.941241 --111.759607,28.467953 --112.228235,28.954409 --112.271824,29.266844 --112.809594,30.021114 --113.163811,30.786881 --113.148669,31.170966 --113.871881,31.567608 --114.205737,31.524045 --114.776451,31.799532 --114.9367,31.393485 --114.771232,30.913617 --114.673899,30.162681 --114.330974,29.750432 --113.588875,29.061611 --113.424053,28.826174 --113.271969,28.754783 --113.140039,28.411289 --112.962298,28.42519 --112.761587,27.780217 --112.457911,27.525814 --112.244952,27.171727 --111.616489,26.662817 --111.284675,25.73259 --110.987819,25.294606 --110.710007,24.826004 --110.655049,24.298595 --110.172856,24.265548 --109.771847,23.811183 --109.409104,23.364672 --109.433392,23.185588 --109.854219,22.818272 --110.031392,22.823078 --110.295071,23.430973 --110.949501,24.000964 --111.670568,24.484423 --112.182036,24.738413 --112.148989,25.470125 --112.300711,26.012004 --112.777297,26.32196 --113.464671,26.768186 --113.59673,26.63946 --113.848937,26.900064 --114.465747,27.14209 --115.055142,27.722727 --114.982253,27.7982 --114.570366,27.741485 --114.199329,28.115003 --114.162018,28.566112 --114.931842,29.279479 --115.518654,29.556362 --115.887365,30.180794 --116.25835,30.836464 --116.721526,31.635744 --117.12776,32.53534 --115.99135,32.61239 --114.72139,32.72083 --114.815,32.52528 --113.30498,32.03914 --111.02361,31.33472 --109.035,31.34194 --108.24194,31.34222 --108.24,31.754854 --106.50759,31.75452 --106.1429,31.39995 --105.63159,31.08383 --105.03737,30.64402 --104.70575,30.12173 --104.45697,29.57196 --103.94,29.27 --103.11,28.97 --102.48,29.76 --101.6624,29.7793 --100.9576,29.38071 --100.45584,28.69612 --100.11,28.11 --99.52,27.54 --99.3,26.84 --99.02,26.37 --98.24,26.06 --97.53,25.84 + + + + + + + -97.140008,25.869997 +-97.528072,24.992144 +-97.702946,24.272343 +-97.776042,22.93258 +-97.872367,22.444212 +-97.699044,21.898689 +-97.38896,21.411019 +-97.189333,20.635433 +-96.525576,19.890931 +-96.292127,19.320371 +-95.900885,18.828024 +-94.839063,18.562717 +-94.42573,18.144371 +-93.548651,18.423837 +-92.786114,18.524839 +-92.037348,18.704569 +-91.407903,18.876083 +-90.77187,19.28412 +-90.53359,19.867418 +-90.451476,20.707522 +-90.278618,20.999855 +-89.601321,21.261726 +-88.543866,21.493675 +-87.658417,21.458846 +-87.05189,21.543543 +-86.811982,21.331515 +-86.845908,20.849865 +-87.383291,20.255405 +-87.621054,19.646553 +-87.43675,19.472403 +-87.58656,19.04013 +-87.837191,18.259816 +-88.090664,18.516648 +-88.300031,18.499982 +-88.490123,18.486831 +-88.848344,17.883198 +-89.029857,18.001511 +-89.150909,17.955468 +-89.14308,17.808319 +-90.067934,17.819326 +-91.00152,17.817595 +-91.002269,17.254658 +-91.453921,17.252177 +-91.08167,16.918477 +-90.711822,16.687483 +-90.600847,16.470778 +-90.438867,16.41011 +-90.464473,16.069562 +-91.74796,16.066565 +-92.229249,15.251447 +-92.087216,15.064585 +-92.20323,14.830103 +-92.22775,14.538829 +-93.359464,15.61543 +-93.875169,15.940164 +-94.691656,16.200975 +-95.250227,16.128318 +-96.053382,15.752088 +-96.557434,15.653515 +-97.263592,15.917065 +-98.01303,16.107312 +-98.947676,16.566043 +-99.697397,16.706164 +-100.829499,17.171071 +-101.666089,17.649026 +-101.918528,17.91609 +-102.478132,17.975751 +-103.50099,18.292295 +-103.917527,18.748572 +-104.99201,19.316134 +-105.493038,19.946767 +-105.731396,20.434102 +-105.397773,20.531719 +-105.500661,20.816895 +-105.270752,21.076285 +-105.265817,21.422104 +-105.603161,21.871146 +-105.693414,22.26908 +-106.028716,22.773752 +-106.90998,23.767774 +-107.915449,24.548915 +-108.401905,25.172314 +-109.260199,25.580609 +-109.444089,25.824884 +-109.291644,26.442934 +-109.801458,26.676176 +-110.391732,27.162115 +-110.641019,27.859876 +-111.178919,27.941241 +-111.759607,28.467953 +-112.228235,28.954409 +-112.271824,29.266844 +-112.809594,30.021114 +-113.163811,30.786881 +-113.148669,31.170966 +-113.871881,31.567608 +-114.205737,31.524045 +-114.776451,31.799532 +-114.9367,31.393485 +-114.771232,30.913617 +-114.673899,30.162681 +-114.330974,29.750432 +-113.588875,29.061611 +-113.424053,28.826174 +-113.271969,28.754783 +-113.140039,28.411289 +-112.962298,28.42519 +-112.761587,27.780217 +-112.457911,27.525814 +-112.244952,27.171727 +-111.616489,26.662817 +-111.284675,25.73259 +-110.987819,25.294606 +-110.710007,24.826004 +-110.655049,24.298595 +-110.172856,24.265548 +-109.771847,23.811183 +-109.409104,23.364672 +-109.433392,23.185588 +-109.854219,22.818272 +-110.031392,22.823078 +-110.295071,23.430973 +-110.949501,24.000964 +-111.670568,24.484423 +-112.182036,24.738413 +-112.148989,25.470125 +-112.300711,26.012004 +-112.777297,26.32196 +-113.464671,26.768186 +-113.59673,26.63946 +-113.848937,26.900064 +-114.465747,27.14209 +-115.055142,27.722727 +-114.982253,27.7982 +-114.570366,27.741485 +-114.199329,28.115003 +-114.162018,28.566112 +-114.931842,29.279479 +-115.518654,29.556362 +-115.887365,30.180794 +-116.25835,30.836464 +-116.721526,31.635744 +-117.12776,32.53534 +-115.99135,32.61239 +-114.72139,32.72083 +-114.815,32.52528 +-113.30498,32.03914 +-111.02361,31.33472 +-109.035,31.34194 +-108.24194,31.34222 +-108.24,31.754854 +-106.50759,31.75452 +-106.1429,31.39995 +-105.63159,31.08383 +-105.03737,30.64402 +-104.70575,30.12173 +-104.45697,29.57196 +-103.94,29.27 +-103.11,28.97 +-102.48,29.76 +-101.6624,29.7793 +-100.9576,29.38071 +-100.45584,28.69612 +-100.11,28.11 +-99.52,27.54 +-99.3,26.84 +-99.02,26.37 +-98.24,26.06 +-97.53,25.84 -97.140008,25.869997 - + #defaultStyle Mali - - - - - - - -12.17075,14.616834 --11.834208,14.799097 --11.666078,15.388208 --11.349095,15.411256 --10.650791,15.132746 --10.086846,15.330486 --9.700255,15.264107 --9.550238,15.486497 --5.537744,15.50169 --5.315277,16.201854 --5.488523,16.325102 --5.971129,20.640833 --6.453787,24.956591 --4.923337,24.974574 --1.550055,22.792666 -1.823228,20.610809 -2.060991,20.142233 -2.683588,19.85623 -3.146661,19.693579 -3.158133,19.057364 -4.267419,19.155265 -4.27021,16.852227 -3.723422,16.184284 -3.638259,15.56812 -2.749993,15.409525 -1.385528,15.323561 -1.015783,14.968182 -0.374892,14.928908 --0.266257,14.924309 --0.515854,15.116158 --1.066363,14.973815 --2.001035,14.559008 --2.191825,14.246418 --2.967694,13.79815 --3.103707,13.541267 --3.522803,13.337662 --4.006391,13.472485 --4.280405,13.228444 --4.427166,12.542646 --5.220942,11.713859 --5.197843,11.375146 --5.470565,10.95127 --5.404342,10.370737 --5.816926,10.222555 --6.050452,10.096361 --6.205223,10.524061 --6.493965,10.411303 --6.666461,10.430811 --6.850507,10.138994 --7.622759,10.147236 --7.89959,10.297382 --8.029944,10.206535 --8.335377,10.494812 --8.282357,10.792597 --8.407311,10.909257 --8.620321,10.810891 --8.581305,11.136246 --8.376305,11.393646 --8.786099,11.812561 --8.905265,12.088358 --9.127474,12.30806 --9.327616,12.334286 --9.567912,12.194243 --9.890993,12.060479 --10.165214,11.844084 --10.593224,11.923975 --10.87083,12.177887 --11.036556,12.211245 --11.297574,12.077971 --11.456169,12.076834 --11.513943,12.442988 --11.467899,12.754519 --11.553398,13.141214 --11.927716,13.422075 --12.124887,13.994727 + + + + + + + -12.17075,14.616834 +-11.834208,14.799097 +-11.666078,15.388208 +-11.349095,15.411256 +-10.650791,15.132746 +-10.086846,15.330486 +-9.700255,15.264107 +-9.550238,15.486497 +-5.537744,15.50169 +-5.315277,16.201854 +-5.488523,16.325102 +-5.971129,20.640833 +-6.453787,24.956591 +-4.923337,24.974574 +-1.550055,22.792666 +1.823228,20.610809 +2.060991,20.142233 +2.683588,19.85623 +3.146661,19.693579 +3.158133,19.057364 +4.267419,19.155265 +4.27021,16.852227 +3.723422,16.184284 +3.638259,15.56812 +2.749993,15.409525 +1.385528,15.323561 +1.015783,14.968182 +0.374892,14.928908 +-0.266257,14.924309 +-0.515854,15.116158 +-1.066363,14.973815 +-2.001035,14.559008 +-2.191825,14.246418 +-2.967694,13.79815 +-3.103707,13.541267 +-3.522803,13.337662 +-4.006391,13.472485 +-4.280405,13.228444 +-4.427166,12.542646 +-5.220942,11.713859 +-5.197843,11.375146 +-5.470565,10.95127 +-5.404342,10.370737 +-5.816926,10.222555 +-6.050452,10.096361 +-6.205223,10.524061 +-6.493965,10.411303 +-6.666461,10.430811 +-6.850507,10.138994 +-7.622759,10.147236 +-7.89959,10.297382 +-8.029944,10.206535 +-8.335377,10.494812 +-8.282357,10.792597 +-8.407311,10.909257 +-8.620321,10.810891 +-8.581305,11.136246 +-8.376305,11.393646 +-8.786099,11.812561 +-8.905265,12.088358 +-9.127474,12.30806 +-9.327616,12.334286 +-9.567912,12.194243 +-9.890993,12.060479 +-10.165214,11.844084 +-10.593224,11.923975 +-10.87083,12.177887 +-11.036556,12.211245 +-11.297574,12.077971 +-11.456169,12.076834 +-11.513943,12.442988 +-11.467899,12.754519 +-11.553398,13.141214 +-11.927716,13.422075 +-12.124887,13.994727 -12.17075,14.616834 - + #defaultStyle Malta - + - 14.566171,35.852721 -14.532684,35.820191 -14.436463,35.821664 -14.352334,35.872281 -14.3513,35.978399 -14.448348,35.957444 -14.537025,35.886285 + 14.566171,35.852721 +14.532684,35.820191 +14.436463,35.821664 +14.352334,35.872281 +14.3513,35.978399 +14.448348,35.957444 +14.537025,35.886285 14.566171,35.852721 - + - 14.313473,36.027569 -14.253632,36.012143 -14.194204,36.042245 -14.180354,36.060383 -14.263243,36.075809 -14.303758,36.062295 -14.320914,36.03625 + 14.313473,36.027569 +14.253632,36.012143 +14.194204,36.042245 +14.180354,36.060383 +14.263243,36.075809 +14.303758,36.062295 +14.320914,36.03625 14.313473,36.027569 - + #defaultStyle Myanmar - - - - - - - 99.543309,20.186598 -98.959676,19.752981 -98.253724,19.708203 -97.797783,18.62708 -97.375896,18.445438 -97.859123,17.567946 -98.493761,16.837836 -98.903348,16.177824 -98.537376,15.308497 -98.192074,15.123703 -98.430819,14.622028 -99.097755,13.827503 -99.212012,13.269294 -99.196354,12.804748 -99.587286,11.892763 -99.038121,10.960546 -98.553551,9.93296 -98.457174,10.675266 -98.764546,11.441292 -98.428339,12.032987 -98.509574,13.122378 -98.103604,13.64046 -97.777732,14.837286 -97.597072,16.100568 -97.16454,16.928734 -96.505769,16.427241 -95.369352,15.71439 -94.808405,15.803454 -94.188804,16.037936 -94.533486,17.27724 -94.324817,18.213514 -93.540988,19.366493 -93.663255,19.726962 -93.078278,19.855145 -92.368554,20.670883 -92.303234,21.475485 -92.652257,21.324048 -92.672721,22.041239 -93.166128,22.27846 -93.060294,22.703111 -93.286327,23.043658 -93.325188,24.078556 -94.106742,23.850741 -94.552658,24.675238 -94.603249,25.162495 -95.155153,26.001307 -95.124768,26.573572 -96.419366,27.264589 -97.133999,27.083774 -97.051989,27.699059 -97.402561,27.882536 -97.327114,28.261583 -97.911988,28.335945 -98.246231,27.747221 -98.68269,27.508812 -98.712094,26.743536 -98.671838,25.918703 -97.724609,25.083637 -97.60472,23.897405 -98.660262,24.063286 -98.898749,23.142722 -99.531992,22.949039 -99.240899,22.118314 -99.983489,21.742937 -100.416538,21.558839 -101.150033,21.849984 -101.180005,21.436573 -100.329101,20.786122 -100.115988,20.41785 + + + + + + + 99.543309,20.186598 +98.959676,19.752981 +98.253724,19.708203 +97.797783,18.62708 +97.375896,18.445438 +97.859123,17.567946 +98.493761,16.837836 +98.903348,16.177824 +98.537376,15.308497 +98.192074,15.123703 +98.430819,14.622028 +99.097755,13.827503 +99.212012,13.269294 +99.196354,12.804748 +99.587286,11.892763 +99.038121,10.960546 +98.553551,9.93296 +98.457174,10.675266 +98.764546,11.441292 +98.428339,12.032987 +98.509574,13.122378 +98.103604,13.64046 +97.777732,14.837286 +97.597072,16.100568 +97.16454,16.928734 +96.505769,16.427241 +95.369352,15.71439 +94.808405,15.803454 +94.188804,16.037936 +94.533486,17.27724 +94.324817,18.213514 +93.540988,19.366493 +93.663255,19.726962 +93.078278,19.855145 +92.368554,20.670883 +92.303234,21.475485 +92.652257,21.324048 +92.672721,22.041239 +93.166128,22.27846 +93.060294,22.703111 +93.286327,23.043658 +93.325188,24.078556 +94.106742,23.850741 +94.552658,24.675238 +94.603249,25.162495 +95.155153,26.001307 +95.124768,26.573572 +96.419366,27.264589 +97.133999,27.083774 +97.051989,27.699059 +97.402561,27.882536 +97.327114,28.261583 +97.911988,28.335945 +98.246231,27.747221 +98.68269,27.508812 +98.712094,26.743536 +98.671838,25.918703 +97.724609,25.083637 +97.60472,23.897405 +98.660262,24.063286 +98.898749,23.142722 +99.531992,22.949039 +99.240899,22.118314 +99.983489,21.742937 +100.416538,21.558839 +101.150033,21.849984 +101.180005,21.436573 +100.329101,20.786122 +100.115988,20.41785 99.543309,20.186598 - + #defaultStyle Montenegro - + - 19.801613,42.500093 -19.738051,42.688247 -19.30449,42.19574 -19.37177,41.87755 -19.16246,41.95502 -18.88214,42.28151 -18.45,42.48 -18.56,42.65 -18.70648,43.20011 -19.03165,43.43253 -19.21852,43.52384 -19.48389,43.35229 -19.63,43.21378 -19.95857,43.10604 -20.3398,42.89852 -20.25758,42.81275 -20.0707,42.58863 + 19.801613,42.500093 +19.738051,42.688247 +19.30449,42.19574 +19.37177,41.87755 +19.16246,41.95502 +18.88214,42.28151 +18.45,42.48 +18.56,42.65 +18.70648,43.20011 +19.03165,43.43253 +19.21852,43.52384 +19.48389,43.35229 +19.63,43.21378 +19.95857,43.10604 +20.3398,42.89852 +20.25758,42.81275 +20.0707,42.58863 19.801613,42.500093 - + #defaultStyle Mongolia - - - - - - - 87.751264,49.297198 -88.805567,49.470521 -90.713667,50.331812 -92.234712,50.802171 -93.104219,50.49529 -94.147566,50.480537 -94.815949,50.013433 -95.814028,49.977467 -97.259728,49.726061 -98.231762,50.422401 -97.82574,51.010995 -98.861491,52.047366 -99.981732,51.634006 -100.88948,51.516856 -102.065223,51.259921 -102.255909,50.510561 -103.676545,50.089966 -104.621552,50.275329 -105.886591,50.406019 -106.888804,50.274296 -107.868176,49.793705 -108.475167,49.282548 -109.402449,49.292961 -110.662011,49.130128 -111.581231,49.377968 -112.89774,49.543565 -114.362456,50.248303 -114.96211,50.140247 -115.485695,49.805177 -116.678801,49.888531 -116.191802,49.134598 -115.485282,48.135383 -115.742837,47.726545 -116.308953,47.85341 -117.295507,47.697709 -118.064143,48.06673 -118.866574,47.74706 -119.772824,47.048059 -119.66327,46.69268 -118.874326,46.805412 -117.421701,46.672733 -116.717868,46.388202 -115.985096,45.727235 -114.460332,45.339817 -113.463907,44.808893 -112.436062,45.011646 -111.873306,45.102079 -111.348377,44.457442 -111.667737,44.073176 -111.829588,43.743118 -111.129682,43.406834 -110.412103,42.871234 -109.243596,42.519446 -107.744773,42.481516 -106.129316,42.134328 -104.964994,41.59741 -104.522282,41.908347 -103.312278,41.907468 -101.83304,42.514873 -100.845866,42.663804 -99.515817,42.524691 -97.451757,42.74889 -96.349396,42.725635 -95.762455,43.319449 -95.306875,44.241331 -94.688929,44.352332 -93.480734,44.975472 -92.133891,45.115076 -90.94554,45.286073 -90.585768,45.719716 -90.970809,46.888146 -90.280826,47.693549 -88.854298,48.069082 -88.013832,48.599463 + + + + + + + 87.751264,49.297198 +88.805567,49.470521 +90.713667,50.331812 +92.234712,50.802171 +93.104219,50.49529 +94.147566,50.480537 +94.815949,50.013433 +95.814028,49.977467 +97.259728,49.726061 +98.231762,50.422401 +97.82574,51.010995 +98.861491,52.047366 +99.981732,51.634006 +100.88948,51.516856 +102.065223,51.259921 +102.255909,50.510561 +103.676545,50.089966 +104.621552,50.275329 +105.886591,50.406019 +106.888804,50.274296 +107.868176,49.793705 +108.475167,49.282548 +109.402449,49.292961 +110.662011,49.130128 +111.581231,49.377968 +112.89774,49.543565 +114.362456,50.248303 +114.96211,50.140247 +115.485695,49.805177 +116.678801,49.888531 +116.191802,49.134598 +115.485282,48.135383 +115.742837,47.726545 +116.308953,47.85341 +117.295507,47.697709 +118.064143,48.06673 +118.866574,47.74706 +119.772824,47.048059 +119.66327,46.69268 +118.874326,46.805412 +117.421701,46.672733 +116.717868,46.388202 +115.985096,45.727235 +114.460332,45.339817 +113.463907,44.808893 +112.436062,45.011646 +111.873306,45.102079 +111.348377,44.457442 +111.667737,44.073176 +111.829588,43.743118 +111.129682,43.406834 +110.412103,42.871234 +109.243596,42.519446 +107.744773,42.481516 +106.129316,42.134328 +104.964994,41.59741 +104.522282,41.908347 +103.312278,41.907468 +101.83304,42.514873 +100.845866,42.663804 +99.515817,42.524691 +97.451757,42.74889 +96.349396,42.725635 +95.762455,43.319449 +95.306875,44.241331 +94.688929,44.352332 +93.480734,44.975472 +92.133891,45.115076 +90.94554,45.286073 +90.585768,45.719716 +90.970809,46.888146 +90.280826,47.693549 +88.854298,48.069082 +88.013832,48.599463 87.751264,49.297198 - + #defaultStyle Mozambique - - - - - - - 34.559989,-11.52002 -35.312398,-11.439146 -36.514082,-11.720938 -36.775151,-11.594537 -37.471284,-11.568751 -37.827645,-11.268769 -38.427557,-11.285202 -39.52103,-10.896854 -40.316589,-10.317096 -40.478387,-10.765441 -40.437253,-11.761711 -40.560811,-12.639177 -40.59962,-14.201975 -40.775475,-14.691764 -40.477251,-15.406294 -40.089264,-16.100774 -39.452559,-16.720891 -38.538351,-17.101023 -37.411133,-17.586368 -36.281279,-18.659688 -35.896497,-18.84226 -35.1984,-19.552811 -34.786383,-19.784012 -34.701893,-20.497043 -35.176127,-21.254361 -35.373428,-21.840837 -35.385848,-22.14 -35.562546,-22.09 -35.533935,-23.070788 -35.371774,-23.535359 -35.60747,-23.706563 -35.458746,-24.12261 -35.040735,-24.478351 -34.215824,-24.816314 -33.01321,-25.357573 -32.574632,-25.727318 -32.660363,-26.148584 -32.915955,-26.215867 -32.83012,-26.742192 -32.071665,-26.73382 -31.985779,-26.29178 -31.837778,-25.843332 -31.752408,-25.484284 -31.930589,-24.369417 -31.670398,-23.658969 -31.191409,-22.25151 -32.244988,-21.116489 -32.508693,-20.395292 -32.659743,-20.30429 -32.772708,-19.715592 -32.611994,-19.419383 -32.654886,-18.67209 -32.849861,-17.979057 -32.847639,-16.713398 -32.328239,-16.392074 -31.852041,-16.319417 -31.636498,-16.07199 -31.173064,-15.860944 -30.338955,-15.880839 -30.274256,-15.507787 -30.179481,-14.796099 -33.214025,-13.97186 -33.7897,-14.451831 -34.064825,-14.35995 -34.459633,-14.61301 -34.517666,-15.013709 -34.307291,-15.478641 -34.381292,-16.18356 -35.03381,-16.8013 -35.339063,-16.10744 -35.771905,-15.896859 -35.686845,-14.611046 -35.267956,-13.887834 -34.907151,-13.565425 -34.559989,-13.579998 -34.280006,-12.280025 + + + + + + + 34.559989,-11.52002 +35.312398,-11.439146 +36.514082,-11.720938 +36.775151,-11.594537 +37.471284,-11.568751 +37.827645,-11.268769 +38.427557,-11.285202 +39.52103,-10.896854 +40.316589,-10.317096 +40.478387,-10.765441 +40.437253,-11.761711 +40.560811,-12.639177 +40.59962,-14.201975 +40.775475,-14.691764 +40.477251,-15.406294 +40.089264,-16.100774 +39.452559,-16.720891 +38.538351,-17.101023 +37.411133,-17.586368 +36.281279,-18.659688 +35.896497,-18.84226 +35.1984,-19.552811 +34.786383,-19.784012 +34.701893,-20.497043 +35.176127,-21.254361 +35.373428,-21.840837 +35.385848,-22.14 +35.562546,-22.09 +35.533935,-23.070788 +35.371774,-23.535359 +35.60747,-23.706563 +35.458746,-24.12261 +35.040735,-24.478351 +34.215824,-24.816314 +33.01321,-25.357573 +32.574632,-25.727318 +32.660363,-26.148584 +32.915955,-26.215867 +32.83012,-26.742192 +32.071665,-26.73382 +31.985779,-26.29178 +31.837778,-25.843332 +31.752408,-25.484284 +31.930589,-24.369417 +31.670398,-23.658969 +31.191409,-22.25151 +32.244988,-21.116489 +32.508693,-20.395292 +32.659743,-20.30429 +32.772708,-19.715592 +32.611994,-19.419383 +32.654886,-18.67209 +32.849861,-17.979057 +32.847639,-16.713398 +32.328239,-16.392074 +31.852041,-16.319417 +31.636498,-16.07199 +31.173064,-15.860944 +30.338955,-15.880839 +30.274256,-15.507787 +30.179481,-14.796099 +33.214025,-13.97186 +33.7897,-14.451831 +34.064825,-14.35995 +34.459633,-14.61301 +34.517666,-15.013709 +34.307291,-15.478641 +34.381292,-16.18356 +35.03381,-16.8013 +35.339063,-16.10744 +35.771905,-15.896859 +35.686845,-14.611046 +35.267956,-13.887834 +34.907151,-13.565425 +34.559989,-13.579998 +34.280006,-12.280025 34.559989,-11.52002 - + #defaultStyle Mauritania - - - - - - - -12.17075,14.616834 --12.830658,15.303692 --13.435738,16.039383 --14.099521,16.304302 --14.577348,16.598264 --15.135737,16.587282 --15.623666,16.369337 --16.12069,16.455663 --16.463098,16.135036 --16.549708,16.673892 --16.270552,17.166963 --16.146347,18.108482 --16.256883,19.096716 --16.377651,19.593817 --16.277838,20.092521 --16.536324,20.567866 --17.063423,20.999752 --16.845194,21.333323 --12.929102,21.327071 --13.118754,22.77122 --12.874222,23.284832 --11.937224,23.374594 --11.969419,25.933353 --8.687294,25.881056 --8.6844,27.395744 --4.923337,24.974574 --6.453787,24.956591 --5.971129,20.640833 --5.488523,16.325102 --5.315277,16.201854 --5.537744,15.50169 --9.550238,15.486497 --9.700255,15.264107 --10.086846,15.330486 --10.650791,15.132746 --11.349095,15.411256 --11.666078,15.388208 --11.834208,14.799097 + + + + + + + -12.17075,14.616834 +-12.830658,15.303692 +-13.435738,16.039383 +-14.099521,16.304302 +-14.577348,16.598264 +-15.135737,16.587282 +-15.623666,16.369337 +-16.12069,16.455663 +-16.463098,16.135036 +-16.549708,16.673892 +-16.270552,17.166963 +-16.146347,18.108482 +-16.256883,19.096716 +-16.377651,19.593817 +-16.277838,20.092521 +-16.536324,20.567866 +-17.063423,20.999752 +-16.845194,21.333323 +-12.929102,21.327071 +-13.118754,22.77122 +-12.874222,23.284832 +-11.937224,23.374594 +-11.969419,25.933353 +-8.687294,25.881056 +-8.6844,27.395744 +-4.923337,24.974574 +-6.453787,24.956591 +-5.971129,20.640833 +-5.488523,16.325102 +-5.315277,16.201854 +-5.537744,15.50169 +-9.550238,15.486497 +-9.700255,15.264107 +-10.086846,15.330486 +-10.650791,15.132746 +-11.349095,15.411256 +-11.666078,15.388208 +-11.834208,14.799097 -12.17075,14.616834 - + #defaultStyle Malawi - - - - - - - 34.559989,-11.52002 -34.280006,-12.280025 -34.559989,-13.579998 -34.907151,-13.565425 -35.267956,-13.887834 -35.686845,-14.611046 -35.771905,-15.896859 -35.339063,-16.10744 -35.03381,-16.8013 -34.381292,-16.18356 -34.307291,-15.478641 -34.517666,-15.013709 -34.459633,-14.61301 -34.064825,-14.35995 -33.7897,-14.451831 -33.214025,-13.97186 -32.688165,-13.712858 -32.991764,-12.783871 -33.306422,-12.435778 -33.114289,-11.607198 -33.31531,-10.79655 -33.485688,-10.525559 -33.231388,-9.676722 -32.759375,-9.230599 -33.739729,-9.417151 -33.940838,-9.693674 -34.280006,-10.16 + + + + + + + 34.559989,-11.52002 +34.280006,-12.280025 +34.559989,-13.579998 +34.907151,-13.565425 +35.267956,-13.887834 +35.686845,-14.611046 +35.771905,-15.896859 +35.339063,-16.10744 +35.03381,-16.8013 +34.381292,-16.18356 +34.307291,-15.478641 +34.517666,-15.013709 +34.459633,-14.61301 +34.064825,-14.35995 +33.7897,-14.451831 +33.214025,-13.97186 +32.688165,-13.712858 +32.991764,-12.783871 +33.306422,-12.435778 +33.114289,-11.607198 +33.31531,-10.79655 +33.485688,-10.525559 +33.231388,-9.676722 +32.759375,-9.230599 +33.739729,-9.417151 +33.940838,-9.693674 +34.280006,-10.16 34.559989,-11.52002 - + #defaultStyle Malaysia - - - - - - - 101.075516,6.204867 -101.154219,5.691384 -101.814282,5.810808 -102.141187,6.221636 -102.371147,6.128205 -102.961705,5.524495 -103.381215,4.855001 -103.438575,4.181606 -103.332122,3.726698 -103.429429,3.382869 -103.502448,2.791019 -103.854674,2.515454 -104.247932,1.631141 -104.228811,1.293048 -103.519707,1.226334 -102.573615,1.967115 -101.390638,2.760814 -101.27354,3.270292 -100.695435,3.93914 -100.557408,4.76728 -100.196706,5.312493 -100.30626,6.040562 -100.085757,6.464489 -100.259596,6.642825 + + + + + + + 101.075516,6.204867 +101.154219,5.691384 +101.814282,5.810808 +102.141187,6.221636 +102.371147,6.128205 +102.961705,5.524495 +103.381215,4.855001 +103.438575,4.181606 +103.332122,3.726698 +103.429429,3.382869 +103.502448,2.791019 +103.854674,2.515454 +104.247932,1.631141 +104.228811,1.293048 +103.519707,1.226334 +102.573615,1.967115 +101.390638,2.760814 +101.27354,3.270292 +100.695435,3.93914 +100.557408,4.76728 +100.196706,5.312493 +100.30626,6.040562 +100.085757,6.464489 +100.259596,6.642825 101.075516,6.204867 - + - 118.618321,4.478202 -117.882035,4.137551 -117.015214,4.306094 -115.865517,4.306559 -115.519078,3.169238 -115.134037,2.821482 -114.621355,1.430688 -113.80585,1.217549 -112.859809,1.49779 -112.380252,1.410121 -111.797548,0.904441 -111.159138,0.976478 -110.514061,0.773131 -109.830227,1.338136 -109.66326,2.006467 -110.396135,1.663775 -111.168853,1.850637 -111.370081,2.697303 -111.796928,2.885897 -112.995615,3.102395 -113.712935,3.893509 -114.204017,4.525874 -114.659596,4.007637 -114.869557,4.348314 -115.347461,4.316636 -115.4057,4.955228 -115.45071,5.44773 -116.220741,6.143191 -116.725103,6.924771 -117.129626,6.928053 -117.643393,6.422166 -117.689075,5.98749 -118.347691,5.708696 -119.181904,5.407836 -119.110694,5.016128 -118.439727,4.966519 + 118.618321,4.478202 +117.882035,4.137551 +117.015214,4.306094 +115.865517,4.306559 +115.519078,3.169238 +115.134037,2.821482 +114.621355,1.430688 +113.80585,1.217549 +112.859809,1.49779 +112.380252,1.410121 +111.797548,0.904441 +111.159138,0.976478 +110.514061,0.773131 +109.830227,1.338136 +109.66326,2.006467 +110.396135,1.663775 +111.168853,1.850637 +111.370081,2.697303 +111.796928,2.885897 +112.995615,3.102395 +113.712935,3.893509 +114.204017,4.525874 +114.659596,4.007637 +114.869557,4.348314 +115.347461,4.316636 +115.4057,4.955228 +115.45071,5.44773 +116.220741,6.143191 +116.725103,6.924771 +117.129626,6.928053 +117.643393,6.422166 +117.689075,5.98749 +118.347691,5.708696 +119.181904,5.407836 +119.110694,5.016128 +118.439727,4.966519 118.618321,4.478202 - + #defaultStyle Namibia - - - - - - - 16.344977,-28.576705 -15.601818,-27.821247 -15.210472,-27.090956 -14.989711,-26.117372 -14.743214,-25.39292 -14.408144,-23.853014 -14.385717,-22.656653 -14.257714,-22.111208 -13.868642,-21.699037 -13.352498,-20.872834 -12.826845,-19.673166 -12.608564,-19.045349 -11.794919,-18.069129 -11.734199,-17.301889 -12.215461,-17.111668 -12.814081,-16.941343 -13.462362,-16.971212 -14.058501,-17.423381 -14.209707,-17.353101 -18.263309,-17.309951 -18.956187,-17.789095 -21.377176,-17.930636 -23.215048,-17.523116 -24.033862,-17.295843 -24.682349,-17.353411 -25.07695,-17.578823 -25.084443,-17.661816 -24.520705,-17.887125 -24.217365,-17.889347 -23.579006,-18.281261 -23.196858,-17.869038 -21.65504,-18.219146 -20.910641,-18.252219 -20.881134,-21.814327 -19.895458,-21.849157 -19.895768,-24.76779 -19.894734,-28.461105 -19.002127,-28.972443 -18.464899,-29.045462 -17.836152,-28.856378 -17.387497,-28.783514 -17.218929,-28.355943 -16.824017,-28.082162 + + + + + + + 16.344977,-28.576705 +15.601818,-27.821247 +15.210472,-27.090956 +14.989711,-26.117372 +14.743214,-25.39292 +14.408144,-23.853014 +14.385717,-22.656653 +14.257714,-22.111208 +13.868642,-21.699037 +13.352498,-20.872834 +12.826845,-19.673166 +12.608564,-19.045349 +11.794919,-18.069129 +11.734199,-17.301889 +12.215461,-17.111668 +12.814081,-16.941343 +13.462362,-16.971212 +14.058501,-17.423381 +14.209707,-17.353101 +18.263309,-17.309951 +18.956187,-17.789095 +21.377176,-17.930636 +23.215048,-17.523116 +24.033862,-17.295843 +24.682349,-17.353411 +25.07695,-17.578823 +25.084443,-17.661816 +24.520705,-17.887125 +24.217365,-17.889347 +23.579006,-18.281261 +23.196858,-17.869038 +21.65504,-18.219146 +20.910641,-18.252219 +20.881134,-21.814327 +19.895458,-21.849157 +19.895768,-24.76779 +19.894734,-28.461105 +19.002127,-28.972443 +18.464899,-29.045462 +17.836152,-28.856378 +17.387497,-28.783514 +17.218929,-28.355943 +16.824017,-28.082162 16.344977,-28.576705 - + #defaultStyle New Caledonia - + - 165.77999,-21.080005 -166.599991,-21.700019 -167.120011,-22.159991 -166.740035,-22.399976 -166.189732,-22.129708 -165.474375,-21.679607 -164.829815,-21.14982 -164.167995,-20.444747 -164.029606,-20.105646 -164.459967,-20.120012 -165.020036,-20.459991 -165.460009,-20.800022 + 165.77999,-21.080005 +166.599991,-21.700019 +167.120011,-22.159991 +166.740035,-22.399976 +166.189732,-22.129708 +165.474375,-21.679607 +164.829815,-21.14982 +164.167995,-20.444747 +164.029606,-20.105646 +164.459967,-20.120012 +165.020036,-20.459991 +165.460009,-20.800022 165.77999,-21.080005 - + #defaultStyle Niger - - - - - - - 2.154474,11.94015 -2.177108,12.625018 -1.024103,12.851826 -0.993046,13.33575 -0.429928,13.988733 -0.295646,14.444235 -0.374892,14.928908 -1.015783,14.968182 -1.385528,15.323561 -2.749993,15.409525 -3.638259,15.56812 -3.723422,16.184284 -4.27021,16.852227 -4.267419,19.155265 -5.677566,19.601207 -8.572893,21.565661 -11.999506,23.471668 -13.581425,23.040506 -14.143871,22.491289 -14.8513,22.86295 -15.096888,21.308519 -15.471077,21.048457 -15.487148,20.730415 -15.903247,20.387619 -15.685741,19.95718 -15.300441,17.92795 -15.247731,16.627306 -13.972202,15.684366 -13.540394,14.367134 -13.956699,13.996691 -13.954477,13.353449 -14.595781,13.330427 -14.495787,12.859396 -14.213531,12.802035 -14.181336,12.483657 -13.995353,12.461565 -13.318702,13.556356 -13.083987,13.596147 -12.302071,13.037189 -11.527803,13.32898 -10.989593,13.387323 -10.701032,13.246918 -10.114814,13.277252 -9.524928,12.851102 -9.014933,12.826659 -7.804671,13.343527 -7.330747,13.098038 -6.820442,13.115091 -6.445426,13.492768 -5.443058,13.865924 -4.368344,13.747482 -4.107946,13.531216 -3.967283,12.956109 -3.680634,12.552903 -3.61118,11.660167 -2.848643,12.235636 -2.490164,12.233052 + + + + + + + 2.154474,11.94015 +2.177108,12.625018 +1.024103,12.851826 +0.993046,13.33575 +0.429928,13.988733 +0.295646,14.444235 +0.374892,14.928908 +1.015783,14.968182 +1.385528,15.323561 +2.749993,15.409525 +3.638259,15.56812 +3.723422,16.184284 +4.27021,16.852227 +4.267419,19.155265 +5.677566,19.601207 +8.572893,21.565661 +11.999506,23.471668 +13.581425,23.040506 +14.143871,22.491289 +14.8513,22.86295 +15.096888,21.308519 +15.471077,21.048457 +15.487148,20.730415 +15.903247,20.387619 +15.685741,19.95718 +15.300441,17.92795 +15.247731,16.627306 +13.972202,15.684366 +13.540394,14.367134 +13.956699,13.996691 +13.954477,13.353449 +14.595781,13.330427 +14.495787,12.859396 +14.213531,12.802035 +14.181336,12.483657 +13.995353,12.461565 +13.318702,13.556356 +13.083987,13.596147 +12.302071,13.037189 +11.527803,13.32898 +10.989593,13.387323 +10.701032,13.246918 +10.114814,13.277252 +9.524928,12.851102 +9.014933,12.826659 +7.804671,13.343527 +7.330747,13.098038 +6.820442,13.115091 +6.445426,13.492768 +5.443058,13.865924 +4.368344,13.747482 +4.107946,13.531216 +3.967283,12.956109 +3.680634,12.552903 +3.61118,11.660167 +2.848643,12.235636 +2.490164,12.233052 2.154474,11.94015 - + #defaultStyle Nigeria - - - - - - - 8.500288,4.771983 -7.462108,4.412108 -7.082596,4.464689 -6.698072,4.240594 -5.898173,4.262453 -5.362805,4.887971 -5.033574,5.611802 -4.325607,6.270651 -3.57418,6.2583 -2.691702,6.258817 -2.749063,7.870734 -2.723793,8.506845 -2.912308,9.137608 -3.220352,9.444153 -3.705438,10.06321 -3.60007,10.332186 -3.797112,10.734746 -3.572216,11.327939 -3.61118,11.660167 -3.680634,12.552903 -3.967283,12.956109 -4.107946,13.531216 -4.368344,13.747482 -5.443058,13.865924 -6.445426,13.492768 -6.820442,13.115091 -7.330747,13.098038 -7.804671,13.343527 -9.014933,12.826659 -9.524928,12.851102 -10.114814,13.277252 -10.701032,13.246918 -10.989593,13.387323 -11.527803,13.32898 -12.302071,13.037189 -13.083987,13.596147 -13.318702,13.556356 -13.995353,12.461565 -14.181336,12.483657 -14.577178,12.085361 -14.468192,11.904752 -14.415379,11.572369 -13.57295,10.798566 -13.308676,10.160362 -13.1676,9.640626 -12.955468,9.417772 -12.753672,8.717763 -12.218872,8.305824 -12.063946,7.799808 -11.839309,7.397042 -11.745774,6.981383 -11.058788,6.644427 -10.497375,7.055358 -10.118277,7.03877 -9.522706,6.453482 -9.233163,6.444491 -8.757533,5.479666 + + + + + + + 8.500288,4.771983 +7.462108,4.412108 +7.082596,4.464689 +6.698072,4.240594 +5.898173,4.262453 +5.362805,4.887971 +5.033574,5.611802 +4.325607,6.270651 +3.57418,6.2583 +2.691702,6.258817 +2.749063,7.870734 +2.723793,8.506845 +2.912308,9.137608 +3.220352,9.444153 +3.705438,10.06321 +3.60007,10.332186 +3.797112,10.734746 +3.572216,11.327939 +3.61118,11.660167 +3.680634,12.552903 +3.967283,12.956109 +4.107946,13.531216 +4.368344,13.747482 +5.443058,13.865924 +6.445426,13.492768 +6.820442,13.115091 +7.330747,13.098038 +7.804671,13.343527 +9.014933,12.826659 +9.524928,12.851102 +10.114814,13.277252 +10.701032,13.246918 +10.989593,13.387323 +11.527803,13.32898 +12.302071,13.037189 +13.083987,13.596147 +13.318702,13.556356 +13.995353,12.461565 +14.181336,12.483657 +14.577178,12.085361 +14.468192,11.904752 +14.415379,11.572369 +13.57295,10.798566 +13.308676,10.160362 +13.1676,9.640626 +12.955468,9.417772 +12.753672,8.717763 +12.218872,8.305824 +12.063946,7.799808 +11.839309,7.397042 +11.745774,6.981383 +11.058788,6.644427 +10.497375,7.055358 +10.118277,7.03877 +9.522706,6.453482 +9.233163,6.444491 +8.757533,5.479666 8.500288,4.771983 - + #defaultStyle Nicaragua - - - - - - - -85.71254,11.088445 --86.058488,11.403439 --86.52585,11.806877 --86.745992,12.143962 --87.167516,12.458258 --87.668493,12.90991 --87.557467,13.064552 --87.392386,12.914018 --87.316654,12.984686 --87.005769,13.025794 --86.880557,13.254204 --86.733822,13.263093 --86.755087,13.754845 --86.520708,13.778487 --86.312142,13.771356 --86.096264,14.038187 --85.801295,13.836055 --85.698665,13.960078 --85.514413,14.079012 --85.165365,14.35437 --85.148751,14.560197 --85.052787,14.551541 --84.924501,14.790493 --84.820037,14.819587 --84.649582,14.666805 --84.449336,14.621614 --84.228342,14.748764 --83.975721,14.749436 --83.628585,14.880074 --83.489989,15.016267 --83.147219,14.995829 --83.233234,14.899866 --83.284162,14.676624 --83.182126,14.310703 --83.4125,13.970078 --83.519832,13.567699 --83.552207,13.127054 --83.498515,12.869292 --83.473323,12.419087 --83.626104,12.32085 --83.719613,11.893124 --83.650858,11.629032 --83.85547,11.373311 --83.808936,11.103044 --83.655612,10.938764 --83.895054,10.726839 --84.190179,10.79345 --84.355931,10.999226 --84.673069,11.082657 --84.903003,10.952303 --85.561852,11.217119 + + + + + + + -85.71254,11.088445 +-86.058488,11.403439 +-86.52585,11.806877 +-86.745992,12.143962 +-87.167516,12.458258 +-87.668493,12.90991 +-87.557467,13.064552 +-87.392386,12.914018 +-87.316654,12.984686 +-87.005769,13.025794 +-86.880557,13.254204 +-86.733822,13.263093 +-86.755087,13.754845 +-86.520708,13.778487 +-86.312142,13.771356 +-86.096264,14.038187 +-85.801295,13.836055 +-85.698665,13.960078 +-85.514413,14.079012 +-85.165365,14.35437 +-85.148751,14.560197 +-85.052787,14.551541 +-84.924501,14.790493 +-84.820037,14.819587 +-84.649582,14.666805 +-84.449336,14.621614 +-84.228342,14.748764 +-83.975721,14.749436 +-83.628585,14.880074 +-83.489989,15.016267 +-83.147219,14.995829 +-83.233234,14.899866 +-83.284162,14.676624 +-83.182126,14.310703 +-83.4125,13.970078 +-83.519832,13.567699 +-83.552207,13.127054 +-83.498515,12.869292 +-83.473323,12.419087 +-83.626104,12.32085 +-83.719613,11.893124 +-83.650858,11.629032 +-83.85547,11.373311 +-83.808936,11.103044 +-83.655612,10.938764 +-83.895054,10.726839 +-84.190179,10.79345 +-84.355931,10.999226 +-84.673069,11.082657 +-84.903003,10.952303 +-85.561852,11.217119 -85.71254,11.088445 - + #defaultStyle Netherlands - + - 6.074183,53.510403 -6.90514,53.482162 -7.092053,53.144043 -6.84287,52.22844 -6.589397,51.852029 -5.988658,51.851616 -6.156658,50.803721 -5.606976,51.037298 -4.973991,51.475024 -4.047071,51.267259 -3.314971,51.345755 -3.830289,51.620545 -4.705997,53.091798 + 6.074183,53.510403 +6.90514,53.482162 +7.092053,53.144043 +6.84287,52.22844 +6.589397,51.852029 +5.988658,51.851616 +6.156658,50.803721 +5.606976,51.037298 +4.973991,51.475024 +4.047071,51.267259 +3.314971,51.345755 +3.830289,51.620545 +4.705997,53.091798 6.074183,53.510403 - + #defaultStyle Norway - - - - - - - 28.165547,71.185474 -31.293418,70.453788 -30.005435,70.186259 -31.101079,69.55808 -29.399581,69.156916 -28.59193,69.064777 -29.015573,69.766491 -27.732292,70.164193 -26.179622,69.825299 -25.689213,69.092114 -24.735679,68.649557 -23.66205,68.891247 -22.356238,68.841741 -21.244936,69.370443 -20.645593,69.106247 -20.025269,69.065139 -19.87856,68.407194 -17.993868,68.567391 -17.729182,68.010552 -16.768879,68.013937 -16.108712,67.302456 -15.108411,66.193867 -13.55569,64.787028 -13.919905,64.445421 -13.571916,64.049114 -12.579935,64.066219 -11.930569,63.128318 -11.992064,61.800362 -12.631147,61.293572 -12.300366,60.117933 -11.468272,59.432393 -11.027369,58.856149 -10.356557,59.469807 -8.382,58.313288 -7.048748,58.078884 -5.665835,58.588155 -5.308234,59.663232 -4.992078,61.970998 -5.9129,62.614473 -8.553411,63.454008 -10.527709,64.486038 -12.358347,65.879726 -14.761146,67.810642 -16.435927,68.563205 -19.184028,69.817444 -21.378416,70.255169 -23.023742,70.202072 -24.546543,71.030497 -26.37005,70.986262 + + + + + + + 28.165547,71.185474 +31.293418,70.453788 +30.005435,70.186259 +31.101079,69.55808 +29.399581,69.156916 +28.59193,69.064777 +29.015573,69.766491 +27.732292,70.164193 +26.179622,69.825299 +25.689213,69.092114 +24.735679,68.649557 +23.66205,68.891247 +22.356238,68.841741 +21.244936,69.370443 +20.645593,69.106247 +20.025269,69.065139 +19.87856,68.407194 +17.993868,68.567391 +17.729182,68.010552 +16.768879,68.013937 +16.108712,67.302456 +15.108411,66.193867 +13.55569,64.787028 +13.919905,64.445421 +13.571916,64.049114 +12.579935,64.066219 +11.930569,63.128318 +11.992064,61.800362 +12.631147,61.293572 +12.300366,60.117933 +11.468272,59.432393 +11.027369,58.856149 +10.356557,59.469807 +8.382,58.313288 +7.048748,58.078884 +5.665835,58.588155 +5.308234,59.663232 +4.992078,61.970998 +5.9129,62.614473 +8.553411,63.454008 +10.527709,64.486038 +12.358347,65.879726 +14.761146,67.810642 +16.435927,68.563205 +19.184028,69.817444 +21.378416,70.255169 +23.023742,70.202072 +24.546543,71.030497 +26.37005,70.986262 28.165547,71.185474 - + - 24.72412,77.85385 -22.49032,77.44493 -20.72601,77.67704 -21.41611,77.93504 -20.8119,78.25463 -22.88426,78.45494 -23.28134,78.07954 + 24.72412,77.85385 +22.49032,77.44493 +20.72601,77.67704 +21.41611,77.93504 +20.8119,78.25463 +22.88426,78.45494 +23.28134,78.07954 24.72412,77.85385 - + - 18.25183,79.70175 -21.54383,78.95611 -19.02737,78.5626 -18.47172,77.82669 -17.59441,77.63796 -17.1182,76.80941 -15.91315,76.77045 -13.76259,77.38035 -14.66956,77.73565 -13.1706,78.02493 -11.22231,78.8693 -10.44453,79.65239 -13.17077,80.01046 -13.71852,79.66039 -15.14282,79.67431 -15.52255,80.01608 -16.99085,80.05086 + 18.25183,79.70175 +21.54383,78.95611 +19.02737,78.5626 +18.47172,77.82669 +17.59441,77.63796 +17.1182,76.80941 +15.91315,76.77045 +13.76259,77.38035 +14.66956,77.73565 +13.1706,78.02493 +11.22231,78.8693 +10.44453,79.65239 +13.17077,80.01046 +13.71852,79.66039 +15.14282,79.67431 +15.52255,80.01608 +16.99085,80.05086 18.25183,79.70175 - + - 25.447625,80.40734 -27.407506,80.056406 -25.924651,79.517834 -23.024466,79.400012 -20.075188,79.566823 -19.897266,79.842362 -18.462264,79.85988 -17.368015,80.318896 -20.455992,80.598156 -21.907945,80.357679 -22.919253,80.657144 + 25.447625,80.40734 +27.407506,80.056406 +25.924651,79.517834 +23.024466,79.400012 +20.075188,79.566823 +19.897266,79.842362 +18.462264,79.85988 +17.368015,80.318896 +20.455992,80.598156 +21.907945,80.357679 +22.919253,80.657144 25.447625,80.40734 - + #defaultStyle Nepal - - - - - - - 88.120441,27.876542 -88.043133,27.445819 -88.174804,26.810405 -88.060238,26.414615 -87.227472,26.397898 -86.024393,26.630985 -85.251779,26.726198 -84.675018,27.234901 -83.304249,27.364506 -81.999987,27.925479 -81.057203,28.416095 -80.088425,28.79447 -80.476721,29.729865 -81.111256,30.183481 -81.525804,30.422717 -82.327513,30.115268 -83.337115,29.463732 -83.898993,29.320226 -84.23458,28.839894 -85.011638,28.642774 -85.82332,28.203576 -86.954517,27.974262 + + + + + + + 88.120441,27.876542 +88.043133,27.445819 +88.174804,26.810405 +88.060238,26.414615 +87.227472,26.397898 +86.024393,26.630985 +85.251779,26.726198 +84.675018,27.234901 +83.304249,27.364506 +81.999987,27.925479 +81.057203,28.416095 +80.088425,28.79447 +80.476721,29.729865 +81.111256,30.183481 +81.525804,30.422717 +82.327513,30.115268 +83.337115,29.463732 +83.898993,29.320226 +84.23458,28.839894 +85.011638,28.642774 +85.82332,28.203576 +86.954517,27.974262 88.120441,27.876542 - + #defaultStyle New Zealand - - - - - - - 173.020375,-40.919052 -173.247234,-41.331999 -173.958405,-40.926701 -174.247587,-41.349155 -174.248517,-41.770008 -173.876447,-42.233184 -173.22274,-42.970038 -172.711246,-43.372288 -173.080113,-43.853344 -172.308584,-43.865694 -171.452925,-44.242519 -171.185138,-44.897104 -170.616697,-45.908929 -169.831422,-46.355775 -169.332331,-46.641235 -168.411354,-46.619945 -167.763745,-46.290197 -166.676886,-46.219917 -166.509144,-45.852705 -167.046424,-45.110941 -168.303763,-44.123973 -168.949409,-43.935819 -169.667815,-43.555326 -170.52492,-43.031688 -171.12509,-42.512754 -171.569714,-41.767424 -171.948709,-41.514417 -172.097227,-40.956104 -172.79858,-40.493962 + + + + + + + 173.020375,-40.919052 +173.247234,-41.331999 +173.958405,-40.926701 +174.247587,-41.349155 +174.248517,-41.770008 +173.876447,-42.233184 +173.22274,-42.970038 +172.711246,-43.372288 +173.080113,-43.853344 +172.308584,-43.865694 +171.452925,-44.242519 +171.185138,-44.897104 +170.616697,-45.908929 +169.831422,-46.355775 +169.332331,-46.641235 +168.411354,-46.619945 +167.763745,-46.290197 +166.676886,-46.219917 +166.509144,-45.852705 +167.046424,-45.110941 +168.303763,-44.123973 +168.949409,-43.935819 +169.667815,-43.555326 +170.52492,-43.031688 +171.12509,-42.512754 +171.569714,-41.767424 +171.948709,-41.514417 +172.097227,-40.956104 +172.79858,-40.493962 173.020375,-40.919052 - + - 174.612009,-36.156397 -175.336616,-37.209098 -175.357596,-36.526194 -175.808887,-36.798942 -175.95849,-37.555382 -176.763195,-37.881253 -177.438813,-37.961248 -178.010354,-37.579825 -178.517094,-37.695373 -178.274731,-38.582813 -177.97046,-39.166343 -177.206993,-39.145776 -176.939981,-39.449736 -177.032946,-39.879943 -176.885824,-40.065978 -176.508017,-40.604808 -176.01244,-41.289624 -175.239567,-41.688308 -175.067898,-41.425895 -174.650973,-41.281821 -175.22763,-40.459236 -174.900157,-39.908933 -173.824047,-39.508854 -173.852262,-39.146602 -174.574802,-38.797683 -174.743474,-38.027808 -174.697017,-37.381129 -174.292028,-36.711092 -174.319004,-36.534824 -173.840997,-36.121981 -173.054171,-35.237125 -172.636005,-34.529107 -173.007042,-34.450662 -173.551298,-35.006183 -174.32939,-35.265496 + 174.612009,-36.156397 +175.336616,-37.209098 +175.357596,-36.526194 +175.808887,-36.798942 +175.95849,-37.555382 +176.763195,-37.881253 +177.438813,-37.961248 +178.010354,-37.579825 +178.517094,-37.695373 +178.274731,-38.582813 +177.97046,-39.166343 +177.206993,-39.145776 +176.939981,-39.449736 +177.032946,-39.879943 +176.885824,-40.065978 +176.508017,-40.604808 +176.01244,-41.289624 +175.239567,-41.688308 +175.067898,-41.425895 +174.650973,-41.281821 +175.22763,-40.459236 +174.900157,-39.908933 +173.824047,-39.508854 +173.852262,-39.146602 +174.574802,-38.797683 +174.743474,-38.027808 +174.697017,-37.381129 +174.292028,-36.711092 +174.319004,-36.534824 +173.840997,-36.121981 +173.054171,-35.237125 +172.636005,-34.529107 +173.007042,-34.450662 +173.551298,-35.006183 +174.32939,-35.265496 174.612009,-36.156397 - + #defaultStyle Oman - - - - - - - 58.861141,21.114035 -58.487986,20.428986 -58.034318,20.481437 -57.826373,20.243002 -57.665762,19.736005 -57.7887,19.06757 -57.694391,18.94471 -57.234264,18.947991 -56.609651,18.574267 -56.512189,18.087113 -56.283521,17.876067 -55.661492,17.884128 -55.269939,17.632309 -55.2749,17.228354 -54.791002,16.950697 -54.239253,17.044981 -53.570508,16.707663 -53.108573,16.651051 -52.782184,17.349742 -52.00001,19.000003 -54.999982,19.999994 -55.666659,22.000001 -55.208341,22.70833 -55.234489,23.110993 -55.525841,23.524869 -55.528632,23.933604 -55.981214,24.130543 -55.804119,24.269604 -55.886233,24.920831 -56.396847,24.924732 -56.84514,24.241673 -57.403453,23.878594 -58.136948,23.747931 -58.729211,23.565668 -59.180502,22.992395 -59.450098,22.660271 -59.80806,22.533612 -59.806148,22.310525 -59.442191,21.714541 -59.282408,21.433886 + + + + + + + 58.861141,21.114035 +58.487986,20.428986 +58.034318,20.481437 +57.826373,20.243002 +57.665762,19.736005 +57.7887,19.06757 +57.694391,18.94471 +57.234264,18.947991 +56.609651,18.574267 +56.512189,18.087113 +56.283521,17.876067 +55.661492,17.884128 +55.269939,17.632309 +55.2749,17.228354 +54.791002,16.950697 +54.239253,17.044981 +53.570508,16.707663 +53.108573,16.651051 +52.782184,17.349742 +52.00001,19.000003 +54.999982,19.999994 +55.666659,22.000001 +55.208341,22.70833 +55.234489,23.110993 +55.525841,23.524869 +55.528632,23.933604 +55.981214,24.130543 +55.804119,24.269604 +55.886233,24.920831 +56.396847,24.924732 +56.84514,24.241673 +57.403453,23.878594 +58.136948,23.747931 +58.729211,23.565668 +59.180502,22.992395 +59.450098,22.660271 +59.80806,22.533612 +59.806148,22.310525 +59.442191,21.714541 +59.282408,21.433886 58.861141,21.114035 - + - 56.391421,25.895991 -56.261042,25.714606 -56.070821,26.055464 -56.362017,26.395934 -56.485679,26.309118 + 56.391421,25.895991 +56.261042,25.714606 +56.070821,26.055464 +56.362017,26.395934 +56.485679,26.309118 56.391421,25.895991 - + #defaultStyle Pakistan - - - - - - - 75.158028,37.133031 -75.896897,36.666806 -76.192848,35.898403 -77.837451,35.49401 -76.871722,34.653544 -75.757061,34.504923 -74.240203,34.748887 -73.749948,34.317699 -74.104294,33.441473 -74.451559,32.7649 -75.258642,32.271105 -74.405929,31.692639 -74.42138,30.979815 -73.450638,29.976413 -72.823752,28.961592 -71.777666,27.91318 -70.616496,27.989196 -69.514393,26.940966 -70.168927,26.491872 -70.282873,25.722229 -70.844699,25.215102 -71.04324,24.356524 -68.842599,24.359134 -68.176645,23.691965 -67.443667,23.944844 -67.145442,24.663611 -66.372828,25.425141 -64.530408,25.237039 -62.905701,25.218409 -61.497363,25.078237 -61.874187,26.239975 -63.316632,26.756532 -63.233898,27.217047 -62.755426,27.378923 -62.72783,28.259645 -61.771868,28.699334 -61.369309,29.303276 -60.874248,29.829239 -62.549857,29.318572 -63.550261,29.468331 -64.148002,29.340819 -64.350419,29.560031 -65.046862,29.472181 -66.346473,29.887943 -66.381458,30.738899 -66.938891,31.304911 -67.683394,31.303154 -67.792689,31.58293 -68.556932,31.71331 -68.926677,31.620189 -69.317764,31.901412 -69.262522,32.501944 -69.687147,33.105499 -70.323594,33.358533 -69.930543,34.02012 -70.881803,33.988856 -71.156773,34.348911 -71.115019,34.733126 -71.613076,35.153203 -71.498768,35.650563 -71.262348,36.074388 -71.846292,36.509942 -72.920025,36.720007 -74.067552,36.836176 -74.575893,37.020841 + + + + + + + 75.158028,37.133031 +75.896897,36.666806 +76.192848,35.898403 +77.837451,35.49401 +76.871722,34.653544 +75.757061,34.504923 +74.240203,34.748887 +73.749948,34.317699 +74.104294,33.441473 +74.451559,32.7649 +75.258642,32.271105 +74.405929,31.692639 +74.42138,30.979815 +73.450638,29.976413 +72.823752,28.961592 +71.777666,27.91318 +70.616496,27.989196 +69.514393,26.940966 +70.168927,26.491872 +70.282873,25.722229 +70.844699,25.215102 +71.04324,24.356524 +68.842599,24.359134 +68.176645,23.691965 +67.443667,23.944844 +67.145442,24.663611 +66.372828,25.425141 +64.530408,25.237039 +62.905701,25.218409 +61.497363,25.078237 +61.874187,26.239975 +63.316632,26.756532 +63.233898,27.217047 +62.755426,27.378923 +62.72783,28.259645 +61.771868,28.699334 +61.369309,29.303276 +60.874248,29.829239 +62.549857,29.318572 +63.550261,29.468331 +64.148002,29.340819 +64.350419,29.560031 +65.046862,29.472181 +66.346473,29.887943 +66.381458,30.738899 +66.938891,31.304911 +67.683394,31.303154 +67.792689,31.58293 +68.556932,31.71331 +68.926677,31.620189 +69.317764,31.901412 +69.262522,32.501944 +69.687147,33.105499 +70.323594,33.358533 +69.930543,34.02012 +70.881803,33.988856 +71.156773,34.348911 +71.115019,34.733126 +71.613076,35.153203 +71.498768,35.650563 +71.262348,36.074388 +71.846292,36.509942 +72.920025,36.720007 +74.067552,36.836176 +74.575893,37.020841 75.158028,37.133031 - + #defaultStyle Panama - - - - - - - -77.881571,7.223771 --78.214936,7.512255 --78.429161,8.052041 --78.182096,8.319182 --78.435465,8.387705 --78.622121,8.718124 --79.120307,8.996092 --79.557877,8.932375 --79.760578,8.584515 --80.164481,8.333316 --80.382659,8.298409 --80.480689,8.090308 --80.00369,7.547524 --80.276671,7.419754 --80.421158,7.271572 --80.886401,7.220541 --81.059543,7.817921 --81.189716,7.647906 --81.519515,7.70661 --81.721311,8.108963 --82.131441,8.175393 --82.390934,8.292362 --82.820081,8.290864 --82.850958,8.073823 --82.965783,8.225028 --82.913176,8.423517 --82.829771,8.626295 --82.868657,8.807266 --82.719183,8.925709 --82.927155,9.07433 --82.932891,9.476812 --82.546196,9.566135 --82.187123,9.207449 --82.207586,8.995575 --81.808567,8.950617 --81.714154,9.031955 --81.439287,8.786234 --80.947302,8.858504 --80.521901,9.111072 --79.9146,9.312765 --79.573303,9.61161 --79.021192,9.552931 --79.05845,9.454565 --78.500888,9.420459 --78.055928,9.24773 --77.729514,8.946844 --77.353361,8.670505 --77.474723,8.524286 --77.242566,7.935278 --77.431108,7.638061 --77.753414,7.70984 + + + + + + + -77.881571,7.223771 +-78.214936,7.512255 +-78.429161,8.052041 +-78.182096,8.319182 +-78.435465,8.387705 +-78.622121,8.718124 +-79.120307,8.996092 +-79.557877,8.932375 +-79.760578,8.584515 +-80.164481,8.333316 +-80.382659,8.298409 +-80.480689,8.090308 +-80.00369,7.547524 +-80.276671,7.419754 +-80.421158,7.271572 +-80.886401,7.220541 +-81.059543,7.817921 +-81.189716,7.647906 +-81.519515,7.70661 +-81.721311,8.108963 +-82.131441,8.175393 +-82.390934,8.292362 +-82.820081,8.290864 +-82.850958,8.073823 +-82.965783,8.225028 +-82.913176,8.423517 +-82.829771,8.626295 +-82.868657,8.807266 +-82.719183,8.925709 +-82.927155,9.07433 +-82.932891,9.476812 +-82.546196,9.566135 +-82.187123,9.207449 +-82.207586,8.995575 +-81.808567,8.950617 +-81.714154,9.031955 +-81.439287,8.786234 +-80.947302,8.858504 +-80.521901,9.111072 +-79.9146,9.312765 +-79.573303,9.61161 +-79.021192,9.552931 +-79.05845,9.454565 +-78.500888,9.420459 +-78.055928,9.24773 +-77.729514,8.946844 +-77.353361,8.670505 +-77.474723,8.524286 +-77.242566,7.935278 +-77.431108,7.638061 +-77.753414,7.70984 -77.881571,7.223771 - + #defaultStyle Peru - - - - - - - -69.590424,-17.580012 --69.858444,-18.092694 --70.372572,-18.347975 --71.37525,-17.773799 --71.462041,-17.363488 --73.44453,-16.359363 --75.237883,-15.265683 --76.009205,-14.649286 --76.423469,-13.823187 --76.259242,-13.535039 --77.106192,-12.222716 --78.092153,-10.377712 --79.036953,-8.386568 --79.44592,-7.930833 --79.760578,-7.194341 --80.537482,-6.541668 --81.249996,-6.136834 --80.926347,-5.690557 --81.410943,-4.736765 --81.09967,-4.036394 --80.302561,-3.404856 --80.184015,-3.821162 --80.469295,-4.059287 --80.442242,-4.425724 --80.028908,-4.346091 --79.624979,-4.454198 --79.205289,-4.959129 --78.639897,-4.547784 --78.450684,-3.873097 --77.837905,-3.003021 --76.635394,-2.608678 --75.544996,-1.56161 --75.233723,-0.911417 --75.373223,-0.152032 --75.106625,-0.057205 --74.441601,-0.53082 --74.122395,-1.002833 --73.659504,-1.260491 --73.070392,-2.308954 --72.325787,-2.434218 --71.774761,-2.16979 --71.413646,-2.342802 --70.813476,-2.256865 --70.047709,-2.725156 --70.692682,-3.742872 --70.394044,-3.766591 --69.893635,-4.298187 --70.794769,-4.251265 --70.928843,-4.401591 --71.748406,-4.593983 --72.891928,-5.274561 --72.964507,-5.741251 --73.219711,-6.089189 --73.120027,-6.629931 --73.724487,-6.918595 --73.723401,-7.340999 --73.987235,-7.52383 --73.571059,-8.424447 --73.015383,-9.032833 --73.226713,-9.462213 --72.563033,-9.520194 --72.184891,-10.053598 --71.302412,-10.079436 --70.481894,-9.490118 --70.548686,-11.009147 --70.093752,-11.123972 --69.529678,-10.951734 --68.66508,-12.5613 --68.88008,-12.899729 --68.929224,-13.602684 --68.948887,-14.453639 --69.339535,-14.953195 --69.160347,-15.323974 --69.389764,-15.660129 --68.959635,-16.500698 + + + + + + + -69.590424,-17.580012 +-69.858444,-18.092694 +-70.372572,-18.347975 +-71.37525,-17.773799 +-71.462041,-17.363488 +-73.44453,-16.359363 +-75.237883,-15.265683 +-76.009205,-14.649286 +-76.423469,-13.823187 +-76.259242,-13.535039 +-77.106192,-12.222716 +-78.092153,-10.377712 +-79.036953,-8.386568 +-79.44592,-7.930833 +-79.760578,-7.194341 +-80.537482,-6.541668 +-81.249996,-6.136834 +-80.926347,-5.690557 +-81.410943,-4.736765 +-81.09967,-4.036394 +-80.302561,-3.404856 +-80.184015,-3.821162 +-80.469295,-4.059287 +-80.442242,-4.425724 +-80.028908,-4.346091 +-79.624979,-4.454198 +-79.205289,-4.959129 +-78.639897,-4.547784 +-78.450684,-3.873097 +-77.837905,-3.003021 +-76.635394,-2.608678 +-75.544996,-1.56161 +-75.233723,-0.911417 +-75.373223,-0.152032 +-75.106625,-0.057205 +-74.441601,-0.53082 +-74.122395,-1.002833 +-73.659504,-1.260491 +-73.070392,-2.308954 +-72.325787,-2.434218 +-71.774761,-2.16979 +-71.413646,-2.342802 +-70.813476,-2.256865 +-70.047709,-2.725156 +-70.692682,-3.742872 +-70.394044,-3.766591 +-69.893635,-4.298187 +-70.794769,-4.251265 +-70.928843,-4.401591 +-71.748406,-4.593983 +-72.891928,-5.274561 +-72.964507,-5.741251 +-73.219711,-6.089189 +-73.120027,-6.629931 +-73.724487,-6.918595 +-73.723401,-7.340999 +-73.987235,-7.52383 +-73.571059,-8.424447 +-73.015383,-9.032833 +-73.226713,-9.462213 +-72.563033,-9.520194 +-72.184891,-10.053598 +-71.302412,-10.079436 +-70.481894,-9.490118 +-70.548686,-11.009147 +-70.093752,-11.123972 +-69.529678,-10.951734 +-68.66508,-12.5613 +-68.88008,-12.899729 +-68.929224,-13.602684 +-68.948887,-14.453639 +-69.339535,-14.953195 +-69.160347,-15.323974 +-69.389764,-15.660129 +-68.959635,-16.500698 -69.590424,-17.580012 - + #defaultStyle Philippines - - - - - - - 126.376814,8.414706 -126.478513,7.750354 -126.537424,7.189381 -126.196773,6.274294 -125.831421,7.293715 -125.363852,6.786485 -125.683161,6.049657 -125.396512,5.581003 -124.219788,6.161355 -123.93872,6.885136 -124.243662,7.36061 -123.610212,7.833527 -123.296071,7.418876 -122.825506,7.457375 -122.085499,6.899424 -121.919928,7.192119 -122.312359,8.034962 -122.942398,8.316237 -123.487688,8.69301 -123.841154,8.240324 -124.60147,8.514158 -124.764612,8.960409 -125.471391,8.986997 -125.412118,9.760335 -126.222714,9.286074 -126.306637,8.782487 + + + + + + + 126.376814,8.414706 +126.478513,7.750354 +126.537424,7.189381 +126.196773,6.274294 +125.831421,7.293715 +125.363852,6.786485 +125.683161,6.049657 +125.396512,5.581003 +124.219788,6.161355 +123.93872,6.885136 +124.243662,7.36061 +123.610212,7.833527 +123.296071,7.418876 +122.825506,7.457375 +122.085499,6.899424 +121.919928,7.192119 +122.312359,8.034962 +122.942398,8.316237 +123.487688,8.69301 +123.841154,8.240324 +124.60147,8.514158 +124.764612,8.960409 +125.471391,8.986997 +125.412118,9.760335 +126.222714,9.286074 +126.306637,8.782487 126.376814,8.414706 - + - 123.982438,10.278779 -123.623183,9.950091 -123.309921,9.318269 -122.995883,9.022189 -122.380055,9.713361 -122.586089,9.981045 -122.837081,10.261157 -122.947411,10.881868 -123.49885,10.940624 -123.337774,10.267384 -124.077936,11.232726 + 123.982438,10.278779 +123.623183,9.950091 +123.309921,9.318269 +122.995883,9.022189 +122.380055,9.713361 +122.586089,9.981045 +122.837081,10.261157 +122.947411,10.881868 +123.49885,10.940624 +123.337774,10.267384 +124.077936,11.232726 123.982438,10.278779 - + - 118.504581,9.316383 -117.174275,8.3675 -117.664477,9.066889 -118.386914,9.6845 -118.987342,10.376292 -119.511496,11.369668 -119.689677,10.554291 -119.029458,10.003653 + 118.504581,9.316383 +117.174275,8.3675 +117.664477,9.066889 +118.386914,9.6845 +118.987342,10.376292 +119.511496,11.369668 +119.689677,10.554291 +119.029458,10.003653 118.504581,9.316383 - + - 121.883548,11.891755 -122.483821,11.582187 -123.120217,11.58366 -123.100838,11.165934 -122.637714,10.741308 -122.00261,10.441017 -121.967367,10.905691 -122.03837,11.415841 + 121.883548,11.891755 +122.483821,11.582187 +123.120217,11.58366 +123.100838,11.165934 +122.637714,10.741308 +122.00261,10.441017 +121.967367,10.905691 +122.03837,11.415841 121.883548,11.891755 - + - 125.502552,12.162695 -125.783465,11.046122 -125.011884,11.311455 -125.032761,10.975816 -125.277449,10.358722 -124.801819,10.134679 -124.760168,10.837995 -124.459101,10.88993 -124.302522,11.495371 -124.891013,11.415583 -124.87799,11.79419 -124.266762,12.557761 -125.227116,12.535721 + 125.502552,12.162695 +125.783465,11.046122 +125.011884,11.311455 +125.032761,10.975816 +125.277449,10.358722 +124.801819,10.134679 +124.760168,10.837995 +124.459101,10.88993 +124.302522,11.495371 +124.891013,11.415583 +124.87799,11.79419 +124.266762,12.557761 +125.227116,12.535721 125.502552,12.162695 - + - 121.527394,13.06959 -121.26219,12.20556 -120.833896,12.704496 -120.323436,13.466413 -121.180128,13.429697 + 121.527394,13.06959 +121.26219,12.20556 +120.833896,12.704496 +120.323436,13.466413 +121.180128,13.429697 121.527394,13.06959 - + - 121.321308,18.504065 -121.937601,18.218552 -122.246006,18.47895 -122.336957,18.224883 -122.174279,17.810283 -122.515654,17.093505 -122.252311,16.262444 -121.662786,15.931018 -121.50507,15.124814 -121.728829,14.328376 -122.258925,14.218202 -122.701276,14.336541 -123.950295,13.782131 -123.855107,13.237771 -124.181289,12.997527 -124.077419,12.536677 -123.298035,13.027526 -122.928652,13.55292 -122.671355,13.185836 -122.03465,13.784482 -121.126385,13.636687 -120.628637,13.857656 -120.679384,14.271016 -120.991819,14.525393 -120.693336,14.756671 -120.564145,14.396279 -120.070429,14.970869 -119.920929,15.406347 -119.883773,16.363704 -120.286488,16.034629 -120.390047,17.599081 -120.715867,18.505227 + 121.321308,18.504065 +121.937601,18.218552 +122.246006,18.47895 +122.336957,18.224883 +122.174279,17.810283 +122.515654,17.093505 +122.252311,16.262444 +121.662786,15.931018 +121.50507,15.124814 +121.728829,14.328376 +122.258925,14.218202 +122.701276,14.336541 +123.950295,13.782131 +123.855107,13.237771 +124.181289,12.997527 +124.077419,12.536677 +123.298035,13.027526 +122.928652,13.55292 +122.671355,13.185836 +122.03465,13.784482 +121.126385,13.636687 +120.628637,13.857656 +120.679384,14.271016 +120.991819,14.525393 +120.693336,14.756671 +120.564145,14.396279 +120.070429,14.970869 +119.920929,15.406347 +119.883773,16.363704 +120.286488,16.034629 +120.390047,17.599081 +120.715867,18.505227 121.321308,18.504065 - + #defaultStyle Papua New Guinea - + - 155.880026,-6.819997 -155.599991,-6.919991 -155.166994,-6.535931 -154.729192,-5.900828 -154.514114,-5.139118 -154.652504,-5.042431 -154.759991,-5.339984 -155.062918,-5.566792 -155.547746,-6.200655 -156.019965,-6.540014 + 155.880026,-6.819997 +155.599991,-6.919991 +155.166994,-6.535931 +154.729192,-5.900828 +154.514114,-5.139118 +154.652504,-5.042431 +154.759991,-5.339984 +155.062918,-5.566792 +155.547746,-6.200655 +156.019965,-6.540014 155.880026,-6.819997 - + - 151.982796,-5.478063 -151.459107,-5.56028 -151.30139,-5.840728 -150.754447,-6.083763 -150.241197,-6.317754 -149.709963,-6.316513 -148.890065,-6.02604 -148.318937,-5.747142 -148.401826,-5.437756 -149.298412,-5.583742 -149.845562,-5.505503 -149.99625,-5.026101 -150.139756,-5.001348 -150.236908,-5.53222 -150.807467,-5.455842 -151.089672,-5.113693 -151.647881,-4.757074 -151.537862,-4.167807 -152.136792,-4.14879 -152.338743,-4.312966 -152.318693,-4.867661 + 151.982796,-5.478063 +151.459107,-5.56028 +151.30139,-5.840728 +150.754447,-6.083763 +150.241197,-6.317754 +149.709963,-6.316513 +148.890065,-6.02604 +148.318937,-5.747142 +148.401826,-5.437756 +149.298412,-5.583742 +149.845562,-5.505503 +149.99625,-5.026101 +150.139756,-5.001348 +150.236908,-5.53222 +150.807467,-5.455842 +151.089672,-5.113693 +151.647881,-4.757074 +151.537862,-4.167807 +152.136792,-4.14879 +152.338743,-4.312966 +152.318693,-4.867661 151.982796,-5.478063 - + - 147.191874,-7.388024 -148.084636,-8.044108 -148.734105,-9.104664 -149.306835,-9.071436 -149.266631,-9.514406 -150.038728,-9.684318 -149.738798,-9.872937 -150.801628,-10.293687 -150.690575,-10.582713 -150.028393,-10.652476 -149.78231,-10.393267 -148.923138,-10.280923 -147.913018,-10.130441 -147.135443,-9.492444 -146.567881,-8.942555 -146.048481,-8.067414 -144.744168,-7.630128 -143.897088,-7.91533 -143.286376,-8.245491 -143.413913,-8.983069 -142.628431,-9.326821 -142.068259,-9.159596 -141.033852,-9.117893 -141.017057,-5.859022 -141.00021,-2.600151 -142.735247,-3.289153 -144.583971,-3.861418 -145.27318,-4.373738 -145.829786,-4.876498 -145.981922,-5.465609 -147.648073,-6.083659 -147.891108,-6.614015 -146.970905,-6.721657 + 147.191874,-7.388024 +148.084636,-8.044108 +148.734105,-9.104664 +149.306835,-9.071436 +149.266631,-9.514406 +150.038728,-9.684318 +149.738798,-9.872937 +150.801628,-10.293687 +150.690575,-10.582713 +150.028393,-10.652476 +149.78231,-10.393267 +148.923138,-10.280923 +147.913018,-10.130441 +147.135443,-9.492444 +146.567881,-8.942555 +146.048481,-8.067414 +144.744168,-7.630128 +143.897088,-7.91533 +143.286376,-8.245491 +143.413913,-8.983069 +142.628431,-9.326821 +142.068259,-9.159596 +141.033852,-9.117893 +141.017057,-5.859022 +141.00021,-2.600151 +142.735247,-3.289153 +144.583971,-3.861418 +145.27318,-4.373738 +145.829786,-4.876498 +145.981922,-5.465609 +147.648073,-6.083659 +147.891108,-6.614015 +146.970905,-6.721657 147.191874,-7.388024 - + - 153.140038,-4.499983 -152.827292,-4.766427 -152.638673,-4.176127 -152.406026,-3.789743 -151.953237,-3.462062 -151.384279,-3.035422 -150.66205,-2.741486 -150.939965,-2.500002 -151.479984,-2.779985 -151.820015,-2.999972 -152.239989,-3.240009 -152.640017,-3.659983 -153.019994,-3.980015 + 153.140038,-4.499983 +152.827292,-4.766427 +152.638673,-4.176127 +152.406026,-3.789743 +151.953237,-3.462062 +151.384279,-3.035422 +150.66205,-2.741486 +150.939965,-2.500002 +151.479984,-2.779985 +151.820015,-2.999972 +152.239989,-3.240009 +152.640017,-3.659983 +153.019994,-3.980015 153.140038,-4.499983 - + #defaultStyle Poland - - - - - - - 15.016996,51.106674 -14.607098,51.745188 -14.685026,52.089947 -14.4376,52.62485 -14.074521,52.981263 -14.353315,53.248171 -14.119686,53.757029 -14.8029,54.050706 -16.363477,54.513159 -17.622832,54.851536 -18.620859,54.682606 -18.696255,54.438719 -19.66064,54.426084 -20.892245,54.312525 -22.731099,54.327537 -23.243987,54.220567 -23.484128,53.912498 -23.527536,53.470122 -23.804935,53.089731 -23.799199,52.691099 -23.199494,52.486977 -23.508002,52.023647 -23.527071,51.578454 -24.029986,50.705407 -23.922757,50.424881 -23.426508,50.308506 -22.51845,49.476774 -22.776419,49.027395 -22.558138,49.085738 -21.607808,49.470107 -20.887955,49.328772 -20.415839,49.431453 -19.825023,49.217125 -19.320713,49.571574 -18.909575,49.435846 -18.853144,49.49623 -18.392914,49.988629 -17.649445,50.049038 -17.554567,50.362146 -16.868769,50.473974 -16.719476,50.215747 -16.176253,50.422607 -16.238627,50.697733 -15.490972,50.78473 + + + + + + + 15.016996,51.106674 +14.607098,51.745188 +14.685026,52.089947 +14.4376,52.62485 +14.074521,52.981263 +14.353315,53.248171 +14.119686,53.757029 +14.8029,54.050706 +16.363477,54.513159 +17.622832,54.851536 +18.620859,54.682606 +18.696255,54.438719 +19.66064,54.426084 +20.892245,54.312525 +22.731099,54.327537 +23.243987,54.220567 +23.484128,53.912498 +23.527536,53.470122 +23.804935,53.089731 +23.799199,52.691099 +23.199494,52.486977 +23.508002,52.023647 +23.527071,51.578454 +24.029986,50.705407 +23.922757,50.424881 +23.426508,50.308506 +22.51845,49.476774 +22.776419,49.027395 +22.558138,49.085738 +21.607808,49.470107 +20.887955,49.328772 +20.415839,49.431453 +19.825023,49.217125 +19.320713,49.571574 +18.909575,49.435846 +18.853144,49.49623 +18.392914,49.988629 +17.649445,50.049038 +17.554567,50.362146 +16.868769,50.473974 +16.719476,50.215747 +16.176253,50.422607 +16.238627,50.697733 +15.490972,50.78473 15.016996,51.106674 - + #defaultStyle Puerto Rico - + - -66.282434,18.514762 --65.771303,18.426679 --65.591004,18.228035 --65.847164,17.975906 --66.599934,17.981823 --67.184162,17.946553 --67.242428,18.37446 --67.100679,18.520601 + -66.282434,18.514762 +-65.771303,18.426679 +-65.591004,18.228035 +-65.847164,17.975906 +-66.599934,17.981823 +-67.184162,17.946553 +-67.242428,18.37446 +-67.100679,18.520601 -66.282434,18.514762 - + #defaultStyle North Korea - - - - - - - 130.640016,42.395009 -130.780007,42.220007 -130.400031,42.280004 -129.965949,41.941368 -129.667362,41.601104 -129.705189,40.882828 -129.188115,40.661808 -129.0104,40.485436 -128.633368,40.189847 -127.967414,40.025413 -127.533436,39.75685 -127.50212,39.323931 -127.385434,39.213472 -127.783343,39.050898 -128.349716,38.612243 -128.205746,38.370397 -127.780035,38.304536 -127.073309,38.256115 -126.68372,37.804773 -126.237339,37.840378 -126.174759,37.749686 -125.689104,37.94001 -125.568439,37.752089 -125.27533,37.669071 -125.240087,37.857224 -124.981033,37.948821 -124.712161,38.108346 -124.985994,38.548474 -125.221949,38.665857 -125.132859,38.848559 -125.38659,39.387958 -125.321116,39.551385 -124.737482,39.660344 -124.265625,39.928493 -125.079942,40.569824 -126.182045,41.107336 -126.869083,41.816569 -127.343783,41.503152 -128.208433,41.466772 -128.052215,41.994285 -129.596669,42.424982 -129.994267,42.985387 + + + + + + + 130.640016,42.395009 +130.780007,42.220007 +130.400031,42.280004 +129.965949,41.941368 +129.667362,41.601104 +129.705189,40.882828 +129.188115,40.661808 +129.0104,40.485436 +128.633368,40.189847 +127.967414,40.025413 +127.533436,39.75685 +127.50212,39.323931 +127.385434,39.213472 +127.783343,39.050898 +128.349716,38.612243 +128.205746,38.370397 +127.780035,38.304536 +127.073309,38.256115 +126.68372,37.804773 +126.237339,37.840378 +126.174759,37.749686 +125.689104,37.94001 +125.568439,37.752089 +125.27533,37.669071 +125.240087,37.857224 +124.981033,37.948821 +124.712161,38.108346 +124.985994,38.548474 +125.221949,38.665857 +125.132859,38.848559 +125.38659,39.387958 +125.321116,39.551385 +124.737482,39.660344 +124.265625,39.928493 +125.079942,40.569824 +126.182045,41.107336 +126.869083,41.816569 +127.343783,41.503152 +128.208433,41.466772 +128.052215,41.994285 +129.596669,42.424982 +129.994267,42.985387 130.640016,42.395009 - + #defaultStyle Portugal - - - - - - - -9.034818,41.880571 --8.671946,42.134689 --8.263857,42.280469 --8.013175,41.790886 --7.422513,41.792075 --7.251309,41.918346 --6.668606,41.883387 --6.389088,41.381815 --6.851127,41.111083 --6.86402,40.330872 --7.026413,40.184524 --7.066592,39.711892 --7.498632,39.629571 --7.098037,39.030073 --7.374092,38.373059 --7.029281,38.075764 --7.166508,37.803894 --7.537105,37.428904 --7.453726,37.097788 --7.855613,36.838269 --8.382816,36.97888 --8.898857,36.868809 --8.746101,37.651346 --8.839998,38.266243 --9.287464,38.358486 --9.526571,38.737429 --9.446989,39.392066 --9.048305,39.755093 --8.977353,40.159306 --8.768684,40.760639 --8.790853,41.184334 --8.990789,41.543459 + + + + + + + -9.034818,41.880571 +-8.671946,42.134689 +-8.263857,42.280469 +-8.013175,41.790886 +-7.422513,41.792075 +-7.251309,41.918346 +-6.668606,41.883387 +-6.389088,41.381815 +-6.851127,41.111083 +-6.86402,40.330872 +-7.026413,40.184524 +-7.066592,39.711892 +-7.498632,39.629571 +-7.098037,39.030073 +-7.374092,38.373059 +-7.029281,38.075764 +-7.166508,37.803894 +-7.537105,37.428904 +-7.453726,37.097788 +-7.855613,36.838269 +-8.382816,36.97888 +-8.898857,36.868809 +-8.746101,37.651346 +-8.839998,38.266243 +-9.287464,38.358486 +-9.526571,38.737429 +-9.446989,39.392066 +-9.048305,39.755093 +-8.977353,40.159306 +-8.768684,40.760639 +-8.790853,41.184334 +-8.990789,41.543459 -9.034818,41.880571 - + #defaultStyle Paraguay - - - - - - - -62.685057,-22.249029 --62.291179,-21.051635 --62.265961,-20.513735 --61.786326,-19.633737 --60.043565,-19.342747 --59.115042,-19.356906 --58.183471,-19.868399 --58.166392,-20.176701 --57.870674,-20.732688 --57.937156,-22.090176 --56.88151,-22.282154 --56.473317,-22.0863 --55.797958,-22.35693 --55.610683,-22.655619 --55.517639,-23.571998 --55.400747,-23.956935 --55.027902,-24.001274 --54.652834,-23.839578 --54.29296,-24.021014 --54.293476,-24.5708 --54.428946,-25.162185 --54.625291,-25.739255 --54.788795,-26.621786 --55.695846,-27.387837 --56.486702,-27.548499 --57.60976,-27.395899 --58.618174,-27.123719 --57.63366,-25.603657 --57.777217,-25.16234 --58.807128,-24.771459 --60.028966,-24.032796 --60.846565,-23.880713 + + + + + + + -62.685057,-22.249029 +-62.291179,-21.051635 +-62.265961,-20.513735 +-61.786326,-19.633737 +-60.043565,-19.342747 +-59.115042,-19.356906 +-58.183471,-19.868399 +-58.166392,-20.176701 +-57.870674,-20.732688 +-57.937156,-22.090176 +-56.88151,-22.282154 +-56.473317,-22.0863 +-55.797958,-22.35693 +-55.610683,-22.655619 +-55.517639,-23.571998 +-55.400747,-23.956935 +-55.027902,-24.001274 +-54.652834,-23.839578 +-54.29296,-24.021014 +-54.293476,-24.5708 +-54.428946,-25.162185 +-54.625291,-25.739255 +-54.788795,-26.621786 +-55.695846,-27.387837 +-56.486702,-27.548499 +-57.60976,-27.395899 +-58.618174,-27.123719 +-57.63366,-25.603657 +-57.777217,-25.16234 +-58.807128,-24.771459 +-60.028966,-24.032796 +-60.846565,-23.880713 -62.685057,-22.249029 - + #defaultStyle Qatar - + - 50.810108,24.754743 -50.743911,25.482424 -51.013352,26.006992 -51.286462,26.114582 -51.589079,25.801113 -51.6067,25.21567 -51.389608,24.627386 -51.112415,24.556331 + 50.810108,24.754743 +50.743911,25.482424 +51.013352,26.006992 +51.286462,26.114582 +51.589079,25.801113 +51.6067,25.21567 +51.389608,24.627386 +51.112415,24.556331 50.810108,24.754743 - + #defaultStyle Romania - - - - - - - 22.710531,47.882194 -23.142236,48.096341 -23.760958,47.985598 -24.402056,47.981878 -24.866317,47.737526 -25.207743,47.891056 -25.945941,47.987149 -26.19745,48.220881 -26.619337,48.220726 -26.924176,48.123264 -27.233873,47.826771 -27.551166,47.405117 -28.12803,46.810476 -28.160018,46.371563 -28.054443,45.944586 -28.233554,45.488283 -28.679779,45.304031 -29.149725,45.464925 -29.603289,45.293308 -29.626543,45.035391 -29.141612,44.82021 -28.837858,44.913874 -28.558081,43.707462 -27.970107,43.812468 -27.2424,44.175986 -26.065159,43.943494 -25.569272,43.688445 -24.100679,43.741051 -23.332302,43.897011 -22.944832,43.823785 -22.65715,44.234923 -22.474008,44.409228 -22.705726,44.578003 -22.459022,44.702517 -22.145088,44.478422 -21.562023,44.768947 -21.483526,45.18117 -20.874313,45.416375 -20.762175,45.734573 -20.220192,46.127469 -21.021952,46.316088 -21.626515,46.994238 -22.099768,47.672439 + + + + + + + 22.710531,47.882194 +23.142236,48.096341 +23.760958,47.985598 +24.402056,47.981878 +24.866317,47.737526 +25.207743,47.891056 +25.945941,47.987149 +26.19745,48.220881 +26.619337,48.220726 +26.924176,48.123264 +27.233873,47.826771 +27.551166,47.405117 +28.12803,46.810476 +28.160018,46.371563 +28.054443,45.944586 +28.233554,45.488283 +28.679779,45.304031 +29.149725,45.464925 +29.603289,45.293308 +29.626543,45.035391 +29.141612,44.82021 +28.837858,44.913874 +28.558081,43.707462 +27.970107,43.812468 +27.2424,44.175986 +26.065159,43.943494 +25.569272,43.688445 +24.100679,43.741051 +23.332302,43.897011 +22.944832,43.823785 +22.65715,44.234923 +22.474008,44.409228 +22.705726,44.578003 +22.459022,44.702517 +22.145088,44.478422 +21.562023,44.768947 +21.483526,45.18117 +20.874313,45.416375 +20.762175,45.734573 +20.220192,46.127469 +21.021952,46.316088 +21.626515,46.994238 +22.099768,47.672439 22.710531,47.882194 - + #defaultStyle Russia - - - - - - - 143.648007,50.7476 -144.654148,48.976391 -143.173928,49.306551 -142.558668,47.861575 -143.533492,46.836728 -143.505277,46.137908 -142.747701,46.740765 -142.09203,45.966755 -141.906925,46.805929 -142.018443,47.780133 -141.904445,48.859189 -142.1358,49.615163 -142.179983,50.952342 -141.594076,51.935435 -141.682546,53.301966 -142.606934,53.762145 -142.209749,54.225476 -142.654786,54.365881 -142.914616,53.704578 -143.260848,52.74076 -143.235268,51.75666 + + + + + + + 143.648007,50.7476 +144.654148,48.976391 +143.173928,49.306551 +142.558668,47.861575 +143.533492,46.836728 +143.505277,46.137908 +142.747701,46.740765 +142.09203,45.966755 +141.906925,46.805929 +142.018443,47.780133 +141.904445,48.859189 +142.1358,49.615163 +142.179983,50.952342 +141.594076,51.935435 +141.682546,53.301966 +142.606934,53.762145 +142.209749,54.225476 +142.654786,54.365881 +142.914616,53.704578 +143.260848,52.74076 +143.235268,51.75666 143.648007,50.7476 - + - 22.731099,54.327537 -20.892245,54.312525 -19.66064,54.426084 -19.888481,54.86616 -21.268449,55.190482 -22.315724,55.015299 -22.757764,54.856574 -22.651052,54.582741 + 22.731099,54.327537 +20.892245,54.312525 +19.66064,54.426084 +19.888481,54.86616 +21.268449,55.190482 +22.315724,55.015299 +22.757764,54.856574 +22.651052,54.582741 22.731099,54.327537 - + - -175.01425,66.58435 --174.33983,66.33556 --174.57182,67.06219 --171.85731,66.91308 --169.89958,65.97724 --170.89107,65.54139 --172.53025,65.43791 --172.555,64.46079 --172.95533,64.25269 --173.89184,64.2826 --174.65392,64.63125 --175.98353,64.92288 --176.20716,65.35667 --177.22266,65.52024 --178.35993,65.39052 --178.90332,65.74044 --178.68611,66.11211 --179.88377,65.87456 --179.43268,65.40411 --180.0,64.979709 --180.0,68.963636 --177.55,68.2 --174.92825,67.20589 + -175.01425,66.58435 +-174.33983,66.33556 +-174.57182,67.06219 +-171.85731,66.91308 +-169.89958,65.97724 +-170.89107,65.54139 +-172.53025,65.43791 +-172.555,64.46079 +-172.95533,64.25269 +-173.89184,64.2826 +-174.65392,64.63125 +-175.98353,64.92288 +-176.20716,65.35667 +-177.22266,65.52024 +-178.35993,65.39052 +-178.90332,65.74044 +-178.68611,66.11211 +-179.88377,65.87456 +-179.43268,65.40411 +-180.0,64.979709 +-180.0,68.963636 +-177.55,68.2 +-174.92825,67.20589 -175.01425,66.58435 - + - 180.0,70.832199 -178.903425,70.78114 -178.7253,71.0988 -180.0,71.515714 + 180.0,70.832199 +178.903425,70.78114 +178.7253,71.0988 +180.0,71.515714 180.0,70.832199 - + - -178.69378,70.89302 --180.0,70.832199 --180.0,71.515714 --179.871875,71.55762 --179.02433,71.55553 --177.577945,71.26948 --177.663575,71.13277 + -178.69378,70.89302 +-180.0,70.832199 +-180.0,71.515714 +-179.871875,71.55762 +-179.02433,71.55553 +-177.577945,71.26948 +-177.663575,71.13277 -178.69378,70.89302 - + - 143.60385,73.21244 -142.08763,73.20544 -140.038155,73.31692 -139.86312,73.36983 -140.81171,73.76506 -142.06207,73.85758 -143.48283,73.47525 + 143.60385,73.21244 +142.08763,73.20544 +140.038155,73.31692 +139.86312,73.36983 +140.81171,73.76506 +142.06207,73.85758 +143.48283,73.47525 143.60385,73.21244 - + - 150.73167,75.08406 -149.575925,74.68892 -147.977465,74.778355 -146.11919,75.17298 -146.358485,75.49682 -148.22223,75.345845 + 150.73167,75.08406 +149.575925,74.68892 +147.977465,74.778355 +146.11919,75.17298 +146.358485,75.49682 +148.22223,75.345845 150.73167,75.08406 - + - 145.086285,75.562625 -144.3,74.82 -140.61381,74.84768 -138.95544,74.61148 -136.97439,75.26167 -137.51176,75.94917 -138.831075,76.13676 -141.471615,76.09289 + 145.086285,75.562625 +144.3,74.82 +140.61381,74.84768 +138.95544,74.61148 +136.97439,75.26167 +137.51176,75.94917 +138.831075,76.13676 +141.471615,76.09289 145.086285,75.562625 - + - 57.535693,70.720464 -56.944979,70.632743 -53.677375,70.762658 -53.412017,71.206662 -51.601895,71.474759 -51.455754,72.014881 -52.478275,72.229442 -52.444169,72.774731 -54.427614,73.627548 -53.50829,73.749814 -55.902459,74.627486 -55.631933,75.081412 -57.868644,75.60939 -61.170044,76.251883 -64.498368,76.439055 -66.210977,76.809782 -68.15706,76.939697 -68.852211,76.544811 -68.180573,76.233642 -64.637326,75.737755 -61.583508,75.260885 -58.477082,74.309056 -56.986786,73.333044 -55.419336,72.371268 -55.622838,71.540595 + 57.535693,70.720464 +56.944979,70.632743 +53.677375,70.762658 +53.412017,71.206662 +51.601895,71.474759 +51.455754,72.014881 +52.478275,72.229442 +52.444169,72.774731 +54.427614,73.627548 +53.50829,73.749814 +55.902459,74.627486 +55.631933,75.081412 +57.868644,75.60939 +61.170044,76.251883 +64.498368,76.439055 +66.210977,76.809782 +68.15706,76.939697 +68.852211,76.544811 +68.180573,76.233642 +64.637326,75.737755 +61.583508,75.260885 +58.477082,74.309056 +56.986786,73.333044 +55.419336,72.371268 +55.622838,71.540595 57.535693,70.720464 - + - 106.97013,76.97419 -107.24,76.48 -108.1538,76.72335 -111.07726,76.71 -113.33151,76.22224 -114.13417,75.84764 -113.88539,75.32779 -112.77918,75.03186 -110.15125,74.47673 -109.4,74.18 -110.64,74.04 -112.11919,73.78774 -113.01954,73.97693 -113.52958,73.33505 -113.96881,73.59488 -115.56782,73.75285 -118.77633,73.58772 -119.02,73.12 -123.20066,72.97122 -123.25777,73.73503 -125.38,73.56 -126.97644,73.56549 -128.59126,73.03871 -129.05157,72.39872 -128.46,71.98 -129.71599,71.19304 -131.28858,70.78699 -132.2535,71.8363 -133.85766,71.38642 -135.56193,71.65525 -137.49755,71.34763 -138.23409,71.62803 -139.86983,71.48783 -139.14791,72.41619 -140.46817,72.84941 -149.5,72.2 -150.35118,71.60643 -152.9689,70.84222 -157.00688,71.03141 -158.99779,70.86672 -159.83031,70.45324 -159.70866,69.72198 -160.94053,69.43728 -162.27907,69.64204 -164.05248,69.66823 -165.94037,69.47199 -167.83567,69.58269 -169.57763,68.6938 -170.81688,69.01363 -170.0082,69.65276 -170.45345,70.09703 -173.64391,69.81743 -175.72403,69.87725 -178.6,69.4 -180.0,68.963636 -180.0,64.979709 -179.99281,64.97433 -178.7072,64.53493 -177.41128,64.60821 -178.313,64.07593 -178.90825,63.25197 -179.37034,62.98262 -179.48636,62.56894 -179.22825,62.3041 -177.3643,62.5219 -174.56929,61.76915 -173.68013,61.65261 -172.15,60.95 -170.6985,60.33618 -170.33085,59.88177 -168.90046,60.57355 -166.29498,59.78855 -165.84,60.16 -164.87674,59.7316 -163.53929,59.86871 -163.21711,59.21101 -162.01733,58.24328 -162.05297,57.83912 -163.19191,57.61503 -163.05794,56.15924 -162.12958,56.12219 -161.70146,55.28568 -162.11749,54.85514 -160.36877,54.34433 -160.02173,53.20257 -158.53094,52.95868 -158.23118,51.94269 -156.78979,51.01105 -156.42,51.7 -155.99182,53.15895 -155.43366,55.38103 -155.91442,56.76792 -156.75815,57.3647 -156.81035,57.83204 -158.36433,58.05575 -160.15064,59.31477 -161.87204,60.343 -163.66969,61.1409 -164.47355,62.55061 -163.25842,62.46627 -162.65791,61.6425 -160.12148,60.54423 -159.30232,61.77396 -156.72068,61.43442 -154.21806,59.75818 -155.04375,59.14495 -152.81185,58.88385 -151.26573,58.78089 -151.33815,59.50396 -149.78371,59.65573 -148.54481,59.16448 -145.48722,59.33637 -142.19782,59.03998 -138.95848,57.08805 -135.12619,54.72959 -136.70171,54.60355 -137.19342,53.97732 -138.1647,53.75501 -138.80463,54.25455 -139.90151,54.18968 -141.34531,53.08957 -141.37923,52.23877 -140.59742,51.23967 -140.51308,50.04553 -140.06193,48.44671 -138.55472,46.99965 -138.21971,46.30795 -136.86232,45.1435 -135.51535,43.989 -134.86939,43.39821 -133.53687,42.81147 -132.90627,42.79849 -132.27807,43.28456 -130.93587,42.55274 -130.78,42.22 -130.64,42.395 -130.633866,42.903015 -131.144688,42.92999 -131.288555,44.11152 -131.02519,44.96796 -131.883454,45.321162 -133.09712,45.14409 -133.769644,46.116927 -134.11235,47.21248 -134.50081,47.57845 -135.026311,48.47823 -133.373596,48.183442 -132.50669,47.78896 -130.98726,47.79013 -130.582293,48.729687 -129.397818,49.4406 -127.6574,49.76027 -127.287456,50.739797 -126.939157,51.353894 -126.564399,51.784255 -125.946349,52.792799 -125.068211,53.161045 -123.57147,53.4588 -122.245748,53.431726 -121.003085,53.251401 -120.177089,52.753886 -120.725789,52.516226 -120.7382,51.96411 -120.18208,51.64355 -119.27939,50.58292 -119.288461,50.142883 -117.879244,49.510983 -116.678801,49.888531 -115.485695,49.805177 -114.96211,50.140247 -114.362456,50.248303 -112.89774,49.543565 -111.581231,49.377968 -110.662011,49.130128 -109.402449,49.292961 -108.475167,49.282548 -107.868176,49.793705 -106.888804,50.274296 -105.886591,50.406019 -104.62158,50.27532 -103.676545,50.089966 -102.25589,50.51056 -102.06521,51.25991 -100.88948,51.516856 -99.981732,51.634006 -98.861491,52.047366 -97.82574,51.010995 -98.231762,50.422401 -97.25976,49.72605 -95.81402,49.97746 -94.815949,50.013433 -94.147566,50.480537 -93.10421,50.49529 -92.234712,50.802171 -90.713667,50.331812 -88.805567,49.470521 -87.751264,49.297198 -87.35997,49.214981 -86.829357,49.826675 -85.54127,49.692859 -85.11556,50.117303 -84.416377,50.3114 -83.935115,50.889246 -83.383004,51.069183 -81.945986,50.812196 -80.568447,51.388336 -80.03556,50.864751 -77.800916,53.404415 -76.525179,54.177003 -76.8911,54.490524 -74.38482,53.54685 -73.425679,53.48981 -73.508516,54.035617 -72.22415,54.376655 -71.180131,54.133285 -70.865267,55.169734 -69.068167,55.38525 -68.1691,54.970392 -65.66687,54.60125 -65.178534,54.354228 -61.4366,54.00625 -60.978066,53.664993 -61.699986,52.979996 -60.739993,52.719986 -60.927269,52.447548 -59.967534,51.96042 -61.588003,51.272659 -61.337424,50.79907 -59.932807,50.842194 -59.642282,50.545442 -58.36332,51.06364 -56.77798,51.04355 -55.71694,50.62171 -54.532878,51.02624 -52.328724,51.718652 -50.766648,51.692762 -48.702382,50.605128 -48.577841,49.87476 -47.54948,50.454698 -46.751596,49.356006 -47.043672,49.152039 -46.466446,48.394152 -47.31524,47.71585 -48.05725,47.74377 -48.694734,47.075628 -48.59325,46.56104 -49.10116,46.39933 -48.64541,45.80629 -47.67591,45.64149 -46.68201,44.6092 -47.59094,43.66016 -47.49252,42.98658 -48.58437,41.80888 -47.987283,41.405819 -47.815666,41.151416 -47.373315,41.219732 -46.686071,41.827137 -46.404951,41.860675 -45.7764,42.09244 -45.470279,42.502781 -44.537623,42.711993 -43.93121,42.55496 -43.75599,42.74083 -42.3944,43.2203 -40.92219,43.38215 -40.076965,43.553104 -39.955009,43.434998 -38.68,44.28 -37.53912,44.65721 -36.67546,45.24469 -37.40317,45.40451 -38.23295,46.24087 -37.67372,46.63657 -39.14767,47.04475 -39.1212,47.26336 -38.223538,47.10219 -38.255112,47.5464 -38.77057,47.82562 -39.738278,47.898937 -39.89562,48.23241 -39.67465,48.78382 -40.080789,49.30743 -40.06904,49.60105 -38.594988,49.926462 -38.010631,49.915662 -37.39346,50.383953 -36.626168,50.225591 -35.356116,50.577197 -35.37791,50.77394 -35.022183,51.207572 -34.224816,51.255993 -34.141978,51.566413 -34.391731,51.768882 -33.7527,52.335075 -32.715761,52.238465 -32.412058,52.288695 -32.15944,52.06125 -31.78597,52.10168 -31.540018,52.742052 -31.305201,53.073996 -31.49764,53.16743 -32.304519,53.132726 -32.693643,53.351421 -32.405599,53.618045 -31.731273,53.794029 -31.791424,53.974639 -31.384472,54.157056 -30.757534,54.811771 -30.971836,55.081548 -30.873909,55.550976 -29.896294,55.789463 -29.371572,55.670091 -29.229513,55.918344 -28.176709,56.16913 -27.855282,56.759326 -27.770016,57.244258 -27.288185,57.474528 -27.716686,57.791899 -27.42015,58.72457 -28.131699,59.300825 -27.98112,59.47537 -29.1177,60.02805 -28.07,60.50352 -30.211107,61.780028 -31.139991,62.357693 -31.516092,62.867687 -30.035872,63.552814 -30.444685,64.204453 -29.54443,64.948672 -30.21765,65.80598 -29.054589,66.944286 -29.977426,67.698297 -28.445944,68.364613 -28.59193,69.064777 -29.39955,69.15692 -31.10108,69.55811 -32.13272,69.90595 -33.77547,69.30142 -36.51396,69.06342 -40.29234,67.9324 -41.05987,67.45713 -41.12595,66.79158 -40.01583,66.26618 -38.38295,65.99953 -33.91871,66.75961 -33.18444,66.63253 -34.81477,65.90015 -34.878574,65.436213 -34.94391,64.41437 -36.23129,64.10945 -37.01273,63.84983 -37.14197,64.33471 -36.539579,64.76446 -37.17604,65.14322 -39.59345,64.52079 -40.4356,64.76446 -39.7626,65.49682 -42.09309,66.47623 -43.01604,66.41858 -43.94975,66.06908 -44.53226,66.75634 -43.69839,67.35245 -44.18795,67.95051 -43.45282,68.57079 -46.25,68.25 -46.82134,67.68997 -45.55517,67.56652 -45.56202,67.01005 -46.34915,66.66767 -47.89416,66.88455 -48.13876,67.52238 -50.22766,67.99867 -53.71743,68.85738 -54.47171,68.80815 -53.48582,68.20131 -54.72628,68.09702 -55.44268,68.43866 -57.31702,68.46628 -58.802,68.88082 -59.94142,68.27844 -61.07784,68.94069 -60.03,69.52 -60.55,69.85 -63.504,69.54739 -64.888115,69.234835 -68.51216,68.09233 -69.18068,68.61563 -68.16444,69.14436 -68.13522,69.35649 -66.93008,69.45461 -67.25976,69.92873 -66.72492,70.70889 -66.69466,71.02897 -68.54006,71.9345 -69.19636,72.84336 -69.94,73.04 -72.58754,72.77629 -72.79603,72.22006 -71.84811,71.40898 -72.47011,71.09019 -72.79188,70.39114 -72.5647,69.02085 -73.66787,68.4079 -73.2387,67.7404 -71.28,66.32 -72.42301,66.17267 -72.82077,66.53267 -73.92099,66.78946 -74.18651,67.28429 -75.052,67.76047 -74.46926,68.32899 -74.93584,68.98918 -73.84236,69.07146 -73.60187,69.62763 -74.3998,70.63175 -73.1011,71.44717 -74.89082,72.12119 -74.65926,72.83227 -75.15801,72.85497 -75.68351,72.30056 -75.28898,71.33556 -76.35911,71.15287 -75.90313,71.87401 -77.57665,72.26717 -79.65202,72.32011 -81.5,71.75 -80.61071,72.58285 -80.51109,73.6482 -82.25,73.85 -84.65526,73.80591 -86.8223,73.93688 -86.00956,74.45967 -87.16682,75.11643 -88.31571,75.14393 -90.26,75.64 -92.90058,75.77333 -93.23421,76.0472 -95.86,76.14 -96.67821,75.91548 -98.92254,76.44689 -100.75967,76.43028 -101.03532,76.86189 -101.99084,77.28754 -104.3516,77.69792 -106.06664,77.37389 -104.705,77.1274 + 106.97013,76.97419 +107.24,76.48 +108.1538,76.72335 +111.07726,76.71 +113.33151,76.22224 +114.13417,75.84764 +113.88539,75.32779 +112.77918,75.03186 +110.15125,74.47673 +109.4,74.18 +110.64,74.04 +112.11919,73.78774 +113.01954,73.97693 +113.52958,73.33505 +113.96881,73.59488 +115.56782,73.75285 +118.77633,73.58772 +119.02,73.12 +123.20066,72.97122 +123.25777,73.73503 +125.38,73.56 +126.97644,73.56549 +128.59126,73.03871 +129.05157,72.39872 +128.46,71.98 +129.71599,71.19304 +131.28858,70.78699 +132.2535,71.8363 +133.85766,71.38642 +135.56193,71.65525 +137.49755,71.34763 +138.23409,71.62803 +139.86983,71.48783 +139.14791,72.41619 +140.46817,72.84941 +149.5,72.2 +150.35118,71.60643 +152.9689,70.84222 +157.00688,71.03141 +158.99779,70.86672 +159.83031,70.45324 +159.70866,69.72198 +160.94053,69.43728 +162.27907,69.64204 +164.05248,69.66823 +165.94037,69.47199 +167.83567,69.58269 +169.57763,68.6938 +170.81688,69.01363 +170.0082,69.65276 +170.45345,70.09703 +173.64391,69.81743 +175.72403,69.87725 +178.6,69.4 +180.0,68.963636 +180.0,64.979709 +179.99281,64.97433 +178.7072,64.53493 +177.41128,64.60821 +178.313,64.07593 +178.90825,63.25197 +179.37034,62.98262 +179.48636,62.56894 +179.22825,62.3041 +177.3643,62.5219 +174.56929,61.76915 +173.68013,61.65261 +172.15,60.95 +170.6985,60.33618 +170.33085,59.88177 +168.90046,60.57355 +166.29498,59.78855 +165.84,60.16 +164.87674,59.7316 +163.53929,59.86871 +163.21711,59.21101 +162.01733,58.24328 +162.05297,57.83912 +163.19191,57.61503 +163.05794,56.15924 +162.12958,56.12219 +161.70146,55.28568 +162.11749,54.85514 +160.36877,54.34433 +160.02173,53.20257 +158.53094,52.95868 +158.23118,51.94269 +156.78979,51.01105 +156.42,51.7 +155.99182,53.15895 +155.43366,55.38103 +155.91442,56.76792 +156.75815,57.3647 +156.81035,57.83204 +158.36433,58.05575 +160.15064,59.31477 +161.87204,60.343 +163.66969,61.1409 +164.47355,62.55061 +163.25842,62.46627 +162.65791,61.6425 +160.12148,60.54423 +159.30232,61.77396 +156.72068,61.43442 +154.21806,59.75818 +155.04375,59.14495 +152.81185,58.88385 +151.26573,58.78089 +151.33815,59.50396 +149.78371,59.65573 +148.54481,59.16448 +145.48722,59.33637 +142.19782,59.03998 +138.95848,57.08805 +135.12619,54.72959 +136.70171,54.60355 +137.19342,53.97732 +138.1647,53.75501 +138.80463,54.25455 +139.90151,54.18968 +141.34531,53.08957 +141.37923,52.23877 +140.59742,51.23967 +140.51308,50.04553 +140.06193,48.44671 +138.55472,46.99965 +138.21971,46.30795 +136.86232,45.1435 +135.51535,43.989 +134.86939,43.39821 +133.53687,42.81147 +132.90627,42.79849 +132.27807,43.28456 +130.93587,42.55274 +130.78,42.22 +130.64,42.395 +130.633866,42.903015 +131.144688,42.92999 +131.288555,44.11152 +131.02519,44.96796 +131.883454,45.321162 +133.09712,45.14409 +133.769644,46.116927 +134.11235,47.21248 +134.50081,47.57845 +135.026311,48.47823 +133.373596,48.183442 +132.50669,47.78896 +130.98726,47.79013 +130.582293,48.729687 +129.397818,49.4406 +127.6574,49.76027 +127.287456,50.739797 +126.939157,51.353894 +126.564399,51.784255 +125.946349,52.792799 +125.068211,53.161045 +123.57147,53.4588 +122.245748,53.431726 +121.003085,53.251401 +120.177089,52.753886 +120.725789,52.516226 +120.7382,51.96411 +120.18208,51.64355 +119.27939,50.58292 +119.288461,50.142883 +117.879244,49.510983 +116.678801,49.888531 +115.485695,49.805177 +114.96211,50.140247 +114.362456,50.248303 +112.89774,49.543565 +111.581231,49.377968 +110.662011,49.130128 +109.402449,49.292961 +108.475167,49.282548 +107.868176,49.793705 +106.888804,50.274296 +105.886591,50.406019 +104.62158,50.27532 +103.676545,50.089966 +102.25589,50.51056 +102.06521,51.25991 +100.88948,51.516856 +99.981732,51.634006 +98.861491,52.047366 +97.82574,51.010995 +98.231762,50.422401 +97.25976,49.72605 +95.81402,49.97746 +94.815949,50.013433 +94.147566,50.480537 +93.10421,50.49529 +92.234712,50.802171 +90.713667,50.331812 +88.805567,49.470521 +87.751264,49.297198 +87.35997,49.214981 +86.829357,49.826675 +85.54127,49.692859 +85.11556,50.117303 +84.416377,50.3114 +83.935115,50.889246 +83.383004,51.069183 +81.945986,50.812196 +80.568447,51.388336 +80.03556,50.864751 +77.800916,53.404415 +76.525179,54.177003 +76.8911,54.490524 +74.38482,53.54685 +73.425679,53.48981 +73.508516,54.035617 +72.22415,54.376655 +71.180131,54.133285 +70.865267,55.169734 +69.068167,55.38525 +68.1691,54.970392 +65.66687,54.60125 +65.178534,54.354228 +61.4366,54.00625 +60.978066,53.664993 +61.699986,52.979996 +60.739993,52.719986 +60.927269,52.447548 +59.967534,51.96042 +61.588003,51.272659 +61.337424,50.79907 +59.932807,50.842194 +59.642282,50.545442 +58.36332,51.06364 +56.77798,51.04355 +55.71694,50.62171 +54.532878,51.02624 +52.328724,51.718652 +50.766648,51.692762 +48.702382,50.605128 +48.577841,49.87476 +47.54948,50.454698 +46.751596,49.356006 +47.043672,49.152039 +46.466446,48.394152 +47.31524,47.71585 +48.05725,47.74377 +48.694734,47.075628 +48.59325,46.56104 +49.10116,46.39933 +48.64541,45.80629 +47.67591,45.64149 +46.68201,44.6092 +47.59094,43.66016 +47.49252,42.98658 +48.58437,41.80888 +47.987283,41.405819 +47.815666,41.151416 +47.373315,41.219732 +46.686071,41.827137 +46.404951,41.860675 +45.7764,42.09244 +45.470279,42.502781 +44.537623,42.711993 +43.93121,42.55496 +43.75599,42.74083 +42.3944,43.2203 +40.92219,43.38215 +40.076965,43.553104 +39.955009,43.434998 +38.68,44.28 +37.53912,44.65721 +36.67546,45.24469 +37.40317,45.40451 +38.23295,46.24087 +37.67372,46.63657 +39.14767,47.04475 +39.1212,47.26336 +38.223538,47.10219 +38.255112,47.5464 +38.77057,47.82562 +39.738278,47.898937 +39.89562,48.23241 +39.67465,48.78382 +40.080789,49.30743 +40.06904,49.60105 +38.594988,49.926462 +38.010631,49.915662 +37.39346,50.383953 +36.626168,50.225591 +35.356116,50.577197 +35.37791,50.77394 +35.022183,51.207572 +34.224816,51.255993 +34.141978,51.566413 +34.391731,51.768882 +33.7527,52.335075 +32.715761,52.238465 +32.412058,52.288695 +32.15944,52.06125 +31.78597,52.10168 +31.540018,52.742052 +31.305201,53.073996 +31.49764,53.16743 +32.304519,53.132726 +32.693643,53.351421 +32.405599,53.618045 +31.731273,53.794029 +31.791424,53.974639 +31.384472,54.157056 +30.757534,54.811771 +30.971836,55.081548 +30.873909,55.550976 +29.896294,55.789463 +29.371572,55.670091 +29.229513,55.918344 +28.176709,56.16913 +27.855282,56.759326 +27.770016,57.244258 +27.288185,57.474528 +27.716686,57.791899 +27.42015,58.72457 +28.131699,59.300825 +27.98112,59.47537 +29.1177,60.02805 +28.07,60.50352 +30.211107,61.780028 +31.139991,62.357693 +31.516092,62.867687 +30.035872,63.552814 +30.444685,64.204453 +29.54443,64.948672 +30.21765,65.80598 +29.054589,66.944286 +29.977426,67.698297 +28.445944,68.364613 +28.59193,69.064777 +29.39955,69.15692 +31.10108,69.55811 +32.13272,69.90595 +33.77547,69.30142 +36.51396,69.06342 +40.29234,67.9324 +41.05987,67.45713 +41.12595,66.79158 +40.01583,66.26618 +38.38295,65.99953 +33.91871,66.75961 +33.18444,66.63253 +34.81477,65.90015 +34.878574,65.436213 +34.94391,64.41437 +36.23129,64.10945 +37.01273,63.84983 +37.14197,64.33471 +36.539579,64.76446 +37.17604,65.14322 +39.59345,64.52079 +40.4356,64.76446 +39.7626,65.49682 +42.09309,66.47623 +43.01604,66.41858 +43.94975,66.06908 +44.53226,66.75634 +43.69839,67.35245 +44.18795,67.95051 +43.45282,68.57079 +46.25,68.25 +46.82134,67.68997 +45.55517,67.56652 +45.56202,67.01005 +46.34915,66.66767 +47.89416,66.88455 +48.13876,67.52238 +50.22766,67.99867 +53.71743,68.85738 +54.47171,68.80815 +53.48582,68.20131 +54.72628,68.09702 +55.44268,68.43866 +57.31702,68.46628 +58.802,68.88082 +59.94142,68.27844 +61.07784,68.94069 +60.03,69.52 +60.55,69.85 +63.504,69.54739 +64.888115,69.234835 +68.51216,68.09233 +69.18068,68.61563 +68.16444,69.14436 +68.13522,69.35649 +66.93008,69.45461 +67.25976,69.92873 +66.72492,70.70889 +66.69466,71.02897 +68.54006,71.9345 +69.19636,72.84336 +69.94,73.04 +72.58754,72.77629 +72.79603,72.22006 +71.84811,71.40898 +72.47011,71.09019 +72.79188,70.39114 +72.5647,69.02085 +73.66787,68.4079 +73.2387,67.7404 +71.28,66.32 +72.42301,66.17267 +72.82077,66.53267 +73.92099,66.78946 +74.18651,67.28429 +75.052,67.76047 +74.46926,68.32899 +74.93584,68.98918 +73.84236,69.07146 +73.60187,69.62763 +74.3998,70.63175 +73.1011,71.44717 +74.89082,72.12119 +74.65926,72.83227 +75.15801,72.85497 +75.68351,72.30056 +75.28898,71.33556 +76.35911,71.15287 +75.90313,71.87401 +77.57665,72.26717 +79.65202,72.32011 +81.5,71.75 +80.61071,72.58285 +80.51109,73.6482 +82.25,73.85 +84.65526,73.80591 +86.8223,73.93688 +86.00956,74.45967 +87.16682,75.11643 +88.31571,75.14393 +90.26,75.64 +92.90058,75.77333 +93.23421,76.0472 +95.86,76.14 +96.67821,75.91548 +98.92254,76.44689 +100.75967,76.43028 +101.03532,76.86189 +101.99084,77.28754 +104.3516,77.69792 +106.06664,77.37389 +104.705,77.1274 106.97013,76.97419 - + - 105.07547,78.30689 -99.43814,77.921 -101.2649,79.23399 -102.08635,79.34641 -102.837815,79.28129 -105.37243,78.71334 + 105.07547,78.30689 +99.43814,77.921 +101.2649,79.23399 +102.08635,79.34641 +102.837815,79.28129 +105.37243,78.71334 105.07547,78.30689 - + - 51.136187,80.54728 -49.793685,80.415428 -48.894411,80.339567 -48.754937,80.175468 -47.586119,80.010181 -46.502826,80.247247 -47.072455,80.559424 -44.846958,80.58981 -46.799139,80.771918 -48.318477,80.78401 -48.522806,80.514569 -49.09719,80.753986 -50.039768,80.918885 -51.522933,80.699726 + 51.136187,80.54728 +49.793685,80.415428 +48.894411,80.339567 +48.754937,80.175468 +47.586119,80.010181 +46.502826,80.247247 +47.072455,80.559424 +44.846958,80.58981 +46.799139,80.771918 +48.318477,80.78401 +48.522806,80.514569 +49.09719,80.753986 +50.039768,80.918885 +51.522933,80.699726 51.136187,80.54728 - + - 99.93976,78.88094 -97.75794,78.7562 -94.97259,79.044745 -93.31288,79.4265 -92.5454,80.14379 -91.18107,80.34146 -93.77766,81.0246 -95.940895,81.2504 -97.88385,80.746975 -100.186655,79.780135 + 99.93976,78.88094 +97.75794,78.7562 +94.97259,79.044745 +93.31288,79.4265 +92.5454,80.14379 +91.18107,80.34146 +93.77766,81.0246 +95.940895,81.2504 +97.88385,80.746975 +100.186655,79.780135 99.93976,78.88094 - + #defaultStyle Rwanda - + - 30.419105,-1.134659 -30.816135,-1.698914 -30.758309,-2.28725 -30.469696,-2.413858 -29.938359,-2.348487 -29.632176,-2.917858 -29.024926,-2.839258 -29.117479,-2.292211 -29.254835,-2.21511 -29.291887,-1.620056 -29.579466,-1.341313 -29.821519,-1.443322 + 30.419105,-1.134659 +30.816135,-1.698914 +30.758309,-2.28725 +30.469696,-2.413858 +29.938359,-2.348487 +29.632176,-2.917858 +29.024926,-2.839258 +29.117479,-2.292211 +29.254835,-2.21511 +29.291887,-1.620056 +29.579466,-1.341313 +29.821519,-1.443322 30.419105,-1.134659 - + #defaultStyle Western Sahara - - - - - - - -8.794884,27.120696 --8.817828,27.656426 --8.66559,27.656426 --8.665124,27.589479 --8.6844,27.395744 --8.687294,25.881056 --11.969419,25.933353 --11.937224,23.374594 --12.874222,23.284832 --13.118754,22.77122 --12.929102,21.327071 --16.845194,21.333323 --17.063423,20.999752 --17.020428,21.42231 --17.002962,21.420734 --14.750955,21.5006 --14.630833,21.86094 --14.221168,22.310163 --13.89111,23.691009 --12.500963,24.770116 --12.030759,26.030866 --11.71822,26.104092 --11.392555,26.883424 --10.551263,26.990808 --10.189424,26.860945 --9.735343,26.860945 --9.413037,27.088476 + + + + + + + -8.794884,27.120696 +-8.817828,27.656426 +-8.66559,27.656426 +-8.665124,27.589479 +-8.6844,27.395744 +-8.687294,25.881056 +-11.969419,25.933353 +-11.937224,23.374594 +-12.874222,23.284832 +-13.118754,22.77122 +-12.929102,21.327071 +-16.845194,21.333323 +-17.063423,20.999752 +-17.020428,21.42231 +-17.002962,21.420734 +-14.750955,21.5006 +-14.630833,21.86094 +-14.221168,22.310163 +-13.89111,23.691009 +-12.500963,24.770116 +-12.030759,26.030866 +-11.71822,26.104092 +-11.392555,26.883424 +-10.551263,26.990808 +-10.189424,26.860945 +-9.735343,26.860945 +-9.413037,27.088476 -8.794884,27.120696 - + #defaultStyle Saudi Arabia - - - - - - - 42.779332,16.347891 -42.649573,16.774635 -42.347989,17.075806 -42.270888,17.474722 -41.754382,17.833046 -41.221391,18.6716 -40.939341,19.486485 -40.247652,20.174635 -39.801685,20.338862 -39.139399,21.291905 -39.023696,21.986875 -39.066329,22.579656 -38.492772,23.688451 -38.02386,24.078686 -37.483635,24.285495 -37.154818,24.858483 -37.209491,25.084542 -36.931627,25.602959 -36.639604,25.826228 -36.249137,26.570136 -35.640182,27.37652 -35.130187,28.063352 -34.632336,28.058546 -34.787779,28.607427 -34.83222,28.957483 -34.956037,29.356555 -36.068941,29.197495 -36.501214,29.505254 -36.740528,29.865283 -37.503582,30.003776 -37.66812,30.338665 -37.998849,30.5085 -37.002166,31.508413 -39.004886,32.010217 -39.195468,32.161009 -40.399994,31.889992 -41.889981,31.190009 -44.709499,29.178891 -46.568713,29.099025 -47.459822,29.002519 -47.708851,28.526063 -48.416094,28.552004 -48.807595,27.689628 -49.299554,27.461218 -49.470914,27.109999 -50.152422,26.689663 -50.212935,26.277027 -50.113303,25.943972 -50.239859,25.60805 -50.527387,25.327808 -50.660557,24.999896 -50.810108,24.754743 -51.112415,24.556331 -51.389608,24.627386 -51.579519,24.245497 -51.617708,24.014219 -52.000733,23.001154 -55.006803,22.496948 -55.208341,22.70833 -55.666659,22.000001 -54.999982,19.999994 -52.00001,19.000003 -49.116672,18.616668 -48.183344,18.166669 -47.466695,17.116682 -47.000005,16.949999 -46.749994,17.283338 -46.366659,17.233315 -45.399999,17.333335 -45.216651,17.433329 -44.062613,17.410359 -43.791519,17.319977 -43.380794,17.579987 -43.115798,17.08844 -43.218375,16.66689 + + + + + + + 42.779332,16.347891 +42.649573,16.774635 +42.347989,17.075806 +42.270888,17.474722 +41.754382,17.833046 +41.221391,18.6716 +40.939341,19.486485 +40.247652,20.174635 +39.801685,20.338862 +39.139399,21.291905 +39.023696,21.986875 +39.066329,22.579656 +38.492772,23.688451 +38.02386,24.078686 +37.483635,24.285495 +37.154818,24.858483 +37.209491,25.084542 +36.931627,25.602959 +36.639604,25.826228 +36.249137,26.570136 +35.640182,27.37652 +35.130187,28.063352 +34.632336,28.058546 +34.787779,28.607427 +34.83222,28.957483 +34.956037,29.356555 +36.068941,29.197495 +36.501214,29.505254 +36.740528,29.865283 +37.503582,30.003776 +37.66812,30.338665 +37.998849,30.5085 +37.002166,31.508413 +39.004886,32.010217 +39.195468,32.161009 +40.399994,31.889992 +41.889981,31.190009 +44.709499,29.178891 +46.568713,29.099025 +47.459822,29.002519 +47.708851,28.526063 +48.416094,28.552004 +48.807595,27.689628 +49.299554,27.461218 +49.470914,27.109999 +50.152422,26.689663 +50.212935,26.277027 +50.113303,25.943972 +50.239859,25.60805 +50.527387,25.327808 +50.660557,24.999896 +50.810108,24.754743 +51.112415,24.556331 +51.389608,24.627386 +51.579519,24.245497 +51.617708,24.014219 +52.000733,23.001154 +55.006803,22.496948 +55.208341,22.70833 +55.666659,22.000001 +54.999982,19.999994 +52.00001,19.000003 +49.116672,18.616668 +48.183344,18.166669 +47.466695,17.116682 +47.000005,16.949999 +46.749994,17.283338 +46.366659,17.233315 +45.399999,17.333335 +45.216651,17.433329 +44.062613,17.410359 +43.791519,17.319977 +43.380794,17.579987 +43.115798,17.08844 +43.218375,16.66689 42.779332,16.347891 - + #defaultStyle Sudan - - - - - - - 33.963393,9.464285 -33.824963,9.484061 -33.842131,9.981915 -33.721959,10.325262 -33.206938,10.720112 -33.086766,11.441141 -33.206938,12.179338 -32.743419,12.248008 -32.67475,12.024832 -32.073892,11.97333 -32.314235,11.681484 -32.400072,11.080626 -31.850716,10.531271 -31.352862,9.810241 -30.837841,9.707237 -29.996639,10.290927 -29.618957,10.084919 -29.515953,9.793074 -29.000932,9.604232 -28.966597,9.398224 -27.97089,9.398224 -27.833551,9.604232 -27.112521,9.638567 -26.752006,9.466893 -26.477328,9.55273 -25.962307,10.136421 -25.790633,10.411099 -25.069604,10.27376 -24.794926,9.810241 -24.537415,8.917538 -24.194068,8.728696 -23.88698,8.61973 -23.805813,8.666319 -23.459013,8.954286 -23.394779,9.265068 -23.55725,9.681218 -23.554304,10.089255 -22.977544,10.714463 -22.864165,11.142395 -22.87622,11.38461 -22.50869,11.67936 -22.49762,12.26024 -22.28801,12.64605 -21.93681,12.58818 -22.03759,12.95546 -22.29658,13.37232 -22.18329,13.78648 -22.51202,14.09318 -22.30351,14.32682 -22.56795,14.94429 -23.02459,15.68072 -23.88689,15.61084 -23.83766,19.58047 -23.85,20.0 -25.0,20.00304 -25.0,22.0 -29.02,22.0 -32.9,22.0 -36.86623,22.0 -37.18872,21.01885 -36.96941,20.83744 -37.1147,19.80796 -37.48179,18.61409 -37.86276,18.36786 -38.41009,17.998307 -37.904,17.42754 -37.16747,17.26314 -36.85253,16.95655 -36.75389,16.29186 -36.32322,14.82249 -36.42951,14.42211 -36.27022,13.56333 -35.86363,12.57828 -35.26049,12.08286 -34.83163,11.31896 -34.73115,10.91017 -34.25745,10.63009 -33.96162,9.58358 + + + + + + + 33.963393,9.464285 +33.824963,9.484061 +33.842131,9.981915 +33.721959,10.325262 +33.206938,10.720112 +33.086766,11.441141 +33.206938,12.179338 +32.743419,12.248008 +32.67475,12.024832 +32.073892,11.97333 +32.314235,11.681484 +32.400072,11.080626 +31.850716,10.531271 +31.352862,9.810241 +30.837841,9.707237 +29.996639,10.290927 +29.618957,10.084919 +29.515953,9.793074 +29.000932,9.604232 +28.966597,9.398224 +27.97089,9.398224 +27.833551,9.604232 +27.112521,9.638567 +26.752006,9.466893 +26.477328,9.55273 +25.962307,10.136421 +25.790633,10.411099 +25.069604,10.27376 +24.794926,9.810241 +24.537415,8.917538 +24.194068,8.728696 +23.88698,8.61973 +23.805813,8.666319 +23.459013,8.954286 +23.394779,9.265068 +23.55725,9.681218 +23.554304,10.089255 +22.977544,10.714463 +22.864165,11.142395 +22.87622,11.38461 +22.50869,11.67936 +22.49762,12.26024 +22.28801,12.64605 +21.93681,12.58818 +22.03759,12.95546 +22.29658,13.37232 +22.18329,13.78648 +22.51202,14.09318 +22.30351,14.32682 +22.56795,14.94429 +23.02459,15.68072 +23.88689,15.61084 +23.83766,19.58047 +23.85,20.0 +25.0,20.00304 +25.0,22.0 +29.02,22.0 +32.9,22.0 +36.86623,22.0 +37.18872,21.01885 +36.96941,20.83744 +37.1147,19.80796 +37.48179,18.61409 +37.86276,18.36786 +38.41009,17.998307 +37.904,17.42754 +37.16747,17.26314 +36.85253,16.95655 +36.75389,16.29186 +36.32322,14.82249 +36.42951,14.42211 +36.27022,13.56333 +35.86363,12.57828 +35.26049,12.08286 +34.83163,11.31896 +34.73115,10.91017 +34.25745,10.63009 +33.96162,9.58358 33.963393,9.464285 - + #defaultStyle South Sudan - - - - - - - 33.963393,9.464285 -33.97498,8.68456 -33.8255,8.37916 -33.2948,8.35458 -32.95418,7.78497 -33.56829,7.71334 -34.0751,7.22595 -34.25032,6.82607 -34.70702,6.59422 -35.298007,5.506 -34.620196,4.847123 -34.005,4.249885 -33.39,3.79 -32.68642,3.79232 -31.88145,3.55827 -31.24556,3.7819 -30.83385,3.50917 -29.95349,4.1737 -29.715995,4.600805 -29.159078,4.389267 -28.696678,4.455077 -28.428994,4.287155 -27.979977,4.408413 -27.374226,5.233944 -27.213409,5.550953 -26.465909,5.946717 -26.213418,6.546603 -25.796648,6.979316 -25.124131,7.500085 -25.114932,7.825104 -24.567369,8.229188 -23.88698,8.61973 -24.194068,8.728696 -24.537415,8.917538 -24.794926,9.810241 -25.069604,10.27376 -25.790633,10.411099 -25.962307,10.136421 -26.477328,9.55273 -26.752006,9.466893 -27.112521,9.638567 -27.833551,9.604232 -27.97089,9.398224 -28.966597,9.398224 -29.000932,9.604232 -29.515953,9.793074 -29.618957,10.084919 -29.996639,10.290927 -30.837841,9.707237 -31.352862,9.810241 -31.850716,10.531271 -32.400072,11.080626 -32.314235,11.681484 -32.073892,11.97333 -32.67475,12.024832 -32.743419,12.248008 -33.206938,12.179338 -33.086766,11.441141 -33.206938,10.720112 -33.721959,10.325262 -33.842131,9.981915 -33.824963,9.484061 + + + + + + + 33.963393,9.464285 +33.97498,8.68456 +33.8255,8.37916 +33.2948,8.35458 +32.95418,7.78497 +33.56829,7.71334 +34.0751,7.22595 +34.25032,6.82607 +34.70702,6.59422 +35.298007,5.506 +34.620196,4.847123 +34.005,4.249885 +33.39,3.79 +32.68642,3.79232 +31.88145,3.55827 +31.24556,3.7819 +30.83385,3.50917 +29.95349,4.1737 +29.715995,4.600805 +29.159078,4.389267 +28.696678,4.455077 +28.428994,4.287155 +27.979977,4.408413 +27.374226,5.233944 +27.213409,5.550953 +26.465909,5.946717 +26.213418,6.546603 +25.796648,6.979316 +25.124131,7.500085 +25.114932,7.825104 +24.567369,8.229188 +23.88698,8.61973 +24.194068,8.728696 +24.537415,8.917538 +24.794926,9.810241 +25.069604,10.27376 +25.790633,10.411099 +25.962307,10.136421 +26.477328,9.55273 +26.752006,9.466893 +27.112521,9.638567 +27.833551,9.604232 +27.97089,9.398224 +28.966597,9.398224 +29.000932,9.604232 +29.515953,9.793074 +29.618957,10.084919 +29.996639,10.290927 +30.837841,9.707237 +31.352862,9.810241 +31.850716,10.531271 +32.400072,11.080626 +32.314235,11.681484 +32.073892,11.97333 +32.67475,12.024832 +32.743419,12.248008 +33.206938,12.179338 +33.086766,11.441141 +33.206938,10.720112 +33.721959,10.325262 +33.842131,9.981915 +33.824963,9.484061 33.963393,9.464285 - + #defaultStyle Senegal - - - - - - - -16.713729,13.594959 --17.126107,14.373516 --17.625043,14.729541 --17.185173,14.919477 --16.700706,15.621527 --16.463098,16.135036 --16.12069,16.455663 --15.623666,16.369337 --15.135737,16.587282 --14.577348,16.598264 --14.099521,16.304302 --13.435738,16.039383 --12.830658,15.303692 --12.17075,14.616834 --12.124887,13.994727 --11.927716,13.422075 --11.553398,13.141214 --11.467899,12.754519 --11.513943,12.442988 --11.658301,12.386583 --12.203565,12.465648 --12.278599,12.35444 --12.499051,12.33209 --13.217818,12.575874 --13.700476,12.586183 --15.548477,12.62817 --15.816574,12.515567 --16.147717,12.547762 --16.677452,12.384852 --16.841525,13.151394 --15.931296,13.130284 --15.691001,13.270353 --15.511813,13.27857 --15.141163,13.509512 --14.712197,13.298207 --14.277702,13.280585 --13.844963,13.505042 --14.046992,13.794068 --14.376714,13.62568 --14.687031,13.630357 --15.081735,13.876492 --15.39877,13.860369 --15.624596,13.623587 + + + + + + + -16.713729,13.594959 +-17.126107,14.373516 +-17.625043,14.729541 +-17.185173,14.919477 +-16.700706,15.621527 +-16.463098,16.135036 +-16.12069,16.455663 +-15.623666,16.369337 +-15.135737,16.587282 +-14.577348,16.598264 +-14.099521,16.304302 +-13.435738,16.039383 +-12.830658,15.303692 +-12.17075,14.616834 +-12.124887,13.994727 +-11.927716,13.422075 +-11.553398,13.141214 +-11.467899,12.754519 +-11.513943,12.442988 +-11.658301,12.386583 +-12.203565,12.465648 +-12.278599,12.35444 +-12.499051,12.33209 +-13.217818,12.575874 +-13.700476,12.586183 +-15.548477,12.62817 +-15.816574,12.515567 +-16.147717,12.547762 +-16.677452,12.384852 +-16.841525,13.151394 +-15.931296,13.130284 +-15.691001,13.270353 +-15.511813,13.27857 +-15.141163,13.509512 +-14.712197,13.298207 +-14.277702,13.280585 +-13.844963,13.505042 +-14.046992,13.794068 +-14.376714,13.62568 +-14.687031,13.630357 +-15.081735,13.876492 +-15.39877,13.860369 +-15.624596,13.623587 -16.713729,13.594959 - + #defaultStyle Solomon Islands - + - 162.119025,-10.482719 -162.398646,-10.826367 -161.700032,-10.820011 -161.319797,-10.204751 -161.917383,-10.446701 + 162.119025,-10.482719 +162.398646,-10.826367 +161.700032,-10.820011 +161.319797,-10.204751 +161.917383,-10.446701 162.119025,-10.482719 - + - 160.852229,-9.872937 -160.462588,-9.89521 -159.849447,-9.794027 -159.640003,-9.63998 -159.702945,-9.24295 -160.362956,-9.400304 -160.688518,-9.610162 + 160.852229,-9.872937 +160.462588,-9.89521 +159.849447,-9.794027 +159.640003,-9.63998 +159.702945,-9.24295 +160.362956,-9.400304 +160.688518,-9.610162 160.852229,-9.872937 - + - 161.679982,-9.599982 -161.529397,-9.784312 -160.788253,-8.917543 -160.579997,-8.320009 -160.920028,-8.320009 -161.280006,-9.120011 + 161.679982,-9.599982 +161.529397,-9.784312 +160.788253,-8.917543 +160.579997,-8.320009 +160.920028,-8.320009 +161.280006,-9.120011 161.679982,-9.599982 - + - 159.875027,-8.33732 -159.917402,-8.53829 -159.133677,-8.114181 -158.586114,-7.754824 -158.21115,-7.421872 -158.359978,-7.320018 -158.820001,-7.560003 -159.640003,-8.020027 + 159.875027,-8.33732 +159.917402,-8.53829 +159.133677,-8.114181 +158.586114,-7.754824 +158.21115,-7.421872 +158.359978,-7.320018 +158.820001,-7.560003 +159.640003,-8.020027 159.875027,-8.33732 - + - 157.538426,-7.34782 -157.33942,-7.404767 -156.90203,-7.176874 -156.491358,-6.765943 -156.542828,-6.599338 -157.14,-7.021638 + 157.538426,-7.34782 +157.33942,-7.404767 +156.90203,-7.176874 +156.491358,-6.765943 +156.542828,-6.599338 +157.14,-7.021638 157.538426,-7.34782 - + #defaultStyle Sierra Leone - - - - - - - -11.438779,6.785917 --11.708195,6.860098 --12.428099,7.262942 --12.949049,7.798646 --13.124025,8.163946 --13.24655,8.903049 --12.711958,9.342712 --12.596719,9.620188 --12.425929,9.835834 --12.150338,9.858572 --11.917277,10.046984 --11.117481,10.045873 --10.839152,9.688246 --10.622395,9.26791 --10.65477,8.977178 --10.494315,8.715541 --10.505477,8.348896 --10.230094,8.406206 --10.695595,7.939464 --11.146704,7.396706 --11.199802,7.105846 + + + + + + + -11.438779,6.785917 +-11.708195,6.860098 +-12.428099,7.262942 +-12.949049,7.798646 +-13.124025,8.163946 +-13.24655,8.903049 +-12.711958,9.342712 +-12.596719,9.620188 +-12.425929,9.835834 +-12.150338,9.858572 +-11.917277,10.046984 +-11.117481,10.045873 +-10.839152,9.688246 +-10.622395,9.26791 +-10.65477,8.977178 +-10.494315,8.715541 +-10.505477,8.348896 +-10.230094,8.406206 +-10.695595,7.939464 +-11.146704,7.396706 +-11.199802,7.105846 -11.438779,6.785917 - + #defaultStyle El Salvador - + - -87.793111,13.38448 --87.904112,13.149017 --88.483302,13.163951 --88.843228,13.259734 --89.256743,13.458533 --89.812394,13.520622 --90.095555,13.735338 --90.064678,13.88197 --89.721934,14.134228 --89.534219,14.244816 --89.587343,14.362586 --89.353326,14.424133 --89.058512,14.340029 --88.843073,14.140507 --88.541231,13.980155 --88.503998,13.845486 --88.065343,13.964626 --87.859515,13.893312 --87.723503,13.78505 + -87.793111,13.38448 +-87.904112,13.149017 +-88.483302,13.163951 +-88.843228,13.259734 +-89.256743,13.458533 +-89.812394,13.520622 +-90.095555,13.735338 +-90.064678,13.88197 +-89.721934,14.134228 +-89.534219,14.244816 +-89.587343,14.362586 +-89.353326,14.424133 +-89.058512,14.340029 +-88.843073,14.140507 +-88.541231,13.980155 +-88.503998,13.845486 +-88.065343,13.964626 +-87.859515,13.893312 +-87.723503,13.78505 -87.793111,13.38448 - + #defaultStyle Somaliland - - - - - - - 48.93813,9.451749 -48.486736,8.837626 -47.78942,8.003 -46.948328,7.996877 -43.67875,9.18358 -43.296975,9.540477 -42.92812,10.02194 -42.55876,10.57258 -42.776852,10.926879 -43.145305,11.46204 -43.47066,11.27771 -43.666668,10.864169 -44.117804,10.445538 -44.614259,10.442205 -45.556941,10.698029 -46.645401,10.816549 -47.525658,11.127228 -48.021596,11.193064 -48.378784,11.375482 -48.948206,11.410622 -48.942005,11.394266 -48.938491,10.982327 -48.938233,9.9735 + + + + + + + 48.93813,9.451749 +48.486736,8.837626 +47.78942,8.003 +46.948328,7.996877 +43.67875,9.18358 +43.296975,9.540477 +42.92812,10.02194 +42.55876,10.57258 +42.776852,10.926879 +43.145305,11.46204 +43.47066,11.27771 +43.666668,10.864169 +44.117804,10.445538 +44.614259,10.442205 +45.556941,10.698029 +46.645401,10.816549 +47.525658,11.127228 +48.021596,11.193064 +48.378784,11.375482 +48.948206,11.410622 +48.942005,11.394266 +48.938491,10.982327 +48.938233,9.9735 48.93813,9.451749 - + #defaultStyle Somalia - - - - - - - 49.72862,11.5789 -50.25878,11.67957 -50.73202,12.0219 -51.1112,12.02464 -51.13387,11.74815 -51.04153,11.16651 -51.04531,10.6409 -50.83418,10.27972 -50.55239,9.19874 -50.07092,8.08173 -49.4527,6.80466 -48.59455,5.33911 -47.74079,4.2194 -46.56476,2.85529 -45.56399,2.04576 -44.06815,1.05283 -43.13597,0.2922 -42.04157,-0.91916 -41.81095,-1.44647 -41.58513,-1.68325 -40.993,-0.85829 -40.98105,2.78452 -41.855083,3.918912 -42.12861,4.23413 -42.76967,4.25259 -43.66087,4.95755 -44.9636,5.00162 -47.78942,8.003 -48.486736,8.837626 -48.93813,9.451749 -48.938233,9.9735 -48.938491,10.982327 -48.942005,11.394266 -48.948205,11.410617 -49.26776,11.43033 + + + + + + + 49.72862,11.5789 +50.25878,11.67957 +50.73202,12.0219 +51.1112,12.02464 +51.13387,11.74815 +51.04153,11.16651 +51.04531,10.6409 +50.83418,10.27972 +50.55239,9.19874 +50.07092,8.08173 +49.4527,6.80466 +48.59455,5.33911 +47.74079,4.2194 +46.56476,2.85529 +45.56399,2.04576 +44.06815,1.05283 +43.13597,0.2922 +42.04157,-0.91916 +41.81095,-1.44647 +41.58513,-1.68325 +40.993,-0.85829 +40.98105,2.78452 +41.855083,3.918912 +42.12861,4.23413 +42.76967,4.25259 +43.66087,4.95755 +44.9636,5.00162 +47.78942,8.003 +48.486736,8.837626 +48.93813,9.451749 +48.938233,9.9735 +48.938491,10.982327 +48.942005,11.394266 +48.948205,11.410617 +49.26776,11.43033 49.72862,11.5789 - + #defaultStyle Republic of Serbia - - - - - - - 20.874313,45.416375 -21.483526,45.18117 -21.562023,44.768947 -22.145088,44.478422 -22.459022,44.702517 -22.705726,44.578003 -22.474008,44.409228 -22.65715,44.234923 -22.410446,44.008063 -22.500157,43.642814 -22.986019,43.211161 -22.604801,42.898519 -22.436595,42.580321 -22.545012,42.461362 -22.380526,42.32026 -21.91708,42.30364 -21.576636,42.245224 -21.54332,42.32025 -21.66292,42.43922 -21.77505,42.6827 -21.63302,42.67717 -21.43866,42.86255 -21.27421,42.90959 -21.143395,43.068685 -20.95651,43.13094 -20.81448,43.27205 -20.63508,43.21671 -20.49679,42.88469 -20.25758,42.81275 -20.3398,42.89852 -19.95857,43.10604 -19.63,43.21378 -19.48389,43.35229 -19.21852,43.52384 -19.454,43.5681 -19.59976,44.03847 -19.11761,44.42307 -19.36803,44.863 -19.00548,44.86023 -19.390476,45.236516 -19.072769,45.521511 -18.82982,45.90888 -19.596045,46.17173 -20.220192,46.127469 -20.762175,45.734573 + + + + + + + 20.874313,45.416375 +21.483526,45.18117 +21.562023,44.768947 +22.145088,44.478422 +22.459022,44.702517 +22.705726,44.578003 +22.474008,44.409228 +22.65715,44.234923 +22.410446,44.008063 +22.500157,43.642814 +22.986019,43.211161 +22.604801,42.898519 +22.436595,42.580321 +22.545012,42.461362 +22.380526,42.32026 +21.91708,42.30364 +21.576636,42.245224 +21.54332,42.32025 +21.66292,42.43922 +21.77505,42.6827 +21.63302,42.67717 +21.43866,42.86255 +21.27421,42.90959 +21.143395,43.068685 +20.95651,43.13094 +20.81448,43.27205 +20.63508,43.21671 +20.49679,42.88469 +20.25758,42.81275 +20.3398,42.89852 +19.95857,43.10604 +19.63,43.21378 +19.48389,43.35229 +19.21852,43.52384 +19.454,43.5681 +19.59976,44.03847 +19.11761,44.42307 +19.36803,44.863 +19.00548,44.86023 +19.390476,45.236516 +19.072769,45.521511 +18.82982,45.90888 +19.596045,46.17173 +20.220192,46.127469 +20.762175,45.734573 20.874313,45.416375 - + #defaultStyle Suriname - - - - - - - -57.147436,5.97315 --55.949318,5.772878 --55.84178,5.953125 --55.03325,6.025291 --53.958045,5.756548 --54.478633,4.896756 --54.399542,4.212611 --54.006931,3.620038 --54.181726,3.18978 --54.269705,2.732392 --54.524754,2.311849 --55.097587,2.523748 --55.569755,2.421506 --55.973322,2.510364 --56.073342,2.220795 --55.9056,2.021996 --55.995698,1.817667 --56.539386,1.899523 --57.150098,2.768927 --57.281433,3.333492 --57.601569,3.334655 --58.044694,4.060864 --57.86021,4.576801 --57.914289,4.812626 --57.307246,5.073567 + + + + + + + -57.147436,5.97315 +-55.949318,5.772878 +-55.84178,5.953125 +-55.03325,6.025291 +-53.958045,5.756548 +-54.478633,4.896756 +-54.399542,4.212611 +-54.006931,3.620038 +-54.181726,3.18978 +-54.269705,2.732392 +-54.524754,2.311849 +-55.097587,2.523748 +-55.569755,2.421506 +-55.973322,2.510364 +-56.073342,2.220795 +-55.9056,2.021996 +-55.995698,1.817667 +-56.539386,1.899523 +-57.150098,2.768927 +-57.281433,3.333492 +-57.601569,3.334655 +-58.044694,4.060864 +-57.86021,4.576801 +-57.914289,4.812626 +-57.307246,5.073567 -57.147436,5.97315 - + #defaultStyle Sweden - - - - - - - 22.183173,65.723741 -21.213517,65.026005 -21.369631,64.413588 -19.778876,63.609554 -17.847779,62.7494 -17.119555,61.341166 -17.831346,60.636583 -18.787722,60.081914 -17.869225,58.953766 -16.829185,58.719827 -16.44771,57.041118 -15.879786,56.104302 -14.666681,56.200885 -14.100721,55.407781 -12.942911,55.361737 -12.625101,56.30708 -11.787942,57.441817 -11.027369,58.856149 -11.468272,59.432393 -12.300366,60.117933 -12.631147,61.293572 -11.992064,61.800362 -11.930569,63.128318 -12.579935,64.066219 -13.571916,64.049114 -13.919905,64.445421 -13.55569,64.787028 -15.108411,66.193867 -16.108712,67.302456 -16.768879,68.013937 -17.729182,68.010552 -17.993868,68.567391 -19.87856,68.407194 -20.025269,69.065139 -20.645593,69.106247 -21.978535,68.616846 -23.539473,67.936009 -23.56588,66.396051 -23.903379,66.006927 + + + + + + + 22.183173,65.723741 +21.213517,65.026005 +21.369631,64.413588 +19.778876,63.609554 +17.847779,62.7494 +17.119555,61.341166 +17.831346,60.636583 +18.787722,60.081914 +17.869225,58.953766 +16.829185,58.719827 +16.44771,57.041118 +15.879786,56.104302 +14.666681,56.200885 +14.100721,55.407781 +12.942911,55.361737 +12.625101,56.30708 +11.787942,57.441817 +11.027369,58.856149 +11.468272,59.432393 +12.300366,60.117933 +12.631147,61.293572 +11.992064,61.800362 +11.930569,63.128318 +12.579935,64.066219 +13.571916,64.049114 +13.919905,64.445421 +13.55569,64.787028 +15.108411,66.193867 +16.108712,67.302456 +16.768879,68.013937 +17.729182,68.010552 +17.993868,68.567391 +19.87856,68.407194 +20.025269,69.065139 +20.645593,69.106247 +21.978535,68.616846 +23.539473,67.936009 +23.56588,66.396051 +23.903379,66.006927 22.183173,65.723741 - + - 17.061767,57.385783 -17.210083,57.326521 -16.430053,56.179196 -16.364135,56.556455 + 17.061767,57.385783 +17.210083,57.326521 +16.430053,56.179196 +16.364135,56.556455 17.061767,57.385783 - + - 19.35791,57.958588 -18.8031,57.651279 -18.825073,57.444949 -18.995361,57.441993 -18.951416,57.370976 -18.693237,57.305756 -18.709716,57.204734 -18.462524,57.127295 -18.319702,56.926992 -18.105468,56.891003 -18.187866,57.109402 -18.072509,57.267163 -18.154907,57.394664 -18.094482,57.545312 -18.660278,57.929434 -19.039306,57.941098 -19.105224,57.993543 -19.374389,57.996454 + 19.35791,57.958588 +18.8031,57.651279 +18.825073,57.444949 +18.995361,57.441993 +18.951416,57.370976 +18.693237,57.305756 +18.709716,57.204734 +18.462524,57.127295 +18.319702,56.926992 +18.105468,56.891003 +18.187866,57.109402 +18.072509,57.267163 +18.154907,57.394664 +18.094482,57.545312 +18.660278,57.929434 +19.039306,57.941098 +19.105224,57.993543 +19.374389,57.996454 19.35791,57.958588 - + - 20.846557,63.82371 -21.066284,63.829768 -20.9729,63.71567 -20.824584,63.579121 -20.695495,63.59134 -20.819091,63.714454 -20.799865,63.780059 + 20.846557,63.82371 +21.066284,63.829768 +20.9729,63.71567 +20.824584,63.579121 +20.695495,63.59134 +20.819091,63.714454 +20.799865,63.780059 20.846557,63.82371 - + #defaultStyle Swaziland - + - 32.071665,-26.73382 -31.86806,-27.177927 -31.282773,-27.285879 -30.685962,-26.743845 -30.676609,-26.398078 -30.949667,-26.022649 -31.04408,-25.731452 -31.333158,-25.660191 -31.837778,-25.843332 -31.985779,-26.29178 + 32.071665,-26.73382 +31.86806,-27.177927 +31.282773,-27.285879 +30.685962,-26.743845 +30.676609,-26.398078 +30.949667,-26.022649 +31.04408,-25.731452 +31.333158,-25.660191 +31.837778,-25.843332 +31.985779,-26.29178 32.071665,-26.73382 - + #defaultStyle Syria - - - - - - - 38.792341,33.378686 -36.834062,32.312938 -35.719918,32.709192 -35.700798,32.716014 -35.836397,32.868123 -35.821101,33.277426 -36.06646,33.824912 -36.61175,34.201789 -36.448194,34.593935 -35.998403,34.644914 -35.905023,35.410009 -36.149763,35.821535 -36.41755,36.040617 -36.685389,36.259699 -36.739494,36.81752 -37.066761,36.623036 -38.167727,36.90121 -38.699891,36.712927 -39.52258,36.716054 -40.673259,37.091276 -41.212089,37.074352 -42.349591,37.229873 -41.837064,36.605854 -41.289707,36.358815 -41.383965,35.628317 -41.006159,34.419372 + + + + + + + 38.792341,33.378686 +36.834062,32.312938 +35.719918,32.709192 +35.700798,32.716014 +35.836397,32.868123 +35.821101,33.277426 +36.06646,33.824912 +36.61175,34.201789 +36.448194,34.593935 +35.998403,34.644914 +35.905023,35.410009 +36.149763,35.821535 +36.41755,36.040617 +36.685389,36.259699 +36.739494,36.81752 +37.066761,36.623036 +38.167727,36.90121 +38.699891,36.712927 +39.52258,36.716054 +40.673259,37.091276 +41.212089,37.074352 +42.349591,37.229873 +41.837064,36.605854 +41.289707,36.358815 +41.383965,35.628317 +41.006159,34.419372 38.792341,33.378686 - + #defaultStyle Chad - - - - - - - 14.495787,12.859396 -14.595781,13.330427 -13.954477,13.353449 -13.956699,13.996691 -13.540394,14.367134 -13.97217,15.68437 -15.247731,16.627306 -15.300441,17.92795 -15.685741,19.95718 -15.903247,20.387619 -15.487148,20.730415 -15.47106,21.04845 -15.096888,21.308519 -14.8513,22.86295 -15.86085,23.40972 -19.84926,21.49509 -23.83766,19.58047 -23.88689,15.61084 -23.02459,15.68072 -22.56795,14.94429 -22.30351,14.32682 -22.51202,14.09318 -22.18329,13.78648 -22.29658,13.37232 -22.03759,12.95546 -21.93681,12.58818 -22.28801,12.64605 -22.49762,12.26024 -22.50869,11.67936 -22.87622,11.38461 -22.864165,11.142395 -22.231129,10.971889 -21.723822,10.567056 -21.000868,9.475985 -20.059685,9.012706 -19.094008,9.074847 -18.81201,8.982915 -18.911022,8.630895 -18.389555,8.281304 -17.96493,7.890914 -16.705988,7.508328 -16.456185,7.734774 -16.290562,7.754307 -16.106232,7.497088 -15.27946,7.421925 -15.436092,7.692812 -15.120866,8.38215 -14.979996,8.796104 -14.544467,8.965861 -13.954218,9.549495 -14.171466,10.021378 -14.627201,9.920919 -14.909354,9.992129 -15.467873,9.982337 -14.923565,10.891325 -14.960152,11.555574 -14.89336,12.21905 + + + + + + + 14.495787,12.859396 +14.595781,13.330427 +13.954477,13.353449 +13.956699,13.996691 +13.540394,14.367134 +13.97217,15.68437 +15.247731,16.627306 +15.300441,17.92795 +15.685741,19.95718 +15.903247,20.387619 +15.487148,20.730415 +15.47106,21.04845 +15.096888,21.308519 +14.8513,22.86295 +15.86085,23.40972 +19.84926,21.49509 +23.83766,19.58047 +23.88689,15.61084 +23.02459,15.68072 +22.56795,14.94429 +22.30351,14.32682 +22.51202,14.09318 +22.18329,13.78648 +22.29658,13.37232 +22.03759,12.95546 +21.93681,12.58818 +22.28801,12.64605 +22.49762,12.26024 +22.50869,11.67936 +22.87622,11.38461 +22.864165,11.142395 +22.231129,10.971889 +21.723822,10.567056 +21.000868,9.475985 +20.059685,9.012706 +19.094008,9.074847 +18.81201,8.982915 +18.911022,8.630895 +18.389555,8.281304 +17.96493,7.890914 +16.705988,7.508328 +16.456185,7.734774 +16.290562,7.754307 +16.106232,7.497088 +15.27946,7.421925 +15.436092,7.692812 +15.120866,8.38215 +14.979996,8.796104 +14.544467,8.965861 +13.954218,9.549495 +14.171466,10.021378 +14.627201,9.920919 +14.909354,9.992129 +15.467873,9.982337 +14.923565,10.891325 +14.960152,11.555574 +14.89336,12.21905 14.495787,12.859396 - + #defaultStyle Togo - + - 1.865241,6.142158 -1.060122,5.928837 -0.836931,6.279979 -0.570384,6.914359 -0.490957,7.411744 -0.712029,8.312465 -0.461192,8.677223 -0.365901,9.465004 -0.36758,10.191213 --0.049785,10.706918 -0.023803,11.018682 -0.899563,10.997339 -0.772336,10.470808 -1.077795,10.175607 -1.425061,9.825395 -1.463043,9.334624 -1.664478,9.12859 -1.618951,6.832038 + 1.865241,6.142158 +1.060122,5.928837 +0.836931,6.279979 +0.570384,6.914359 +0.490957,7.411744 +0.712029,8.312465 +0.461192,8.677223 +0.365901,9.465004 +0.36758,10.191213 +-0.049785,10.706918 +0.023803,11.018682 +0.899563,10.997339 +0.772336,10.470808 +1.077795,10.175607 +1.425061,9.825395 +1.463043,9.334624 +1.664478,9.12859 +1.618951,6.832038 1.865241,6.142158 - + #defaultStyle Thailand - - - - - - - 102.584932,12.186595 -101.687158,12.64574 -100.83181,12.627085 -100.978467,13.412722 -100.097797,13.406856 -100.018733,12.307001 -99.478921,10.846367 -99.153772,9.963061 -99.222399,9.239255 -99.873832,9.207862 -100.279647,8.295153 -100.459274,7.429573 -101.017328,6.856869 -101.623079,6.740622 -102.141187,6.221636 -101.814282,5.810808 -101.154219,5.691384 -101.075516,6.204867 -100.259596,6.642825 -100.085757,6.464489 -99.690691,6.848213 -99.519642,7.343454 -98.988253,7.907993 -98.503786,8.382305 -98.339662,7.794512 -98.150009,8.350007 -98.25915,8.973923 -98.553551,9.93296 -99.038121,10.960546 -99.587286,11.892763 -99.196354,12.804748 -99.212012,13.269294 -99.097755,13.827503 -98.430819,14.622028 -98.192074,15.123703 -98.537376,15.308497 -98.903348,16.177824 -98.493761,16.837836 -97.859123,17.567946 -97.375896,18.445438 -97.797783,18.62708 -98.253724,19.708203 -98.959676,19.752981 -99.543309,20.186598 -100.115988,20.41785 -100.548881,20.109238 -100.606294,19.508344 -101.282015,19.462585 -101.035931,18.408928 -101.059548,17.512497 -102.113592,18.109102 -102.413005,17.932782 -102.998706,17.961695 -103.200192,18.309632 -103.956477,18.240954 -104.716947,17.428859 -104.779321,16.441865 -105.589039,15.570316 -105.544338,14.723934 -105.218777,14.273212 -104.281418,14.416743 -102.988422,14.225721 -102.348099,13.394247 + + + + + + + 102.584932,12.186595 +101.687158,12.64574 +100.83181,12.627085 +100.978467,13.412722 +100.097797,13.406856 +100.018733,12.307001 +99.478921,10.846367 +99.153772,9.963061 +99.222399,9.239255 +99.873832,9.207862 +100.279647,8.295153 +100.459274,7.429573 +101.017328,6.856869 +101.623079,6.740622 +102.141187,6.221636 +101.814282,5.810808 +101.154219,5.691384 +101.075516,6.204867 +100.259596,6.642825 +100.085757,6.464489 +99.690691,6.848213 +99.519642,7.343454 +98.988253,7.907993 +98.503786,8.382305 +98.339662,7.794512 +98.150009,8.350007 +98.25915,8.973923 +98.553551,9.93296 +99.038121,10.960546 +99.587286,11.892763 +99.196354,12.804748 +99.212012,13.269294 +99.097755,13.827503 +98.430819,14.622028 +98.192074,15.123703 +98.537376,15.308497 +98.903348,16.177824 +98.493761,16.837836 +97.859123,17.567946 +97.375896,18.445438 +97.797783,18.62708 +98.253724,19.708203 +98.959676,19.752981 +99.543309,20.186598 +100.115988,20.41785 +100.548881,20.109238 +100.606294,19.508344 +101.282015,19.462585 +101.035931,18.408928 +101.059548,17.512497 +102.113592,18.109102 +102.413005,17.932782 +102.998706,17.961695 +103.200192,18.309632 +103.956477,18.240954 +104.716947,17.428859 +104.779321,16.441865 +105.589039,15.570316 +105.544338,14.723934 +105.218777,14.273212 +104.281418,14.416743 +102.988422,14.225721 +102.348099,13.394247 102.584932,12.186595 - + #defaultStyle Tajikistan - - - - - - - 71.014198,40.244366 -70.648019,39.935754 -69.55961,40.103211 -69.464887,39.526683 -70.549162,39.604198 -71.784694,39.279463 -73.675379,39.431237 -73.928852,38.505815 -74.257514,38.606507 -74.864816,38.378846 -74.829986,37.990007 -74.980002,37.41999 -73.948696,37.421566 -73.260056,37.495257 -72.63689,37.047558 -72.193041,36.948288 -71.844638,36.738171 -71.448693,37.065645 -71.541918,37.905774 -71.239404,37.953265 -71.348131,38.258905 -70.806821,38.486282 -70.376304,38.138396 -70.270574,37.735165 -70.116578,37.588223 -69.518785,37.608997 -69.196273,37.151144 -68.859446,37.344336 -68.135562,37.023115 -67.83,37.144994 -68.392033,38.157025 -68.176025,38.901553 -67.44222,39.140144 -67.701429,39.580478 -68.536416,39.533453 -69.011633,40.086158 -69.329495,40.727824 -70.666622,40.960213 -70.45816,40.496495 -70.601407,40.218527 + + + + + + + 71.014198,40.244366 +70.648019,39.935754 +69.55961,40.103211 +69.464887,39.526683 +70.549162,39.604198 +71.784694,39.279463 +73.675379,39.431237 +73.928852,38.505815 +74.257514,38.606507 +74.864816,38.378846 +74.829986,37.990007 +74.980002,37.41999 +73.948696,37.421566 +73.260056,37.495257 +72.63689,37.047558 +72.193041,36.948288 +71.844638,36.738171 +71.448693,37.065645 +71.541918,37.905774 +71.239404,37.953265 +71.348131,38.258905 +70.806821,38.486282 +70.376304,38.138396 +70.270574,37.735165 +70.116578,37.588223 +69.518785,37.608997 +69.196273,37.151144 +68.859446,37.344336 +68.135562,37.023115 +67.83,37.144994 +68.392033,38.157025 +68.176025,38.901553 +67.44222,39.140144 +67.701429,39.580478 +68.536416,39.533453 +69.011633,40.086158 +69.329495,40.727824 +70.666622,40.960213 +70.45816,40.496495 +70.601407,40.218527 71.014198,40.244366 - + #defaultStyle Turkmenistan - - - - - - - 61.210817,35.650072 -61.123071,36.491597 -60.377638,36.527383 -59.234762,37.412988 -58.436154,37.522309 -57.330434,38.029229 -56.619366,38.121394 -56.180375,37.935127 -55.511578,37.964117 -54.800304,37.392421 -53.921598,37.198918 -53.735511,37.906136 -53.880929,38.952093 -53.101028,39.290574 -53.357808,39.975286 -52.693973,40.033629 -52.915251,40.876523 -53.858139,40.631034 -54.736845,40.951015 -54.008311,41.551211 -53.721713,42.123191 -52.91675,41.868117 -52.814689,41.135371 -52.50246,41.783316 -52.944293,42.116034 -54.079418,42.324109 -54.755345,42.043971 -55.455251,41.259859 -55.968191,41.308642 -57.096391,41.32231 -56.932215,41.826026 -57.78653,42.170553 -58.629011,42.751551 -59.976422,42.223082 -60.083341,41.425146 -60.465953,41.220327 -61.547179,41.26637 -61.882714,41.084857 -62.37426,40.053886 -63.518015,39.363257 -64.170223,38.892407 -65.215999,38.402695 -66.54615,37.974685 -66.518607,37.362784 -66.217385,37.39379 -65.745631,37.661164 -65.588948,37.305217 -64.746105,37.111818 -64.546479,36.312073 -63.982896,36.007957 -63.193538,35.857166 -62.984662,35.404041 -62.230651,35.270664 + + + + + + + 61.210817,35.650072 +61.123071,36.491597 +60.377638,36.527383 +59.234762,37.412988 +58.436154,37.522309 +57.330434,38.029229 +56.619366,38.121394 +56.180375,37.935127 +55.511578,37.964117 +54.800304,37.392421 +53.921598,37.198918 +53.735511,37.906136 +53.880929,38.952093 +53.101028,39.290574 +53.357808,39.975286 +52.693973,40.033629 +52.915251,40.876523 +53.858139,40.631034 +54.736845,40.951015 +54.008311,41.551211 +53.721713,42.123191 +52.91675,41.868117 +52.814689,41.135371 +52.50246,41.783316 +52.944293,42.116034 +54.079418,42.324109 +54.755345,42.043971 +55.455251,41.259859 +55.968191,41.308642 +57.096391,41.32231 +56.932215,41.826026 +57.78653,42.170553 +58.629011,42.751551 +59.976422,42.223082 +60.083341,41.425146 +60.465953,41.220327 +61.547179,41.26637 +61.882714,41.084857 +62.37426,40.053886 +63.518015,39.363257 +64.170223,38.892407 +65.215999,38.402695 +66.54615,37.974685 +66.518607,37.362784 +66.217385,37.39379 +65.745631,37.661164 +65.588948,37.305217 +64.746105,37.111818 +64.546479,36.312073 +63.982896,36.007957 +63.193538,35.857166 +62.984662,35.404041 +62.230651,35.270664 61.210817,35.650072 - + #defaultStyle East Timor - + - 124.968682,-8.89279 -125.086246,-8.656887 -125.947072,-8.432095 -126.644704,-8.398247 -126.957243,-8.273345 -127.335928,-8.397317 -126.967992,-8.668256 -125.925885,-9.106007 -125.08852,-9.393173 -125.07002,-9.089987 + 124.968682,-8.89279 +125.086246,-8.656887 +125.947072,-8.432095 +126.644704,-8.398247 +126.957243,-8.273345 +127.335928,-8.397317 +126.967992,-8.668256 +125.925885,-9.106007 +125.08852,-9.393173 +125.07002,-9.089987 124.968682,-8.89279 - + #defaultStyle Trinidad and Tobago - + - -61.68,10.76 --61.105,10.89 --60.895,10.855 --60.935,10.11 --61.77,10.0 --61.95,10.09 --61.66,10.365 + -61.68,10.76 +-61.105,10.89 +-60.895,10.855 +-60.935,10.11 +-61.77,10.0 +-61.95,10.09 +-61.66,10.365 -61.68,10.76 - + #defaultStyle Tunisia - - - - - - - 9.48214,30.307556 -9.055603,32.102692 -8.439103,32.506285 -8.430473,32.748337 -7.612642,33.344115 -7.524482,34.097376 -8.140981,34.655146 -8.376368,35.479876 -8.217824,36.433177 -8.420964,36.946427 -9.509994,37.349994 -10.210002,37.230002 -10.18065,36.724038 -11.028867,37.092103 -11.100026,36.899996 -10.600005,36.41 -10.593287,35.947444 -10.939519,35.698984 -10.807847,34.833507 -10.149593,34.330773 -10.339659,33.785742 -10.856836,33.76874 -11.108501,33.293343 -11.488787,33.136996 -11.432253,32.368903 -10.94479,32.081815 -10.636901,31.761421 -9.950225,31.37607 -10.056575,30.961831 -9.970017,30.539325 + + + + + + + 9.48214,30.307556 +9.055603,32.102692 +8.439103,32.506285 +8.430473,32.748337 +7.612642,33.344115 +7.524482,34.097376 +8.140981,34.655146 +8.376368,35.479876 +8.217824,36.433177 +8.420964,36.946427 +9.509994,37.349994 +10.210002,37.230002 +10.18065,36.724038 +11.028867,37.092103 +11.100026,36.899996 +10.600005,36.41 +10.593287,35.947444 +10.939519,35.698984 +10.807847,34.833507 +10.149593,34.330773 +10.339659,33.785742 +10.856836,33.76874 +11.108501,33.293343 +11.488787,33.136996 +11.432253,32.368903 +10.94479,32.081815 +10.636901,31.761421 +9.950225,31.37607 +10.056575,30.961831 +9.970017,30.539325 9.48214,30.307556 - + #defaultStyle Turkey - - - - - - - 36.913127,41.335358 -38.347665,40.948586 -39.512607,41.102763 -40.373433,41.013673 -41.554084,41.535656 -42.619549,41.583173 -43.582746,41.092143 -43.752658,40.740201 -43.656436,40.253564 -44.400009,40.005 -44.79399,39.713003 -44.109225,39.428136 -44.421403,38.281281 -44.225756,37.971584 -44.772699,37.170445 -44.293452,37.001514 -43.942259,37.256228 -42.779126,37.385264 -42.349591,37.229873 -41.212089,37.074352 -40.673259,37.091276 -39.52258,36.716054 -38.699891,36.712927 -38.167727,36.90121 -37.066761,36.623036 -36.739494,36.81752 -36.685389,36.259699 -36.41755,36.040617 -36.149763,35.821535 -35.782085,36.274995 -36.160822,36.650606 -35.550936,36.565443 -34.714553,36.795532 -34.026895,36.21996 -32.509158,36.107564 -31.699595,36.644275 -30.621625,36.677865 -30.391096,36.262981 -29.699976,36.144357 -28.732903,36.676831 -27.641187,36.658822 -27.048768,37.653361 -26.318218,38.208133 -26.8047,38.98576 -26.170785,39.463612 -27.28002,40.420014 -28.819978,40.460011 -29.240004,41.219991 -31.145934,41.087622 -32.347979,41.736264 -33.513283,42.01896 -35.167704,42.040225 + + + + + + + 36.913127,41.335358 +38.347665,40.948586 +39.512607,41.102763 +40.373433,41.013673 +41.554084,41.535656 +42.619549,41.583173 +43.582746,41.092143 +43.752658,40.740201 +43.656436,40.253564 +44.400009,40.005 +44.79399,39.713003 +44.109225,39.428136 +44.421403,38.281281 +44.225756,37.971584 +44.772699,37.170445 +44.293452,37.001514 +43.942259,37.256228 +42.779126,37.385264 +42.349591,37.229873 +41.212089,37.074352 +40.673259,37.091276 +39.52258,36.716054 +38.699891,36.712927 +38.167727,36.90121 +37.066761,36.623036 +36.739494,36.81752 +36.685389,36.259699 +36.41755,36.040617 +36.149763,35.821535 +35.782085,36.274995 +36.160822,36.650606 +35.550936,36.565443 +34.714553,36.795532 +34.026895,36.21996 +32.509158,36.107564 +31.699595,36.644275 +30.621625,36.677865 +30.391096,36.262981 +29.699976,36.144357 +28.732903,36.676831 +27.641187,36.658822 +27.048768,37.653361 +26.318218,38.208133 +26.8047,38.98576 +26.170785,39.463612 +27.28002,40.420014 +28.819978,40.460011 +29.240004,41.219991 +31.145934,41.087622 +32.347979,41.736264 +33.513283,42.01896 +35.167704,42.040225 36.913127,41.335358 - + - 27.192377,40.690566 -26.358009,40.151994 -26.043351,40.617754 -26.056942,40.824123 -26.294602,40.936261 -26.604196,41.562115 -26.117042,41.826905 -27.135739,42.141485 -27.99672,42.007359 -28.115525,41.622886 -28.988443,41.299934 -28.806438,41.054962 -27.619017,40.999823 + 27.192377,40.690566 +26.358009,40.151994 +26.043351,40.617754 +26.056942,40.824123 +26.294602,40.936261 +26.604196,41.562115 +26.117042,41.826905 +27.135739,42.141485 +27.99672,42.007359 +28.115525,41.622886 +28.988443,41.299934 +28.806438,41.054962 +27.619017,40.999823 27.192377,40.690566 - + #defaultStyle Taiwan - + - 121.777818,24.394274 -121.175632,22.790857 -120.74708,21.970571 -120.220083,22.814861 -120.106189,23.556263 -120.69468,24.538451 -121.495044,25.295459 -121.951244,24.997596 + 121.777818,24.394274 +121.175632,22.790857 +120.74708,21.970571 +120.220083,22.814861 +120.106189,23.556263 +120.69468,24.538451 +121.495044,25.295459 +121.951244,24.997596 121.777818,24.394274 - + #defaultStyle United Republic of Tanzania - - - - - - - 33.903711,-0.95 -34.07262,-1.05982 -37.69869,-3.09699 -37.7669,-3.67712 -39.20222,-4.67677 -38.74054,-5.90895 -38.79977,-6.47566 -39.44,-6.84 -39.47,-7.1 -39.19469,-7.7039 -39.25203,-8.00781 -39.18652,-8.48551 -39.53574,-9.11237 -39.9496,-10.0984 -40.31659,-10.3171 -39.521,-10.89688 -38.427557,-11.285202 -37.82764,-11.26879 -37.47129,-11.56876 -36.775151,-11.594537 -36.514082,-11.720938 -35.312398,-11.439146 -34.559989,-11.52002 -34.28,-10.16 -33.940838,-9.693674 -33.73972,-9.41715 -32.759375,-9.230599 -32.191865,-8.930359 -31.556348,-8.762049 -31.157751,-8.594579 -30.74,-8.34 -30.2,-7.08 -29.62,-6.52 -29.419993,-5.939999 -29.519987,-5.419979 -29.339998,-4.499983 -29.753512,-4.452389 -30.11632,-4.09012 -30.50554,-3.56858 -30.75224,-3.35931 -30.74301,-3.03431 -30.52766,-2.80762 -30.46967,-2.41383 -30.758309,-2.28725 -30.816135,-1.698914 -30.419105,-1.134659 -30.76986,-1.01455 -31.86617,-1.02736 + + + + + + + 33.903711,-0.95 +34.07262,-1.05982 +37.69869,-3.09699 +37.7669,-3.67712 +39.20222,-4.67677 +38.74054,-5.90895 +38.79977,-6.47566 +39.44,-6.84 +39.47,-7.1 +39.19469,-7.7039 +39.25203,-8.00781 +39.18652,-8.48551 +39.53574,-9.11237 +39.9496,-10.0984 +40.31659,-10.3171 +39.521,-10.89688 +38.427557,-11.285202 +37.82764,-11.26879 +37.47129,-11.56876 +36.775151,-11.594537 +36.514082,-11.720938 +35.312398,-11.439146 +34.559989,-11.52002 +34.28,-10.16 +33.940838,-9.693674 +33.73972,-9.41715 +32.759375,-9.230599 +32.191865,-8.930359 +31.556348,-8.762049 +31.157751,-8.594579 +30.74,-8.34 +30.2,-7.08 +29.62,-6.52 +29.419993,-5.939999 +29.519987,-5.419979 +29.339998,-4.499983 +29.753512,-4.452389 +30.11632,-4.09012 +30.50554,-3.56858 +30.75224,-3.35931 +30.74301,-3.03431 +30.52766,-2.80762 +30.46967,-2.41383 +30.758309,-2.28725 +30.816135,-1.698914 +30.419105,-1.134659 +30.76986,-1.01455 +31.86617,-1.02736 33.903711,-0.95 - + #defaultStyle Uganda - - - - - - - 31.86617,-1.02736 -30.76986,-1.01455 -30.419105,-1.134659 -29.821519,-1.443322 -29.579466,-1.341313 -29.587838,-0.587406 -29.8195,-0.2053 -29.875779,0.59738 -30.086154,1.062313 -30.468508,1.583805 -30.85267,1.849396 -31.174149,2.204465 -30.77332,2.33989 -30.83385,3.50917 -31.24556,3.7819 -31.88145,3.55827 -32.68642,3.79232 -33.39,3.79 -34.005,4.249885 -34.47913,3.5556 -34.59607,3.05374 -35.03599,1.90584 -34.6721,1.17694 -34.18,0.515 -33.893569,0.109814 -33.903711,-0.95 + + + + + + + 31.86617,-1.02736 +30.76986,-1.01455 +30.419105,-1.134659 +29.821519,-1.443322 +29.579466,-1.341313 +29.587838,-0.587406 +29.8195,-0.2053 +29.875779,0.59738 +30.086154,1.062313 +30.468508,1.583805 +30.85267,1.849396 +31.174149,2.204465 +30.77332,2.33989 +30.83385,3.50917 +31.24556,3.7819 +31.88145,3.55827 +32.68642,3.79232 +33.39,3.79 +34.005,4.249885 +34.47913,3.5556 +34.59607,3.05374 +35.03599,1.90584 +34.6721,1.17694 +34.18,0.515 +33.893569,0.109814 +33.903711,-0.95 31.86617,-1.02736 - + #defaultStyle Ukraine - - - - - - - 31.785998,52.101678 -32.159412,52.061267 -32.412058,52.288695 -32.715761,52.238465 -33.7527,52.335075 -34.391731,51.768882 -34.141978,51.566413 -34.224816,51.255993 -35.022183,51.207572 -35.377924,50.773955 -35.356116,50.577197 -36.626168,50.225591 -37.39346,50.383953 -38.010631,49.915662 -38.594988,49.926462 -40.069058,49.601055 -40.080789,49.30743 -39.674664,48.783818 -39.895632,48.232405 -39.738278,47.898937 -38.770585,47.825608 -38.255112,47.5464 -38.223538,47.10219 -37.425137,47.022221 -36.759855,46.6987 -35.823685,46.645964 -34.962342,46.273197 -35.020788,45.651219 -35.510009,45.409993 -36.529998,45.46999 -36.334713,45.113216 -35.239999,44.939996 -33.882511,44.361479 -33.326421,44.564877 -33.546924,45.034771 -32.454174,45.327466 -32.630804,45.519186 -33.588162,45.851569 -33.298567,46.080598 -31.74414,46.333348 -31.675307,46.706245 -30.748749,46.5831 -30.377609,46.03241 -29.603289,45.293308 -29.149725,45.464925 -28.679779,45.304031 -28.233554,45.488283 -28.485269,45.596907 -28.659987,45.939987 -28.933717,46.25883 -28.862972,46.437889 -29.072107,46.517678 -29.170654,46.379262 -29.759972,46.349988 -30.024659,46.423937 -29.83821,46.525326 -29.908852,46.674361 -29.559674,46.928583 -29.415135,47.346645 -29.050868,47.510227 -29.122698,47.849095 -28.670891,48.118149 -28.259547,48.155562 -27.522537,48.467119 -26.857824,48.368211 -26.619337,48.220726 -26.19745,48.220881 -25.945941,47.987149 -25.207743,47.891056 -24.866317,47.737526 -24.402056,47.981878 -23.760958,47.985598 -23.142236,48.096341 -22.710531,47.882194 -22.64082,48.15024 -22.085608,48.422264 -22.280842,48.825392 -22.558138,49.085738 -22.776419,49.027395 -22.51845,49.476774 -23.426508,50.308506 -23.922757,50.424881 -24.029986,50.705407 -23.527071,51.578454 -24.005078,51.617444 -24.553106,51.888461 -25.327788,51.910656 -26.337959,51.832289 -27.454066,51.592303 -28.241615,51.572227 -28.617613,51.427714 -28.992835,51.602044 -29.254938,51.368234 -30.157364,51.416138 -30.555117,51.319503 -30.619454,51.822806 -30.927549,52.042353 + + + + + + + 31.785998,52.101678 +32.159412,52.061267 +32.412058,52.288695 +32.715761,52.238465 +33.7527,52.335075 +34.391731,51.768882 +34.141978,51.566413 +34.224816,51.255993 +35.022183,51.207572 +35.377924,50.773955 +35.356116,50.577197 +36.626168,50.225591 +37.39346,50.383953 +38.010631,49.915662 +38.594988,49.926462 +40.069058,49.601055 +40.080789,49.30743 +39.674664,48.783818 +39.895632,48.232405 +39.738278,47.898937 +38.770585,47.825608 +38.255112,47.5464 +38.223538,47.10219 +37.425137,47.022221 +36.759855,46.6987 +35.823685,46.645964 +34.962342,46.273197 +35.020788,45.651219 +35.510009,45.409993 +36.529998,45.46999 +36.334713,45.113216 +35.239999,44.939996 +33.882511,44.361479 +33.326421,44.564877 +33.546924,45.034771 +32.454174,45.327466 +32.630804,45.519186 +33.588162,45.851569 +33.298567,46.080598 +31.74414,46.333348 +31.675307,46.706245 +30.748749,46.5831 +30.377609,46.03241 +29.603289,45.293308 +29.149725,45.464925 +28.679779,45.304031 +28.233554,45.488283 +28.485269,45.596907 +28.659987,45.939987 +28.933717,46.25883 +28.862972,46.437889 +29.072107,46.517678 +29.170654,46.379262 +29.759972,46.349988 +30.024659,46.423937 +29.83821,46.525326 +29.908852,46.674361 +29.559674,46.928583 +29.415135,47.346645 +29.050868,47.510227 +29.122698,47.849095 +28.670891,48.118149 +28.259547,48.155562 +27.522537,48.467119 +26.857824,48.368211 +26.619337,48.220726 +26.19745,48.220881 +25.945941,47.987149 +25.207743,47.891056 +24.866317,47.737526 +24.402056,47.981878 +23.760958,47.985598 +23.142236,48.096341 +22.710531,47.882194 +22.64082,48.15024 +22.085608,48.422264 +22.280842,48.825392 +22.558138,49.085738 +22.776419,49.027395 +22.51845,49.476774 +23.426508,50.308506 +23.922757,50.424881 +24.029986,50.705407 +23.527071,51.578454 +24.005078,51.617444 +24.553106,51.888461 +25.327788,51.910656 +26.337959,51.832289 +27.454066,51.592303 +28.241615,51.572227 +28.617613,51.427714 +28.992835,51.602044 +29.254938,51.368234 +30.157364,51.416138 +30.555117,51.319503 +30.619454,51.822806 +30.927549,52.042353 31.785998,52.101678 - + #defaultStyle Uruguay - + - -57.625133,-30.216295 --56.976026,-30.109686 --55.973245,-30.883076 --55.60151,-30.853879 --54.572452,-31.494511 --53.787952,-32.047243 --53.209589,-32.727666 --53.650544,-33.202004 --53.373662,-33.768378 --53.806426,-34.396815 --54.935866,-34.952647 --55.67409,-34.752659 --56.215297,-34.859836 --57.139685,-34.430456 --57.817861,-34.462547 --58.427074,-33.909454 --58.349611,-33.263189 --58.132648,-33.040567 --58.14244,-32.044504 --57.874937,-31.016556 + -57.625133,-30.216295 +-56.976026,-30.109686 +-55.973245,-30.883076 +-55.60151,-30.853879 +-54.572452,-31.494511 +-53.787952,-32.047243 +-53.209589,-32.727666 +-53.650544,-33.202004 +-53.373662,-33.768378 +-53.806426,-34.396815 +-54.935866,-34.952647 +-55.67409,-34.752659 +-56.215297,-34.859836 +-57.139685,-34.430456 +-57.817861,-34.462547 +-58.427074,-33.909454 +-58.349611,-33.263189 +-58.132648,-33.040567 +-58.14244,-32.044504 +-57.874937,-31.016556 -57.625133,-30.216295 - + #defaultStyle Uzbekistan - - - - - - - 66.518607,37.362784 -66.54615,37.974685 -65.215999,38.402695 -64.170223,38.892407 -63.518015,39.363257 -62.37426,40.053886 -61.882714,41.084857 -61.547179,41.26637 -60.465953,41.220327 -60.083341,41.425146 -59.976422,42.223082 -58.629011,42.751551 -57.78653,42.170553 -56.932215,41.826026 -57.096391,41.32231 -55.968191,41.308642 -55.928917,44.995858 -58.503127,45.586804 -58.689989,45.500014 -60.239972,44.784037 -61.05832,44.405817 -62.0133,43.504477 -63.185787,43.650075 -64.900824,43.728081 -66.098012,42.99766 -66.023392,41.994646 -66.510649,41.987644 -66.714047,41.168444 -67.985856,41.135991 -68.259896,40.662325 -68.632483,40.668681 -69.070027,41.384244 -70.388965,42.081308 -70.962315,42.266154 -71.259248,42.167711 -70.420022,41.519998 -71.157859,41.143587 -71.870115,41.3929 -73.055417,40.866033 -71.774875,40.145844 -71.014198,40.244366 -70.601407,40.218527 -70.45816,40.496495 -70.666622,40.960213 -69.329495,40.727824 -69.011633,40.086158 -68.536416,39.533453 -67.701429,39.580478 -67.44222,39.140144 -68.176025,38.901553 -68.392033,38.157025 -67.83,37.144994 -67.075782,37.356144 + + + + + + + 66.518607,37.362784 +66.54615,37.974685 +65.215999,38.402695 +64.170223,38.892407 +63.518015,39.363257 +62.37426,40.053886 +61.882714,41.084857 +61.547179,41.26637 +60.465953,41.220327 +60.083341,41.425146 +59.976422,42.223082 +58.629011,42.751551 +57.78653,42.170553 +56.932215,41.826026 +57.096391,41.32231 +55.968191,41.308642 +55.928917,44.995858 +58.503127,45.586804 +58.689989,45.500014 +60.239972,44.784037 +61.05832,44.405817 +62.0133,43.504477 +63.185787,43.650075 +64.900824,43.728081 +66.098012,42.99766 +66.023392,41.994646 +66.510649,41.987644 +66.714047,41.168444 +67.985856,41.135991 +68.259896,40.662325 +68.632483,40.668681 +69.070027,41.384244 +70.388965,42.081308 +70.962315,42.266154 +71.259248,42.167711 +70.420022,41.519998 +71.157859,41.143587 +71.870115,41.3929 +73.055417,40.866033 +71.774875,40.145844 +71.014198,40.244366 +70.601407,40.218527 +70.45816,40.496495 +70.666622,40.960213 +69.329495,40.727824 +69.011633,40.086158 +68.536416,39.533453 +67.701429,39.580478 +67.44222,39.140144 +68.176025,38.901553 +68.392033,38.157025 +67.83,37.144994 +67.075782,37.356144 66.518607,37.362784 - + #defaultStyle Venezuela - - - - - - - -71.331584,11.776284 --71.360006,11.539994 --71.94705,11.423282 --71.620868,10.96946 --71.633064,10.446494 --72.074174,9.865651 --71.695644,9.072263 --71.264559,9.137195 --71.039999,9.859993 --71.350084,10.211935 --71.400623,10.968969 --70.155299,11.375482 --70.293843,11.846822 --69.943245,12.162307 --69.5843,11.459611 --68.882999,11.443385 --68.233271,10.885744 --68.194127,10.554653 --67.296249,10.545868 --66.227864,10.648627 --65.655238,10.200799 --64.890452,10.077215 --64.329479,10.389599 --64.318007,10.641418 --63.079322,10.701724 --61.880946,10.715625 --62.730119,10.420269 --62.388512,9.948204 --61.588767,9.873067 --60.830597,9.38134 --60.671252,8.580174 --60.150096,8.602757 --59.758285,8.367035 --60.550588,7.779603 --60.637973,7.415 --60.295668,7.043911 --60.543999,6.856584 --61.159336,6.696077 --61.139415,6.234297 --61.410303,5.959068 --60.733574,5.200277 --60.601179,4.918098 --60.966893,4.536468 --62.08543,4.162124 --62.804533,4.006965 --63.093198,3.770571 --63.888343,4.02053 --64.628659,4.148481 --64.816064,4.056445 --64.368494,3.79721 --64.408828,3.126786 --64.269999,2.497006 --63.422867,2.411068 --63.368788,2.2009 --64.083085,1.916369 --64.199306,1.492855 --64.611012,1.328731 --65.354713,1.095282 --65.548267,0.789254 --66.325765,0.724452 --66.876326,1.253361 --67.181294,2.250638 --67.447092,2.600281 --67.809938,2.820655 --67.303173,3.318454 --67.337564,3.542342 --67.621836,3.839482 --67.823012,4.503937 --67.744697,5.221129 --67.521532,5.55687 --67.34144,6.095468 --67.695087,6.267318 --68.265052,6.153268 --68.985319,6.206805 --69.38948,6.099861 --70.093313,6.960376 --70.674234,7.087785 --71.960176,6.991615 --72.198352,7.340431 --72.444487,7.423785 --72.479679,7.632506 --72.360901,8.002638 --72.439862,8.405275 --72.660495,8.625288 --72.78873,9.085027 --73.304952,9.152 --73.027604,9.73677 --72.905286,10.450344 --72.614658,10.821975 --72.227575,11.108702 --71.973922,11.608672 + + + + + + + -71.331584,11.776284 +-71.360006,11.539994 +-71.94705,11.423282 +-71.620868,10.96946 +-71.633064,10.446494 +-72.074174,9.865651 +-71.695644,9.072263 +-71.264559,9.137195 +-71.039999,9.859993 +-71.350084,10.211935 +-71.400623,10.968969 +-70.155299,11.375482 +-70.293843,11.846822 +-69.943245,12.162307 +-69.5843,11.459611 +-68.882999,11.443385 +-68.233271,10.885744 +-68.194127,10.554653 +-67.296249,10.545868 +-66.227864,10.648627 +-65.655238,10.200799 +-64.890452,10.077215 +-64.329479,10.389599 +-64.318007,10.641418 +-63.079322,10.701724 +-61.880946,10.715625 +-62.730119,10.420269 +-62.388512,9.948204 +-61.588767,9.873067 +-60.830597,9.38134 +-60.671252,8.580174 +-60.150096,8.602757 +-59.758285,8.367035 +-60.550588,7.779603 +-60.637973,7.415 +-60.295668,7.043911 +-60.543999,6.856584 +-61.159336,6.696077 +-61.139415,6.234297 +-61.410303,5.959068 +-60.733574,5.200277 +-60.601179,4.918098 +-60.966893,4.536468 +-62.08543,4.162124 +-62.804533,4.006965 +-63.093198,3.770571 +-63.888343,4.02053 +-64.628659,4.148481 +-64.816064,4.056445 +-64.368494,3.79721 +-64.408828,3.126786 +-64.269999,2.497006 +-63.422867,2.411068 +-63.368788,2.2009 +-64.083085,1.916369 +-64.199306,1.492855 +-64.611012,1.328731 +-65.354713,1.095282 +-65.548267,0.789254 +-66.325765,0.724452 +-66.876326,1.253361 +-67.181294,2.250638 +-67.447092,2.600281 +-67.809938,2.820655 +-67.303173,3.318454 +-67.337564,3.542342 +-67.621836,3.839482 +-67.823012,4.503937 +-67.744697,5.221129 +-67.521532,5.55687 +-67.34144,6.095468 +-67.695087,6.267318 +-68.265052,6.153268 +-68.985319,6.206805 +-69.38948,6.099861 +-70.093313,6.960376 +-70.674234,7.087785 +-71.960176,6.991615 +-72.198352,7.340431 +-72.444487,7.423785 +-72.479679,7.632506 +-72.360901,8.002638 +-72.439862,8.405275 +-72.660495,8.625288 +-72.78873,9.085027 +-73.304952,9.152 +-73.027604,9.73677 +-72.905286,10.450344 +-72.614658,10.821975 +-72.227575,11.108702 +-71.973922,11.608672 -71.331584,11.776284 - + #defaultStyle United States of America - + - -155.54211,19.08348 --155.68817,18.91619 --155.93665,19.05939 --155.90806,19.33888 --156.07347,19.70294 --156.02368,19.81422 --155.85008,19.97729 --155.91907,20.17395 --155.86108,20.26721 --155.78505,20.2487 --155.40214,20.07975 --155.22452,19.99302 --155.06226,19.8591 --154.80741,19.50871 --154.83147,19.45328 --155.22217,19.23972 + -155.54211,19.08348 +-155.68817,18.91619 +-155.93665,19.05939 +-155.90806,19.33888 +-156.07347,19.70294 +-156.02368,19.81422 +-155.85008,19.97729 +-155.91907,20.17395 +-155.86108,20.26721 +-155.78505,20.2487 +-155.40214,20.07975 +-155.22452,19.99302 +-155.06226,19.8591 +-154.80741,19.50871 +-154.83147,19.45328 +-155.22217,19.23972 -155.54211,19.08348 - + - -156.07926,20.64397 --156.41445,20.57241 --156.58673,20.783 --156.70167,20.8643 --156.71055,20.92676 --156.61258,21.01249 --156.25711,20.91745 --155.99566,20.76404 + -156.07926,20.64397 +-156.41445,20.57241 +-156.58673,20.783 +-156.70167,20.8643 +-156.71055,20.92676 +-156.61258,21.01249 +-156.25711,20.91745 +-155.99566,20.76404 -156.07926,20.64397 - + - -156.75824,21.17684 --156.78933,21.06873 --157.32521,21.09777 --157.25027,21.21958 + -156.75824,21.17684 +-156.78933,21.06873 +-157.32521,21.09777 +-157.25027,21.21958 -156.75824,21.17684 - + - -157.65283,21.32217 --157.70703,21.26442 --157.7786,21.27729 --158.12667,21.31244 --158.2538,21.53919 --158.29265,21.57912 --158.0252,21.71696 --157.94161,21.65272 + -157.65283,21.32217 +-157.70703,21.26442 +-157.7786,21.27729 +-158.12667,21.31244 +-158.2538,21.53919 +-158.29265,21.57912 +-158.0252,21.71696 +-157.94161,21.65272 -157.65283,21.32217 - + - -159.34512,21.982 --159.46372,21.88299 --159.80051,22.06533 --159.74877,22.1382 --159.5962,22.23618 --159.36569,22.21494 + -159.34512,21.982 +-159.46372,21.88299 +-159.80051,22.06533 +-159.74877,22.1382 +-159.5962,22.23618 +-159.36569,22.21494 -159.34512,21.982 - + - -94.81758,49.38905 --94.64,48.84 --94.32914,48.67074 --93.63087,48.60926 --92.61,48.45 --91.64,48.14 --90.83,48.27 --89.6,48.01 --89.272917,48.019808 --88.378114,48.302918 --87.439793,47.94 --86.461991,47.553338 --85.652363,47.220219 --84.87608,46.900083 --84.779238,46.637102 --84.543749,46.538684 --84.6049,46.4396 --84.3367,46.40877 --84.14212,46.512226 --84.091851,46.275419 --83.890765,46.116927 --83.616131,46.116927 --83.469551,45.994686 --83.592851,45.816894 --82.550925,45.347517 --82.337763,44.44 --82.137642,43.571088 --82.43,42.98 --82.9,42.43 --83.12,42.08 --83.142,41.975681 --83.02981,41.832796 --82.690089,41.675105 --82.439278,41.675105 --81.277747,42.209026 --80.247448,42.3662 --78.939362,42.863611 --78.92,42.965 --79.01,43.27 --79.171674,43.466339 --78.72028,43.625089 --77.737885,43.629056 --76.820034,43.628784 --76.5,44.018459 --76.375,44.09631 --75.31821,44.81645 --74.867,45.00048 --73.34783,45.00738 --71.50506,45.0082 --71.405,45.255 --71.08482,45.30524 --70.66,45.46 --70.305,45.915 --69.99997,46.69307 --69.237216,47.447781 --68.905,47.185 --68.23444,47.35486 --67.79046,47.06636 --67.79134,45.70281 --67.13741,45.13753 --66.96466,44.8097 --68.03252,44.3252 --69.06,43.98 --70.11617,43.68405 --70.645476,43.090238 --70.81489,42.8653 --70.825,42.335 --70.495,41.805 --70.08,41.78 --70.185,42.145 --69.88497,41.92283 --69.96503,41.63717 --70.64,41.475 --71.12039,41.49445 --71.86,41.32 --72.295,41.27 --72.87643,41.22065 --73.71,40.931102 --72.24126,41.11948 --71.945,40.93 --73.345,40.63 --73.982,40.628 --73.952325,40.75075 --74.25671,40.47351 --73.96244,40.42763 --74.17838,39.70926 --74.90604,38.93954 --74.98041,39.1964 --75.20002,39.24845 --75.52805,39.4985 --75.32,38.96 --75.071835,38.782032 --75.05673,38.40412 --75.37747,38.01551 --75.94023,37.21689 --76.03127,37.2566 --75.72205,37.93705 --76.23287,38.319215 --76.35,39.15 --76.542725,38.717615 --76.32933,38.08326 --76.989998,38.239992 --76.30162,37.917945 --76.25874,36.9664 --75.9718,36.89726 --75.86804,36.55125 --75.72749,35.55074 --76.36318,34.80854 --77.397635,34.51201 --78.05496,33.92547 --78.55435,33.86133 --79.06067,33.49395 --79.20357,33.15839 --80.301325,32.509355 --80.86498,32.0333 --81.33629,31.44049 --81.49042,30.72999 --81.31371,30.03552 --80.98,29.18 --80.535585,28.47213 --80.53,28.04 --80.056539,26.88 --80.088015,26.205765 --80.13156,25.816775 --80.38103,25.20616 --80.68,25.08 --81.17213,25.20126 --81.33,25.64 --81.71,25.87 --82.24,26.73 --82.70515,27.49504 --82.85526,27.88624 --82.65,28.55 --82.93,29.1 --83.70959,29.93656 --84.1,30.09 --85.10882,29.63615 --85.28784,29.68612 --85.7731,30.15261 --86.4,30.4 --87.53036,30.27433 --88.41782,30.3849 --89.18049,30.31598 --89.593831,30.159994 --89.413735,29.89419 --89.43,29.48864 --89.21767,29.29108 --89.40823,29.15961 --89.77928,29.30714 --90.15463,29.11743 --90.880225,29.148535 --91.626785,29.677 --92.49906,29.5523 --93.22637,29.78375 --93.84842,29.71363 --94.69,29.48 --95.60026,28.73863 --96.59404,28.30748 --97.14,27.83 --97.37,27.38 --97.38,26.69 --97.33,26.21 --97.14,25.87 --97.53,25.84 --98.24,26.06 --99.02,26.37 --99.3,26.84 --99.52,27.54 --100.11,28.11 --100.45584,28.69612 --100.9576,29.38071 --101.6624,29.7793 --102.48,29.76 --103.11,28.97 --103.94,29.27 --104.45697,29.57196 --104.70575,30.12173 --105.03737,30.64402 --105.63159,31.08383 --106.1429,31.39995 --106.50759,31.75452 --108.24,31.754854 --108.24194,31.34222 --109.035,31.34194 --111.02361,31.33472 --113.30498,32.03914 --114.815,32.52528 --114.72139,32.72083 --115.99135,32.61239 --117.12776,32.53534 --117.295938,33.046225 --117.944,33.621236 --118.410602,33.740909 --118.519895,34.027782 --119.081,34.078 --119.438841,34.348477 --120.36778,34.44711 --120.62286,34.60855 --120.74433,35.15686 --121.71457,36.16153 --122.54747,37.55176 --122.51201,37.78339 --122.95319,38.11371 --123.7272,38.95166 --123.86517,39.76699 --124.39807,40.3132 --124.17886,41.14202 --124.2137,41.99964 --124.53284,42.76599 --124.14214,43.70838 --124.020535,44.615895 --123.89893,45.52341 --124.079635,46.86475 --124.39567,47.72017 --124.68721,48.184433 --124.566101,48.379715 --123.12,48.04 --122.58736,47.096 --122.34,47.36 --122.5,48.18 --122.84,49.0 --120.0,49.0 --117.03121,49.0 --116.04818,49.0 --113.0,49.0 --110.05,49.0 --107.05,49.0 --104.04826,48.99986 --100.65,49.0 --97.22872,49.0007 --95.15907,49.0 --95.15609,49.38425 + -94.81758,49.38905 +-94.64,48.84 +-94.32914,48.67074 +-93.63087,48.60926 +-92.61,48.45 +-91.64,48.14 +-90.83,48.27 +-89.6,48.01 +-89.272917,48.019808 +-88.378114,48.302918 +-87.439793,47.94 +-86.461991,47.553338 +-85.652363,47.220219 +-84.87608,46.900083 +-84.779238,46.637102 +-84.543749,46.538684 +-84.6049,46.4396 +-84.3367,46.40877 +-84.14212,46.512226 +-84.091851,46.275419 +-83.890765,46.116927 +-83.616131,46.116927 +-83.469551,45.994686 +-83.592851,45.816894 +-82.550925,45.347517 +-82.337763,44.44 +-82.137642,43.571088 +-82.43,42.98 +-82.9,42.43 +-83.12,42.08 +-83.142,41.975681 +-83.02981,41.832796 +-82.690089,41.675105 +-82.439278,41.675105 +-81.277747,42.209026 +-80.247448,42.3662 +-78.939362,42.863611 +-78.92,42.965 +-79.01,43.27 +-79.171674,43.466339 +-78.72028,43.625089 +-77.737885,43.629056 +-76.820034,43.628784 +-76.5,44.018459 +-76.375,44.09631 +-75.31821,44.81645 +-74.867,45.00048 +-73.34783,45.00738 +-71.50506,45.0082 +-71.405,45.255 +-71.08482,45.30524 +-70.66,45.46 +-70.305,45.915 +-69.99997,46.69307 +-69.237216,47.447781 +-68.905,47.185 +-68.23444,47.35486 +-67.79046,47.06636 +-67.79134,45.70281 +-67.13741,45.13753 +-66.96466,44.8097 +-68.03252,44.3252 +-69.06,43.98 +-70.11617,43.68405 +-70.645476,43.090238 +-70.81489,42.8653 +-70.825,42.335 +-70.495,41.805 +-70.08,41.78 +-70.185,42.145 +-69.88497,41.92283 +-69.96503,41.63717 +-70.64,41.475 +-71.12039,41.49445 +-71.86,41.32 +-72.295,41.27 +-72.87643,41.22065 +-73.71,40.931102 +-72.24126,41.11948 +-71.945,40.93 +-73.345,40.63 +-73.982,40.628 +-73.952325,40.75075 +-74.25671,40.47351 +-73.96244,40.42763 +-74.17838,39.70926 +-74.90604,38.93954 +-74.98041,39.1964 +-75.20002,39.24845 +-75.52805,39.4985 +-75.32,38.96 +-75.071835,38.782032 +-75.05673,38.40412 +-75.37747,38.01551 +-75.94023,37.21689 +-76.03127,37.2566 +-75.72205,37.93705 +-76.23287,38.319215 +-76.35,39.15 +-76.542725,38.717615 +-76.32933,38.08326 +-76.989998,38.239992 +-76.30162,37.917945 +-76.25874,36.9664 +-75.9718,36.89726 +-75.86804,36.55125 +-75.72749,35.55074 +-76.36318,34.80854 +-77.397635,34.51201 +-78.05496,33.92547 +-78.55435,33.86133 +-79.06067,33.49395 +-79.20357,33.15839 +-80.301325,32.509355 +-80.86498,32.0333 +-81.33629,31.44049 +-81.49042,30.72999 +-81.31371,30.03552 +-80.98,29.18 +-80.535585,28.47213 +-80.53,28.04 +-80.056539,26.88 +-80.088015,26.205765 +-80.13156,25.816775 +-80.38103,25.20616 +-80.68,25.08 +-81.17213,25.20126 +-81.33,25.64 +-81.71,25.87 +-82.24,26.73 +-82.70515,27.49504 +-82.85526,27.88624 +-82.65,28.55 +-82.93,29.1 +-83.70959,29.93656 +-84.1,30.09 +-85.10882,29.63615 +-85.28784,29.68612 +-85.7731,30.15261 +-86.4,30.4 +-87.53036,30.27433 +-88.41782,30.3849 +-89.18049,30.31598 +-89.593831,30.159994 +-89.413735,29.89419 +-89.43,29.48864 +-89.21767,29.29108 +-89.40823,29.15961 +-89.77928,29.30714 +-90.15463,29.11743 +-90.880225,29.148535 +-91.626785,29.677 +-92.49906,29.5523 +-93.22637,29.78375 +-93.84842,29.71363 +-94.69,29.48 +-95.60026,28.73863 +-96.59404,28.30748 +-97.14,27.83 +-97.37,27.38 +-97.38,26.69 +-97.33,26.21 +-97.14,25.87 +-97.53,25.84 +-98.24,26.06 +-99.02,26.37 +-99.3,26.84 +-99.52,27.54 +-100.11,28.11 +-100.45584,28.69612 +-100.9576,29.38071 +-101.6624,29.7793 +-102.48,29.76 +-103.11,28.97 +-103.94,29.27 +-104.45697,29.57196 +-104.70575,30.12173 +-105.03737,30.64402 +-105.63159,31.08383 +-106.1429,31.39995 +-106.50759,31.75452 +-108.24,31.754854 +-108.24194,31.34222 +-109.035,31.34194 +-111.02361,31.33472 +-113.30498,32.03914 +-114.815,32.52528 +-114.72139,32.72083 +-115.99135,32.61239 +-117.12776,32.53534 +-117.295938,33.046225 +-117.944,33.621236 +-118.410602,33.740909 +-118.519895,34.027782 +-119.081,34.078 +-119.438841,34.348477 +-120.36778,34.44711 +-120.62286,34.60855 +-120.74433,35.15686 +-121.71457,36.16153 +-122.54747,37.55176 +-122.51201,37.78339 +-122.95319,38.11371 +-123.7272,38.95166 +-123.86517,39.76699 +-124.39807,40.3132 +-124.17886,41.14202 +-124.2137,41.99964 +-124.53284,42.76599 +-124.14214,43.70838 +-124.020535,44.615895 +-123.89893,45.52341 +-124.079635,46.86475 +-124.39567,47.72017 +-124.68721,48.184433 +-124.566101,48.379715 +-123.12,48.04 +-122.58736,47.096 +-122.34,47.36 +-122.5,48.18 +-122.84,49.0 +-120.0,49.0 +-117.03121,49.0 +-116.04818,49.0 +-113.0,49.0 +-110.05,49.0 +-107.05,49.0 +-104.04826,48.99986 +-100.65,49.0 +-97.22872,49.0007 +-95.15907,49.0 +-95.15609,49.38425 -94.81758,49.38905 - + - -153.006314,57.115842 --154.00509,56.734677 --154.516403,56.992749 --154.670993,57.461196 --153.76278,57.816575 --153.228729,57.968968 --152.564791,57.901427 --152.141147,57.591059 + -153.006314,57.115842 +-154.00509,56.734677 +-154.516403,56.992749 +-154.670993,57.461196 +-153.76278,57.816575 +-153.228729,57.968968 +-152.564791,57.901427 +-152.141147,57.591059 -153.006314,57.115842 - + - -165.579164,59.909987 --166.19277,59.754441 --166.848337,59.941406 --167.455277,60.213069 --166.467792,60.38417 --165.67443,60.293607 + -165.579164,59.909987 +-166.19277,59.754441 +-166.848337,59.941406 +-167.455277,60.213069 +-166.467792,60.38417 +-165.67443,60.293607 -165.579164,59.909987 - + - -171.731657,63.782515 --171.114434,63.592191 --170.491112,63.694975 --169.682505,63.431116 --168.689439,63.297506 --168.771941,63.188598 --169.52944,62.976931 --170.290556,63.194438 --170.671386,63.375822 --171.553063,63.317789 --171.791111,63.405846 + -171.731657,63.782515 +-171.114434,63.592191 +-170.491112,63.694975 +-169.682505,63.431116 +-168.689439,63.297506 +-168.771941,63.188598 +-169.52944,62.976931 +-170.290556,63.194438 +-170.671386,63.375822 +-171.553063,63.317789 +-171.791111,63.405846 -171.731657,63.782515 - + - -155.06779,71.147776 --154.344165,70.696409 --153.900006,70.889989 --152.210006,70.829992 --152.270002,70.600006 --150.739992,70.430017 --149.720003,70.53001 --147.613362,70.214035 --145.68999,70.12001 --144.920011,69.989992 --143.589446,70.152514 --142.07251,69.851938 --140.985988,69.711998 --140.992499,66.000029 --140.99777,60.306397 --140.012998,60.276838 --139.039,60.000007 --138.34089,59.56211 --137.4525,58.905 --136.47972,59.46389 --135.47583,59.78778 --134.945,59.27056 --134.27111,58.86111 --133.355549,58.410285 --132.73042,57.69289 --131.70781,56.55212 --130.00778,55.91583 --129.979994,55.284998 --130.53611,54.802753 --131.085818,55.178906 --131.967211,55.497776 --132.250011,56.369996 --133.539181,57.178887 --134.078063,58.123068 --135.038211,58.187715 --136.628062,58.212209 --137.800006,58.499995 --139.867787,59.537762 --140.825274,59.727517 --142.574444,60.084447 --143.958881,59.99918 --145.925557,60.45861 --147.114374,60.884656 --148.224306,60.672989 --148.018066,59.978329 --148.570823,59.914173 --149.727858,59.705658 --150.608243,59.368211 --151.716393,59.155821 --151.859433,59.744984 --151.409719,60.725803 --150.346941,61.033588 --150.621111,61.284425 --151.895839,60.727198 --152.57833,60.061657 --154.019172,59.350279 --153.287511,58.864728 --154.232492,58.146374 --155.307491,57.727795 --156.308335,57.422774 --156.556097,56.979985 --158.117217,56.463608 --158.433321,55.994154 --159.603327,55.566686 --160.28972,55.643581 --161.223048,55.364735 --162.237766,55.024187 --163.069447,54.689737 --164.785569,54.404173 --164.942226,54.572225 --163.84834,55.039431 --162.870001,55.348043 --161.804175,55.894986 --160.563605,56.008055 --160.07056,56.418055 --158.684443,57.016675 --158.461097,57.216921 --157.72277,57.570001 --157.550274,58.328326 --157.041675,58.918885 --158.194731,58.615802 --158.517218,58.787781 --159.058606,58.424186 --159.711667,58.93139 --159.981289,58.572549 --160.355271,59.071123 --161.355003,58.670838 --161.968894,58.671665 --162.054987,59.266925 --161.874171,59.633621 --162.518059,59.989724 --163.818341,59.798056 --164.662218,60.267484 --165.346388,60.507496 --165.350832,61.073895 --166.121379,61.500019 --165.734452,62.074997 --164.919179,62.633076 --164.562508,63.146378 --163.753332,63.219449 --163.067224,63.059459 --162.260555,63.541936 --161.53445,63.455817 --160.772507,63.766108 --160.958335,64.222799 --161.518068,64.402788 --160.777778,64.788604 --161.391926,64.777235 --162.45305,64.559445 --162.757786,64.338605 --163.546394,64.55916 --164.96083,64.446945 --166.425288,64.686672 --166.845004,65.088896 --168.11056,65.669997 --166.705271,66.088318 --164.47471,66.57666 --163.652512,66.57666 --163.788602,66.077207 --161.677774,66.11612 --162.489715,66.735565 --163.719717,67.116395 --164.430991,67.616338 --165.390287,68.042772 --166.764441,68.358877 --166.204707,68.883031 --164.430811,68.915535 --163.168614,69.371115 --162.930566,69.858062 --161.908897,70.33333 --160.934797,70.44769 --159.039176,70.891642 --158.119723,70.824721 --156.580825,71.357764 + -155.06779,71.147776 +-154.344165,70.696409 +-153.900006,70.889989 +-152.210006,70.829992 +-152.270002,70.600006 +-150.739992,70.430017 +-149.720003,70.53001 +-147.613362,70.214035 +-145.68999,70.12001 +-144.920011,69.989992 +-143.589446,70.152514 +-142.07251,69.851938 +-140.985988,69.711998 +-140.992499,66.000029 +-140.99777,60.306397 +-140.012998,60.276838 +-139.039,60.000007 +-138.34089,59.56211 +-137.4525,58.905 +-136.47972,59.46389 +-135.47583,59.78778 +-134.945,59.27056 +-134.27111,58.86111 +-133.355549,58.410285 +-132.73042,57.69289 +-131.70781,56.55212 +-130.00778,55.91583 +-129.979994,55.284998 +-130.53611,54.802753 +-131.085818,55.178906 +-131.967211,55.497776 +-132.250011,56.369996 +-133.539181,57.178887 +-134.078063,58.123068 +-135.038211,58.187715 +-136.628062,58.212209 +-137.800006,58.499995 +-139.867787,59.537762 +-140.825274,59.727517 +-142.574444,60.084447 +-143.958881,59.99918 +-145.925557,60.45861 +-147.114374,60.884656 +-148.224306,60.672989 +-148.018066,59.978329 +-148.570823,59.914173 +-149.727858,59.705658 +-150.608243,59.368211 +-151.716393,59.155821 +-151.859433,59.744984 +-151.409719,60.725803 +-150.346941,61.033588 +-150.621111,61.284425 +-151.895839,60.727198 +-152.57833,60.061657 +-154.019172,59.350279 +-153.287511,58.864728 +-154.232492,58.146374 +-155.307491,57.727795 +-156.308335,57.422774 +-156.556097,56.979985 +-158.117217,56.463608 +-158.433321,55.994154 +-159.603327,55.566686 +-160.28972,55.643581 +-161.223048,55.364735 +-162.237766,55.024187 +-163.069447,54.689737 +-164.785569,54.404173 +-164.942226,54.572225 +-163.84834,55.039431 +-162.870001,55.348043 +-161.804175,55.894986 +-160.563605,56.008055 +-160.07056,56.418055 +-158.684443,57.016675 +-158.461097,57.216921 +-157.72277,57.570001 +-157.550274,58.328326 +-157.041675,58.918885 +-158.194731,58.615802 +-158.517218,58.787781 +-159.058606,58.424186 +-159.711667,58.93139 +-159.981289,58.572549 +-160.355271,59.071123 +-161.355003,58.670838 +-161.968894,58.671665 +-162.054987,59.266925 +-161.874171,59.633621 +-162.518059,59.989724 +-163.818341,59.798056 +-164.662218,60.267484 +-165.346388,60.507496 +-165.350832,61.073895 +-166.121379,61.500019 +-165.734452,62.074997 +-164.919179,62.633076 +-164.562508,63.146378 +-163.753332,63.219449 +-163.067224,63.059459 +-162.260555,63.541936 +-161.53445,63.455817 +-160.772507,63.766108 +-160.958335,64.222799 +-161.518068,64.402788 +-160.777778,64.788604 +-161.391926,64.777235 +-162.45305,64.559445 +-162.757786,64.338605 +-163.546394,64.55916 +-164.96083,64.446945 +-166.425288,64.686672 +-166.845004,65.088896 +-168.11056,65.669997 +-166.705271,66.088318 +-164.47471,66.57666 +-163.652512,66.57666 +-163.788602,66.077207 +-161.677774,66.11612 +-162.489715,66.735565 +-163.719717,67.116395 +-164.430991,67.616338 +-165.390287,68.042772 +-166.764441,68.358877 +-166.204707,68.883031 +-164.430811,68.915535 +-163.168614,69.371115 +-162.930566,69.858062 +-161.908897,70.33333 +-160.934797,70.44769 +-159.039176,70.891642 +-158.119723,70.824721 +-156.580825,71.357764 -155.06779,71.147776 - + #defaultStyle Vietnam - - - - - - - 108.05018,21.55238 -106.715068,20.696851 -105.881682,19.75205 -105.662006,19.058165 -106.426817,18.004121 -107.361954,16.697457 -108.269495,16.079742 -108.877107,15.276691 -109.33527,13.426028 -109.200136,11.666859 -108.36613,11.008321 -107.220929,10.364484 -106.405113,9.53084 -105.158264,8.59976 -104.795185,9.241038 -105.076202,9.918491 -104.334335,10.486544 -105.199915,10.88931 -106.24967,10.961812 -105.810524,11.567615 -107.491403,12.337206 -107.614548,13.535531 -107.382727,14.202441 -107.564525,15.202173 -107.312706,15.908538 -106.556008,16.604284 -105.925762,17.485315 -105.094598,18.666975 -103.896532,19.265181 -104.183388,19.624668 -104.822574,19.886642 -104.435,20.758733 -103.203861,20.766562 -102.754896,21.675137 -102.170436,22.464753 -102.706992,22.708795 -103.504515,22.703757 -104.476858,22.81915 -105.329209,23.352063 -105.811247,22.976892 -106.725403,22.794268 -106.567273,22.218205 -107.04342,21.811899 + + + + + + + 108.05018,21.55238 +106.715068,20.696851 +105.881682,19.75205 +105.662006,19.058165 +106.426817,18.004121 +107.361954,16.697457 +108.269495,16.079742 +108.877107,15.276691 +109.33527,13.426028 +109.200136,11.666859 +108.36613,11.008321 +107.220929,10.364484 +106.405113,9.53084 +105.158264,8.59976 +104.795185,9.241038 +105.076202,9.918491 +104.334335,10.486544 +105.199915,10.88931 +106.24967,10.961812 +105.810524,11.567615 +107.491403,12.337206 +107.614548,13.535531 +107.382727,14.202441 +107.564525,15.202173 +107.312706,15.908538 +106.556008,16.604284 +105.925762,17.485315 +105.094598,18.666975 +103.896532,19.265181 +104.183388,19.624668 +104.822574,19.886642 +104.435,20.758733 +103.203861,20.766562 +102.754896,21.675137 +102.170436,22.464753 +102.706992,22.708795 +103.504515,22.703757 +104.476858,22.81915 +105.329209,23.352063 +105.811247,22.976892 +106.725403,22.794268 +106.567273,22.218205 +107.04342,21.811899 108.05018,21.55238 - + #defaultStyle Vanuatu - + - 167.844877,-16.466333 -167.515181,-16.59785 -167.180008,-16.159995 -167.216801,-15.891846 + 167.844877,-16.466333 +167.515181,-16.59785 +167.180008,-16.159995 +167.216801,-15.891846 167.844877,-16.466333 - + - 167.107712,-14.93392 -167.270028,-15.740021 -167.001207,-15.614602 -166.793158,-15.668811 -166.649859,-15.392704 -166.629137,-14.626497 + 167.107712,-14.93392 +167.270028,-15.740021 +167.001207,-15.614602 +166.793158,-15.668811 +166.649859,-15.392704 +166.629137,-14.626497 167.107712,-14.93392 - + #defaultStyle West Bank - + - 35.545665,32.393992 -35.545252,31.782505 -35.397561,31.489086 -34.927408,31.353435 -34.970507,31.616778 -35.225892,31.754341 -34.974641,31.866582 -35.18393,32.532511 + 35.545665,32.393992 +35.545252,31.782505 +35.397561,31.489086 +34.927408,31.353435 +34.970507,31.616778 +35.225892,31.754341 +34.974641,31.866582 +35.18393,32.532511 35.545665,32.393992 - + #defaultStyle Yemen - - - - - - - 53.108573,16.651051 -52.385206,16.382411 -52.191729,15.938433 -52.168165,15.59742 -51.172515,15.17525 -49.574576,14.708767 -48.679231,14.003202 -48.238947,13.94809 -47.938914,14.007233 -47.354454,13.59222 -46.717076,13.399699 -45.877593,13.347764 -45.62505,13.290946 -45.406459,13.026905 -45.144356,12.953938 -44.989533,12.699587 -44.494576,12.721653 -44.175113,12.58595 -43.482959,12.6368 -43.222871,13.22095 -43.251448,13.767584 -43.087944,14.06263 -42.892245,14.802249 -42.604873,15.213335 -42.805015,15.261963 -42.702438,15.718886 -42.823671,15.911742 -42.779332,16.347891 -43.218375,16.66689 -43.115798,17.08844 -43.380794,17.579987 -43.791519,17.319977 -44.062613,17.410359 -45.216651,17.433329 -45.399999,17.333335 -46.366659,17.233315 -46.749994,17.283338 -47.000005,16.949999 -47.466695,17.116682 -48.183344,18.166669 -49.116672,18.616668 -52.00001,19.000003 -52.782184,17.349742 + + + + + + + 53.108573,16.651051 +52.385206,16.382411 +52.191729,15.938433 +52.168165,15.59742 +51.172515,15.17525 +49.574576,14.708767 +48.679231,14.003202 +48.238947,13.94809 +47.938914,14.007233 +47.354454,13.59222 +46.717076,13.399699 +45.877593,13.347764 +45.62505,13.290946 +45.406459,13.026905 +45.144356,12.953938 +44.989533,12.699587 +44.494576,12.721653 +44.175113,12.58595 +43.482959,12.6368 +43.222871,13.22095 +43.251448,13.767584 +43.087944,14.06263 +42.892245,14.802249 +42.604873,15.213335 +42.805015,15.261963 +42.702438,15.718886 +42.823671,15.911742 +42.779332,16.347891 +43.218375,16.66689 +43.115798,17.08844 +43.380794,17.579987 +43.791519,17.319977 +44.062613,17.410359 +45.216651,17.433329 +45.399999,17.333335 +46.366659,17.233315 +46.749994,17.283338 +47.000005,16.949999 +47.466695,17.116682 +48.183344,18.166669 +49.116672,18.616668 +52.00001,19.000003 +52.782184,17.349742 53.108573,16.651051 - + #defaultStyle South Africa - - - - - - - 31.521001,-29.257387 -31.325561,-29.401978 -30.901763,-29.909957 -30.622813,-30.423776 -30.055716,-31.140269 -28.925553,-32.172041 -28.219756,-32.771953 -27.464608,-33.226964 -26.419452,-33.61495 -25.909664,-33.66704 -25.780628,-33.944646 -25.172862,-33.796851 -24.677853,-33.987176 -23.594043,-33.794474 -22.988189,-33.916431 -22.574157,-33.864083 -21.542799,-34.258839 -20.689053,-34.417175 -20.071261,-34.795137 -19.616405,-34.819166 -19.193278,-34.462599 -18.855315,-34.444306 -18.424643,-33.997873 -18.377411,-34.136521 -18.244499,-33.867752 -18.25008,-33.281431 -17.92519,-32.611291 -18.24791,-32.429131 -18.221762,-31.661633 -17.566918,-30.725721 -17.064416,-29.878641 -17.062918,-29.875954 -16.344977,-28.576705 -16.824017,-28.082162 -17.218929,-28.355943 -17.387497,-28.783514 -17.836152,-28.856378 -18.464899,-29.045462 -19.002127,-28.972443 -19.894734,-28.461105 -19.895768,-24.76779 -20.165726,-24.917962 -20.758609,-25.868136 -20.66647,-26.477453 -20.889609,-26.828543 -21.605896,-26.726534 -22.105969,-26.280256 -22.579532,-25.979448 -22.824271,-25.500459 -23.312097,-25.26869 -23.73357,-25.390129 -24.211267,-25.670216 -25.025171,-25.71967 -25.664666,-25.486816 -25.765849,-25.174845 -25.941652,-24.696373 -26.485753,-24.616327 -26.786407,-24.240691 -27.11941,-23.574323 -28.017236,-22.827754 -29.432188,-22.091313 -29.839037,-22.102216 -30.322883,-22.271612 -30.659865,-22.151567 -31.191409,-22.25151 -31.670398,-23.658969 -31.930589,-24.369417 -31.752408,-25.484284 -31.837778,-25.843332 -31.333158,-25.660191 -31.04408,-25.731452 -30.949667,-26.022649 -30.676609,-26.398078 -30.685962,-26.743845 -31.282773,-27.285879 -31.86806,-27.177927 -32.071665,-26.73382 -32.83012,-26.742192 -32.580265,-27.470158 -32.462133,-28.301011 -32.203389,-28.752405 + + + + + + + 31.521001,-29.257387 +31.325561,-29.401978 +30.901763,-29.909957 +30.622813,-30.423776 +30.055716,-31.140269 +28.925553,-32.172041 +28.219756,-32.771953 +27.464608,-33.226964 +26.419452,-33.61495 +25.909664,-33.66704 +25.780628,-33.944646 +25.172862,-33.796851 +24.677853,-33.987176 +23.594043,-33.794474 +22.988189,-33.916431 +22.574157,-33.864083 +21.542799,-34.258839 +20.689053,-34.417175 +20.071261,-34.795137 +19.616405,-34.819166 +19.193278,-34.462599 +18.855315,-34.444306 +18.424643,-33.997873 +18.377411,-34.136521 +18.244499,-33.867752 +18.25008,-33.281431 +17.92519,-32.611291 +18.24791,-32.429131 +18.221762,-31.661633 +17.566918,-30.725721 +17.064416,-29.878641 +17.062918,-29.875954 +16.344977,-28.576705 +16.824017,-28.082162 +17.218929,-28.355943 +17.387497,-28.783514 +17.836152,-28.856378 +18.464899,-29.045462 +19.002127,-28.972443 +19.894734,-28.461105 +19.895768,-24.76779 +20.165726,-24.917962 +20.758609,-25.868136 +20.66647,-26.477453 +20.889609,-26.828543 +21.605896,-26.726534 +22.105969,-26.280256 +22.579532,-25.979448 +22.824271,-25.500459 +23.312097,-25.26869 +23.73357,-25.390129 +24.211267,-25.670216 +25.025171,-25.71967 +25.664666,-25.486816 +25.765849,-25.174845 +25.941652,-24.696373 +26.485753,-24.616327 +26.786407,-24.240691 +27.11941,-23.574323 +28.017236,-22.827754 +29.432188,-22.091313 +29.839037,-22.102216 +30.322883,-22.271612 +30.659865,-22.151567 +31.191409,-22.25151 +31.670398,-23.658969 +31.930589,-24.369417 +31.752408,-25.484284 +31.837778,-25.843332 +31.333158,-25.660191 +31.04408,-25.731452 +30.949667,-26.022649 +30.676609,-26.398078 +30.685962,-26.743845 +31.282773,-27.285879 +31.86806,-27.177927 +32.071665,-26.73382 +32.83012,-26.742192 +32.580265,-27.470158 +32.462133,-28.301011 +32.203389,-28.752405 31.521001,-29.257387 - 28.978263,-28.955597 -28.5417,-28.647502 -28.074338,-28.851469 -27.532511,-29.242711 -26.999262,-29.875954 -27.749397,-30.645106 -28.107205,-30.545732 -28.291069,-30.226217 -28.8484,-30.070051 -29.018415,-29.743766 -29.325166,-29.257387 + 28.978263,-28.955597 +28.5417,-28.647502 +28.074338,-28.851469 +27.532511,-29.242711 +26.999262,-29.875954 +27.749397,-30.645106 +28.107205,-30.545732 +28.291069,-30.226217 +28.8484,-30.070051 +29.018415,-29.743766 +29.325166,-29.257387 28.978263,-28.955597 @@ -13974,128 +13974,128 @@ #defaultStyle Zambia - - - - - - - 32.759375,-9.230599 -33.231388,-9.676722 -33.485688,-10.525559 -33.31531,-10.79655 -33.114289,-11.607198 -33.306422,-12.435778 -32.991764,-12.783871 -32.688165,-13.712858 -33.214025,-13.97186 -30.179481,-14.796099 -30.274256,-15.507787 -29.516834,-15.644678 -28.947463,-16.043051 -28.825869,-16.389749 -28.467906,-16.4684 -27.598243,-17.290831 -27.044427,-17.938026 -26.706773,-17.961229 -26.381935,-17.846042 -25.264226,-17.73654 -25.084443,-17.661816 -25.07695,-17.578823 -24.682349,-17.353411 -24.033862,-17.295843 -23.215048,-17.523116 -22.562478,-16.898451 -21.887843,-16.08031 -21.933886,-12.898437 -24.016137,-12.911046 -23.930922,-12.565848 -24.079905,-12.191297 -23.904154,-11.722282 -24.017894,-11.237298 -23.912215,-10.926826 -24.257155,-10.951993 -24.314516,-11.262826 -24.78317,-11.238694 -25.418118,-11.330936 -25.75231,-11.784965 -26.553088,-11.92444 -27.16442,-11.608748 -27.388799,-12.132747 -28.155109,-12.272481 -28.523562,-12.698604 -28.934286,-13.248958 -29.699614,-13.257227 -29.616001,-12.178895 -29.341548,-12.360744 -28.642417,-11.971569 -28.372253,-11.793647 -28.49607,-10.789884 -28.673682,-9.605925 -28.449871,-9.164918 -28.734867,-8.526559 -29.002912,-8.407032 -30.346086,-8.238257 -30.740015,-8.340007 -31.157751,-8.594579 -31.556348,-8.762049 -32.191865,-8.930359 + + + + + + + 32.759375,-9.230599 +33.231388,-9.676722 +33.485688,-10.525559 +33.31531,-10.79655 +33.114289,-11.607198 +33.306422,-12.435778 +32.991764,-12.783871 +32.688165,-13.712858 +33.214025,-13.97186 +30.179481,-14.796099 +30.274256,-15.507787 +29.516834,-15.644678 +28.947463,-16.043051 +28.825869,-16.389749 +28.467906,-16.4684 +27.598243,-17.290831 +27.044427,-17.938026 +26.706773,-17.961229 +26.381935,-17.846042 +25.264226,-17.73654 +25.084443,-17.661816 +25.07695,-17.578823 +24.682349,-17.353411 +24.033862,-17.295843 +23.215048,-17.523116 +22.562478,-16.898451 +21.887843,-16.08031 +21.933886,-12.898437 +24.016137,-12.911046 +23.930922,-12.565848 +24.079905,-12.191297 +23.904154,-11.722282 +24.017894,-11.237298 +23.912215,-10.926826 +24.257155,-10.951993 +24.314516,-11.262826 +24.78317,-11.238694 +25.418118,-11.330936 +25.75231,-11.784965 +26.553088,-11.92444 +27.16442,-11.608748 +27.388799,-12.132747 +28.155109,-12.272481 +28.523562,-12.698604 +28.934286,-13.248958 +29.699614,-13.257227 +29.616001,-12.178895 +29.341548,-12.360744 +28.642417,-11.971569 +28.372253,-11.793647 +28.49607,-10.789884 +28.673682,-9.605925 +28.449871,-9.164918 +28.734867,-8.526559 +29.002912,-8.407032 +30.346086,-8.238257 +30.740015,-8.340007 +31.157751,-8.594579 +31.556348,-8.762049 +32.191865,-8.930359 32.759375,-9.230599 - + #defaultStyle Zimbabwe - - - - - - - 31.191409,-22.25151 -30.659865,-22.151567 -30.322883,-22.271612 -29.839037,-22.102216 -29.432188,-22.091313 -28.794656,-21.639454 -28.02137,-21.485975 -27.727228,-20.851802 -27.724747,-20.499059 -27.296505,-20.39152 -26.164791,-19.293086 -25.850391,-18.714413 -25.649163,-18.536026 -25.264226,-17.73654 -26.381935,-17.846042 -26.706773,-17.961229 -27.044427,-17.938026 -27.598243,-17.290831 -28.467906,-16.4684 -28.825869,-16.389749 -28.947463,-16.043051 -29.516834,-15.644678 -30.274256,-15.507787 -30.338955,-15.880839 -31.173064,-15.860944 -31.636498,-16.07199 -31.852041,-16.319417 -32.328239,-16.392074 -32.847639,-16.713398 -32.849861,-17.979057 -32.654886,-18.67209 -32.611994,-19.419383 -32.772708,-19.715592 -32.659743,-20.30429 -32.508693,-20.395292 -32.244988,-21.116489 + + + + + + + 31.191409,-22.25151 +30.659865,-22.151567 +30.322883,-22.271612 +29.839037,-22.102216 +29.432188,-22.091313 +28.794656,-21.639454 +28.02137,-21.485975 +27.727228,-20.851802 +27.724747,-20.499059 +27.296505,-20.39152 +26.164791,-19.293086 +25.850391,-18.714413 +25.649163,-18.536026 +25.264226,-17.73654 +26.381935,-17.846042 +26.706773,-17.961229 +27.044427,-17.938026 +27.598243,-17.290831 +28.467906,-16.4684 +28.825869,-16.389749 +28.947463,-16.043051 +29.516834,-15.644678 +30.274256,-15.507787 +30.338955,-15.880839 +31.173064,-15.860944 +31.636498,-16.07199 +31.852041,-16.319417 +32.328239,-16.392074 +32.847639,-16.713398 +32.849861,-17.979057 +32.654886,-18.67209 +32.611994,-19.419383 +32.772708,-19.715592 +32.659743,-20.30429 +32.508693,-20.395292 +32.244988,-21.116489 31.191409,-22.25151 - + diff --git a/docs/samples/kml/features.kml b/docs/samples/kml/features.kml index c9b1e7c85..ce28571ea 100644 --- a/docs/samples/kml/features.kml +++ b/docs/samples/kml/features.kml @@ -284,7 +284,7 @@ GPS Height: 3869. m
    Air Temperature: -2. ° C
    Pressure: 634. hPa
    Rel.Hum.: 59. %
    Wind Speed: 9. m/s
    Wind Direction: 292. deg
    Time: 09/09/2019 07:07:29 UTC
    -

    ]]> +

    ]]> absolute @@ -323,7 +323,7 @@ GPS Height: 3869. m
    Air Temperature: -2. ° C
    Pressure: 634. hPa
    R GPS Height: 9490. m
    Air Temperature: -39. ° C
    Pressure: 297. hPa
    Rel.Hum.: 79. %
    Wind Speed: 48. m/s
    Wind Direction: 336. deg
    Time: 09/09/2019 07:17:29 UTC
    -

    ]]> +

    ]]> absolute @@ -422,7 +422,7 @@ GPS Height: 9490. m
    Air Temperature: -39. ° C
    Pressure: 297. hPa
    GPS Height: 12514. m
    Air Temperature: -64. ° C
    Pressure: 185. hPa
    Rel.Hum.: 59. %
    Wind Speed: 79. m/s
    Wind Direction: 336. deg
    Time: 09/09/2019 07:27:29 UTC
    -

    ]]> +

    ]]> absolute diff --git a/docs/samples/kml/geometry_collection.kml b/docs/samples/kml/geometry_collection.kml index e9569a8ae..8fc56b206 100644 --- a/docs/samples/kml/geometry_collection.kml +++ b/docs/samples/kml/geometry_collection.kml @@ -1,6 +1,6 @@ diff --git a/docs/samples/kml/line.kml b/docs/samples/kml/line.kml index be8c0fca6..6e5530fd4 100644 --- a/docs/samples/kml/line.kml +++ b/docs/samples/kml/line.kml @@ -1,10 +1,10 @@ - + - + - 15.2,40.4,0. + 15.2,40.4,0. 15.4,45.6,0. 30.2,55.6,0. diff --git a/docs/samples/kml/style.kml b/docs/samples/kml/style.kml index fb760f9c7..6f5c30d9c 100644 --- a/docs/samples/kml/style.kml +++ b/docs/samples/kml/style.kml @@ -124,4 +124,3 @@ - diff --git a/docs/samples/plugins/navaid.rst b/docs/samples/plugins/navaid.rst index d439b6645..81e8d0406 100644 --- a/docs/samples/plugins/navaid.rst +++ b/docs/samples/plugins/navaid.rst @@ -22,7 +22,7 @@ table containing a column of the so-determined waypoint names. For locations ouside the given set of NAVAID points , e.g. over the oceans, the naming convention switches to coordinate based name like xxyyNwwwzzW for latitude xx°yy'N and longitude www°zz'W. This is done if the closest NAVAID -waypoint is more than 500 nm. +waypoint is more than 500 nm. Installation ~~~~~~~~~~~~ @@ -35,4 +35,3 @@ Installation 1. Add additional modules to your mssenv by :: (mssenv): mamba install geomag geopy geographiclib - diff --git a/docs/samples/sites-available/mss.yourserver.de.conf b/docs/samples/sites-available/mss.yourserver.de.conf index 62f3d0311..30ffb10a0 100644 --- a/docs/samples/sites-available/mss.yourserver.de.conf +++ b/docs/samples/sites-available/mss.yourserver.de.conf @@ -32,9 +32,9 @@ # Please see the documentation here : http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives WSGIProcessGroup MSS - # Python Simplified GIL State API ( ISSUE ) + # Python Simplified GIL State API ( ISSUE ) # http://code.google.com/p/modwsgi/wiki/ApplicationIssues - WSGIApplicationGroup %{GLOBAL} + WSGIApplicationGroup %{GLOBAL} DocumentRoot /home/mss/htdocs @@ -49,4 +49,3 @@ ServerSignature On - diff --git a/docs/samples/sites-available/mss_proxy.conf b/docs/samples/sites-available/mss_proxy.conf index 0f4bd707f..05a563b42 100644 --- a/docs/samples/sites-available/mss_proxy.conf +++ b/docs/samples/sites-available/mss_proxy.conf @@ -13,4 +13,3 @@ ProxyPreserveHost On ProxyPass /demo http://127.0.0.1/demo - diff --git a/docs/samples/sites-available/proxy_demo.yourserver.de.conf b/docs/samples/sites-available/proxy_demo.yourserver.de.conf index d82e7961e..3d2b24a3b 100644 --- a/docs/samples/sites-available/proxy_demo.yourserver.de.conf +++ b/docs/samples/sites-available/proxy_demo.yourserver.de.conf @@ -8,7 +8,7 @@ AuthType Basic AuthName "mss" AuthDigestDomain / - AuthUserFile /home/mss/DEMO/config/apache_users + AuthUserFile /home/mss/DEMO/config/apache_users Require valid-user @@ -28,16 +28,16 @@ # WSGI Options # home: Initial working directory of the script, make sure you change it. # python-path : Directories to search for Modules, make sure you change it as well. - # Please see the documentation here : http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives + # Please see the documentation here : http://code.google.com/p/modwsgi/wiki/ConfigurationDirectives WSGIScriptAlias /demo /home/mss/DEMO/wsgi/wms.wsgi WSGIDaemonProcess MSSDEMO python-home="/home/mss/miniconda3/envs/demo" python-path="/home/mss/miniconda3/envs/demo/lib/python3.7/site-packages/" home="/home/mss/DEMO/config" user=mss group=mss processes=2 threads=1 deadlock-timeout=25 display-name=MSSDEMO WSGIProcessGroup MSSDEMO - # Python Simplified GIL State API ( ISSUE ) + # Python Simplified GIL State API ( ISSUE ) # http://code.google.com/p/modwsgi/wiki/ApplicationIssues - WSGIApplicationGroup %{GLOBAL} + WSGIApplicationGroup %{GLOBAL} # don't loose time with IP address lookups HostnameLookups Off @@ -49,4 +49,3 @@ ServerSignature On - diff --git a/docs/samples/xml_templates/get_capabilities.pt b/docs/samples/xml_templates/get_capabilities.pt index af18c6c9e..54b503cad 100644 --- a/docs/samples/xml_templates/get_capabilities.pt +++ b/docs/samples/xml_templates/get_capabilities.pt @@ -91,4 +91,3 @@ - diff --git a/docs/sso_via_saml_mscolab.rst b/docs/sso_via_saml_mscolab.rst index 4290b172c..95ba29fc3 100644 --- a/docs/sso_via_saml_mscolab.rst +++ b/docs/sso_via_saml_mscolab.rst @@ -4,31 +4,31 @@ SSO via SAML Integration Guide for MSColab Server In this documentation, you will go through the following topics. 1. Introduction - + 2. Configuring an existing IdP - + * Private key and certificate - + * Configuring MSColab settings - + * MSColab configurations * Establish pysaml2, Saml2Client for the MSColab server - + * Configuration `mss_saml2_backend.yaml` file - + * Access SAML2Client metadata of MSColab - + * Guide to IDP Configuration 3. Configuration example through Keycloak 13.0.1 - + * Setting Up Keycloak - + * Installation and run Keycloak * Setup Keycloak IdP - + * Configure MSColab server - + * Configuration in MSColab settings for Keycloak * Configuration `mss_saml2_backend.yaml` file @@ -53,11 +53,11 @@ Furthermore, you will need to configure saml2 setup in your `setup_saml2_backend .. note:: When you want to set a parameter or change a default add it to that file, - + eg:- $ more mscolab_settings.py - + USE_SAML2 = True Also, you should be careful to return the attributes `username` and `email` address accordingly from the IdP along with the SAML response. @@ -78,7 +78,7 @@ MSColab configurations This section provides a guide for implementing MSColab with a single IdP. You can make the necessary changes in your `mscolab_settings.py` or `conf.py` file and your `setup_saml2_backend.py`. -.. note:: +.. note:: Sensible defaults of MSColab are opinionated. All these are defined in conf.py and those which you want to change you can add to a mscolab_settings.py in your search path. Before running the MSColab server, ensure `USE_SAML` is set to `True` in your `mscolab_settings.py`. @@ -86,7 +86,7 @@ Before running the MSColab server, ensure `USE_SAML` is set to `True` in your `m .. code:: text # enable login by identity provider - USE_SAML2 = True + USE_SAML2 = True To enabling login via the Identity Provider; need to implement `mss_saml2_backend.yaml` with paths for .crt and .key files, configure mscolab_settings.py, and configure `setup_saml2_backend.py` @@ -111,7 +111,7 @@ In this implementation, as we are enabling only one IdP, there is no need to con Please refer to the sample template `setup_saml2_backend.py.sample` located in the `docs/samples/config/mscolab` directory. Idp_identity_name refers to the specific name used to identify the particular Identity Provider within the MSColab server. This name should be used in the `mss_saml2_backend.yaml` file when configuring your IdP, as well as in the MSColab server configurations. It's important to note that this name is not visible to end users - + Remember to use underscore for the blanks in your `idp_identity_name`. Idp_name refers to the name of the Identity Provider that will be displayed in the MSColab server web interface for end users to select when configuring SSO. @@ -128,10 +128,10 @@ You should do implementation by your `setup_saml2_backend.py` file. # if multiple 3rd party exists, development should need to implement accordingly below """ - if 'idp_2'== configured_idp['idp_identity_name']: - # rest of code - # set CRTs and metadata paths for the idp_2 - # configuration idp_2 Saml2Client + if 'idp_2'== configured_idp['idp_identity_name']: + # rest of code + # set CRTs and metadata paths for the idp_2 + # configuration idp_2 Saml2Client """ After completing these steps, you can proceed to configure the `mss_saml2_backend.yaml` file. @@ -152,11 +152,11 @@ Please refer the yaml file template (`mss_saml2_backend.yaml.samlple`) in the di key_file: mslib/mscolab/app/key_sp.key cert_file: mslib/mscolab/app/crt_sp.crt organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} - + contact_person: - - {contact_type: technical, email_address: technical@example.com, given_name: Technical} - - {contact_type: support, email_address: support@example.com, given_name: Support} - + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + metadata: local: [mslib/mscolab/app/idp.xml] entityid: http://localhost:5000/proxy_saml2_backend.xml @@ -200,12 +200,12 @@ Please refer the yaml file template (`mss_saml2_backend.yaml.samlple`) in the di name_id_format_allow_create: true .. note:: - Make sure to update - entityid : 'idp_identity_name' + Make sure to update + entityid : 'idp_identity_name' Assertion_consumer_service : with the urls of assertion consumer services functionalities URL that going to implement next step, may be better to explain here Key_file : if need can be update through the server - Cert_file : if need can be update through the server + Cert_file : if need can be update through the server Metadata.local : if need can be update through the server @@ -263,7 +263,7 @@ Via Docker (requires Docker installed) .. note:: You can define KEYCLOAK_USER and KEYCLOAK_PASSWORD as you wish. Recommends using tools like pwgen to generate strong and random passwords. - + * Open your terminal and run .. code:: text @@ -289,7 +289,7 @@ Access Keycloak Login as an admin You can go to the admin console and login as an admin by providing the above provided credentials. - + .. image:: images/sso_via_saml_conf/ss_admin_login.png :width: 400 @@ -312,7 +312,7 @@ Create a client specifically for SAML .. image:: images/sso_via_saml_conf/ss_left_nav_client.png :width: 200 - + In the client section you can see `create` button in the top right corner. Create a new client by clicking `create` button in the top right corner. @@ -323,7 +323,7 @@ Create a client specifically for SAML .. note:: When creating client ID, it should be same as the issuer ID of the MSColab server. In here, the MSColab server used different issuer IDs for the particular idp_iedentity_name, and issued it by url bellow - + http://127.0.0.1:8083/metadata/idp_identityname/ @@ -335,28 +335,28 @@ Create a client specifically for SAML Eg:- http://127.0.0.1:8083/* - + http://localhost:8083/* - + Generate keys and certificates To generate keys and certificates first navigate into saml keys tab and click `Generate new keys` button. .. image:: images/sso_via_saml_conf/ss_gen_keys_crts.png :width: 800 - + You can copy generated keys and certificates by clicking top of the key and certificate. After clicked you should need to create .crt and .key file accordingly. .. note:: In here when you creating .key and .crt make sure to begin creating file structure accordingly. - Eg:- + Eg:- .key file ----BEGIN RSA PRIVATE KEY----- Key key key key key key key - + -----END RSA PRIVATE KEY----- | @@ -394,7 +394,7 @@ Create a client specifically for SAML eg:- clients>yourcreatedCliet>Mappers>Add Builtin Protocol Mapper enable email - + First navigate into client section through left navigation. .. image:: images/sso_via_saml_conf/ss_left_nav_client.png @@ -415,7 +415,7 @@ Create a client specifically for SAML .. image:: images/sso_via_saml_conf/ss_enable_mappers.png :width: 800 - Then you can see Added mappers in your interface + Then you can see Added mappers in your interface .. image:: images/sso_via_saml_conf/ss_view_mappers.png :width: 800 @@ -548,7 +548,7 @@ Configuration mss_saml2_backend.yaml file .. note:: may be can be occured invalid redirect url problem, since we defined localhost in keycloak admin, and using 127.0..... be careful to set it correctly. - eg:- + eg:- assertion_consumer_service: - [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] - [http://localhost:8083/localhost_test_idp/acs/redirect,] diff --git a/docs/tutorial.rst b/docs/tutorial.rst index ca7bd5282..ae693e88a 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -21,4 +21,3 @@ Some videos about the Mission Support System (MSS) tutorials/tutorial_hexagoncontrol tutorials/tutorial_performance_settings tutorials/tutorial_archive - diff --git a/docs/tutorials/tutorial_mscolab.rst b/docs/tutorials/tutorial_mscolab.rst index c94010dc5..783235984 100644 --- a/docs/tutorials/tutorial_mscolab.rst +++ b/docs/tutorials/tutorial_mscolab.rst @@ -5,4 +5,3 @@ In comparison to the standalone mode of the MSUI an example setup of users is shown on a MSColab server and the possibilities of interactions. .. image:: /videos/gif/tutorial_mscolab.gif - diff --git a/docs/tutorials/tutorial_msui_views.rst b/docs/tutorials/tutorial_msui_views.rst index 396c0b18d..9ac7e0418 100644 --- a/docs/tutorials/tutorial_msui_views.rst +++ b/docs/tutorials/tutorial_msui_views.rst @@ -4,4 +4,3 @@ Using the different views of the MSUI with a fictitious flight path and demo dat .. image:: /videos/gif/tutorial_views.gif - diff --git a/docs/tutorials/tutorial_satellitetrack.rst b/docs/tutorials/tutorial_satellitetrack.rst index 9ad34c29c..34ec96c16 100644 --- a/docs/tutorials/tutorial_satellitetrack.rst +++ b/docs/tutorials/tutorial_satellitetrack.rst @@ -5,4 +5,3 @@ To combine a flight path with a satellite overflight, the remotesensing widget i .. image:: /videos/gif/tutorial_satellitetrack.gif - diff --git a/docs/usage.rst b/docs/usage.rst index d347e81cd..7dae56fba 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -148,8 +148,8 @@ the UI settings file: More details about Plugins on :ref:`msuiplugins`. - - + + Web Proxy ......... @@ -313,4 +313,3 @@ Some examples for publicly accessible WMS Servers * http://eumetview.eumetsat.int/geoserver/wms * https://apps.ecmwf.int/wms/?token=public * https://maps.dwd.de/geoserver/wms - diff --git a/mslib/mscolab/migrations/versions/cd8b7108713a_.py b/mslib/mscolab/migrations/versions/cd8b7108713a_.py index e6d2ac4ca..970b96816 100644 --- a/mslib/mscolab/migrations/versions/cd8b7108713a_.py +++ b/mslib/mscolab/migrations/versions/cd8b7108713a_.py @@ -3,7 +3,7 @@ DB setup until v6.x Revision ID: cd8b7108713a -Revises: +Revises: Create Date: 2021-10-04 09:10:35.606793 """ diff --git a/mslib/msui/icons/config_editor/Help-browser.svg b/mslib/msui/icons/config_editor/Help-browser.svg index 7578f5f27..917fd8b89 100644 --- a/mslib/msui/icons/config_editor/Help-browser.svg +++ b/mslib/msui/icons/config_editor/Help-browser.svg @@ -1,5 +1,5 @@ - diff --git a/mslib/msui/qt5/ui_add_user_dialog.py b/mslib/msui/qt5/ui_add_user_dialog.py index 33dc33fb9..bf4b0003f 100644 --- a/mslib/msui/qt5/ui_add_user_dialog.py +++ b/mslib/msui/qt5/ui_add_user_dialog.py @@ -71,4 +71,3 @@ def retranslateUi(self, addUserDialog): self.rePassword.setPlaceholderText(_translate("addUserDialog", "Confirm your password")) self.emailIDLabel.setText(_translate("addUserDialog", "Email:")) self.usernameLabel.setText(_translate("addUserDialog", "Username:")) - diff --git a/mslib/msui/qt5/ui_customize_kml.py b/mslib/msui/qt5/ui_customize_kml.py index 0b08d8ace..1decd71eb 100644 --- a/mslib/msui/qt5/ui_customize_kml.py +++ b/mslib/msui/qt5/ui_customize_kml.py @@ -49,4 +49,3 @@ def retranslateUi(self, CustomizeKMLDialog): self.label.setText(_translate("CustomizeKMLDialog", "Placemark Colour")) self.pushButton_colour.setText(_translate("CustomizeKMLDialog", "Change Colour")) self.label_2.setText(_translate("CustomizeKMLDialog", "LineWidth ")) - diff --git a/mslib/msui/qt5/ui_kmloverlay_dockwidget.py b/mslib/msui/qt5/ui_kmloverlay_dockwidget.py index b3901e3cf..12b49bec1 100644 --- a/mslib/msui/qt5/ui_kmloverlay_dockwidget.py +++ b/mslib/msui/qt5/ui_kmloverlay_dockwidget.py @@ -125,4 +125,3 @@ def retranslateUi(self, KMLOverlayDockWidget): ui.setupUi(KMLOverlayDockWidget) KMLOverlayDockWidget.show() sys.exit(app.exec_()) - diff --git a/mslib/msui/qt5/ui_mscolab_merge_waypoints_dialog.py b/mslib/msui/qt5/ui_mscolab_merge_waypoints_dialog.py index 2943c5aa0..10e04adb2 100644 --- a/mslib/msui/qt5/ui_mscolab_merge_waypoints_dialog.py +++ b/mslib/msui/qt5/ui_mscolab_merge_waypoints_dialog.py @@ -120,4 +120,3 @@ def retranslateUi(self, MergeWaypointsDialog): self.label_3.setText(_translate("MergeWaypointsDialog", "New Waypoints")) self.saveBtn.setToolTip(_translate("MergeWaypointsDialog", "Save the new merged waypoints to the server")) self.saveBtn.setText(_translate("MergeWaypointsDialog", "Save new waypoints on server")) - diff --git a/mslib/msui/qt5/ui_remotesensing_dockwidget.py b/mslib/msui/qt5/ui_remotesensing_dockwidget.py index 39503d5ab..0b469d092 100644 --- a/mslib/msui/qt5/ui_remotesensing_dockwidget.py +++ b/mslib/msui/qt5/ui_remotesensing_dockwidget.py @@ -123,4 +123,3 @@ def retranslateUi(self, RemoteSensingDockWidget): self.cbSolarBody.setItemText(2, _translate("RemoteSensingDockWidget", "azimuth")) self.cbSolarBody.setItemText(3, _translate("RemoteSensingDockWidget", "elevation")) self.lbSolarCmap.setText(_translate("RemoteSensingDockWidget", "fill me")) - diff --git a/mslib/msui/qt5/ui_wms_capabilities.py b/mslib/msui/qt5/ui_wms_capabilities.py index fb507e625..cbc769912 100644 --- a/mslib/msui/qt5/ui_wms_capabilities.py +++ b/mslib/msui/qt5/ui_wms_capabilities.py @@ -64,4 +64,3 @@ def retranslateUi(self, WMSCapabilitiesBrowser): self.lblURL.setText(_translate("WMSCapabilitiesBrowser", "")) self.cbFullView.setText(_translate("WMSCapabilitiesBrowser", "show full XML document")) self.btClose.setText(_translate("WMSCapabilitiesBrowser", "close")) - diff --git a/mslib/msui/qt5/ui_wms_password_dialog.py b/mslib/msui/qt5/ui_wms_password_dialog.py index 6bcfc5577..e65b01265 100644 --- a/mslib/msui/qt5/ui_wms_password_dialog.py +++ b/mslib/msui/qt5/ui_wms_password_dialog.py @@ -68,4 +68,3 @@ def retranslateUi(self, WMSAuthenticationDialog): self.label.setText(_translate("WMSAuthenticationDialog", "The server you are trying to connect requires a username and a password:")) self.label_2.setText(_translate("WMSAuthenticationDialog", "User name:")) self.label_3.setText(_translate("WMSAuthenticationDialog", "Password:")) - diff --git a/mslib/mswms/xml_templates/get_capabilities.pt b/mslib/mswms/xml_templates/get_capabilities.pt index a6505d03c..a9a351643 100644 --- a/mslib/mswms/xml_templates/get_capabilities.pt +++ b/mslib/mswms/xml_templates/get_capabilities.pt @@ -108,4 +108,3 @@ - diff --git a/mslib/mswms/xml_templates/get_capabilities130.pt b/mslib/mswms/xml_templates/get_capabilities130.pt index 6b1aa96bf..2e5f36ede 100644 --- a/mslib/mswms/xml_templates/get_capabilities130.pt +++ b/mslib/mswms/xml_templates/get_capabilities130.pt @@ -1,115 +1,114 @@ - - - - ${ service_name } - ${ service_title } - ${ service_abstract } - - - - ${ service_contact_person } - ${ service_contact_organisation } - - ${ service_contact_position } - - ${ service_address_type } -
    ${ service_address }
    - ${ service_city } - ${ service_state_or_province } - ${ service_post_code } - ${ service_country } -
    - ${ service_email } -
    - ${ service_fees } - ${ service_access_constraints } -
    - - - - text/xml - - - - - - - - - - image/png - - - - - - - - - - - XML - - - Mission Support WMS Server - Mission Support WMS Server - - ${ "%s.%s" % (dataset, layer.name) } - ${ layer.title.strip() } - ${ layer.abstract.strip() } - ${ crs } - - -180 - 180 - -90 - 90 - - ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_all_valid_times()]) } - ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_init_times()]) } - ${ ",".join(layer.get_elevations()) } - - - - ${ "%s.%s" % (dataset, layer.name) } - ${ layer.title.strip() } - ${ layer.abstract.strip() } - ${ crs } - - -180 - 180 - -90 - 90 - - ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_all_valid_times()]) } - ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_init_times()]) } - - - - ${ "%s.%s" % (dataset, layer.name) } - ${ layer.title.strip() } - ${ layer.abstract.strip() } - ${ crs } - - -180 - 180 - -90 - 90 - - ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_all_valid_times()]) } - ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_init_times()]) } - - - -
    - + + + + ${ service_name } + ${ service_title } + ${ service_abstract } + + + + ${ service_contact_person } + ${ service_contact_organisation } + + ${ service_contact_position } + + ${ service_address_type } +
    ${ service_address }
    + ${ service_city } + ${ service_state_or_province } + ${ service_post_code } + ${ service_country } +
    + ${ service_email } +
    + ${ service_fees } + ${ service_access_constraints } +
    + + + + text/xml + + + + + + + + + + image/png + + + + + + + + + + + XML + + + Mission Support WMS Server + Mission Support WMS Server + + ${ "%s.%s" % (dataset, layer.name) } + ${ layer.title.strip() } + ${ layer.abstract.strip() } + ${ crs } + + -180 + 180 + -90 + 90 + + ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_all_valid_times()]) } + ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_init_times()]) } + ${ ",".join(layer.get_elevations()) } + + + + ${ "%s.%s" % (dataset, layer.name) } + ${ layer.title.strip() } + ${ layer.abstract.strip() } + ${ crs } + + -180 + 180 + -90 + 90 + + ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_all_valid_times()]) } + ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_init_times()]) } + + + + ${ "%s.%s" % (dataset, layer.name) } + ${ layer.title.strip() } + ${ layer.abstract.strip() } + ${ crs } + + -180 + 180 + -90 + 90 + + ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_all_valid_times()]) } + ${ (",").join([dt.strftime("%Y-%m-%dT%H:%M:%SZ") for dt in layer.get_init_times()]) } + + + +
    diff --git a/mslib/mswms/xml_templates/service_exception130.pt b/mslib/mswms/xml_templates/service_exception130.pt index 671c087ab..61b92f35a 100644 --- a/mslib/mswms/xml_templates/service_exception130.pt +++ b/mslib/mswms/xml_templates/service_exception130.pt @@ -1,7 +1,7 @@ - - - ${ text } - + + + ${ text } + diff --git a/mslib/static/docs/about.md b/mslib/static/docs/about.md index e20542cf3..ee09fb062 100644 --- a/mslib/static/docs/about.md +++ b/mslib/static/docs/about.md @@ -61,4 +61,3 @@ and the German research institutions DLR, KIT, and Forschungszentrum Jülich. Improving the software will improve the quality of the research flights and will also enable its use for other scientific areas, e.g. planning of ship routes. - diff --git a/mslib/static/docs/help.md b/mslib/static/docs/help.md index 13830db30..263092515 100644 --- a/mslib/static/docs/help.md +++ b/mslib/static/docs/help.md @@ -13,6 +13,3 @@ The example shows defining of waypoints for a flight path, moved and deleted. Further tutorials about the Mission Support System Software on: [https://mss.readthedocs.io/en/stable/tutorial.html](https://mss.readthedocs.io/en/stable/tutorial.html) - - - diff --git a/mslib/static/docs/installation.md b/mslib/static/docs/installation.md index 09b04c1c0..8c8051e33 100644 --- a/mslib/static/docs/installation.md +++ b/mslib/static/docs/installation.md @@ -31,7 +31,7 @@ You can install it either automatically with the help of a script or manually. ### Manually -As **Beginner** start with an installation of Mambaforge +As **Beginner** start with an installation of Mambaforge Get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) for your Operation System @@ -47,7 +47,7 @@ to leave out the 'source' here and below). For updating an existing MSS installation to the current version, it is best to install it into a new environment. If an existing environment shall be updated, it is important to update all packages in this -environment. +environment. ``` $ mamba activate mssenv @@ -84,7 +84,7 @@ For a simple test you could start the builtin standalone *mswms* and Point a browser for the verification of both servers installed on - - + - - Further details in the components section on diff --git a/mslib/static/img/README b/mslib/static/img/README index 4a1d68aa8..b3b29afb5 100644 --- a/mslib/static/img/README +++ b/mslib/static/img/README @@ -1,2 +1 @@ The files in the img directory are logos from scientific campaigns using MSS. - diff --git a/mslib/static/templates/idp/available_idps.html b/mslib/static/templates/idp/available_idps.html index ce361c22e..e6ad7eab4 100644 --- a/mslib/static/templates/idp/available_idps.html +++ b/mslib/static/templates/idp/available_idps.html @@ -44,14 +44,12 @@ display: flex; justify-content: center; align-items: center; - }

    Choose Identity Provider

      - {% for idp in configured_idps %}
    • @@ -69,6 +67,5 @@

      Choose Identity Provider

      }
    -
  • {% endblock %} diff --git a/mslib/static/templates/idp/idp_login_success.html b/mslib/static/templates/idp/idp_login_success.html index fefabd304..af46f2d04 100644 --- a/mslib/static/templates/idp/idp_login_success.html +++ b/mslib/static/templates/idp/idp_login_success.html @@ -11,7 +11,7 @@

    Congratulations! You have successfully logged in to the mscolab server using Identity Provider.

    Please proceed to log in using the user interface by bellow token.

    -

    Token : {{token}} +

    Token : {{token}}

    diff --git a/mslib/static/templates/theme.html b/mslib/static/templates/theme.html index 4b66a7b12..d094472b4 100644 --- a/mslib/static/templates/theme.html +++ b/mslib/static/templates/theme.html @@ -56,7 +56,7 @@ +