diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index f54fb92cc..d9f77c29b 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -3,7 +3,16 @@ name: flake8 -on: [ push, pull_request ] +on: + push: + branches: + - develop + - stable + pull_request: + branches: + - develop + - stable + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 902d12b60..20d512bf9 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,6 +1,15 @@ name: Pytest MSS -on: [ push, pull_request ] +on: + push: + branches: + - develop + - stable + pull_request: + branches: + - develop + - stable + env: PAT: ${{ secrets.PAT }} diff --git a/.github/workflows/xdist_testing.yml b/.github/workflows/xdist_testing.yml index 634589482..57cfae793 100644 --- a/.github/workflows/xdist_testing.yml +++ b/.github/workflows/xdist_testing.yml @@ -1,6 +1,15 @@ name: Pytest MSS -on: [ push, pull_request ] +on: + push: + branches: + - develop + - stable + pull_request: + branches: + - develop + - stable + env: PAT: ${{ secrets.PAT }} diff --git a/AUTHORS b/AUTHORS index e5ec7d594..9a3ea79a8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -19,6 +19,7 @@ in alphabetic order by first name - Reimar Bauer - Sakshi Chopkar - Shivashis Padhi +- Sreelakshmi Jayarajan - Tanish Grover - Thomas Breuer - Vaibhav Mehra diff --git a/CHANGES.rst b/CHANGES.rst index a15b7dc40..7ca540068 100755 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,17 @@ Changelog ========= +Version 6.1.0 +~~~~~~~~~~~~~ + +This release includes some small improvements of the usablity. +A few bug fixes, a new plugin for NAVAID waypoints is decribed and +mscolab gots few improvements for user creation. +Wind speed plots can now be created for altitudes > 25km. + +All changes: +https://github.com/Open-MSS/MSS/milestone/70?closed=1 + Version 6.0.6 ~~~~~~~~~~~~~ diff --git a/conftest.py b/conftest.py index d0ca25ff9..b4520a5b1 100644 --- a/conftest.py +++ b/conftest.py @@ -25,7 +25,6 @@ limitations under the License. """ -import importlib import importlib.machinery import os import sys @@ -37,9 +36,13 @@ import pytest import fs +import shutil from mslib.mswms.demodata import DataFiles import mslib._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__), "docs", "samples", "flight-tracks") +shutil.copy(os.path.join(sample_path, "example.ftml"), constants.ROOT_DIR) def pytest_addoption(parser): parser.addoption("--mss_settings", action="store") diff --git a/docs/components.rst b/docs/components.rst index 8073b880b..5277febf9 100644 --- a/docs/components.rst +++ b/docs/components.rst @@ -5,6 +5,7 @@ Components :maxdepth: 3 usage + plugins mswms mscolab tutorials diff --git a/docs/development.rst b/docs/development.rst index f9cdbc0c2..4ebb03b86 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -143,7 +143,7 @@ Create an environment and install the whole mss package dependencies then remove $ conda create -n mssdev mamba $ conda activate mssdev - $ mamba install mss=$mss_version --only-deps + $ mamba install mss=$mss_version python --only-deps You can also use conda to install mss, but mamba is a way faster. Compare versions used in the meta.yaml between stable and develop branch and apply needed changes. @@ -172,7 +172,7 @@ You can view the default configuration of mscolab in the file `mslib/mscolab/con If you want to change any values of the configuration, please take a look at the "Configuring Your Mscolab Server" section in :ref:`mscolab` -When using for the first time you need to initialise your database. Use the command :code:`python mslib/mscolab/mscolab db --init` +When using for the first time you need to initialise your database. Use the command :code:`python mslib/mscolab/mscolab.py db --init` to initialise it. The default database is a sqlite3 database. You can add some dummy data to your database by using the command :code:`python mslib/mscolab/mscolab.py db --seed`. The content of the dummy data can be found in the file `mslib/mscolab/seed.py`. diff --git a/docs/mscolab.rst b/docs/mscolab.rst index 0c4402877..730e4d395 100644 --- a/docs/mscolab.rst +++ b/docs/mscolab.rst @@ -23,10 +23,10 @@ Protecting Login ~~~~~~~~~~~~~~~~ The login to the MSColab server can be protected by an additional auth method. -**mss_mscolab_auth.py** - .. literalinclude:: samples/config/mscolab/mss_mscolab_auth.py.sample +Make a copy of the above file, rename it to mss_mscolab_auth.py, make the necessary changes in the file and add it to your $PYTHONPATH. + Steps to Run MSColab Server ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - The MSColab server comes included in the MSS python package. diff --git a/docs/mswms.rst b/docs/mswms.rst index 85d1f9281..db233c789 100644 --- a/docs/mswms.rst +++ b/docs/mswms.rst @@ -20,6 +20,8 @@ For more information on WMS, see http://www.opengeospatial.org/standards/wms WMS Server Deployment ===================== +.. _deployment: + Once installation and configuration are complete, you can start the Web Map Service application (provided you have forecast data to visualise). The file "mswms" is an executable Python script starting up a Flask HTTP server @@ -449,4 +451,4 @@ Detailed server configuration *mss_wms_settings.py* for this demodata For setting authentication see *mss_wms_auth.py* - .. literalinclude:: samples/config/wms/mss_wms_auth.py.sample \ No newline at end of file + .. literalinclude:: samples/config/wms/mss_wms_auth.py.sample diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 000000000..384e65882 --- /dev/null +++ b/docs/plugins.rst @@ -0,0 +1,61 @@ +MSUI plugins +============ + +.. _msuiplugins: + + +Flight track import/export +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As the planned flight track has to be quickly communicated to different parties having different +desired file formats, MSS supports a simple plugin system for exporting planned flights and +importing changed files back in addition to the main FTML format. These filters may be accessed +from the File menu of the Main Window. + +MSS currently offers several import/export filters in the mslib.plugins.io module, which may serve +as an example for the definition of own plugins. Take care that added plugins use different file extensions. +They are listed below. The CSV plugin is enabled by default. +Enabling the experimental FliteStar text import plugin would require those lines in +the UI settings file: + +.. code:: text + + "import_plugins": { + "FliteStar": ["fls", "mslib.plugins.io.flitestar", "load_from_flitestar"] + }, + +The dictionary entry defines the name of the filter in the File menu. The list specifies in this +order the extension, the python module implementing the function, and finally the function name. +The module may be placed in any location of the PYTHONPATH or into the configuration directory +path. + +An exemplary test file format that can be ex- and imported may be activated by: + +.. code:: text + + "import_plugins": { + "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"] + }, + "export_plugins": { + "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"] + }, + +The given plugins demonstrate, how additional plugins may be implemented. Please be advised that several +attributes of the waypoints are automatically computed by MSS (for example all time and performance data) +and will be overwritten after reading back the file. + +**Available Export Formats:** + +.. code:: text + + "export_plugins": { + "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"], + "KML": ["kml", "mslib.plugins.io.kml", "save_to_kml"], + "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] + }, + + +User contributed Plugins: +------------------------- + +.. include:: samples/plugins/navaid.rst diff --git a/docs/samples/automation/retriever.py b/docs/samples/automation/retriever.py index d27e56a62..c30b2f6e2 100644 --- a/docs/samples/automation/retriever.py +++ b/docs/samples/automation/retriever.py @@ -39,6 +39,7 @@ import mslib import mslib.utils from mslib.utils import thermolib +from mslib.utils.config import config_loader, read_config_file from mslib.utils.units import units import mslib.msui import mslib.msui.mpl_map @@ -129,8 +130,8 @@ def main(): sys.exit() mslib.utils.setup_logging(args) - - config = mslib.utils.config_loader() + read_config_file(path=mslib.msui.constants.MSS_SETTINGS) + config = config_loader() num_interpolation_points = config["num_interpolation_points"] num_labels = config["num_labels"] tick_index_step = num_interpolation_points // num_labels @@ -138,7 +139,7 @@ def main(): fig = plt.figure() for flight, section, vertical, filename, init_time, time in \ config["automated_plotting"]["flights"]: - params = mslib.utils.get_projection_params( + params = mslib.utils.coordinate.get_projection_params( config["predefined_map_sections"][section]["CRS"].lower()) params["basemap"].update(config["predefined_map_sections"][section]["map"]) wps = load_from_ftml(filename) @@ -188,8 +189,9 @@ def main(): # prepare vsec plots path = [(wp[0], wp[1], datetime.datetime.now()) for wp in wps] - lats, lons, _ = mslib.utils.path_points( - path, numpoints=num_interpolation_points + 1, connection="greatcircle") + lats, lons = mslib.utils.coordinate.path_points( + [_x[0] for _x in path], + [_x[1] for _x in path], numpoints=num_interpolation_points + 1, connection="greatcircle") intermediate_indexes = [] ipoint = 0 for i, (lat, lon) in enumerate(zip(lats, lons)): @@ -207,7 +209,7 @@ def main(): ax.set_yscale("log") p_bot, p_top = [float(x) * 100 for x in vertical.split(",")] bbox = ",".join(str(x) for x in (num_interpolation_points, p_bot / 100, num_labels, p_top / 100)) - ax.grid(b=True) + ax.grid(visible=True) ax.patch.set_facecolor("None") pres_maj = mslib.msui.mpl_qtwidget.MplSideViewCanvas._pres_maj pres_min = mslib.msui.mpl_qtwidget.MplSideViewCanvas._pres_min diff --git a/docs/samples/config/mscolab/mss_mscolab_auth.py.sample b/docs/samples/config/mscolab/mss_mscolab_auth.py.sample index 33d49482a..1921f6e56 100644 --- a/docs/samples/config/mscolab/mss_mscolab_auth.py.sample +++ b/docs/samples/config/mscolab/mss_mscolab_auth.py.sample @@ -1,3 +1,3 @@ class mss_mscolab_auth(object): password = "please use the methods to save only the encrypted value" - allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())),] \ No newline at end of file + allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] diff --git a/docs/samples/config/mss/empty_mss_settings.json.sample b/docs/samples/config/mss/empty_mss_settings.json.sample new file mode 100644 index 000000000..0e0dcd235 --- /dev/null +++ b/docs/samples/config/mss/empty_mss_settings.json.sample @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/docs/samples/config/mss/mss_settings.json.sample b/docs/samples/config/mss/mss_settings.json.sample index 8ecf27aac..9f30ddf56 100644 --- a/docs/samples/config/mss/mss_settings.json.sample +++ b/docs/samples/config/mss/mss_settings.json.sample @@ -4,13 +4,11 @@ "filepicker_default": "default", "import_plugins": { - "CSV": ["csv", "mslib.plugins.io.csv", "load_from_csv"], "FliteStar": ["fls", "mslib.plugins.io.flitestar", "load_from_flitestar"], "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"] }, "export_plugins": { - "CSV": ["csv", "mslib.plugins.io.csv", "save_to_csv"], "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"], "KML": ["kml", "mslib.plugins.io.kml", "save_to_kml"], "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] diff --git a/docs/samples/config/mss/performance_simple.json b/docs/samples/config/mss/performance_simple.json.sample similarity index 100% rename from docs/samples/config/mss/performance_simple.json rename to docs/samples/config/mss/performance_simple.json.sample diff --git a/docs/samples/plugins/navaid.py b/docs/samples/plugins/navaid.py new file mode 100644 index 000000000..567c25d56 --- /dev/null +++ b/docs/samples/plugins/navaid.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" + + mslib.plugins.io.navaid + ~~~~~~~~~~~~~~~~~~~~ + + plugin for navaid format flight track export + + This file is part of mss. + + :copyright: Copyright 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 +""" +import os +import datetime +import csv +import codecs +import numpy as np +import geomag +from geopy.distance import geodesic +from geographiclib.geodesic import Geodesic +from mslib.msui.constants import MSS_CONFIG_PATH + + +def radial_dme(lat, lon, elev=12., test_date=datetime.date.today(), maxdist = 250.): + # Read the list of NAVAIDs + # PATH needs to be replaced by generic + navaid_file = os.path.join(MSS_CONFIG_PATH, 'plugins', 'NAVAID_System.csv') + navaid = open(navaid_file, encoding='utf-8-sig' ) + csvreader = csv.reader(navaid) + header = next(csvreader) + ix = header.index('X') + iy = header.index('Y') + itype = header.index('TYPE_CODE') + iident = header.index('IDENT') + locations = [] + navidents = [] + for row in csvreader: + # Find the ones that are useful to the pilots (types 6, 7, or 8) + type_code = int(row[itype]) + if type_code == 6 or type_code == 7 or type_code == 8: + navlon = float(row[ix]) + navlat = float(row[iy]) + locations.append((navlat, navlon)) + navidents.append(row[iident]) + + # Determine nearest NAVAID to the lat/lon point in nautical miles. + position = (lat, lon) + dist = [geodesic(position, i).nm for i in locations] + minpos = dist.index(min(dist)) + navident = navidents[minpos] + navlat = locations[minpos][0] + navlon = locations[minpos][1] + + # Calculate the true heading to the nearest NAVAID + # Uses WGS84 ellipsoid for the earth + true_bear = Geodesic.WGS84.Inverse(navlat, navlon, lat, lon)['azi1'] + + # Convert to magnetic heading (note: elevation converted to feet) + mag_bear = geomag.mag_heading(true_bear, dlat=lat, dlon=lon, + h=elev * 3280.8399, time=test_date) + + # Round to whole numbers + az = round(mag_bear) + rng = round(dist[minpos]) + + # Print and return values + lonx = np.mod((lon + 180), 360) - 180 + hemx = 'E' + if lonx < 0: + hemx = 'W' + hemy = 'N' + if lat < 0: + hemy = 'S' + + lonx = np.abs(lonx) + latx = np.abs(lat) + latdeg = int(np.floor(latx)) + londeg = int(np.floor(lonx)) + latmin = int(round((latx - np.floor(latx)) * 60.)) + lonmin = int(round((lonx - np.floor(lonx)) * 60.)) + + if rng > maxdist: + wpt_str = '{:02d}{:02d}{}{:03d}{:02d}{}'.format(latdeg, latmin, hemy, + londeg, lonmin, hemx) + else: + wpt_str = '{}{:03d}{:03d}'.format(navident, az, rng) + if rng <= 1: + wpt_str = navident + return wpt_str + + +def save_to_navaid(filename, name, waypoints): + maxdist = 250. + if not filename: + raise ValueError("filename to save flight track cannot be None") + max_loc_len, max_com_len = len("Location"), len("Comments") + for wp in waypoints: + if len(str(wp.location)) > max_loc_len: + max_loc_len = len(str(wp.location)) + if len(str(wp.comments)) > max_com_len: + max_com_len = len(str(wp.comments)) + with codecs.open(filename, "w", encoding="utf-8") as out_file: + out_file.write(u"# Do not modify if you plan to import this file again!\n") + out_file.write(u"# This file contains NAVAID-DME names for points less than {0:.0f} nm distance from closest NAVAID point\n".format(maxdist)) + out_file.write(f"Track name: {name:}\n") + line = u"{0:5d} {1:{2}} {3:11} {4:4.0f}° {5:02.0f}' {6:4.0f}° {7:02.0f}' {8:7.1f} {9:7.1f} {10:8.1f}" \ + u" {11:8.1f} {12:{13}}\n" + header = f"Index {'Location':{max_loc_len}} NAVAID-DME Lat Lon FL (hft) P (hPa) " \ + f"Leg (km) Cum. (km) {'Comments':{max_com_len}}\n" + out_file.write(header) + for i, wp in enumerate(waypoints): + # ToDo check str(str( .. ) and may be use csv write + loc = str(wp.location) + lat = wp.lat + lon = wp.lon + # transform to degrees and minutes + latdeg = np.floor(lat) + if lat < 0: + latdeg = - np.floor(-lat) + latmin = abs(round((lat - latdeg) * 60.)) + + londeg = np.floor(lon) + if lon < 0: + londeg = - np.floor(-lon) + lonmin = abs(round((lon - londeg) * 60.)) + + nav = radial_dme(lat, lon, maxdist=maxdist) + lvl = wp.flightlevel + pre = wp.pressure / 100. + leg = wp.distance_to_prev + cum = wp.distance_total + com = str(wp.comments) + out_file.write(line.format(i, loc, max_loc_len, nav, latdeg, latmin, + londeg, lonmin, lvl, pre, leg, cum, com, + max_com_len)) diff --git a/docs/samples/plugins/navaid.rst b/docs/samples/plugins/navaid.rst new file mode 100644 index 000000000..6afe44bd6 --- /dev/null +++ b/docs/samples/plugins/navaid.rst @@ -0,0 +1,38 @@ +NAVAID Plugin for exporting and importing flight path data +========================================================== + +.. _navaid: + +The communication of the flight path data (e.g. with the aircraft +authorities) is always individual. For this reason, import and of +these data can be done using different pre-defined templates. +Alternative plugins could be placed into the settings directory. + +Details +~~~~~~~ +It may be requiered to compute the waypoints in the format WPT012034, +where WPT is the NAVAID ID, 012 is the heading and 034 is the distance +in nautical miles. This standard is used by several flight authorities. +Data for the location of the NAVAID waypoints can be obtained as csv from +https://adds-faa.opendata.arcgis.com/search?collection=Dataset +The dataset should be named NAVAID_System.csv and placed into the subdir +plugins of the config dir. +For a given set of waypoints, the navaid export plugin exports an ASCII +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. + +Installation +~~~~~~~~~~~~ + +1. Copy :download:`navaid.py ` to a PYTHONPATH directory e.g. .config/mss + +1. Save the NAVAID waypoint from https://adds-faa.opendata.arcgis.com/search?collection=Dataset select "Pending NAVAID System" and Download as CSV to your local directory `.config/mss/plugins/NAVAID_System.csv` + + +1. Add additional modules to your mssenv by :: + + (mssenv): mamba install geomag geopy geographiclib + diff --git a/docs/usage.rst b/docs/usage.rst index cc4765f06..cfe0c2851 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -83,13 +83,9 @@ by the environment variable MSS_SETTINGS pointing to your mss_settings.json. .. literalinclude:: samples/config/mss/mss_settings.json.sample -Flight track import/export -~~~~~~~~~~~~~~~~~~~~~~~~~~ +MSUI Flight track import/export plugins +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -As the planned flight track has to be quickly communicated to different parties having different -desired file formats, MSS supports a simple plugin system for exporting planned flights and -importing changed files back in addition to the main FTML format. These filters may be accessed -from the File menu of the Main Window. MSS currently offers several import/export filters in the mslib.plugins.io module, which may serve as an example for the definition of own plugins. Take care that added plugins use different file extensions. @@ -103,36 +99,10 @@ the UI settings file: "FliteStar": ["fls", "mslib.plugins.io.flitestar", "load_from_flitestar"] }, -The dictionary entry defines the name of the filter in the File menu. The list specifies in this -order the extension, the python module implementing the function, and finally the function name. -The module may be placed in any location of the PYTHONPATH or into the configuration directory -path. - -An exemplary test file format that can be ex- and imported may be activated by: - -.. code:: text - - "import_plugins": { - "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"] - }, - "export_plugins": { - "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"] - }, - -The given plugins demonstrate, how additional plugins may be implemented. Please be advised that several -attributes of the waypoints are automatically computed by MSS (for example all time and performance data) -and will be overwritten after reading back the file. - -**Available Export Formats:** - -.. code:: text - - "export_plugins": { - "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"], - "KML": ["kml", "mslib.plugins.io.kml", "save_to_kml"], - "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] - }, +More details about Plugins on :ref:`msuiplugins`. + + Web Proxy ~~~~~~~~~ diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 98f6ea2f7..aa3430bcb 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -28,10 +28,10 @@ requirements: - python - defusedxml - menuinst # [win] - - basemap >1.2.1 + - basemap >1.2.1 - chameleon - execnet - - fastkml =0.11 + - fastkml =0.11 - gsl =2.7.0 - isodate - lxml diff --git a/mslib/mscolab/migrations/env.py b/mslib/mscolab/migrations/env.py index eb797f7ff..9bb35bb93 100644 --- a/mslib/mscolab/migrations/env.py +++ b/mslib/mscolab/migrations/env.py @@ -23,8 +23,6 @@ """ # source https://github.com/sunscrapers/flask-boilerplate/blob/master/migrations/env.py -from __future__ import with_statement - import logging from logging.config import fileConfig diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 4b41a1690..48ecbc7ac 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -484,7 +484,12 @@ def update_operation(): attribute = request.form['attribute'] value = request.form['value'] user = g.user - return str(fm.update_operation(int(op_id), attribute, value, user)) + r = str(fm.update_operation(int(op_id), attribute, value, user)) + if r == "True": + token = request.args.get('token', request.form.get('token', False)) + json_config = {"token": token} + sockio.sm.update_operation_list(json_config) + return r @APP.route('/operation_details', methods=["GET"]) diff --git a/mslib/msui/_tests/test_mscolab.py b/mslib/msui/_tests/test_mscolab.py index a580a317f..8004ef1ad 100644 --- a/mslib/msui/_tests/test_mscolab.py +++ b/mslib/msui/_tests/test_mscolab.py @@ -37,10 +37,12 @@ from mslib.mscolab.models import Permission, User from mslib.msui.flighttrack import WaypointsTableModel from PyQt5 import QtCore, QtTest, QtWidgets -from mslib._tests.utils import mscolab_start_server, ExceptionMock +from mslib.utils.config import read_config_file, config_loader +from mslib._tests.utils import mscolab_start_server, create_mss_settings_file, ExceptionMock import mslib.msui.mss_pyui as mss_pyui from mslib.msui import mscolab from mslib.mscolab.mscolab import handle_db_reset +from mslib._tests.constants import MSS_CONFIG_PATH from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation PORTS = list(range(25000, 25500)) @@ -49,6 +51,7 @@ class Test_Mscolab_connect_window(): def setup(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" @@ -67,6 +70,7 @@ def setup(self): self.window.show() def teardown(self): + self.main_window.mscolab.logout() self.window.hide() self.main_window.hide() QtWidgets.QApplication.processEvents() @@ -89,8 +93,11 @@ def test_connect(self, 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) 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() @@ -103,21 +110,76 @@ def test_login(self): assert self.main_window.local_active is True # test operation listing visibility assert self.main_window.listOperationsMSC.model().rowCount() == 1 - # test logout + + def test_logout_action_trigger(self): + # Login + self._connect_to_mscolab() + 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 + assert self.main_window.usernameLabel.text() == "User" + + def test_logout(self): + # Login + self._connect_to_mscolab() + 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() + assert self.main_window.usernameLabel.text() == "User" + assert self.main_window.connectBtn.isVisible() is True + assert self.main_window.listOperationsMSC.model().rowCount() == 0 + assert self.main_window.mscolab.conn is None + assert self.main_window.local_active is True def test_add_user(self): 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="MSCOLAB_password") == "something" # assert self.window.stackedWidget.currentWidget() == self.window.newuserPage - self._login("something@something.org", "something") assert self.main_window.usernameLabel.text() == 'something' 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): + create_mss_settings_file('{"MSCOLAB_mailid": "something@something.org", "MSCOLAB_password": "something"}') + read_config_file() + # check current settings + assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert config_loader(dataset="MSCOLAB_password") == "something" + self._connect_to_mscolab() + assert self.window.mscolab_server_url is not None + self._create_user("anand", "anand@something.org", "anand") + # check changed settings + assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert config_loader(dataset="MSCOLAB_password") == "something" + # 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_mss_settings_file('{"MSCOLAB_mailid": "something@something.org", "MSCOLAB_password": "something"}') + read_config_file() + # check current settings + assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert config_loader(dataset="MSCOLAB_password") == "something" + self._connect_to_mscolab() + assert self.window.mscolab_server_url is not None + self._create_user("anand", "anand@something.org", "anand") + # check changed settings + assert config_loader(dataset="MSCOLAB_mailid") == "anand@something.org" + assert config_loader(dataset="MSCOLAB_password") == "anand" + # check user is logged in + assert self.main_window.usernameLabel.text() == "anand" + def test_failed_authorize(self): class response: def __init__(self, code, text): @@ -178,6 +240,11 @@ def _create_user(self, username, email, password): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() + def _reset_config_file(self): + create_mss_settings_file('{ }') + config_file = fs.path.combine(MSS_CONFIG_PATH, "mss_settings.json") + read_config_file(path=config_file) + @pytest.mark.skipif(os.name == "nt", reason="multiprocessing needs currently start_method fork") @@ -194,6 +261,7 @@ class Test_Mscolab(object): def setup(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" @@ -208,6 +276,7 @@ def setup(self): self.window.show() def teardown(self): + self.window.mscolab.logout() if self.window.mscolab.version_window: self.window.mscolab.version_window.close() if self.window.mscolab.conn: @@ -280,6 +349,7 @@ def test_handle_export(self, mockbox): @pytest.mark.parametrize("ext", [".ftml", ".csv", ".txt"]) @mock.patch("PyQt5.QtWidgets.QMessageBox") def test_import_file(self, mockbox, ext): + self.window.remove_plugins() with mock.patch("mslib.msui.mss_pyui.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") with mock.patch("mslib.msui.mss_pyui.config_loader", return_value=self.export_plugins): @@ -334,12 +404,12 @@ 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): self._connect_to_mscolab() self._create_user("something", "something@something.org", "something") - self._login("something@something.org", "something") assert self.window.listOperationsMSC.model().rowCount() == 0 self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() @@ -354,14 +424,18 @@ def test_browse_add_operation(self, mockopen, mockmessage): okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) + # 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" + assert item.access_level == "creator" @mock.patch("PyQt5.QtWidgets.QErrorMessage") def test_add_operation(self, mockbox): self._connect_to_mscolab() self._create_user("something", "something@something.org", "something") - self._login("something@something.org", "something") assert self.window.usernameLabel.text() == 'something' assert self.window.connectBtn.isVisible() is False self._create_operation("Alpha", "Description Alpha") @@ -386,7 +460,6 @@ def test_handle_delete_operation(self, mocktext, mockbox): # pytest.skip('needs a review for the delete button pressed. Seems to delete a None operation') self._connect_to_mscolab() self._create_user("berta", "berta@something.org", "something") - self._login("berta@something.org", "something") assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 @@ -407,7 +480,6 @@ def test_handle_delete_operation(self, mocktext, mockbox): def test_get_recent_op_id(self): self._connect_to_mscolab() self._create_user("anton", "anton@something.org", "something") - self._login("anton@something.org", "something") assert self.window.usernameLabel.text() == 'anton' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 @@ -421,7 +493,6 @@ def test_get_recent_op_id(self): def test_get_recent_operation(self): self._connect_to_mscolab() self._create_user("berta", "berta@something.org", "something") - self._login("berta@something.org", "something") assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 @@ -434,7 +505,6 @@ def test_get_recent_operation(self): def test_delete_operation_from_list(self): self._connect_to_mscolab() self._create_user("other", "other@something.org", "something") - self._login("other@something.org", "something") assert self.window.usernameLabel.text() == 'other' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 @@ -448,7 +518,6 @@ def test_delete_operation_from_list(self): def test_user_delete(self, mockmessage): self._connect_to_mscolab() self._create_user("something", "something@something.org", "something") - self._login("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) @@ -495,7 +564,6 @@ def test_create_dir_exceptions(self, mockexit, mockbox): def test_profile_dialog(self, mockbox): self._connect_to_mscolab() self._create_user("something", "something@something.org", "something") - self._login("something@something.org", "something") self.window.mscolab.profile_action.trigger() QtWidgets.QApplication.processEvents() # case: default gravatar is set and no messagebox is called @@ -537,6 +605,11 @@ def _create_user(self, username, email, password): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() + def _reset_config_file(self): + create_mss_settings_file('{ }') + config_file = fs.path.combine(MSS_CONFIG_PATH, "mss_settings.json") + read_config_file(path=config_file) + @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") def _create_operation(self, path, description, mockbox): self.window.actionAddOperation.trigger() diff --git a/mslib/msui/_tests/test_mscolab_admin_window.py b/mslib/msui/_tests/test_mscolab_admin_window.py index f9c4c46b1..72de322c9 100644 --- a/mslib/msui/_tests/test_mscolab_admin_window.py +++ b/mslib/msui/_tests/test_mscolab_admin_window.py @@ -81,6 +81,7 @@ def setup(self): QtWidgets.QApplication.processEvents() def teardown(self): + self.window.mscolab.logout() if self.window.mscolab.admin_window: self.window.mscolab.admin_window.close() if self.window.mscolab.conn: diff --git a/mslib/msui/_tests/test_mscolab_merge_waypoints.py b/mslib/msui/_tests/test_mscolab_merge_waypoints.py index 0d540df05..c3d63ac86 100644 --- a/mslib/msui/_tests/test_mscolab_merge_waypoints.py +++ b/mslib/msui/_tests/test_mscolab_merge_waypoints.py @@ -55,6 +55,7 @@ def setup(self): self.emailid = 'merge@alpha.org' def teardown(self): + self.window.mscolab.logout() with self.app.app_context(): mscolab_delete_all_operations(self.app, self.url, self.emailid, 'abcdef', 'alpha') mscolab_delete_user(self.app, self.url, self.emailid, 'abcdef') diff --git a/mslib/msui/_tests/test_mscolab_operation.py b/mslib/msui/_tests/test_mscolab_operation.py index 5fbd9a339..937f81796 100644 --- a/mslib/msui/_tests/test_mscolab_operation.py +++ b/mslib/msui/_tests/test_mscolab_operation.py @@ -76,6 +76,7 @@ def setup(self): QtWidgets.QApplication.processEvents() def teardown(self): + self.window.mscolab.logout() if self.window.mscolab.chat_window: self.window.mscolab.chat_window.hide() if self.window.mscolab.conn: diff --git a/mslib/msui/_tests/test_mscolab_version_history.py b/mslib/msui/_tests/test_mscolab_version_history.py index 2c32dd8db..86c392b1a 100644 --- a/mslib/msui/_tests/test_mscolab_version_history.py +++ b/mslib/msui/_tests/test_mscolab_version_history.py @@ -70,6 +70,7 @@ def setup(self): QtWidgets.QApplication.processEvents() def teardown(self): + self.window.mscolab.logout() if self.window.mscolab.version_window: self.window.mscolab.version_window.close() if self.window.mscolab.conn: diff --git a/mslib/msui/_tests/test_mss_pyui.py b/mslib/msui/_tests/test_mss_pyui.py index 8c32fe7ad..117a928c8 100644 --- a/mslib/msui/_tests/test_mss_pyui.py +++ b/mslib/msui/_tests/test_mss_pyui.py @@ -36,6 +36,7 @@ from mslib._tests.constants import ROOT_DIR import mslib.msui.mss_pyui as mss_pyui from mslib._tests.utils import ExceptionMock +from mslib.utils.config import read_config_file class Test_MSS_AboutDialog(): @@ -119,6 +120,14 @@ class Test_MSSSideViewWindow(object): } def setup(self): + self.sample_path = os.path.join( + os.path.dirname(os.path.abspath(mss_pyui.__file__)), + '../', + '../', + 'docs', + 'samples', + 'config', + 'mss') self.application = QtWidgets.QApplication(sys.argv) self.window = mss_pyui.MSSMainWindow() @@ -129,6 +138,11 @@ def setup(self): QtWidgets.QApplication.processEvents() def teardown(self): + config_file = os.path.join( + self.sample_path, + 'empty_mss_settings.json.sample', + ) + read_config_file(path=config_file) for i in range(self.window.listViews.count()): self.window.listViews.item(i).window.hide() self.window.hide() @@ -219,7 +233,7 @@ def test_plugin_saveas(self, save_file): os.remove(save_file[0]) @pytest.mark.parametrize( - "open_file", [(open_ftml, "actionImportFlightTrackFTML"), (open_csv, "actionImportFlightTrackCSV"), + "open_file", [(open_ftml, "actionImportFlightTrackFTML"), (open_txt, "actionImportFlightTrackText"), (open_fls, "actionImportFlightTrackFliteStar")]) def test_plugin_import(self, open_file): with mock.patch("mslib.msui.mss_pyui.config_loader", return_value=self.import_plugins): @@ -238,7 +252,6 @@ def test_plugin_import(self, open_file): assert self.window.listFlightTracks.count() == 2 @pytest.mark.parametrize("save_file", [[save_ftml, "actionExportFlightTrackFTML"], - [save_csv, "actionExportFlightTrackCSV"], [save_txt, "actionExportFlightTrackText"]]) def test_plugin_export(self, save_file): with mock.patch("mslib.msui.mss_pyui.config_loader", return_value=self.export_plugins): diff --git a/mslib/msui/_tests/test_tableview.py b/mslib/msui/_tests/test_tableview.py index 7fe932169..f682de905 100644 --- a/mslib/msui/_tests/test_tableview.py +++ b/mslib/msui/_tests/test_tableview.py @@ -107,7 +107,7 @@ def test_insertremove_hexagon(self, mockbox): @mock.patch("mslib.msui.performance_settings.get_open_filename", return_value=fs.path.join( os.path.dirname(__file__), "..", "..", "..", "docs", "samples", "config", - "mss", "performance_simple.json")) + "mss", "performance_simple.json.sample")) def test_performance(self, mockopen, mockcrit): """ Check effect of performance settings on TableView diff --git a/mslib/msui/_tests/test_topview.py b/mslib/msui/_tests/test_topview.py index 96b1ddbe6..e8868a39d 100644 --- a/mslib/msui/_tests/test_topview.py +++ b/mslib/msui/_tests/test_topview.py @@ -25,8 +25,6 @@ limitations under the License. """ -from __future__ import division - import mock import os import pytest diff --git a/mslib/msui/airdata_dockwidget.py b/mslib/msui/airdata_dockwidget.py index 4de408861..0040ffa96 100644 --- a/mslib/msui/airdata_dockwidget.py +++ b/mslib/msui/airdata_dockwidget.py @@ -28,7 +28,7 @@ from mslib.msui.mss_qt 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 _airspace_cache, update_airspace, get_airports +from mslib.utils.airdata import get_available_airspaces, update_airspace, get_airports class AirdataDockwidget(QtWidgets.QWidget, ui.Ui_AirdataDockwidget): @@ -41,9 +41,11 @@ def __init__(self, parent=None, view=None): code_to_name = {country.alpha_2.lower(): country.name for country in pycountry.countries} self.cbAirspaces.addItems([f"{code_to_name.get(airspace[0].split('_')[0], 'Unknown')} " f"{airspace[0].split('_')[0]}" - for airspace in _airspace_cache]) + for airspace in get_available_airspaces()]) self.cbAirportType.addItems(["small_airport", "medium_airport", "large_airport", "heliport", "balloonport", "seaplane_base", "closed"]) + self.cbAirportType.empty_text = "Click here to select airports..." + self.cbAirspaces.empty_text = "Click here to select airspaces..." self.settings_tag = "airdatadock" settings = load_settings_qsettings(self.settings_tag, {"draw_airports": False, "draw_airspaces": False, @@ -56,8 +58,12 @@ def __init__(self, parent=None, view=None): self.cbAirspaces.currentData()])) self.btApply.clicked.connect(self.redraw_map) + self.cbAirspaces.currentTextChanged.connect(self.adjust_ui_airspaces) + self.cbAirportType.currentTextChanged.connect(self.adjust_ui_airports) + self.cbDrawAirports.setChecked(settings["draw_airports"]) self.cbDrawAirspaces.setChecked(settings["draw_airspaces"]) + for airspace in settings["airspaces"]: i = self.cbAirspaces.findText(airspace) if i != -1: @@ -71,6 +77,24 @@ def __init__(self, parent=None, view=None): self.sbFrom.setValue(settings["filter_from"]) self.sbTo.setValue(settings["filter_to"]) + def adjust_ui_airspaces(self): + """ + Disables and unchecks, or vice versa, airspace UI elements depending on the current user selection + """ + airspaces_enabled = len(self.cbAirspaces.currentData()) > 0 + self.cbDrawAirspaces.setChecked(airspaces_enabled) + self.cbDrawAirspaces.setEnabled(airspaces_enabled) + self.btDownloadAsp.setEnabled(airspaces_enabled) + + def adjust_ui_airports(self): + """ + Disables and unchecks, or vice versa, airport UI elements depending on the current user selection + """ + airports_enabled = len(self.cbAirportType.currentData()) > 0 + self.cbDrawAirports.setChecked(airports_enabled) + self.cbDrawAirports.setEnabled(airports_enabled) + self.btDownload.setEnabled(airports_enabled) + def redraw_map(self): if self.view.map is not None: self.view.map.set_draw_airports(self.cbDrawAirports.isChecked(), port_type=self.cbAirportType.currentData()) diff --git a/mslib/msui/constants.py b/mslib/msui/constants.py index 9eb2a46b9..a38993fe0 100644 --- a/mslib/msui/constants.py +++ b/mslib/msui/constants.py @@ -46,6 +46,8 @@ if not os.path.exists(_dir): os.makedirs(_dir) +GRAVATAR_DIR_PATH = fs.path.join(MSS_CONFIG_PATH, "gravatars") + MSS_SETTINGS = os.getenv('MSS_SETTINGS', os.path.join(MSS_CONFIG_PATH, "mss_settings.json")) # We try to create an empty MSS_SETTINGS file if not existing diff --git a/mslib/msui/editor.py b/mslib/msui/editor.py index d36724d49..f44707ab6 100644 --- a/mslib/msui/editor.py +++ b/mslib/msui/editor.py @@ -36,7 +36,7 @@ from mslib.msui.constants import MSS_SETTINGS from mslib.msui.icons import icons from mslib.utils.config import MissionSupportSystemDefaultConfig as mss_default -from mslib.utils.config import config_loader, dict_raise_on_duplicates_empty, merge_data +from mslib.utils.config import config_loader, dict_raise_on_duplicates_empty, merge_dict from mslib.support.qt_json_view import delegate @@ -565,7 +565,7 @@ def import_config(self): if json_file_data: json_model_data = self.json_model.serialize() - options = merge_data(copy.deepcopy(json_model_data), json_file_data) + options = merge_dict(copy.deepcopy(json_model_data), json_file_data) if options == json_model_data: self.statusbar.showMessage("No option with new values found") return diff --git a/mslib/msui/mpl_map.py b/mslib/msui/mpl_map.py index 3c107066d..bc19d701a 100644 --- a/mslib/msui/mpl_map.py +++ b/mslib/msui/mpl_map.py @@ -349,7 +349,7 @@ def set_draw_airports(self, value, port_type=["small_airport"], reload=True): """ Sets airports to visible or not visible """ - if (reload or not value) and self.airports: + if (reload or not value or len(port_type) == 0) and self.airports: if OURAIRPORTS_NOTICE in self.crs_text.get_text(): self.crs_text.set_text(self.crs_text.get_text().replace(f"{OURAIRPORTS_NOTICE}\n", "")) self.airports.remove() @@ -357,14 +357,14 @@ def set_draw_airports(self, value, port_type=["small_airport"], reload=True): self.airports = None self.airtext = None self.ax.figure.canvas.mpl_disconnect(self.airports_event) - if value: + if value and len(port_type) > 0: self.draw_airports(port_type) def set_draw_airspaces(self, value, airspaces=[], range_km=None, reload=True): """ Sets airspaces to visible or not visible """ - if (reload or not value) and self.airspaces: + if (reload or not value or len(airspaces) == 0) and self.airspaces: if OPENAIP_NOTICE in self.crs_text.get_text(): self.crs_text.set_text(self.crs_text.get_text().replace(f"{OPENAIP_NOTICE}\n", "")) self.airspaces.remove() @@ -372,7 +372,7 @@ def set_draw_airspaces(self, value, airspaces=[], range_km=None, reload=True): self.airspaces = None self.airspacetext = None self.ax.figure.canvas.mpl_disconnect(self.airspace_event) - if value: + if value and len(airspaces) > 0: country_codes = [airspace.split(" ")[-1] for airspace in airspaces] self.draw_airspaces(country_codes, range_km) @@ -383,7 +383,7 @@ def draw_airspaces(self, countries=[], range_km=None): if not self.airspaces: airspaces = copy.deepcopy(get_airspaces(countries)) if not airspaces: - logging.error("Tried to draw airspaces without .aip files.") + logging.error("Tried to draw airspaces without asp files.") return for i, airspace in enumerate(airspaces): diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index aecd5c390..092df0634 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -58,7 +58,7 @@ from mslib.msui.mss_qt import ui_mscolab_connect_dialog as ui_conn from mslib.msui.mss_qt import ui_mscolab_profile_dialog as ui_profile from mslib.msui import constants -from mslib.utils.config import config_loader, load_settings_qsettings, save_settings_qsettings +from mslib.utils.config import config_loader, load_settings_qsettings, save_settings_qsettings, modify_config_file class MSColab_ConnectDialog(QtWidgets.QDialog, ui_conn.Ui_MSColabConnectDialog): @@ -236,12 +236,12 @@ def authenticate(self, data, r, url): return r def login_handler(self): + # get mscolab /token http auth credentials from cache for key, value in config_loader(dataset="MSC_login").items(): - if key not in constants.MSC_LOGIN_CACHE: + if key not in constants.MSC_LOGIN_CACHE or constants.MSC_LOGIN_CACHE[key] != value: constants.MSC_LOGIN_CACHE[key] = value auth = constants.MSC_LOGIN_CACHE.get(self.mscolab_server_url, (None, None)) - # get mscolab /token http auth credentials from cache emailid = self.loginEmailLe.text() password = self.loginPasswordLe.text() data = { @@ -268,7 +268,7 @@ def login_handler(self): self.set_status("Error", 'Oh no, your credentials were incorrect.') elif r.text == "Unauthorized Access": # Server auth required for logging in - self.login_data = [data, r, url, auth] + self.login_data = [data, r, url] self.connectBtn.setEnabled(False) self.stackedWidget.setCurrentWidget(self.httpAuthPage) # ToDo disconnect functions already connected to httpBb buttonBox @@ -277,21 +277,39 @@ def login_handler(self): else: self.mscolab.after_login(emailid, self.mscolab_server_url, r) + def save_user_credentials_to_config_file(self, emailid, password): + data_to_save_in_config_file = { + "MSCOLAB_mailid": emailid, + "MSCOLAB_password": password + } + + if config_loader(dataset="MSCOLAB_mailid") != "" and config_loader(dataset="MSCOLAB_password") != "": + 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, auth = self.login_data + data, r, url = self.login_data emailid = data['email'] if r.status_code == 401: r = self.authenticate(data, r, url) if r.status_code == 200 and r.text not in ["False", "Unauthorized Access"]: - constants.MSC_LOGIN_CACHE[self.mscolab_server_url] = (auth[0], auth[1]) + self.save_auth_credentials_to_config_file() self.mscolab.after_login(emailid, self.mscolab_server_url, r) else: self.set_status("Error", 'Oh no, server authentication were incorrect.') self.stackedWidget.setCurrentWidget(self.loginPage) def new_user_handler(self): + # get mscolab /token http auth credentials from cache for key, value in config_loader(dataset="MSC_login").items(): - if key not in constants.MSC_LOGIN_CACHE: + if key not in constants.MSC_LOGIN_CACHE or constants.MSC_LOGIN_CACHE[key] != value: constants.MSC_LOGIN_CACHE[key] = value auth = constants.MSC_LOGIN_CACHE.get(self.mscolab_server_url, (None, None)) @@ -325,10 +343,16 @@ def new_user_handler(self): if r.status_code == 204: self.set_status("Success", 'You are registered, confirm your email to log in.') + self.save_user_credentials_to_config_file(emailid, password) self.stackedWidget.setCurrentWidget(self.loginPage) + self.loginEmailLe.setText(emailid) + self.loginPasswordLe.setText(password) elif r.status_code == 201: - self.set_status("Success", 'You are registered') - self.stackedWidget.setCurrentWidget(self.loginPage) + self.set_status("Success", 'You are registered.') + self.save_user_credentials_to_config_file(emailid, password) + 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) @@ -343,13 +367,41 @@ 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): + msc_login_data = config_loader(dataset="MSC_login") + msc_login_data[self.mscolab_server_url] = ( + self.settings["auth"][self.mscolab_server_url][0], + self.settings["auth"][self.mscolab_server_url][1] + ) + data_to_save_in_config_file = { + "MSC_login": msc_login_data + } + modify_config_file(data_to_save_in_config_file) + def newuser_server_auth(self): data, r, url = self.newuser_data r = self.authenticate(data, r, url) if r.status_code == 201: - constants.MSC_LOGIN_CACHE[self.mscolab_server_url] = (data['username'], data['password']) - self.set_status("Success", "You are registered, you can now log in.") + 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(f"Unexpected error occured {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) @@ -373,6 +425,13 @@ def __init__(self, parent=None, data_dir=None): self.ui.userOptionsTb.hide() self.ui.actionAddOperation.setEnabled(False) self.hide_operation_options() + self.ui.activeOperationDesc.setHidden(True) + + # reset operation description label for flight tracks and open views + self.ui.listFlightTracks.itemDoubleClicked.connect( + lambda: self.ui.activeOperationDesc.setText("Select Operation to View Description.")) + self.ui.listViews.itemDoubleClicked.connect( + lambda: self.ui.activeOperationDesc.setText("Select Operation to View Description.")) # connect operation options menu actions self.ui.actionAddOperation.triggered.connect(self.add_operation_handler) @@ -380,6 +439,12 @@ def __init__(self, parent=None, data_dir=None): self.ui.actionVersionHistory.triggered.connect(self.operation_options_handler) self.ui.actionManageUsers.triggered.connect(self.operation_options_handler) self.ui.actionDeleteOperation.triggered.connect(self.operation_options_handler) + self.ui.actionUpdateOperationDesc.triggered.connect(self.update_description_handler) + self.ui.actionRenameOperation.triggered.connect(self.rename_operation_handler) + self.ui.actionDescription.triggered.connect( + lambda: QtWidgets.QMessageBox.information(None, + "Operation Description", + f"{self.active_operation_desc}")) self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) # connect slot for handling operation options combobox @@ -410,10 +475,10 @@ def __init__(self, parent=None, data_dir=None): self.waypoints_model = None # Store active operation's file path self.local_ftml_file = None + # Store active_operation_description + self.active_operation_desc = None # connection object to interact with sockets self.conn = None - # assign ids to view-window - # self.view_id = 0 # operation window self.chat_window = None # Admin Window @@ -430,7 +495,9 @@ def __init__(self, parent=None, data_dir=None): self.mscolab_server_url = None # User email self.email = None + # Display all categories by default self.selected_category = "ANY" + # Gravatar image path self.gravatar = None # set data dir, uri @@ -523,32 +590,46 @@ 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.actionDeleteOperation.setEnabled(False) + # disable category change selector + self.ui.filterCategoryCb.setEnabled(False) + def fetch_gravatar(self, refresh=False): email_hash = hashlib.md5(bytes(self.email.encode('utf-8')).lower()).hexdigest() - email_in_settings = self.email in config_loader(dataset="gravatar_ids") - gravatar_path = os.path.join(constants.MSS_CONFIG_PATH, 'gravatars') - gravatar = os.path.join(gravatar_path, f"{email_hash}.png") - - if refresh or email_in_settings: - if not os.path.exists(gravatar_path): + email_in_config = self.email in config_loader(dataset="gravatar_ids") + gravatar_img_path = fs.path.join(constants.GRAVATAR_DIR_PATH, f"{email_hash}.png") + config_fs = fs.open_fs(constants.MSS_CONFIG_PATH) + + # refresh is used to fetch new gravatar associated with the email + if refresh or email_in_config: + # create directory to store cached gravatar images + if not config_fs.exists("gravatars"): try: - os.makedirs(gravatar_path) - except Exception as e: - logging.debug("Error %s", str(e)) - show_popup(self.prof_diag, "Error", "Could not create gravatar folder in config folder") + config_fs.makedirs("gravatars") + except fs.errors.CreateFailed: + logging.error('Creation of gravatar directory failed') + return + except fs.opener.errors.UnsupportedProtocol: + logging.error('FS url not supported') return - if not refresh and email_in_settings and os.path.exists(gravatar): - self.set_gravatar(gravatar) + # use cached image if refresh not requested + if not refresh and email_in_config and \ + config_fs.exists(fs.path.join("gravatars", f"{email_hash}.png")): + self.set_gravatar(gravatar_img_path) + return - gravatar = os.path.join(gravatar_path, f"{email_hash}.jpg") - gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}?s=80&d=404" + # fetch gravatar image + gravatar_url = f"https://www.gravatar.com/avatar/{email_hash}.png?s=80&d=404" try: - urllib.request.urlretrieve(gravatar_url, gravatar) - img = Image.open(gravatar) - img.save(gravatar.replace(".jpg", ".png")) - os.remove(gravatar) - gravatar = gravatar.replace(".jpg", ".png") + urllib.request.urlretrieve(gravatar_url, gravatar_img_path) + img = Image.open(gravatar_img_path) + img.save(gravatar_img_path) except urllib.error.HTTPError: if refresh: show_popup(self.prof_diag, "Error", "Gravatar not found") @@ -558,15 +639,15 @@ def fetch_gravatar(self, refresh=False): show_popup(self.prof_diag, "Error", "Could not fetch Gravatar") return - if refresh and not email_in_settings: + if refresh and not email_in_config: show_popup( self.prof_diag, "Information", "Please add your email to the gravatar_ids section in your " "mss_settings.json to automatically fetch your gravatar", - icon=1,) + icon=1, ) - self.set_gravatar(gravatar) + self.set_gravatar(gravatar_img_path) def set_gravatar(self, gravatar=None): self.gravatar = gravatar @@ -596,15 +677,18 @@ def remove_gravatar(self): if self.gravatar is None: return - if os.path.exists(self.gravatar): - os.remove(self.gravatar) - if self.email in config_loader(dataset="gravatar_ids"): - show_popup( - self.prof_diag, - "Information", - "Please remove your email from gravatar_ids section in your " - "mss_settings.json to not fetch gravatar automatically", - icon=1,) + # remove cached gravatar image if not found in config + config_fs = fs.open_fs(constants.MSS_CONFIG_PATH) + if config_fs.exists("gravatars"): + if fs.open_fs(constants.GRAVATAR_DIR_PATH).exists(fs.path.basename(self.gravatar)): + fs.open_fs(constants.GRAVATAR_DIR_PATH).remove(fs.path.basename(self.gravatar)) + if self.email in config_loader(dataset="gravatar_ids"): + show_popup( + self.prof_diag, + "Information", + "Please remove your email from gravatar_ids section in your " + "mss_settings.json to not fetch gravatar automatically", + icon=1, ) self.set_gravatar() @@ -914,6 +998,86 @@ def handle_delete_operation(self): show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() + def set_operation_desc_label(self, op_desc): + self.active_operation_desc = op_desc + desc_count = len(str(self.active_operation_desc)) + if desc_count < 95: + self.ui.activeOperationDesc.setText( + self.ui.tr(f"{self.active_operation_name}: {self.active_operation_desc}")) + else: + self.ui.activeOperationDesc.setText( + "Description is too long to show here, for long descriptions go " + "to operations menu.") + + def update_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( + "You're about to update the operation description" + "\nEnter new operation description: " + ), + text=self.active_operation_desc + ) + if ok: + data = { + "token": self.token, + "op_id": self.active_op_id, + "attribute": 'description', + "value": entered_operation_desc + } + url = url_join(self.mscolab_server_url, 'update_operation') + r = requests.post(url, data=data) + if r.text == "True": + # Update active operation description label + 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.") + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() + + def rename_operation_handler(self): + # only after login + if verify_user_token(self.mscolab_server_url, self.token): + entered_operation_name, ok = QtWidgets.QInputDialog.getText( + self.ui, + self.ui.tr("Rename Operation"), + self.ui.tr( + f"You're about to rename the operation - '{self.active_operation_name}' " + f"Enter new operation name: " + ), + ) + if ok: + data = { + "token": self.token, + "op_id": self.active_op_id, + "attribute": 'path', + "value": entered_operation_name + } + url = url_join(self.mscolab_server_url, 'update_operation') + r = requests.post(url, data=data) + 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_desc) + 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) + + 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() + def handle_work_locally_toggle(self): if verify_user_token(self.mscolab_server_url, self.token): if self.ui.workLocallyCheckbox.isChecked(): @@ -1149,6 +1313,8 @@ def delete_operation_from_list(self, op_id): # self.ui.workingStatusLabel.setEnabled(False) self.close_external_windows() self.hide_operation_options() + # reset operation_description label text + self.ui.activeOperationDesc.setText("Select Operation to View Description.") # Update operation list remove_item = None @@ -1214,6 +1380,7 @@ def add_operations_to_ui(self): for operation in operations: operation_desc = f'{operation["path"]} - {operation["access_level"]}' widgetItem = QtWidgets.QListWidgetItem(operation_desc, parent=self.ui.listOperationsMSC) + widgetItem.active_operation_desc = operation["description"] widgetItem.op_id = operation["op_id"] widgetItem.access_level = operation["access_level"] widgetItem.operation_path = operation["path"] @@ -1251,8 +1418,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.waypoints_model = None + # Set active operation description + self.set_operation_desc_label(self.active_operation_desc) # set active flightpath here self.load_wps_from_server() # display working status @@ -1308,7 +1478,8 @@ def show_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.menuProperties.setEnabled(True) + self.ui.actionRenameOperation.setEnabled(False) if self.access_level == "viewer": self.ui.menuImportFlightTrack.setEnabled(False) return @@ -1330,12 +1501,15 @@ def show_operation_options(self): if self.access_level in ["creator", "admin"]: self.ui.actionManageUsers.setEnabled(True) + self.ui.actionUpdateOperationDesc.setEnabled(True) + self.ui.filterCategoryCb.setEnabled(True) else: if self.admin_window is not None: self.admin_window.close() if self.access_level in ["creator"]: - self.ui.menuProperties.setEnabled(True) + self.ui.actionDeleteOperation.setEnabled(True) + self.ui.actionRenameOperation.setEnabled(True) self.ui.menuImportFlightTrack.setEnabled(True) @@ -1502,6 +1676,12 @@ def logout(self): self.ui.userOptionsTb.hide() self.ui.connectBtn.show() self.ui.actionAddOperation.setEnabled(False) + # hide operation description + self.ui.activeOperationDesc.setHidden(True) + # reset description label text + 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() @@ -1512,12 +1692,11 @@ def logout(self): self.ui.workLocallyCheckbox.blockSignals(False) # remove temporary gravatar image - if self.gravatar is not None: - if self.email not in config_loader(dataset="gravatar_ids") and os.path.exists(self.gravatar): - try: - os.remove(self.gravatar) - except Exception as e: - logging.debug(f"Error while removing gravatar cache... {e}") + config_fs = fs.open_fs(constants.MSS_CONFIG_PATH) + if config_fs.exists("gravatars") and self.gravatar is not None: + if self.email not in config_loader(dataset="gravatar_ids") and \ + fs.open_fs(constants.GRAVATAR_DIR_PATH).exists(fs.path.basename(self.gravatar)): + fs.open_fs(constants.GRAVATAR_DIR_PATH).remove(fs.path.basename(self.gravatar)) # clear gravatar image path self.gravatar = None # clear user email diff --git a/mslib/msui/mscolab_admin_window.py b/mslib/msui/mscolab_admin_window.py index f7f3fbc4d..6f5a4861a 100644 --- a/mslib/msui/mscolab_admin_window.py +++ b/mslib/msui/mscolab_admin_window.py @@ -56,7 +56,9 @@ def __init__(self, token, op_id, user, operation_name, operations, conn, parent= self.user = user self.operation_name = operation_name self.operations = operations + self.initial_operations = self.operations self.conn = conn + self.mscolab_category = config_loader(dataset="MSCOLAB_category") self.addUsers = [] self.modifyUsers = [] @@ -81,11 +83,32 @@ def __init__(self, token, op_id, user, operation_name, operations, conn, parent= # Setting handlers for connection manager self.conn.signal_operation_permissions_updated.connect(self.handle_permissions_updated) - + self.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) self.set_label_text() self.load_import_operations() self.load_users_without_permission() self.load_users_with_permission() + categories = set(["ANY"]) + for operation in self.operations: + categories.add(operation["category"]) + categories.remove("ANY") + categories = sorted(list(categories)) + self.filterCategoryCb.addItems(categories) + if self.mscolab_category in categories: + self.filterCategoryCb.setCurrentIndex(categories.index(self.mscolab_category) + 1) + + def operation_category_handler(self): + # only after_login + self.operations = self.initial_operations + if self.mscolab_server_url is not None: + self.selected_category = self.filterCategoryCb.currentText() + _operations = [] + if self.selected_category != "ANY": + for operation in self.operations: + if operation["category"] == self.selected_category: + _operations.append(operation) + self.operations = _operations + self.populate_import_permission_cb() def populate_table(self, table, users): users.sort() diff --git a/mslib/msui/mss_pyui.py b/mslib/msui/mss_pyui.py index 460792353..a2dbd9ae6 100644 --- a/mslib/msui/mss_pyui.py +++ b/mslib/msui/mss_pyui.py @@ -42,6 +42,7 @@ import shutil import sys import fs +import warnings from mslib import __version__ from mslib.msui.mss_qt import ui_mainwindow as ui @@ -485,8 +486,8 @@ def add_plugins(self): picker_default = config_loader(dataset="filepicker_default") 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.import_plugins = {"CSV": (load_from_csv, "csv")} - self.export_plugins = {"CSV": (save_to_csv, "csv")} + self.import_plugins = {} + self.export_plugins = {} self.add_import_plugins(picker_default) self.add_export_plugins(picker_default) @@ -977,6 +978,8 @@ def closeEvent(self, event): def main(): + warnings.warn("In the next major version we will rename the mss command to msui" + " and the module from mss_pyui to msui", DeprecationWarning) try: prefix = os.environ["CONDA_DEFAULT_ENV"] except KeyError: diff --git a/mslib/msui/mss_qt.py b/mslib/msui/mss_qt.py index 280f42697..c73893935 100644 --- a/mslib/msui/mss_qt.py +++ b/mslib/msui/mss_qt.py @@ -276,6 +276,8 @@ def __init__(self, *args, **kwargs): # Prevent popup from closing when clicking on an item self.view().viewport().installEventFilter(self) + self.empty_text = "" + def resizeEvent(self, event): # Recompute text to elide as needed self.updateText() @@ -326,6 +328,9 @@ def updateText(self): if self.model().item(i).checkState() == QtCore.Qt.Checked: texts.append(self.model().item(i).text()) text = ", ".join(texts) + if len(text) == 0: + text = self.empty_text + self.lineEdit().setText(text) def addItem(self, text, data=None): diff --git a/mslib/msui/multilayers.py b/mslib/msui/multilayers.py index 156c088da..1300196f5 100644 --- a/mslib/msui/multilayers.py +++ b/mslib/msui/multilayers.py @@ -289,6 +289,15 @@ def get_active_layers(self, only_synced=False): active_layers.append(widget) return sorted(active_layers, key=lambda layer: self.get_multilayer_priority(layer)) + def get_plot_title(self): + """ + Returns the plot title + """ + title = self.get_current_layer().layerobj.title + if len(self.get_active_layers()) > 1 and self.get_current_layer().checkState(0): + title = f"{title} (and {len(self.get_active_layers()) - 1} more)" + return title + def update_priority_selection(self): """ Updates the priority numbers for the selected layers to the sorted self.layers_priority list diff --git a/mslib/msui/qt5/ui_mainwindow.py b/mslib/msui/qt5/ui_mainwindow.py index a676dfed2..fc2fff426 100644 --- a/mslib/msui/qt5/ui_mainwindow.py +++ b/mslib/msui/qt5/ui_mainwindow.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'mslib/msui/ui/ui_mainwindow.ui' +# Form implementation generated from reading ui file 'ui_mainwindow.ui' # # Created by: PyQt5 UI code generator 5.12.3 # @@ -32,7 +32,20 @@ def setupUi(self, MSSMainWindow): self.listViews = QtWidgets.QListWidget(self.openViewsGb) self.listViews.setObjectName("listViews") self.openViewsVL.addWidget(self.listViews) - self.gridLayout.addWidget(self.openViewsGb, 2, 0, 1, 1) + self.gridLayout.addWidget(self.openViewsGb, 3, 0, 1, 1) + self.openFlightTracksGb = QtWidgets.QGroupBox(self.centralwidget) + self.openFlightTracksGb.setTitle("") + self.openFlightTracksGb.setObjectName("openFlightTracksGb") + self.verticalLayout = QtWidgets.QVBoxLayout(self.openFlightTracksGb) + self.verticalLayout.setContentsMargins(8, 8, 8, 8) + self.verticalLayout.setObjectName("verticalLayout") + self.openFlightTracksLabel = QtWidgets.QLabel(self.openFlightTracksGb) + self.openFlightTracksLabel.setObjectName("openFlightTracksLabel") + self.verticalLayout.addWidget(self.openFlightTracksLabel) + self.listFlightTracks = QtWidgets.QListWidget(self.openFlightTracksGb) + self.listFlightTracks.setObjectName("listFlightTracks") + self.verticalLayout.addWidget(self.listFlightTracks) + self.gridLayout.addWidget(self.openFlightTracksGb, 2, 0, 1, 1) self.userOptionsHL = QtWidgets.QHBoxLayout() self.userOptionsHL.setContentsMargins(0, -1, 0, -1) self.userOptionsHL.setObjectName("userOptionsHL") @@ -87,25 +100,15 @@ def setupUi(self, MSSMainWindow): self.filterCategoryCb.setObjectName("filterCategoryCb") self.filterCategoryCb.addItem("") self.gridLayout_3.addWidget(self.filterCategoryCb, 5, 1, 1, 1) - self.gridLayout.addWidget(self.openOperationsGb, 1, 1, 2, 1) - self.openFlightTracksGb = QtWidgets.QGroupBox(self.centralwidget) - self.openFlightTracksGb.setTitle("") - self.openFlightTracksGb.setObjectName("openFlightTracksGb") - self.verticalLayout = QtWidgets.QVBoxLayout(self.openFlightTracksGb) - self.verticalLayout.setContentsMargins(8, 8, 8, 8) - self.verticalLayout.setObjectName("verticalLayout") - self.openFlightTracksLabel = QtWidgets.QLabel(self.openFlightTracksGb) - self.openFlightTracksLabel.setObjectName("openFlightTracksLabel") - self.verticalLayout.addWidget(self.openFlightTracksLabel) - self.listFlightTracks = QtWidgets.QListWidget(self.openFlightTracksGb) - self.listFlightTracks.setObjectName("listFlightTracks") - self.verticalLayout.addWidget(self.listFlightTracks) - self.gridLayout.addWidget(self.openFlightTracksGb, 1, 0, 1, 1) + self.gridLayout.addWidget(self.openOperationsGb, 2, 1, 2, 1) + self.activeOperationDesc = QtWidgets.QLabel(self.centralwidget) + self.activeOperationDesc.setObjectName("activeOperationDesc") + self.gridLayout.addWidget(self.activeOperationDesc, 1, 0, 1, 2) self.gridLayout.setColumnStretch(0, 1) self.gridLayout.setColumnStretch(1, 1) MSSMainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MSSMainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 20)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 26)) self.menubar.setNativeMenuBar(False) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) @@ -169,8 +172,15 @@ def setupUi(self, MSSMainWindow): self.actionNewFlightTrack = QtWidgets.QAction(MSSMainWindow) self.actionNewFlightTrack.setObjectName("actionNewFlightTrack") self.actionAddOperation = QtWidgets.QAction(MSSMainWindow) + self.actionAddOperation.setObjectName("actionAddOperation") self.actionSearch = QtWidgets.QAction(MSSMainWindow) self.actionSearch.setObjectName("actionSearch") + self.actionDescription = QtWidgets.QAction(MSSMainWindow) + self.actionDescription.setObjectName("actionDescription") + self.actionUpdateOperationDesc = QtWidgets.QAction(MSSMainWindow) + self.actionUpdateOperationDesc.setObjectName("actionUpdateOperationDesc") + self.actionRenameOperation = QtWidgets.QAction(MSSMainWindow) + self.actionRenameOperation.setObjectName("actionRenameOperation") self.menuNew.addAction(self.actionNewFlightTrack) self.menuNew.addAction(self.actionAddOperation) self.menuFile.addAction(self.menuNew.menuAction()) @@ -197,6 +207,9 @@ def setupUi(self, MSSMainWindow): 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.actionRenameOperation) self.menuOperation.addAction(self.actionChat) self.menuOperation.addAction(self.actionVersionHistory) self.menuOperation.addAction(self.actionManageUsers) @@ -223,13 +236,18 @@ def retranslateUi(self, MSSMainWindow): MSSMainWindow.setWindowTitle(_translate("MSSMainWindow", "Mission Support System")) self.openViewsLabel.setText(_translate("MSSMainWindow", "Open Views:")) self.listViews.setToolTip(_translate("MSSMainWindow", "Double-click a view to bring it to the front.")) + self.openFlightTracksLabel.setText(_translate("MSSMainWindow", "Flight Tracks:")) + self.listFlightTracks.setToolTip(_translate("MSSMainWindow", "List of open flight tracks.\n" +"Double-click a flight track to activate it.\n" +"Save a flight track to name it.")) self.mscStatusLabel.setText(_translate("MSSMainWindow", "Status: Disconnected")) self.usernameLabel.setText(_translate("MSSMainWindow", "User")) self.userOptionsTb.setToolTip(_translate("MSSMainWindow", "Profile options")) self.connectBtn.setToolTip(_translate("MSSMainWindow", "Connect to an MSColab Server")) self.connectBtn.setText(_translate("MSSMainWindow", "Connect to MSColab")) self.workingStatusLabel.setText(_translate("MSSMainWindow", "No operations selected")) - self.listOperationsMSC.setToolTip(_translate("MSSMainWindow", "List of mscolab operations.")) + self.listOperationsMSC.setToolTip(_translate("MSSMainWindow", "List of mscolab operations.\n" +"Double click a operation to activate and view its description.")) self.serverOptionsCb.setToolTip(_translate("MSSMainWindow", "Fetch/Save Server options")) self.serverOptionsCb.setItemText(0, _translate("MSSMainWindow", "Server Options")) self.serverOptionsCb.setItemText(1, _translate("MSSMainWindow", "Fetch From Server")) @@ -241,10 +259,7 @@ def retranslateUi(self, MSSMainWindow): self.filterCategoryCb.setWhatsThis(_translate("MSSMainWindow", "filter by operation category")) self.filterCategoryCb.setCurrentText(_translate("MSSMainWindow", "ANY")) self.filterCategoryCb.setItemText(0, _translate("MSSMainWindow", "ANY")) - self.openFlightTracksLabel.setText(_translate("MSSMainWindow", "Flight Tracks:")) - self.listFlightTracks.setToolTip(_translate("MSSMainWindow", "List of open flight tracks.\n" -"Double-click a flight track to activate it.\n" -"Save a flight track to name it.")) + self.activeOperationDesc.setText(_translate("MSSMainWindow", "Select Operation to View Description.")) self.menuFile.setTitle(_translate("MSSMainWindow", "&File")) self.menuImportFlightTrack.setTitle(_translate("MSSMainWindow", "Import Flight Track")) self.menuExportActiveFlightTrack.setTitle(_translate("MSSMainWindow", "Export Flight Track")) @@ -287,3 +302,7 @@ def retranslateUi(self, MSSMainWindow): self.actionSearch.setText(_translate("MSSMainWindow", "Search")) self.actionSearch.setToolTip(_translate("MSSMainWindow", "Search for interactive text in the UI")) self.actionSearch.setShortcut(_translate("MSSMainWindow", "Ctrl+F")) + self.actionDescription.setText(_translate("MSSMainWindow", "View Description")) + self.actionUpdateOperationDesc.setText(_translate("MSSMainWindow", "Update Description")) + self.actionRenameOperation.setText(_translate("MSSMainWindow", "Rename Operation")) + diff --git a/mslib/msui/qt5/ui_mscolab_admin_window.py b/mslib/msui/qt5/ui_mscolab_admin_window.py index 07d76e073..8c952d3ff 100644 --- a/mslib/msui/qt5/ui_mscolab_admin_window.py +++ b/mslib/msui/qt5/ui_mscolab_admin_window.py @@ -93,6 +93,10 @@ def setupUi(self, MscolabAdminWindow): self.label_3 = QtWidgets.QLabel(self.centralwidget) self.label_3.setObjectName("label_3") self.horizontalLayout_8.addWidget(self.label_3) + self.filterCategoryCb = QtWidgets.QComboBox(self.centralwidget) + self.filterCategoryCb.setObjectName("filterCategoryCb") + self.filterCategoryCb.addItem("") + self.horizontalLayout_8.addWidget(self.filterCategoryCb) self.importPermissionsCB = QtWidgets.QComboBox(self.centralwidget) self.importPermissionsCB.setObjectName("importPermissionsCB") self.horizontalLayout_8.addWidget(self.importPermissionsCB) @@ -181,7 +185,7 @@ def setupUi(self, MscolabAdminWindow): def retranslateUi(self, MscolabAdminWindow): _translate = QtCore.QCoreApplication.translate - MscolabAdminWindow.setWindowTitle(_translate("MscolabAdminWindow", "Admin Window")) + MscolabAdminWindow.setWindowTitle(_translate("MscolabAdminWindow", "Manage Users")) self.usernameLabel.setText(_translate("MscolabAdminWindow", "Logged In: ")) self.operationNameLabel.setText(_translate("MscolabAdminWindow", "Operation: ")) self.label.setText(_translate("MscolabAdminWindow", "All Users Without Permission:")) @@ -196,6 +200,7 @@ def retranslateUi(self, MscolabAdminWindow): self.addUsersBtn.setToolTip(_translate("MscolabAdminWindow", "Add the selected users to the operation")) self.addUsersBtn.setText(_translate("MscolabAdminWindow", "Add")) self.label_3.setText(_translate("MscolabAdminWindow", "Clone Operation Permissions:")) + self.filterCategoryCb.setItemText(0, _translate("MscolabAdminWindow", "ANY")) self.importPermissionsBtn.setToolTip(_translate("MscolabAdminWindow", "Import permissions from another operation")) self.importPermissionsBtn.setText(_translate("MscolabAdminWindow", "Clone")) self.label_2.setText(_translate("MscolabAdminWindow", "All Users With Permission:")) diff --git a/mslib/msui/ui/ui_mainwindow.ui b/mslib/msui/ui/ui_mainwindow.ui index e563fd857..0a522d5e6 100644 --- a/mslib/msui/ui/ui_mainwindow.ui +++ b/mslib/msui/ui/ui_mainwindow.ui @@ -20,7 +20,7 @@ Mission Support System - + 8 @@ -36,7 +36,7 @@ 2 - + @@ -68,6 +68,43 @@ + + + + + + + + 8 + + + 8 + + + 8 + + + 8 + + + + + Flight Tracks: + + + + + + + List of open flight tracks. +Double-click a flight track to activate it. +Save a flight track to name it. + + + + + + @@ -118,7 +155,7 @@ - + @@ -149,7 +186,8 @@ - List of mscolab operations. + List of mscolab operations. +Double click a operation to activate and view its description. @@ -226,41 +264,11 @@ - - - - + + + + Select Operation to View Description. - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Flight Tracks: - - - - - - - List of open flight tracks. -Double-click a flight track to activate it. -Save a flight track to name it. - - - - @@ -271,7 +279,7 @@ Save a flight track to name it. 0 0 738 - 20 + 26 @@ -341,6 +349,9 @@ Save a flight track to name it. Properties + + + @@ -500,6 +511,21 @@ Save a flight track to name it. Ctrl+F + + + View Description + + + + + Update Description + + + + + Rename Operation + + connectBtn diff --git a/mslib/msui/ui/ui_mscolab_admin_window.ui b/mslib/msui/ui/ui_mscolab_admin_window.ui index c8a0229e1..38364b941 100644 --- a/mslib/msui/ui/ui_mscolab_admin_window.ui +++ b/mslib/msui/ui/ui_mscolab_admin_window.ui @@ -11,7 +11,7 @@ - Admin Window + Manage Users @@ -191,6 +191,15 @@ + + + + + ANY + + + + diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index a2431dd7d..bc9a83aaf 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -1547,7 +1547,7 @@ def display_retrieved_image(self, imgs, legend_imgs, layer, style, init_time, va self.view.draw_image(self.squash_multiple_images(imgs)) self.view.draw_legend(self.append_multiple_images(legend_imgs)) style_title = self.multilayers.get_current_layer().get_style() - self.view.draw_metadata(title=self.multilayers.get_current_layer().layerobj.title, + self.view.draw_metadata(title=self.multilayers.get_plot_title(), init_time=init_time, valid_time=valid_time, style=style_title) @@ -1607,7 +1607,7 @@ def get_map(self, layers=None): def display_retrieved_image(self, imgs, legend_imgs, layer, style, init_time, valid_time, level): # Plot the image on the view canvas. style_title = self.multilayers.get_current_layer().get_style() - self.view.draw_metadata(title=self.multilayers.get_current_layer().layerobj.title, + self.view.draw_metadata(title=self.multilayers.get_plot_title(), init_time=init_time, valid_time=valid_time, level=level, @@ -1688,7 +1688,7 @@ def display_retrieved_image(self, imgs, legend_imgs, layer, style, init_time, va self.view.draw_image(imgs, colors, scales) self.view.draw_legend(self.append_multiple_images(legend_imgs)) style_title = self.multilayers.get_current_layer().get_style() - self.view.draw_metadata(title=self.multilayers.get_current_layer().layerobj.title, + self.view.draw_metadata(title=self.multilayers.get_plot_title(), init_time=init_time, valid_time=valid_time, style=style_title) diff --git a/mslib/mswms/_tests/test_demodata.py b/mslib/mswms/_tests/test_demodata.py index e8c662a0f..d4cb1f0e9 100644 --- a/mslib/mswms/_tests/test_demodata.py +++ b/mslib/mswms/_tests/test_demodata.py @@ -26,8 +26,6 @@ limitations under the License. """ -from past.builtins import basestring - import numpy as np from mslib._tests.constants import SERVER_CONFIG_FS, DATA_FS, ROOT_FS, SERVER_CONFIG_FILE import mslib.mswms.demodata as demodata @@ -56,14 +54,14 @@ def test_get_profile(self): def test_generate_field(self): data, unit = demodata.generate_field("air_pressure", [10, 100, 500], "geopotential_height", 2, 4, 5) assert isinstance(data, np.ndarray) - assert isinstance(unit, basestring) + assert isinstance(unit, str) assert len(data.shape) == 4 assert all(_x == _y for _x, _y in zip(data.shape, (2, 3, 4, 5))) def test_generate_surface(self): data, unit = demodata.generate_surface("atmosphere_boundary_layer_thickness", 2, 4, 5) assert isinstance(data, np.ndarray) - assert isinstance(unit, basestring) + assert isinstance(unit, str) assert len(data.shape) == 3 assert all(_x == _y for _x, _y in zip(data.shape, (2, 4, 5))) @@ -72,7 +70,7 @@ def test_SURFACE(self): for key, entry in list(demodata._SURFACE.items()): assert "data" in entry assert "unit" in entry - assert isinstance(entry["unit"], basestring) + assert isinstance(entry["unit"], str) assert isinstance(entry["data"], np.ndarray) def test_PROFILES(self): @@ -80,5 +78,5 @@ def test_PROFILES(self): for key, entry in list(demodata._PROFILES.items()): assert "data" in entry assert "unit" in entry - assert isinstance(entry["unit"], basestring) + assert isinstance(entry["unit"], str) assert isinstance(entry["data"], np.ndarray) diff --git a/mslib/mswms/_tests/test_utils.py b/mslib/mswms/_tests/test_targets.py similarity index 87% rename from mslib/mswms/_tests/test_utils.py rename to mslib/mswms/_tests/test_targets.py index 6819da502..590cedea9 100644 --- a/mslib/mswms/_tests/test_utils.py +++ b/mslib/mswms/_tests/test_targets.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ - mslib.mswms._tests.test_utils + mslib.mswms._tests.test_targets ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This module provides pytest functions to tests mswms.wms @@ -31,10 +31,12 @@ def test_targets(): for standard_name in Targets.get_targets(): unit = Targets.get_unit(standard_name) - units(unit[0]) # ensure that the unit may be parsed - assert unit[1] == 1 # no conversion outside pint! + units(unit) # ensure that the unit may be parsed Targets.get_range(standard_name) Targets.get_thresholds(standard_name) Targets.get_range(standard_name) Targets.UNITS[standard_name] Targets.TITLES[standard_name] + + for ent in Targets.THRESHOLDS: + units(Targets.THRESHOLDS[ent][0]) diff --git a/mslib/mswms/mpl_hsec_styles.py b/mslib/mswms/mpl_hsec_styles.py index 42d69e1c0..2f6e56c68 100644 --- a/mslib/mswms/mpl_hsec_styles.py +++ b/mslib/mswms/mpl_hsec_styles.py @@ -461,7 +461,7 @@ class fnord(HS_GenericStyle): dataname = entity title = Targets.TITLES.get(entity, entity) long_name = entity - units, _ = Targets.get_unit(entity) + units = Targets.get_unit(entity) if units: title += f" ({units})" @@ -591,6 +591,7 @@ class HS_GeopotentialWindStyle_PL(MPLBasemapHorizontalSectionStyle): title = "Geopotential Height (m) and Horizontal Wind (m/s)" styles = [ ("default", "Wind Speed 10-85 m/s"), + ("wind_10_105", "Wind Speed 10-105 m/s"), ("wind_10_65", "Wind Speed 10-65 m/s"), ("wind_20_55", "Wind Speed 20-55 m/s"), ("wind_15_55", "Wind Speed 15-55 m/s")] @@ -626,11 +627,32 @@ def _plot_style(self): wind_contours = np.arange(20, 60, 5) elif self.style.lower() == "wind_15_55": wind_contours = np.arange(15, 60, 5) + elif self.style.lower() == "wind_10_105": + wind_contours = np.arange(10, 110, 5) cs = bm.contourf(self.lonmesh, self.latmesh, wind, - # wind_contours, cmap=plt.cm.hot_r, alpha=0.8) - wind_contours, cmap=plt.cm.hot_r) + wind_contours, cmap=plt.cm.inferno_r) self.add_colorbar(cs, "Wind Speed (m/s)") + # Plot geopotential height contours. + gpm = self.data["geopotential_height"] + + gpm_interval = 20 + if self.level <= 20: + gpm_interval = 120 + elif self.level <= 100: + gpm_interval = 80 + elif self.level <= 500: + gpm_interval = 40 + + geop_contours = np.arange(400, 55000, gpm_interval) + cs = bm.contour(self.lonmesh, self.latmesh, gpm, + geop_contours, colors="green", linewidths=2) + if cs.levels[0] in geop_contours[::2]: + lablevels = cs.levels[::2] + else: + lablevels = cs.levels[1::2] + ax.clabel(cs, lablevels, fontsize=14, fmt='%.0f') + # Convert wind data from m/s to knots for the wind barbs. uk = convert_to(u, "m/s", "knots") vk = convert_to(v, "m/s", "knots") @@ -645,19 +667,7 @@ def _plot_style(self): # Plot wind barbs. bm.barbs(xv, yv, udat, vdat, barbcolor='firebrick', flagcolor='firebrick', pivot='middle', - linewidths=0.5, length=6) - - # Plot geopotential height contours. - gpm = self.data["geopotential_height"] - gpm_interval = 40 if self.level <= 500 else 20 - geop_contours = np.arange(400, 28000, gpm_interval) - cs = bm.contour(self.lonmesh, self.latmesh, gpm, - geop_contours, colors="green", linewidths=2) - if cs.levels[0] in geop_contours[::2]: - lablevels = cs.levels[::2] - else: - lablevels = cs.levels[1::2] - ax.clabel(cs, lablevels, fontsize=14, fmt='%.0f') + linewidths=0.5, length=6, zorder=1) # Plot title. titlestring = "Geopotential Height (m) and Horizontal Wind (m/s) " \ @@ -996,7 +1006,7 @@ def _plot_style(self): # Shift lat/lon grid for PCOLOR (see comments in HS_EMAC_TracerStyle_SFC_01). tc = bm.pcolormesh(self.lonmesh, self.latmesh, tracer, - cmap=plt.cm.hot_r, + cmap=plt.cm.inferno_r, norm=matplotlib.colors.LogNorm(vmin=1., vmax=100.), shading='nearest', edgecolors='none') @@ -1044,7 +1054,7 @@ def _plot_style(self): tracer = data["emac_column_density"] tc = bm.pcolormesh(self.lonmesh, self.latmesh, tracer, - cmap=plt.cm.hot_r, + cmap=plt.cm.inferno_r, norm=matplotlib.colors.LogNorm(vmin=0.05, vmax=0.5), shading="nearest", edgecolors='none') diff --git a/mslib/mswms/mpl_vsec_styles.py b/mslib/mswms/mpl_vsec_styles.py index 5fab265d2..07c7b3223 100644 --- a/mslib/mswms/mpl_vsec_styles.py +++ b/mslib/mswms/mpl_vsec_styles.py @@ -191,7 +191,7 @@ def make_generic_class(name, entity, vert, add_data=None, add_contours=None, class fnord(VS_GenericStyle): name = f"VS_{entity}_{vert}" dataname = entity - units, _ = Targets.get_unit(dataname) + units = Targets.get_unit(dataname) title = Targets.TITLES.get(entity, entity) if units: title += f" ({units})" diff --git a/mslib/mswms/utils.py b/mslib/mswms/utils.py index 9b6cd0047..3abb26ab5 100644 --- a/mslib/mswms/utils.py +++ b/mslib/mswms/utils.py @@ -27,9 +27,9 @@ import numpy as np import matplotlib -import pint -UR = pint.UnitRegistry() +from mslib.utils.units import convert_to + N_LEVELS = 16 @@ -101,43 +101,43 @@ class Targets(object): ] UNITS = { - "air_temperature": ("K", 1), - "eastward_wind": ("1/ms", 1), - "equivalent_latitude": ("degree N", 1), - "ertel_potential_vorticity": ("PVU", 1), - "gravity_wave_temperature_perturbation": ("K", 1), - "mean_age_of_air": ("month", 1), - "median_of_age_of_air_spectrum": ("month", 1), - "northward_wind": ("1/ms", 1), - "square_of_brunt_vaisala_frequency_in_air": ("1/s²", 1), - "tropopause_altitude": ("km", 1), - "cloud_ice_mixing_ratio": ("ppmv", 1), - "number_concentration_of_ice_crystals_in_air": ("1/cm³", 1), - "mean_mass_radius_of_cloud_ice_crystals": ("µm", 1), - "maximum_pressure_on_backtrajectory": ("hPa", 1), - "maximum_relative_humidity_wrt_ice_on_backtrajectory": ("percent", 1), + "air_temperature": "K", + "eastward_wind": "1/ms", + "equivalent_latitude": "degree N", + "ertel_potential_vorticity": "PVU", + "gravity_wave_temperature_perturbation": "K", + "mean_age_of_air": "month", + "median_of_age_of_air_spectrum": "month", + "northward_wind": "1/ms", + "square_of_brunt_vaisala_frequency_in_air": "1/s²", + "tropopause_altitude": "km", + "cloud_ice_mixing_ratio": "ppmv", + "number_concentration_of_ice_crystals_in_air": "1/cm³", + "mean_mass_radius_of_cloud_ice_crystals": "µm", + "maximum_pressure_on_backtrajectory": "hPa", + "maximum_relative_humidity_wrt_ice_on_backtrajectory": "percent", } # The THRESHOLDS are used to determine a single colourmap suitable for all plotting purposes (that is vertical # and horizontal on all levels. The given thresholds have been manually designed. THRESHOLDS = { "ertel_potential_vorticity": - (-1, 0, 1, 2, 4, 6, 9, 12, 15, 25, 40), + ("PVU", (-1, 0, 1, 2, 4, 6, 9, 12, 15, 25, 40)), "mole_fraction_of_carbon_monoxide_in_air": - (10e-9, 20e-9, 30e-9, 40e-9, 50e-9, 60e-9, 70e-9, 80e-9, 90e-9, 100e-9, 300e-9), + ("nmol/mol", (10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 300)), "mole_fraction_of_nitric_acid_in_air": - (0e-9, 0.3e-9, 0.5e-9, 0.7e-9, 0.9e-9, 1.1e-9, 1.3e-9, 1.5e-9, 2e-9, 4e-9), + ("nmol/mol", (0, 0.3, 0.5, 0.7, 0.9, 1.1, 1.3, 1.5, 2, 4)), "mole_fraction_of_ozone_in_air": - (0e-6, 0.02e-6, 0.03e-6, 0.04e-6, 0.06e-6, 0.1e-6, 0.16e-6, 0.25e-6, 0.45e-6, 1e-6, 4e-6), + ("µmol/mol", (0, 0.02, 0.03, 0.04, 0.06, 0.1, 0.16, 0.25, 0.45, 1, 4)), "mole_fraction_of_peroxyacetyl_nitrate_in_air": - (0, 50e-12, 70e-12, 100e-12, 150e-12, 200e-12, 250e-12, 300e-12, 350e-12, 400e-12, 450e-12, 500e-12), + ("pmol/mol", (0, 50, 70, 100, 150, 200, 250, 300, 350, 400, 450, 500)), "mole_fraction_of_water_vapor_in_air": - (0, 3e-6, 4e-6, 6e-6, 10e-6, 16e-6, 60e-6, 150e-6, 500e-6, 1000e-6), + ("µmol/mol", (0, 3, 4, 6, 10, 16, 60, 150, 500, 1000)), } for standard_name in _TARGETS: if standard_name.startswith("surface_origin_tracer_from_"): - UNITS[standard_name] = ("percent", 1) + UNITS[standard_name] = "percent" for standard_name in [ "mole_fraction_of_carbon_dioxide_in_air", @@ -145,7 +145,7 @@ class Targets(object): "mole_fraction_of_ozone_in_air", "mole_fraction_of_water_vapor_in_air", ]: - UNITS[standard_name] = ("µmol/mol", 1) + UNITS[standard_name] = "µmol/mol" for standard_name in [ "mole_fraction_of_active_chlorine_in_air", @@ -155,7 +155,7 @@ class Targets(object): "mole_fraction_of_nitric_acid_in_air", "mole_fraction_of_nitrous_oxide_in_air", ]: - UNITS[standard_name] = ("nmol/mol", 1) + UNITS[standard_name] = "nmol/mol" for standard_name in [ "mole_fraction_of_bromine_nitrate_in_air", @@ -176,13 +176,13 @@ class Targets(object): "mole_fraction_of_peroxyacetyl_nitrate_in_air", "mole_fraction_of_sulfur_dioxide_in_air", ]: - UNITS[standard_name] = ("pmol/mol", 1) + UNITS[standard_name] = "pmol/mol" for standard_name in [ "fraction_below_6months_of_age_of_air_spectrum", "fraction_above_24months_of_age_of_air_spectrum", ]: - UNITS[standard_name] = ("percent", 1) + UNITS[standard_name] = "percent" TITLES = { "ertel_potential_vorticity": "PV", @@ -216,7 +216,7 @@ def get_unit(standard_name): Tuple of string describing the unit and scaling factor to apply on data. """ - return Targets.UNITS.get(standard_name, (None, 1)) + return Targets.UNITS.get(standard_name, "dimensionless") @staticmethod def get_range(standard_name, level="total", typ=None): @@ -231,21 +231,21 @@ def get_range(standard_name, level="total", typ=None): Tuple of lowest and highest valid value """ if standard_name in Targets.RANGES: + if level == "total" and "total" in Targets.RANGES[standard_name]: + unit, values = Targets.RANGES[standard_name]["total"] + return convert_to(values, unit, Targets.get_unit(standard_name)) if typ in Targets.RANGES[standard_name]: if level in Targets.RANGES[standard_name][typ]: - return [_x * Targets.get_unit(standard_name)[1] - for _x in Targets.RANGES[standard_name][typ][level]] + unit, values = Targets.RANGES[standard_name][typ][level] + return convert_to(values, unit, Targets.get_unit(standard_name)) elif level is None: return 0, 0 - if level == "total" and "total" in Targets.RANGES[standard_name]: - return [_x * Targets.get_unit(standard_name)[1] - for _x in Targets.RANGES[standard_name]["total"]] if standard_name.startswith("surface_origin_tracer_from_"): return 0, 100 return None, None @staticmethod - def get_thresholds(standard_name, level=None, type=None): + def get_thresholds(standard_name): """ Returns a list of meaningful values for a BoundaryNorm for plotting. Args: @@ -257,7 +257,8 @@ def get_thresholds(standard_name, level=None, type=None): Tuple of threshold values to be supplied to a BoundaryNorm. """ try: - return [_x * Targets.get_unit(standard_name)[1] for _x in Targets.THRESHOLDS[standard_name]] + threshold_unit, thresholds = Targets.THRESHOLDS[standard_name] + return convert_to(thresholds, threshold_unit, Targets.get_unit(standard_name)) except KeyError: return None diff --git a/mslib/plugins/io/csv.py b/mslib/plugins/io/csv.py index bcff29153..6aa5547a1 100644 --- a/mslib/plugins/io/csv.py +++ b/mslib/plugins/io/csv.py @@ -26,11 +26,6 @@ limitations under the License. """ -from __future__ import absolute_import -from __future__ import division - -from builtins import str - import unicodecsv as csv import os diff --git a/mslib/plugins/io/flitestar.py b/mslib/plugins/io/flitestar.py index 99daac262..cea38839c 100644 --- a/mslib/plugins/io/flitestar.py +++ b/mslib/plugins/io/flitestar.py @@ -25,7 +25,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -from __future__ import division import numpy as np import os diff --git a/mslib/plugins/io/gpx.py b/mslib/plugins/io/gpx.py index 96e8d97a8..007ad8a90 100644 --- a/mslib/plugins/io/gpx.py +++ b/mslib/plugins/io/gpx.py @@ -27,9 +27,6 @@ limitations under the License. """ -from __future__ import absolute_import -from __future__ import division - import os from fs import open_fs import gpxpy diff --git a/mslib/plugins/io/text.py b/mslib/plugins/io/text.py index 97c300ee8..5091bf499 100644 --- a/mslib/plugins/io/text.py +++ b/mslib/plugins/io/text.py @@ -26,10 +26,6 @@ limitations under the License. """ -from __future__ import division - -from builtins import str - import logging import codecs import os diff --git a/mslib/utils/_tests/test_config.py b/mslib/utils/_tests/test_config.py index 56dfce736..ed75f44d7 100644 --- a/mslib/utils/_tests/test_config.py +++ b/mslib/utils/_tests/test_config.py @@ -32,7 +32,8 @@ from mslib import utils from mslib.utils.config import MissionSupportSystemDefaultConfig as mss_default -from mslib.utils.config import config_loader, read_config_file +from mslib.utils.config import config_loader, read_config_file, modify_config_file +from mslib.utils.config import merge_dict from mslib._tests.constants import MSS_CONFIG_PATH from mslib._tests.utils import create_mss_settings_file @@ -63,9 +64,24 @@ class TestConfigLoader(object): tests config file for client """ + def setup(self): + self.sample_path = os.path.join( + os.path.dirname(os.path.abspath(utils.__file__)), + '../', + '../', + 'docs', + 'samples', + 'config', + 'mss') + def teardown(self): if fs.open_fs(MSS_CONFIG_PATH).exists("mss_settings.json"): fs.open_fs(MSS_CONFIG_PATH).remove("mss_settings.json") + config_file = os.path.join( + self.sample_path, + 'empty_mss_settings.json.sample' + ) + read_config_file(config_file) def test_option_types(self): # check if all config options are added to the appropriate type of options @@ -94,15 +110,8 @@ def test_default_config_wrong_file(self): read_config_file(path="foo.json") def test_sample_config_file(self): - utils_path = os.path.dirname(os.path.abspath(utils.__file__)) config_file = os.path.join( - utils_path, - '../', - '../', - 'docs', - 'samples', - 'config', - 'mss', + self.sample_path, 'mss_settings.json.sample', ) read_config_file(path=config_file) @@ -112,18 +121,6 @@ def test_sample_config_file(self): config_loader(dataset="UNDEFINED") with pytest.raises(KeyError): assert config_loader(dataset="UNDEFINED") - with pytest.raises(FileNotFoundError): - config_file = os.path.join( - utils_path, - '../', - '../', - 'docs', - 'samples', - 'config', - 'mss', - 'non_existent_mss_settings.json.sample', - ) - read_config_file(config_file) def test_existing_empty_config_file(self): """ @@ -213,3 +210,87 @@ def test_existing_config_file_invalid_parameters(self): assert "num_labels" in file_content with pytest.raises(utils.FatalUserError): read_config_file(path=config_file) + + 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_mss_settings_file('{ }') + if not fs.open_fs(MSS_CONFIG_PATH).exists("mss_settings.json"): + pytest.skip('undefined test mss_settings.json') + data_to_save_in_config_file = { + "MSCOLAB_mailid": "something@something.org" + } + modify_config_file(data_to_save_in_config_file) + config_file = fs.path.combine(MSS_CONFIG_PATH, "mss_settings.json") + read_config_file(path=config_file) + data = config_loader() + assert data["MSCOLAB_mailid"] == "something@something.org" + + 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_mss_settings_file('{"MSCOLAB_mailid": "anand@something.org"}') + if not fs.open_fs(MSS_CONFIG_PATH).exists("mss_settings.json"): + pytest.skip('undefined test mss_settings.json') + data_to_save_in_config_file = { + "MSCOLAB_mailid": "sree@something.org" + } + modify_config_file(data_to_save_in_config_file) + config_file = fs.path.combine(MSS_CONFIG_PATH, "mss_settings.json") + read_config_file(path=config_file) + data = config_loader() + assert data["MSCOLAB_mailid"] == "sree@something.org" + + 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_mss_settings_file('{ }') + if not fs.open_fs(MSS_CONFIG_PATH).exists("mss_settings.json"): + pytest.skip('undefined test mss_settings.json') + data_to_save_in_config_file = { + "": "sree", + "MSCOLAB_mailid": "sree@something.org" + } + with pytest.raises(KeyError): + modify_config_file(data_to_save_in_config_file) + + +class TestMergeDict: + """ + merge_dict can only merge keys which are predefined in the mss_default. All other have to be skipped + """ + def setup(self): + self.default_dict = dict(mss_default.__dict__) + + def test_no_differences(self): + users_options_dict = self.default_dict + assert merge_dict(self.default_dict, users_options_dict) == self.default_dict + users_options_dict = {} + assert merge_dict(self.default_dict, users_options_dict) == self.default_dict + + def test_user_option_changed(self): + users_options_dict = { + "new_flighttrack_template": ["Kona", "Anchorage"], + "new_flighttrack_flightlevel": 350, + } + assert self.default_dict["num_interpolation_points"] == 201 + assert self.default_dict["new_flighttrack_template"] == ['Nagpur', 'Delhi'] + assert self.default_dict["new_flighttrack_flightlevel"] == 0 + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["num_interpolation_points"] == 201 + assert changed_dict["new_flighttrack_template"] == ["Kona", "Anchorage"] + assert changed_dict["new_flighttrack_flightlevel"] == 350 + + def test_user_unknown_option(self): + users_options_dict = {"unknown_option": 1} + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict.get("num_interpolation_points") == 201 + assert changed_dict.get("unknown_option", None) is None + + def test_add_filepicker_default_to_plugins(self): + users_options_dict = {"export_plugins": {"Text": ["txt", "mslib.plugins.io.text", "save_to_txt"]}} + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["export_plugins"]["Text"] == ["txt", "mslib.plugins.io.text", "save_to_txt", "default"] diff --git a/mslib/utils/airdata.py b/mslib/utils/airdata.py index 2a61f181f..c3f645278 100644 --- a/mslib/utils/airdata.py +++ b/mslib/utils/airdata.py @@ -28,6 +28,7 @@ import csv import defusedxml.ElementTree as etree +import humanfriendly import os import requests import re as regex @@ -42,24 +43,21 @@ _airports = [] _airports_mtime = 0 _airspaces_mtime = {} -_airspace_url = "https://www.openaip.net/customer_export_akfshb9237tgwiuvb4tgiwbf" -# Updated Jul 27 2021 +_airspace_url = "https://storage.googleapis.com/29f98e10-a489-4c82-ae5e-489dbcd4912f" +_airspace_download_url = "https://storage.googleapis.com/storage/v1/b/29f98e10-a489-4c82-ae5e-489dbcd4912f/o/" \ + "{}_asp.xml?alt=media" +# Updated Dec 07 2021 _airspace_cache = \ - [('al_asp.aip', '13K'), ('ar_asp.aip', '1.0M'), ('at_asp.aip', '169K'), ('au_asp.aip', '4.7M'), - ('ba_asp.aip', '80K'), ('be_asp.aip', '308K'), ('bg_asp.aip', '28K'), ('bh_asp.aip', '76K'), - ('br_asp.aip', '853K'), ('ca_asp.aip', '2.9M'), ('ch_asp.aip', '163K'), ('co_asp.aip', '121K'), - ('cz_asp.aip', '574K'), ('de_asp.aip', '843K'), ('dk_asp.aip', '120K'), ('ee_asp.aip', '66K'), - ('es_asp.aip', '923K'), ('fi_asp.aip', '213K'), ('fr_asp.aip', '1.4M'), ('gb_asp.aip', '1.2M'), - ('gr_asp.aip', '164K'), ('hr_asp.aip', '598K'), ('hu_asp.aip', '252K'), ('ie_asp.aip', '205K'), - ('is_asp.aip', '33K'), ('it_asp.aip', '1.9M'), ('jp_asp.aip', '1.8M'), ('la_asp.aip', '3.5M'), - ('lt_asp.aip', '450K'), ('lu_asp.aip', '45K'), ('lv_asp.aip', '65K'), ('na_asp.aip', '117K'), - ('nl_asp.aip', '352K'), ('no_asp.aip', '296K'), ('np_asp.aip', '521K'), ('nz_asp.aip', '656K'), - ('pl_asp.aip', '845K'), ('pt_asp.aip', '165K'), ('ro_asp.aip', '240K'), ('rs_asp.aip', '1.4M'), - ('se_asp.aip', '263K'), ('si_asp.aip', '80K'), ('sk_asp.aip', '296K'), ('us_asp.aip', '7.0M'), - ('za_asp.aip', '197K')] - - -def download_progress(file_path, url, progress_callback=lambda f: logging.info(f"{int(f * 100)}% Downloaded")): + [('al_asp.xml', '2817'), ('ar_asp.xml', '262968'), ('at_asp.xml', '20933'), ('au_asp.xml', '1686931'), + ('ba_asp.xml', '20865'), ('be_asp.xml', '70624'), ('bg_asp.xml', '4696'), ('bh_asp.xml', '23073'), + ('br_asp.xml', '250204'), ('ca_asp.xml', '1011153'), ('ch_asp.xml', '28961'), ('co_asp.xml', '38061'), + ('cz_asp.xml', '143524'), ('de_asp.xml', '217490'), ('dk_asp.xml', '19854'), ('ee_asp.xml', '17761'), + ('es_asp.xml', '255423'), ('fi_asp.xml', '25058'), ('fr_asp.xml', '319716'), ('gb_asp.xml', '1410038'), + ('gr_asp.xml', '55492'), ('hr_asp.xml', '135531'), ('hu_asp.xml', '52526'), ('ie_asp.xml', '61167'), + ('is_asp.xml', '10499'), ('it_asp.xml', '1063320'), ('jp_asp.xml', '540727')] + + +def download_progress(file_path, url, progress_callback=lambda f: logging.info(f"{int(f)}KB Downloaded")): """ Downloads the file at the given url to file_path and keeps track of the progress """ @@ -72,11 +70,10 @@ def download_progress(file_path, url, progress_callback=lambda f: logging.info(f file.write(response.content) else: dl = 0 - length = int(length) for data in response.iter_content(chunk_size=1024 * 1024): dl += len(data) file.write(data) - progress_callback(dl / length) + progress_callback(dl / 1024) except requests.exceptions.RequestException: os.remove(file_path) QtWidgets.QMessageBox.information(None, "Download failed", f"{url} was unreachable, please try again later.") @@ -97,8 +94,8 @@ def get_airports(force_download=False): if (force_download or is_outdated or not file_exists) \ and QtWidgets.QMessageBox.question(None, "Allow download", f"You selected airports to be " - f"{'drawn' if not force_download else 'downloaded (~10MB)'}." + - ("\nThe airports file first needs to be downloaded or updated (~10MB)." + f"{'drawn' if not force_download else 'downloaded (~10 MB)'}." + + ("\nThe airports file first needs to be downloaded or updated (~10 MB)." if not force_download else "") + "\nIs now a good time?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) \ == QtWidgets.QMessageBox.Yes: @@ -121,8 +118,8 @@ def get_available_airspaces(): directory = requests.get(_airspace_url, timeout=5) if directory.status_code == 404: return _airspace_cache - airspaces = regex.findall(r">(.._asp\.aip)<", directory.text) - sizes = regex.findall(r".._asp.aip.*?>[ ]*([0-9\.]+[KM]*)[ ]*<\/td", directory.text) + airspaces = regex.findall(r">(.._asp\.xml)<", directory.text) + sizes = regex.findall(r".._asp.xml.*?([0-9]+)<\/Size", directory.text) airspaces = [airspace for airspace in zip(airspaces, sizes) if airspace[-1] != "0"] return airspaces except requests.exceptions.RequestException: @@ -135,8 +132,8 @@ def update_airspace(force_download=False, countries=["de"]): """ global _airspaces, _airspaces_mtime for country in countries: - location = os.path.join(MSS_CONFIG_PATH, f"{country}_asp.aip") - url = f"{_airspace_url}/{country}_asp.aip" + location = os.path.join(MSS_CONFIG_PATH, f"{country}_asp.xml") + url = _airspace_download_url.format(country) data = [airspace for airspace in get_available_airspaces() if airspace[0].startswith(country)][0] file_exists = os.path.exists(location) @@ -145,8 +142,8 @@ def update_airspace(force_download=False, countries=["de"]): if (force_download or is_outdated or not file_exists) \ and QtWidgets.QMessageBox.question( None, "Allow download", - f"The selected {country} airspace needs to be downloaded ({data[-1]})" - f"\nIs now a good time?", + f"The selected {country} airspace needs to be downloaded " + f"({humanfriendly.format_size(int(data[-1]))})\nIs now a good time?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) \ == QtWidgets.QMessageBox.Yes: download_progress(location, url) @@ -154,11 +151,11 @@ def update_airspace(force_download=False, countries=["de"]): def get_airspaces(countries=[]): """ - Gets the .aip files in ~/.config/mss and returns all airspaces within + Gets the .xml files in ~/.config/mss and returns all airspaces within """ global _airspaces, _airspaces_mtime reload = False - files = [f"{country}_asp.aip" for country in countries] + files = [f"{country}_asp.xml" for country in countries] update_airspace(countries=countries) files = [file for file in files if os.path.exists(os.path.join(MSS_CONFIG_PATH, file))] @@ -202,7 +199,7 @@ def get_airspaces(countries=[]): airspace_data.pop("bottom_unit") airspace_data["polygon"] = [(float(data.split(" ")[0]), float(data.split(" ")[-1])) - for data in airspace_data["polygon"].split(", ")] + for data in airspace_data["polygon"].split(",")] _airspaces.append(airspace_data) _airspaces_mtime[file] = os.path.getmtime(os.path.join(MSS_CONFIG_PATH, file)) return _airspaces diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 02593867f..b5f812e20 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -379,13 +379,53 @@ def read_config_file(path=constants.MSS_SETTINGS): global user_options if json_file_data: - user_options = merge_data(copy.deepcopy(default_options), 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.MSS_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(f"Error while loading json file {e}") + error_message = f"Unexpected error while loading config\n{e}" + raise FatalUserError(error_message) + except ValueError as e: + logging.error(f"Error while loading json file {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 @@ -464,19 +504,21 @@ def load_settings_qsettings(tag, default_settings=None, ignore_test=False): return default_settings -def merge_data(options, json_file_data): +def merge_dict(existing_dict, new_dict): """ Merge two dictionaries by comparing all the options from the MissionSupportSystemDefaultConfig class Arguments: - options -- Dict to merge options into - json_file_data -- Dict with new values + 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 MissionSupportSystemDefaultConfig.fixed_dict_options: - if key in json_file_data: - options[key] = compare_data(options[key], json_file_data[key])[0] + 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(MissionSupportSystemDefaultConfig.dict_option_structure) @@ -485,46 +527,48 @@ def merge_data(options, json_file_data): 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 json_file_data: + if key in new_dict: temp_data = {} - for option_key in json_file_data[key]: + for option_key in new_dict[key]: for dos_key_key in dos[key]: - data, match = compare_data(dos[key][dos_key_key], json_file_data[key][option_key]) + data, match = compare_data(dos[key][dos_key_key], new_dict[key][option_key]) if match: - temp_data[option_key] = json_file_data[key][option_key] + temp_data[option_key] = new_dict[key][option_key] break if temp_data != {}: - options[key] = temp_data + existing_dict[key] = temp_data # Check if list options with predefined structure match data types from default los = copy.deepcopy(MissionSupportSystemDefaultConfig.list_option_structure) for key in los: - if key in json_file_data: + if key in new_dict: temp_data = [] - for i in range(len(json_file_data[key])): + for i in range(len(new_dict[key])): for los_key_item in los[key]: - data, match = compare_data(los_key_item, json_file_data[key][i]) + data, match = compare_data(los_key_item, new_dict[key][i]) if match: temp_data.append(data) break if temp_data != []: - options[key] = temp_data + existing_dict[key] = temp_data # Check if options with fixed key/value pair structure match data types from default for key in MissionSupportSystemDefaultConfig.key_value_options: - if key in json_file_data: - data, match = compare_data(options[key], json_file_data[key]) + if key in new_dict: + data, match = compare_data(existing_dict[key], new_dict[key]) if match: - options[key] = data + 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 options: - for plugin in options[plugin_type]: - if len(options[plugin_type][plugin]) == 3: - options[plugin_type][plugin].append(options.get("filepicker_default", "default")) - - return options + 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): diff --git a/mslib/version.py b/mslib/version.py index 1430ef5b1..02c8a8113 100644 --- a/mslib/version.py +++ b/mslib/version.py @@ -24,4 +24,4 @@ See the License for the specific language governing permissions and limitations under the License. """ -__version__ = u'6.0.6.' +__version__ = u'6.1.0.'