From bd96839c164e0553cde2618906cddede04e07607 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 6 Aug 2024 10:18:10 +0200 Subject: [PATCH 01/29] removing pinned xmlschema (#2117) --- requirements.d/development.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.d/development.txt b/requirements.d/development.txt index 232063ce6..d6403b27c 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -20,4 +20,3 @@ eventlet>0.30.2 dnspython>=2.0.0, <2.3.0 gsl==2.7.0 boa -xmlschema<2.5.0 From 261b47586ee834d2862bbd7e1c1012a4dd75879f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Fri, 9 Aug 2024 21:11:02 +0200 Subject: [PATCH 02/29] Stop ignoring some test files (#2291) --- .github/workflows/testing-all-oses.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/testing-all-oses.yml b/.github/workflows/testing-all-oses.yml index 86fb996fc..58df9ccdb 100644 --- a/.github/workflows/testing-all-oses.yml +++ b/.github/workflows/testing-all-oses.yml @@ -48,6 +48,4 @@ jobs: # I have no idea yet on why this happens and how to fix it. # Even a module level skip is not enough, they need to be completely ignored. # TODO: fix those tests and drop the ignores - run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib - --ignore=tests/_test_msui/test_sideview.py --ignore=tests/_test_msui/test_topview.py --ignore=tests/_test_msui/test_wms_control.py - tests + run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib tests From d88bf9205a94173aac8f4a1cf5881023a61b7e5e Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 15 Aug 2024 11:40:33 +0200 Subject: [PATCH 03/29] Reconfigured tutorial videos for RTFD (#2458) * changed to videos/ * use mss style on rtfd --- docs/conf.py | 2 +- docs/environment.yml | 2 +- docs/tutorial.rst | 2 +- docs/tutorials/tutorial_hexagoncontrol.rst | 2 +- docs/tutorials/tutorial_introduction_topview.rst | 2 +- docs/tutorials/tutorial_kml.rst | 4 ++-- docs/tutorials/tutorial_mscolab.rst | 2 +- docs/tutorials/tutorial_msui_views.rst | 2 +- docs/tutorials/tutorial_performance_settings.rst | 2 +- docs/tutorials/tutorial_remotesensing.rst | 2 +- docs/tutorials/tutorial_satellitetrack.rst | 2 +- docs/tutorials/tutorial_waypoints.rst | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index bbd933540..80e5e185d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -226,7 +226,7 @@ def get_tutorial_images(): html_style = 'css/mss.css' else: htmls_static_path = ['_static'] - html_css_files = ['mss.css'] + html_css_files = ['css/mss.css'] html_context = { 'display_github': False, # Add 'Edit on Bitbucket' link instead of 'View page source' 'last_updated': True, diff --git a/docs/environment.yml b/docs/environment.yml index 8e703ec7b..2a0fbaf92 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -15,7 +15,7 @@ dependencies: - xstatic - defusedxml - sphinx_rtd_theme - - sphinxcontrib-video + - sphinxcontrib-video>=0.2.1 - sphinx - fs - netCDF4 diff --git a/docs/tutorial.rst b/docs/tutorial.rst index a188127e3..8fe213c4b 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -9,7 +9,7 @@ a `public share `_ Get familiar by some videos about the Mission Support System (MSS). - .. video:: _static/mp4/tutorial_waypoints.mp4 + .. video:: videos/mp4/tutorial_waypoints.mp4 :autoplay: :loop: :alt: This is the waypoints tutorial,i.e., whenever we are going to plan diff --git a/docs/tutorials/tutorial_hexagoncontrol.rst b/docs/tutorials/tutorial_hexagoncontrol.rst index 0c3dae428..a212a70e1 100644 --- a/docs/tutorials/tutorial_hexagoncontrol.rst +++ b/docs/tutorials/tutorial_hexagoncontrol.rst @@ -3,7 +3,7 @@ Table View and Hexagon Flight Patterns For tomographic imaging, a hexagonal flight pattern can be integrated by a docking widget of Table View - .. video:: ../_static/mp4/tutorial_hexagoncontrol.mp4 + .. video:: ../videos/mp4/tutorial_hexagoncontrol.mp4 :alt: Top View windows is opened (CTRL+H). We select the "global (cyl)" for the world map. Zooming in for the required region. diff --git a/docs/tutorials/tutorial_introduction_topview.rst b/docs/tutorials/tutorial_introduction_topview.rst index b4edd8a1f..b72815f05 100644 --- a/docs/tutorials/tutorial_introduction_topview.rst +++ b/docs/tutorials/tutorial_introduction_topview.rst @@ -3,7 +3,7 @@ Top View and Selecting of Layers Selection and display of different data in the Top View with the help of the layer chooser. - .. video:: ../_static/mp4/tutorial_wms.mp4 + .. video:: ../videos/mp4/tutorial_wms.mp4 :alt: When we open the Top View (CTRL+H) of the map, the Web Map Service is already opened by default. It collects its data from the server: "open-mss dot org" that provides demodata for the meteorological or atmospheric information as layer lists. diff --git a/docs/tutorials/tutorial_kml.rst b/docs/tutorials/tutorial_kml.rst index 5d8c3d951..68a85903a 100644 --- a/docs/tutorials/tutorial_kml.rst +++ b/docs/tutorials/tutorial_kml.rst @@ -3,7 +3,7 @@ Top View and KML Data es can be displayed in the Top View. Color and line width can be adjusted. - .. video:: ../_static/mp4/tutorial_kml.mp4 + .. video:: ../videos/mp4/tutorial_kml.mp4 :alt: Open the TopView (CTRL+H) After clicking on "(select to open control)", click on KML OVERLAY. The UI will look as shown. KML files can be used to show the geographical boundary which helps in planning the WAY POINTS. @@ -23,4 +23,4 @@ es can be displayed in the Top View. Color and line width can be adjusted. Changing it for some time... Now, we change line width by changing its numerical value whose range is 0 to 10. We change it to different values to obtain different linewidths. - The top view is closed and the tutorial ends. \ No newline at end of file + The top view is closed and the tutorial ends. diff --git a/docs/tutorials/tutorial_mscolab.rst b/docs/tutorials/tutorial_mscolab.rst index c4164fa9c..7d9cb4a92 100644 --- a/docs/tutorials/tutorial_mscolab.rst +++ b/docs/tutorials/tutorial_mscolab.rst @@ -5,7 +5,7 @@ Using the different views of the MSUI with a fictitious flight path and demo dat In comparison to the standalone mode of the MSUI an example setup of users is shown on a MSColab server and the possibilities of interactions. - .. video:: ../_static/mp4/tutorial_mscolab.mp4 + .. video:: ../videos/mp4/tutorial_mscolab.mp4 :alt: MSColab stores data in an online server, and can be used to access the data remotely as also working in a team where everyone contributes his part. It is used for collaborating with the users as a team together and working on a shared MSColab operation. diff --git a/docs/tutorials/tutorial_msui_views.rst b/docs/tutorials/tutorial_msui_views.rst index 7e1ba7dcd..27910e9ab 100644 --- a/docs/tutorials/tutorial_msui_views.rst +++ b/docs/tutorials/tutorial_msui_views.rst @@ -4,7 +4,7 @@ Introduction to MSUI Using the different views of the MSUI with a fictitious flight path and demo data. - .. video:: ../_static/mp4//tutorial_views.mp4 + .. video:: ../videos/mp4/tutorial_views.mp4 :alt: Lets look at the tutorial of the various views required for flight planning: Top View (CTRL+H), Side View (CTRL+V), Linear View (CTRL+L) and Table View (CTRL+T). At first, lets open Top View and Side View. diff --git a/docs/tutorials/tutorial_performance_settings.rst b/docs/tutorials/tutorial_performance_settings.rst index 82954355a..59820e2c2 100644 --- a/docs/tutorials/tutorial_performance_settings.rst +++ b/docs/tutorials/tutorial_performance_settings.rst @@ -3,7 +3,7 @@ Table View and Aircraft Performance Data The range-specific data of an aircraft can be taken into account in Tableview for flight planning. - .. video:: ../_static/mp4/tutorial_performancesettings.mp4 + .. video:: ../videos/mp4/tutorial_performancesettings.mp4 :alt: This is the Performance Settings dockwidget opened in the Table View (CTRL+T) of the MSS software where by putting and changing some parameters, we can evaluate the performance of the aircraft. Parameters like Flight Altitude, Aviation fuel, Aircraft weight, Maximum take off weight diff --git a/docs/tutorials/tutorial_remotesensing.rst b/docs/tutorials/tutorial_remotesensing.rst index e1a1d1253..65d1dbf4e 100644 --- a/docs/tutorials/tutorial_remotesensing.rst +++ b/docs/tutorials/tutorial_remotesensing.rst @@ -4,7 +4,7 @@ Top View and Remotesensing Tools In order to be able to take into account the viewing angle and solar level for measuring instruments, the remotesensing tools are used - .. video:: ../_static/mp4/tutorial_remotesensing.mp4 + .. video:: ../videos/mp4/tutorial_remotesensing.mp4 :alt: This is the Remote Sensing Section of the Top View. It shows the position and angle of the flight from any particular object in the sky. Azimuth is the forward direction line of the flight. If we go above tHE AZIMUTH, angle is in positive, diff --git a/docs/tutorials/tutorial_satellitetrack.rst b/docs/tutorials/tutorial_satellitetrack.rst index 04561d811..7c6b55550 100644 --- a/docs/tutorials/tutorial_satellitetrack.rst +++ b/docs/tutorials/tutorial_satellitetrack.rst @@ -4,7 +4,7 @@ Top View and Satellite Overflight To combine a flight path with a satellite overflight, the remotesensing widget is used. - .. video:: ../_static/mp4/tutorial_satellitetrack.mp4 + .. video:: ../videos/mp4/tutorial_satellitetrack.mp4 :alt: This is Satellite Tracking Prediction System that can be used to check the accuracy of the path travelled by a Satellite by the help of data collected from different space agencies and planning a flight accordingly. diff --git a/docs/tutorials/tutorial_waypoints.rst b/docs/tutorials/tutorial_waypoints.rst index e43d21f3d..87938a01c 100644 --- a/docs/tutorials/tutorial_waypoints.rst +++ b/docs/tutorials/tutorial_waypoints.rst @@ -3,7 +3,7 @@ Top View Drawing Waypoints Waypoints for a flight path are defined, shifted and deleted. - .. video:: ../_static/mp4/tutorial_waypoints.mp4 + .. video:: ../videos/mp4/tutorial_waypoints.mp4 :alt: This is the waypoints tutorial,i.e., whenever we are going to plan a flight track, we have to place the waypoints in some places and, form a flight path. From a04e7ee56fe49081dd823523dcd6d36c6a8d4645 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 15 Aug 2024 12:26:27 +0200 Subject: [PATCH 04/29] fix urls and text (#2460) --- mslib/static/docs/help.md | 2 +- mslib/static/docs/installation.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mslib/static/docs/help.md b/mslib/static/docs/help.md index 5ea50caa7..c0a2c0777 100644 --- a/mslib/static/docs/help.md +++ b/mslib/static/docs/help.md @@ -8,7 +8,7 @@ software that simplifies the process for planning a scientific flight. # Drawing waypoints in the MSUI Topview The example shows defining of waypoints for a flight path, moved and deleted. -![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_static/mp4/tutorial_waypoints.mp4) +![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_images/tutorial_waypoints.mp4) Further tutorials about the Mission Support System Software on: diff --git a/mslib/static/docs/installation.md b/mslib/static/docs/installation.md index 8c8051e33..b31e57473 100644 --- a/mslib/static/docs/installation.md +++ b/mslib/static/docs/installation.md @@ -7,7 +7,7 @@ The Mission Support System (MSS) including a Web Map Service a Collaboration Ser [conda-forge](https://anaconda.org/conda-forge/mss) package. -We strongly recommend to start from [Mambaforge](https://mamba.readthedocs.io/en/latest/installation.html) +We strongly recommend to start from [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) a community project of the conda-forge community. You can install it either automatically with the help of a script or manually. @@ -31,8 +31,8 @@ You can install it either automatically with the help of a script or manually. ### Manually -As **Beginner** start with an installation of Mambaforge -Get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) for your Operation System +As **Beginner** start with an installation of Miniforge3 +Get [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) for your Operation System You must install mss into a new environment to ensure the most recent @@ -65,10 +65,10 @@ user for the apache2 wsgi script. We suggest to create a mss user. - login as mss user - create a *src* directory in /home/mss - cd src -- get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) +- get [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) - set execute bit on install script - execute script, enable environment in .bashrc -- login again or export PATH="/home/mss/mambaforge/bin:\$PATH" +- start your shell again (new login) - python --version should tell Python 3.X.X - mamba create -n mssenv - mamba activate mssenv From ae1928ffd2fb60235d7c3f4a8917ee9e1a203628 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:23:23 +0200 Subject: [PATCH 05/29] Bump benc-uk/workflow-dispatch from 1.2.3 to 1.2.4 (#2450) Bumps [benc-uk/workflow-dispatch](https://github.com/benc-uk/workflow-dispatch) from 1.2.3 to 1.2.4. - [Release notes](https://github.com/benc-uk/workflow-dispatch/releases) - [Commits](https://github.com/benc-uk/workflow-dispatch/compare/v1.2.3...v1.2.4) --- updated-dependencies: - dependency-name: benc-uk/workflow-dispatch dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/testing-scheduled.yml | 4 ++-- .github/workflows/testing.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/testing-scheduled.yml b/.github/workflows/testing-scheduled.yml index a4bab8ae0..580003604 100644 --- a/.github/workflows/testing-scheduled.yml +++ b/.github/workflows/testing-scheduled.yml @@ -10,7 +10,7 @@ jobs: permissions: actions: write steps: - - uses: benc-uk/workflow-dispatch@v1.2.3 + - uses: benc-uk/workflow-dispatch@v1.2.4 with: workflow: testing-stable.yml ref: stable @@ -20,7 +20,7 @@ jobs: permissions: actions: write steps: - - uses: benc-uk/workflow-dispatch@v1.2.3 + - uses: benc-uk/workflow-dispatch@v1.2.4 with: workflow: testing-develop.yml ref: develop diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0e1f739f6..ae1eb46c7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -40,7 +40,7 @@ jobs: # scheduled tests should just check that new dependency versions do not break the # tests, but should not update the image. if: ${{ (github.ref_name == 'stable' || github.ref_name == 'develop') && github.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} - uses: benc-uk/workflow-dispatch@v1.2.3 + uses: benc-uk/workflow-dispatch@v1.2.4 with: workflow: Update Image testing-${{ inputs.image_suffix }} repo: Open-MSS/dockertesting From 1591386a318ed21b9c85ef04998d39351a912dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 15 Aug 2024 19:22:22 +0200 Subject: [PATCH 06/29] Limit video width to the containing block's width (#2463) --- docs/mss_theme/css/mss.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/mss_theme/css/mss.css b/docs/mss_theme/css/mss.css index de441f52a..58fa4d1b7 100644 --- a/docs/mss_theme/css/mss.css +++ b/docs/mss_theme/css/mss.css @@ -14,3 +14,7 @@ margin-bottom: auto; margin-left: auto; } + +video { + max-width: 100%; +} From f67c237feeaea1cd784159c064f2da91c11c0bde Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 20 Aug 2024 13:08:07 +0200 Subject: [PATCH 07/29] pinning of libtiff is not needed but blocks a more recent libxml2 and others (#2469) --- localbuild/meta.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 3088dfce2..9e2b52fdc 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -77,7 +77,7 @@ requirements: - metpy - pycountry - websocket-client - - libtiff <4.5.0 + - libtiff - flask-wtf - email_validator - keyring From 02999a0356daa3f998a359b0e2120ac011b2f9a7 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Tue, 20 Aug 2024 14:48:02 +0200 Subject: [PATCH 08/29] fixed the model.Message description and tests now using op_id (#2470) --- mslib/mscolab/chat_manager.py | 8 ++--- mslib/mscolab/models.py | 38 ++++++++++++------------ tests/_test_mscolab/test_chat_manager.py | 14 ++++----- tests/_test_mscolab/test_models.py | 6 ++-- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index 475ed5200..075a4febb 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -42,16 +42,16 @@ class ChatManager: def __init__(self): pass - def add_message(self, user, text, operation_name, message_type=MessageType.TEXT, reply_id=None): + def add_message(self, user, text, op_id, message_type=MessageType.TEXT, reply_id=None): """ - text: message to be emitted to operation and saved to db - operation_name: operation-name(op_id) to which message is emitted, user: User object, one which emits the message + text: message to be emitted to operation and saved to db + op_id: operation id to which message is emitted, message_type: Enum of type MessageType. values: TEXT, SYSTEM_MESSAGE, IMAGE, DOCUMENT """ if reply_id == -1: reply_id = None - message = Message(operation_name, user.id, text, message_type, reply_id) + message = Message(op_id, user.id, text, message_type, reply_id) db.session.add(message) db.session.commit() return message diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 877041fc0..938f7ede1 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -65,13 +65,13 @@ class User(db.Model): authentication_backend = db.Column(db.String(255), nullable=False, default='local') def __init__(self, emailid, username, password, confirmed=False, confirmed_on=None, authentication_backend='local'): - self.username = username - self.emailid = emailid + self.username = str(username) + self.emailid = str(emailid) self.hash_password(password) self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc) - self.confirmed = confirmed + self.confirmed = bool(confirmed) self.confirmed_on = confirmed_on - self.authentication_backend = authentication_backend + self.authentication_backend = str(authentication_backend) def __repr__(self): return f'' @@ -134,9 +134,9 @@ def __init__(self, u_id, op_id, access_level): op_id: process-id access_level: the type of authorization to the operation """ - self.u_id = u_id - self.op_id = op_id - self.access_level = access_level + self.u_id = int(u_id) + self.op_id = int(op_id) + self.access_level = str(access_level) def __repr__(self): return f'' @@ -158,10 +158,10 @@ def __init__(self, path, description, last_used=None, category="default", active description: small description of operation category: name of category """ - self.path = path - self.description = description - self.category = category - self.active = active + self.path = str(path) + self.description = str(description) + self.category = str(category) + self.active = bool(active) if self.last_used is None: self.last_used = datetime.datetime.now(tz=datetime.timezone.utc) else: @@ -187,9 +187,9 @@ class Message(db.Model): replies = db.relationship('Message', cascade='all,delete,delete-orphan', single_parent=True) def __init__(self, op_id, u_id, text, message_type=MessageType.TEXT, reply_id=None): - self.op_id = op_id - self.u_id = u_id - self.text = text + self.op_id = int(op_id) + self.u_id = int(u_id) + self.text = str(text) self.message_type = message_type self.reply_id = reply_id @@ -210,8 +210,8 @@ class Change(db.Model): user = db.relationship('User') def __init__(self, op_id, u_id, commit_hash, version_name=None, comment=None): - self.op_id = op_id - self.u_id = u_id - self.commit_hash = commit_hash - self.version_name = version_name - self.comment = comment + self.op_id = int(op_id) + self.u_id = int(u_id) + self.commit_hash = str(commit_hash) + self.version_name = str(version_name) + self.comment = str(comment) diff --git a/tests/_test_mscolab/test_chat_manager.py b/tests/_test_mscolab/test_chat_manager.py index 5a720f433..d479b29ac 100644 --- a/tests/_test_mscolab/test_chat_manager.py +++ b/tests/_test_mscolab/test_chat_manager.py @@ -31,8 +31,8 @@ from werkzeug.datastructures import FileStorage from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Operation, Message, MessageType -from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.mscolab.models import Message, MessageType +from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation, get_operation class Test_Chat_Manager: @@ -47,20 +47,21 @@ def setup(self, mscolab_app, mscolab_managers): assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) + self.operation = get_operation(self.operation_name) with self.app.app_context(): yield def test_add_message(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some message', - self.operation_name, message_type=MessageType.TEXT, + self.operation.id, message_type=MessageType.TEXT, reply_id=None) assert message.text == 'some message' def test_edit_messages(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some test message', - self.operation_name, message_type=MessageType.TEXT, + self.operation.id, message_type=MessageType.TEXT, reply_id=None) new_message_text = "Wonderland" self.cm.edit_message(message.id, new_message_text) @@ -70,7 +71,7 @@ def test_edit_messages(self): def test_delete_messages(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some test example message', - self.operation_name, message_type=MessageType.TEXT, + self.operation.id, message_type=MessageType.TEXT, reply_id=None) assert 'some test example message' in message.text self.cm.delete_message(message.id) @@ -82,11 +83,10 @@ def test_add_attachment(self): filename = "example.csv" name, ext = filename.split('.') open_csv = os.path.join(sample_path, "example.csv") - operation = Operation.query.filter_by(path=self.operation_name).first() token = secrets.token_urlsafe(16) with open(open_csv, 'rb') as fp: file = FileStorage(fp, filename=filename, content_type="text/csv") - static_path = self.cm.add_attachment(operation.id, mscolab_settings.UPLOAD_FOLDER, file, token) + static_path = self.cm.add_attachment(self.operation.id, mscolab_settings.UPLOAD_FOLDER, file, token) assert name in static_path assert static_path.endswith(ext) assert token in static_path diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index 8b0a2140f..bf38a4894 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -30,7 +30,7 @@ from zoneinfo import ZoneInfo from mslib.mscolab.server import register_user -from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, Change +from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, MessageType, Change def test_aware_datetime_conversion(): @@ -90,12 +90,12 @@ def test_operation_repr(): def test_message_creation(): - message = Message(1, 1, "Hello, this is a test message", "TEXT", None) + message = Message(1, 1, "Hello, this is a test message", MessageType.TEXT, None) assert message.op_id == 1 assert message.u_id == 1 assert message.text == "Hello, this is a test message" - assert message.message_type == "TEXT" + assert message.message_type == MessageType.TEXT assert message.reply_id is None From 83c6189134acd4df40f31d91adf7aa13641ab305 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 21 Aug 2024 15:26:19 +0200 Subject: [PATCH 09/29] bug fix for incomplete xml imports (#2468) validate xml content --- docs/samples/automation/retriever.py | 3 +- mslib/mscolab/file_manager.py | 33 +++-- mslib/msui/flighttrack.py | 8 +- mslib/msui/mscolab.py | 8 +- mslib/utils/verify_waypoint_data.py | 53 ++++++++ tests/_test_mscolab/test_files.py | 52 ++++++-- tests/_test_mscolab/test_files_api.py | 21 ++-- tests/_test_mscolab/test_server.py | 64 +++++++--- .../_test_utils/test_verify_waypoint_data.py | 119 ++++++++++++++++++ tests/utils.py | 51 ++++++++ 10 files changed, 361 insertions(+), 51 deletions(-) create mode 100644 mslib/utils/verify_waypoint_data.py create mode 100644 tests/_test_utils/test_verify_waypoint_data.py diff --git a/docs/samples/automation/retriever.py b/docs/samples/automation/retriever.py index 0226c9c6c..989faa95f 100644 --- a/docs/samples/automation/retriever.py +++ b/docs/samples/automation/retriever.py @@ -31,6 +31,7 @@ import io import os import xml +import defusedxml.minidom import requests from fs import open_fs import PIL.Image @@ -58,7 +59,7 @@ def load_from_ftml(filename): _fs = open_fs(_dirname) datasource = _fs.open(_name) try: - doc = xml.dom.minidom.parse(datasource) + doc = defusedxml.minidom.parse(datasource) except xml.parsers.expat.ExpatError as ex: raise SyntaxError(str(ex)) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 3be1a0179..25bb33a55 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -31,6 +31,7 @@ import git import threading from sqlalchemy.exc import IntegrityError +from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.mscolab.models import db, Operation, Permission, User, Change, Message from mslib.mscolab.conf import mscolab_settings @@ -53,9 +54,19 @@ def _get_operation_lock(self, op_id): def create_operation(self, path, description, user, last_used=None, content=None, category="default", active=True): """ - path: path to the operation - description: description of the operation + Creates a new operation in the mscolab system. + + :param path: The path of the operation. + :param description: The description of the operation. + :param user: The user object creating the operation. + :param last_used: The last used datetime of the operation. Default is None. + :param content: The content of the operation. Default is None. + :param category: The category of the operation. Default is 'default'. + :param active: The activity status of the operation. Default is True. + :return: True if the operation is created successfully, False otherwise. """ + if content is not None and not verify_waypoint_data(content): + return False # set codes on these later if path.find("/") != -1 or path.find("\\") != -1 or (" " in path): logging.debug("malicious request: %s", user) @@ -263,14 +274,14 @@ def update_operation(self, op_id, attribute, value, user): if value.find("/") != -1 or value.find("\\") != -1 or (" " in value): logging.debug("malicious request: %s", user) return False - data = fs.open_fs(self.data_dir) - if data.exists(value): - return False - # will be move when operations are introduced - # make a directory, else movedir - data.makedir(value) - data.movedir(operation.path, value) - # when renamed to a Group operation + with fs.open_fs(self.data_dir) as data: + if data.exists(value): + return False + # will be move when operations are introduced + # make a directory, else movedir + data.makedir(value) + data.movedir(operation.path, value) + # when renamed to a Group operation if value.endswith(mscolab_settings.GROUP_POSTFIX): # getting the category category = value.split(mscolab_settings.GROUP_POSTFIX)[0] @@ -318,6 +329,8 @@ def save_file(self, op_id, content, user, comment=""): content: content of the file to be saved # ToDo save change in schema """ + if not verify_waypoint_data(content): + return False # ToDo use comment operation = Operation.query.filter_by(id=op_id).first() if not operation: diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index 1d3ad2b15..cc6d51467 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -48,6 +48,7 @@ from mslib.utils.units import units from mslib.utils.coordinate import find_location, path_points, get_distance from mslib.utils import thermolib +from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.utils.config import config_loader, save_settings_qsettings, load_settings_qsettings from mslib.utils.config import MSUIDefaultConfig as mss_default from mslib.utils.qt import variant_to_string, variant_to_float @@ -649,8 +650,11 @@ def load_from_ftml(self, filename): def load_from_xml_data(self, xml_content, name="Flight track"): self.name = name - _waypoints_list = load_from_xml_data(xml_content, name) - self.replace_waypoints(_waypoints_list) + if verify_waypoint_data(xml_content): + _waypoints_list = load_from_xml_data(xml_content, name) + self.replace_waypoints(_waypoints_list) + else: + raise SyntaxError(f"Invalid flight track filename: {name}") def get_filename(self): return self.filename diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 42c086035..2044e7054 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -57,6 +57,7 @@ from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring from mslib.utils.verify_user_token import verify_user_token +from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog from mslib.msui.qt5 import ui_add_operation_dialog as add_operation_ui @@ -1962,10 +1963,12 @@ def handle_import_msc(self, file_path, extension, function, pickertype): return dir_path, file_name = fs.path.split(file_path) file_name = fs.path.basename(file_path) - name, file_ext = fs.path.splitext(file_name) if function is None: with open_fs(dir_path) as file_dir: xml_content = file_dir.readtext(file_name) + if not verify_waypoint_data(xml_content): + show_popup(self.ui, "Import Failed", f"The file - {file_name}, does not contain valid XML") + return try: model = ft.WaypointsTableModel(xml_content=xml_content) except SyntaxError: @@ -1977,6 +1980,9 @@ def handle_import_msc(self, file_path, extension, function, pickertype): model = ft.WaypointsTableModel(waypoints=new_waypoints) xml_doc = self.waypoints_model.get_xml_doc() xml_content = xml_doc.toprettyxml(indent=" ", newl="\n") + if not verify_waypoint_data(xml_content): + show_popup(self.ui, "Import Failed", f"The file - {file_name}, was not imported!", 0) + return self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) self.waypoints_model = model self.handle_waypoints_changed() diff --git a/mslib/utils/verify_waypoint_data.py b/mslib/utils/verify_waypoint_data.py new file mode 100644 index 000000000..455912bfe --- /dev/null +++ b/mslib/utils/verify_waypoint_data.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.verify_waypoint_data + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + basic checks for xml waypoint data. + + This file is part of MSS. + + :copyright: Copyright 2024 Reimar Bauer + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + + +import defusedxml.minidom +import xml.parsers.expat + + +def verify_waypoint_data(xml_content): + try: + doc = defusedxml.minidom.parseString(xml_content) + except xml.parsers.expat.ExpatError: + return False + + ft_el = doc.getElementsByTagName("FlightTrack")[0] + waypoints = ft_el.getElementsByTagName("Waypoint") + if (len(waypoints)) < 2: + return False + + for wp_el in ft_el.getElementsByTagName("Waypoint"): + try: + wp_el.getAttribute("location") + float(wp_el.getAttribute("lat")) + float(wp_el.getAttribute("lon")) + float(wp_el.getAttribute("flightlevel")) + wp_el.getElementsByTagName("Comments")[0] + except ValueError: + return False + + return True diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index 1710ec9d5..35f0da5d2 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -32,6 +32,7 @@ from mslib.mscolab.models import User, Operation, Permission, Change, Message from mslib.mscolab.seed import add_user, get_user from mslib.mscolab.utils import get_recent_op_id +from tests.utils import XML_CONTENT1, XML_CONTENT2, XML_CONTENT3 class Test_Files: @@ -94,25 +95,54 @@ def test_is_creator(self): def test_file_save(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="operation77") - assert self.fm.save_file(operation.id, "beta", self.user) - assert self.fm.get_file(operation.id, self.user) == "beta" - assert self.fm.save_file(operation.id, "gamma", self.user) - assert self.fm.get_file(operation.id, self.user) == "gamma" + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) + assert self.fm.get_file(operation.id, self.user) == XML_CONTENT1 + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) + assert self.fm.get_file(operation.id, self.user) == XML_CONTENT2 # check if change is saved properly changes = self.fm.get_all_changes(operation.id, self.user) assert len(changes) == 2 + def test_cant_save(self): + with self.app.test_client(): + flight_path, operation = self._create_operation(flight_path="operation911") + assert self.fm.save_file(operation.id, "text", self.user) is False + incomplete = """ + + + """ + assert self.fm.save_file(operation.id, incomplete, self.user) is False + incomplete = """ + + + + + + + + + + """ + assert self.fm.save_file(operation.id, incomplete, self.user) is False + + def test_stub_data(self): + with self.app.test_client(): + flight_path, operation = self._create_operation(flight_path="operationstub") + content = self.fm.get_file(operation.id, self.user) + assert flight_path == "operationstub" + assert content == mscolab_settings.STUB_CODE + def test_undo(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="operation7", content="alpha") - assert self.fm.save_file(operation.id, "beta", self.user) - assert self.fm.save_file(operation.id, "gamma", self.user) + flight_path, operation = self._create_operation(flight_path="operation7", content=XML_CONTENT1) + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT3, self.user) changes = Change.query.filter_by(op_id=operation.id).all() assert changes is not None assert changes[0].id == 1 assert self.fm.undo_changes(changes[0].id, self.user) is True assert len(self.fm.get_all_changes(operation.id, self.user)) == 3 - assert "beta" in self.fm.get_file(operation.id, self.user) + assert XML_CONTENT2 == self.fm.get_file(operation.id, self.user) def test_get_operation(self): with self.app.test_client(): @@ -156,8 +186,7 @@ def test_delete_operation(self): assert len(messages) == 0 def _example_data(self): - self.content1 = """\ - + self.content1 = """ new flight track (1) @@ -178,8 +207,7 @@ def _example_data(self): """ - self.content2 = """\ - + self.content2 = """ new flight track (1) diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 43dcb4864..14e4b871e 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -29,6 +29,7 @@ from mslib.mscolab.models import Operation from mslib.mscolab.seed import add_user, get_user +from tests.utils import XML_CONTENT1, XML_CONTENT2, XML_CONTENT3 class Test_Files: @@ -170,8 +171,8 @@ def test_delete_operation(self): def test_get_all_changes(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V11") - assert self.fm.save_file(operation.id, "content1", self.user) - assert self.fm.save_file(operation.id, "content2", self.user) + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) # the newest change is on index 0, because it has a recent created_at time assert len(all_changes) == 2 @@ -181,20 +182,20 @@ def test_get_all_changes(self): def test_get_change_content(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="V12", content='initial') - assert self.fm.save_file(operation.id, "content1", self.user) - assert self.fm.save_file(operation.id, "content2", self.user) - assert self.fm.save_file(operation.id, "content3", self.user) + flight_path, operation = self._create_operation(flight_path="V12", content=XML_CONTENT3) + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT3, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) previous_change = self.fm.get_change_content(all_changes[2]["id"], self.user) - assert previous_change == "content1" + assert previous_change == XML_CONTENT1 previous_change = self.fm.get_change_content(all_changes[1]["id"], self.user) - assert previous_change == "content2" + assert previous_change == XML_CONTENT2 def test_set_version_name(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="V13", content='initial') - assert self.fm.save_file(operation.id, "content1", self.user) + flight_path, operation = self._create_operation(flight_path="V13", content=XML_CONTENT3) + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) ch_id = all_changes[-1]["id"] self.fm.set_version_name(ch_id, operation.id, self.user.id, "berlin") diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 9ffb47a0c..f7085e450 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -33,12 +33,14 @@ from mslib.mscolab.server import check_login, register_user from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user +from tests.utils import XML_CONTENT1, XML_CONTENT2 class Test_Server: @pytest.fixture(autouse=True) - def setup(self, mscolab_app): + def setup(self, mscolab_app, mscolab_managers): self.app = mscolab_app + self.sockio, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' with self.app.app_context(): yield @@ -194,6 +196,17 @@ def test_create_operation(self): assert operation.active is False assert token is not None + def test_dont_create_operation(self): + content = """ + + + +""" + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) + with self.app.test_client() as test_client: + operation, token = self._create_operation(test_client, self.userdata, content=content) + assert operation is None + def test_get_operation_by_id(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: @@ -232,8 +245,16 @@ def test_get_all_changes(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - fm, user = self._save_content(operation, self.userdata) - fm.save_file(operation.id, "content2", user) + self._save_content(operation, self.userdata) + sio = self.sockio.test_client(self.app) + # ToDo implement storing comment + sio.emit('file-save', { + "op_id": operation.id, + "token": token, + "content": XML_CONTENT2, + "comment": "XML_CONTENT2"}) + sio.emit('disconnect') + # the newest change is on index 0, because it has a recent created_at time response = test_client.get('/get_all_changes', data={"token": token, "op_id": operation.id}) @@ -249,22 +270,35 @@ def test_get_change_content(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - fm, user = self._save_content(operation, self.userdata) - fm.save_file(operation.id, "content2", user) - all_changes = fm.get_all_changes(operation.id, user) + user = self._save_content(operation, self.userdata) + sio = self.sockio.test_client(self.app) + # ToDo implement storing comment + sio.emit('file-save', { + "op_id": operation.id, + "token": token, + "content": XML_CONTENT2, + "comment": "XML_CONTENT2"}) + sio.emit('disconnect') + all_changes = self.fm.get_all_changes(operation.id, user) response = test_client.get('/get_change_content', data={"token": token, "ch_id": all_changes[1]["id"]}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) - assert data == {'content': 'content1'} + assert data == {'content': XML_CONTENT1} def test_set_version_name(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - fm, user = self._save_content(operation, self.userdata) - fm.save_file(operation.id, "content2", user) - all_changes = fm.get_all_changes(operation.id, user) + user = self._save_content(operation, self.userdata) + sio = self.sockio.test_client(self.app) + sio.emit('file-save', { + "op_id": operation.id, + "token": token, + "content": XML_CONTENT2, + "comment": "XML_CONTENT2"}) + sio.emit("disconnect") + all_changes = self.fm.get_all_changes(operation.id, user) ch_id = all_changes[1]["id"] version_name = "THIS" response = test_client.post('/set_version_name', data={"token": token, @@ -380,7 +414,8 @@ def test_import_permissions(self): # creator is not listed assert data["success"] is True - def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True): + def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True, + content=None): if userdata is None: userdata = self.userdata response = test_client.post('/token', data={"email": userdata[0], "password": userdata[2]}) @@ -389,9 +424,9 @@ def _create_operation(self, test_client, userdata=None, path="firstflight", desc response = test_client.post('/create_operation', data={"token": token, "path": path, "description": description, + "content": content, "active": str(active)}) assert response.status_code == 200 - assert response.data.decode('utf-8') == "True" operation = Operation.query.filter_by(path=path).first() return operation, token @@ -409,6 +444,5 @@ def _save_content(self, operation, userdata=None): if userdata is None: userdata = self.userdata user = get_user(userdata[0]) - fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) - fm.save_file(operation.id, "content1", user) - return fm, user + self.fm.save_file(operation.id, XML_CONTENT1, user) + return user diff --git a/tests/_test_utils/test_verify_waypoint_data.py b/tests/_test_utils/test_verify_waypoint_data.py new file mode 100644 index 000000000..c87d802e5 --- /dev/null +++ b/tests/_test_utils/test_verify_waypoint_data.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +""" + + tests._test_utils.test_verify_xml_waypoint + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This tests for valid xml data of waypoint data. + + This file is part of MSS. + + :copyright: Copyright 2024 Reimar Bauer + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import pytest + +from mslib.utils.verify_waypoint_data import verify_waypoint_data + + +flight_track_with_waypoints = """ + + + + + + + + + + + + + + + + """ + +flight_track_with_waypoints_and_broken_by_linebreak = """ + + + + + + + + + + + + + + + + + """ + +flight_track_empty = """ + + + + """ + +flight_track_incomplete = """ + + + + + + + + + + + + + + + + """ + +flight_track_with_typo = """ + + + + + + + + + + + + + """ # typo is "233.0"" + +cases = [ + (flight_track_with_waypoints, True), + (flight_track_with_waypoints_and_broken_by_linebreak, False), + (flight_track_empty, False), + (flight_track_incomplete, False), + (flight_track_with_typo, False), +] + + +@pytest.mark.parametrize("xml_content, verification_result", cases) +def test_verify_xml_waypoint(xml_content, verification_result): + """Test xml verification.""" + assert verify_waypoint_data(xml_content) is verification_result diff --git a/tests/utils.py b/tests/utils.py index 7e0083f5d..b7a47b7e9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -34,6 +34,57 @@ from tests.constants import MSUI_CONFIG_PATH +XML_CONTENT1 = """ + + + + + + + + + + + + + + + + """ + + +XML_CONTENT2 = """ + + + + + + + + + + """ + + +XML_CONTENT3 = """ + + + + + + + + + + + + + + + + """ + + def callback_ok_image(status, response_headers): assert status == "200 OK" assert response_headers[0] == ('Content-type', 'image/png') From da36488f41574702b3ec2293adad496c99999e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Wed, 21 Aug 2024 16:15:28 +0200 Subject: [PATCH 10/29] Fix sequences after migrating data to PostgreSQL (#2466) When inserting rows into PostgreSQL tables and explicitly setting values for e.g. auto-increment columns, like the IDs, the associated sequences aren't automatically updated. This means that without explicitly fixing the sequences PostgreSQL would try to insert new rows with existing values for the IDs, failing in the process due to unique constraints. This issue doesn't affect MariaDB nor SQLite. The former automatically updates auto-increment counters on insert, the latter simply uses the last rows ID as a starting point. This change also extends the tests to try adding a new user after the migration, which should catch this kind of issue. --- mslib/mscolab/server.py | 28 ++++++++++++++++++++++++++ tests/_test_mscolab/test_migrations.py | 4 ++++ 2 files changed, 32 insertions(+) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index c84c57f3a..04bbd42b0 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -110,6 +110,34 @@ def _handle_db_upgrade(): ) target_connection.execute(stmt.values(row)) target_connection.commit() + if target_engine.name == "postgresql": + # Fix the databases auto-increment sequences, if it is a PostgreSQL database + # For reference, see: https://wiki.postgresql.org/wiki/Fixing_Sequences + logging.info("Using a PostgreSQL database, will fix up sequences") + cur = target_connection.execute(sqlalchemy.text(r""" +SELECT + 'SELECT SETVAL(' || + quote_literal(quote_ident(sequence_namespace.nspname) || '.' || quote_ident(class_sequence.relname)) || + ', COALESCE(MAX(' ||quote_ident(pg_attribute.attname)|| '), 1) ) FROM ' || + quote_ident(table_namespace.nspname)|| '.'||quote_ident(class_table.relname)|| ';' +FROM pg_depend + INNER JOIN pg_class AS class_sequence + ON class_sequence.oid = pg_depend.objid + AND class_sequence.relkind = 'S' + INNER JOIN pg_class AS class_table + ON class_table.oid = pg_depend.refobjid + INNER JOIN pg_attribute + ON pg_attribute.attrelid = class_table.oid + AND pg_depend.refobjsubid = pg_attribute.attnum + INNER JOIN pg_namespace as table_namespace + ON table_namespace.oid = class_table.relnamespace + INNER JOIN pg_namespace AS sequence_namespace + ON sequence_namespace.oid = class_sequence.relnamespace +ORDER BY sequence_namespace.nspname, class_sequence.relname; +""")) + for stmt, in cur.all(): + target_connection.execute(sqlalchemy.text(stmt)) + target_connection.commit() logging.info("Data migration finished") # Upgrade to the latest database revision diff --git a/tests/_test_mscolab/test_migrations.py b/tests/_test_mscolab/test_migrations.py index ae0b7fba4..2c908d5c4 100644 --- a/tests/_test_mscolab/test_migrations.py +++ b/tests/_test_mscolab/test_migrations.py @@ -129,5 +129,9 @@ def test_upgrade_from(revision, iterations, mscolab_app, tmp_path): del actual_data_after_downgrade["alembic_version"] # expected data doesn't have the revision table # Check that after a downgrade the data is definitely the same assert expected_data == actual_data_after_downgrade + + # Try to add a new user after the migration + flask_migrate.upgrade(directory=migrations_path) + assert mslib.mscolab.seed.add_user('test123@test456', 'test123', 'test456') finally: mscolab_settings.SQLALCHEMY_DB_URI_TO_MIGRATE_FROM = None From 4344e0052b824a5d2339681ed98790ccf1bb9231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= <9308656+matrss@users.noreply.github.com> Date: Thu, 22 Aug 2024 09:55:06 +0200 Subject: [PATCH 11/29] Fixes for using PostgreSQL (#2467) Created enum types should also be dropped on downgrade. Dropping tables must be committed to become visible. --- .../versions/92eaba86a92e_to_version_8_3_5_initial_migration.py | 2 ++ tests/_test_mscolab/test_migrations.py | 1 + 2 files changed, 3 insertions(+) diff --git a/mslib/mscolab/migrations/versions/92eaba86a92e_to_version_8_3_5_initial_migration.py b/mslib/mscolab/migrations/versions/92eaba86a92e_to_version_8_3_5_initial_migration.py index ec675eab2..fc307551a 100644 --- a/mslib/mscolab/migrations/versions/92eaba86a92e_to_version_8_3_5_initial_migration.py +++ b/mslib/mscolab/migrations/versions/92eaba86a92e_to_version_8_3_5_initial_migration.py @@ -81,7 +81,9 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('permissions') + sa.Enum(name='access_level').drop(op.get_bind(), checkfirst=False) op.drop_table('messages') + sa.Enum(name='messagetype').drop(op.get_bind(), checkfirst=False) op.drop_table('changes') op.drop_table('users') op.drop_table('operations') diff --git a/tests/_test_mscolab/test_migrations.py b/tests/_test_mscolab/test_migrations.py index 2c908d5c4..75cd0e651 100644 --- a/tests/_test_mscolab/test_migrations.py +++ b/tests/_test_mscolab/test_migrations.py @@ -102,6 +102,7 @@ def test_upgrade_from(revision, iterations, mscolab_app, tmp_path): with mscolab_app.app_context(): db.drop_all() db.session.execute(sqlalchemy.text("DROP TABLE alembic_version")) + db.session.commit() inspector = sqlalchemy.inspect(db.engine) existing_tables = inspector.get_table_names() assert existing_tables == [] From f0d58cef0ba5472cd903accc5363c5c909121d88 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 22 Aug 2024 14:43:29 +0200 Subject: [PATCH 12/29] prepare v9.2.0 (#2481) --- CHANGES.rst | 10 ++++++++++ mslib/version.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index a92d20429..b1aef8422 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,16 @@ Changelog ========= +Version 9.2.0 +~~~~~~~~~~~~~ + +Bug fix release and minor enhancements: +We added a verification for xml data and changed the startup of the SocketsManager. + +All changes: +https://github.com/Open-MSS/MSS/milestone/106?closed=1 + + Version 9.1.0 ~~~~~~~~~~~~~ diff --git a/mslib/version.py b/mslib/version.py index a179012a9..9181d64ab 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'9.1.0' +__version__ = u'9.2.0' From 2c89c795684500fc846d131c2e273ccfab4a2a6a Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 28 Aug 2024 11:43:37 +0200 Subject: [PATCH 13/29] Merge stable to develop (#2482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * removing pinned xmlschema (#2117) * Stop ignoring some test files (#2291) * Reconfigured tutorial videos for RTFD (#2458) * changed to videos/ * use mss style on rtfd * fix urls and text (#2460) * Bump benc-uk/workflow-dispatch from 1.2.3 to 1.2.4 (#2450) Bumps [benc-uk/workflow-dispatch](https://github.com/benc-uk/workflow-dispatch) from 1.2.3 to 1.2.4. - [Release notes](https://github.com/benc-uk/workflow-dispatch/releases) - [Commits](https://github.com/benc-uk/workflow-dispatch/compare/v1.2.3...v1.2.4) --- updated-dependencies: - dependency-name: benc-uk/workflow-dispatch dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Limit video width to the containing block's width (#2463) * pinning of libtiff is not needed but blocks a more recent libxml2 and others (#2469) * fixed the model.Message description and tests now using op_id (#2470) * bug fix for incomplete xml imports (#2468) validate xml content * prepare v9.2.0 (#2481) --------- Signed-off-by: dependabot[bot] Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/testing-all-oses.yml | 4 +- CHANGES.rst | 10 ++ docs/conf.py | 2 +- docs/environment.yml | 2 +- docs/mss_theme/css/mss.css | 4 + docs/samples/automation/retriever.py | 3 +- docs/tutorial.rst | 2 +- docs/tutorials/tutorial_hexagoncontrol.rst | 2 +- .../tutorial_introduction_topview.rst | 2 +- docs/tutorials/tutorial_kml.rst | 4 +- docs/tutorials/tutorial_mscolab.rst | 2 +- docs/tutorials/tutorial_msui_views.rst | 2 +- .../tutorial_performance_settings.rst | 2 +- docs/tutorials/tutorial_remotesensing.rst | 2 +- docs/tutorials/tutorial_satellitetrack.rst | 2 +- docs/tutorials/tutorial_waypoints.rst | 2 +- localbuild/meta.yaml | 2 +- mslib/mscolab/chat_manager.py | 8 +- mslib/mscolab/file_manager.py | 33 +++-- mslib/mscolab/models.py | 38 +++--- mslib/msui/flighttrack.py | 8 +- mslib/msui/mscolab.py | 8 +- mslib/static/docs/help.md | 2 +- mslib/static/docs/installation.md | 10 +- mslib/utils/verify_waypoint_data.py | 53 ++++++++ mslib/version.py | 2 +- requirements.d/development.txt | 1 - tests/_test_mscolab/test_chat_manager.py | 9 +- tests/_test_mscolab/test_files.py | 52 ++++++-- tests/_test_mscolab/test_files_api.py | 21 ++-- tests/_test_mscolab/test_models.py | 6 +- tests/_test_mscolab/test_server.py | 64 +++++++--- .../_test_utils/test_verify_waypoint_data.py | 119 ++++++++++++++++++ tests/utils.py | 51 ++++++++ 34 files changed, 428 insertions(+), 106 deletions(-) create mode 100644 mslib/utils/verify_waypoint_data.py create mode 100644 tests/_test_utils/test_verify_waypoint_data.py diff --git a/.github/workflows/testing-all-oses.yml b/.github/workflows/testing-all-oses.yml index 86fb996fc..58df9ccdb 100644 --- a/.github/workflows/testing-all-oses.yml +++ b/.github/workflows/testing-all-oses.yml @@ -48,6 +48,4 @@ jobs: # I have no idea yet on why this happens and how to fix it. # Even a module level skip is not enough, they need to be completely ignored. # TODO: fix those tests and drop the ignores - run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib - --ignore=tests/_test_msui/test_sideview.py --ignore=tests/_test_msui/test_topview.py --ignore=tests/_test_msui/test_wms_control.py - tests + run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib tests diff --git a/CHANGES.rst b/CHANGES.rst index a92d20429..b1aef8422 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,16 @@ Changelog ========= +Version 9.2.0 +~~~~~~~~~~~~~ + +Bug fix release and minor enhancements: +We added a verification for xml data and changed the startup of the SocketsManager. + +All changes: +https://github.com/Open-MSS/MSS/milestone/106?closed=1 + + Version 9.1.0 ~~~~~~~~~~~~~ diff --git a/docs/conf.py b/docs/conf.py index bbd933540..80e5e185d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -226,7 +226,7 @@ def get_tutorial_images(): html_style = 'css/mss.css' else: htmls_static_path = ['_static'] - html_css_files = ['mss.css'] + html_css_files = ['css/mss.css'] html_context = { 'display_github': False, # Add 'Edit on Bitbucket' link instead of 'View page source' 'last_updated': True, diff --git a/docs/environment.yml b/docs/environment.yml index 8e703ec7b..2a0fbaf92 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -15,7 +15,7 @@ dependencies: - xstatic - defusedxml - sphinx_rtd_theme - - sphinxcontrib-video + - sphinxcontrib-video>=0.2.1 - sphinx - fs - netCDF4 diff --git a/docs/mss_theme/css/mss.css b/docs/mss_theme/css/mss.css index de441f52a..58fa4d1b7 100644 --- a/docs/mss_theme/css/mss.css +++ b/docs/mss_theme/css/mss.css @@ -14,3 +14,7 @@ margin-bottom: auto; margin-left: auto; } + +video { + max-width: 100%; +} diff --git a/docs/samples/automation/retriever.py b/docs/samples/automation/retriever.py index 0226c9c6c..989faa95f 100644 --- a/docs/samples/automation/retriever.py +++ b/docs/samples/automation/retriever.py @@ -31,6 +31,7 @@ import io import os import xml +import defusedxml.minidom import requests from fs import open_fs import PIL.Image @@ -58,7 +59,7 @@ def load_from_ftml(filename): _fs = open_fs(_dirname) datasource = _fs.open(_name) try: - doc = xml.dom.minidom.parse(datasource) + doc = defusedxml.minidom.parse(datasource) except xml.parsers.expat.ExpatError as ex: raise SyntaxError(str(ex)) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index a188127e3..8fe213c4b 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -9,7 +9,7 @@ a `public share `_ Get familiar by some videos about the Mission Support System (MSS). - .. video:: _static/mp4/tutorial_waypoints.mp4 + .. video:: videos/mp4/tutorial_waypoints.mp4 :autoplay: :loop: :alt: This is the waypoints tutorial,i.e., whenever we are going to plan diff --git a/docs/tutorials/tutorial_hexagoncontrol.rst b/docs/tutorials/tutorial_hexagoncontrol.rst index 0c3dae428..a212a70e1 100644 --- a/docs/tutorials/tutorial_hexagoncontrol.rst +++ b/docs/tutorials/tutorial_hexagoncontrol.rst @@ -3,7 +3,7 @@ Table View and Hexagon Flight Patterns For tomographic imaging, a hexagonal flight pattern can be integrated by a docking widget of Table View - .. video:: ../_static/mp4/tutorial_hexagoncontrol.mp4 + .. video:: ../videos/mp4/tutorial_hexagoncontrol.mp4 :alt: Top View windows is opened (CTRL+H). We select the "global (cyl)" for the world map. Zooming in for the required region. diff --git a/docs/tutorials/tutorial_introduction_topview.rst b/docs/tutorials/tutorial_introduction_topview.rst index b4edd8a1f..b72815f05 100644 --- a/docs/tutorials/tutorial_introduction_topview.rst +++ b/docs/tutorials/tutorial_introduction_topview.rst @@ -3,7 +3,7 @@ Top View and Selecting of Layers Selection and display of different data in the Top View with the help of the layer chooser. - .. video:: ../_static/mp4/tutorial_wms.mp4 + .. video:: ../videos/mp4/tutorial_wms.mp4 :alt: When we open the Top View (CTRL+H) of the map, the Web Map Service is already opened by default. It collects its data from the server: "open-mss dot org" that provides demodata for the meteorological or atmospheric information as layer lists. diff --git a/docs/tutorials/tutorial_kml.rst b/docs/tutorials/tutorial_kml.rst index 5d8c3d951..68a85903a 100644 --- a/docs/tutorials/tutorial_kml.rst +++ b/docs/tutorials/tutorial_kml.rst @@ -3,7 +3,7 @@ Top View and KML Data es can be displayed in the Top View. Color and line width can be adjusted. - .. video:: ../_static/mp4/tutorial_kml.mp4 + .. video:: ../videos/mp4/tutorial_kml.mp4 :alt: Open the TopView (CTRL+H) After clicking on "(select to open control)", click on KML OVERLAY. The UI will look as shown. KML files can be used to show the geographical boundary which helps in planning the WAY POINTS. @@ -23,4 +23,4 @@ es can be displayed in the Top View. Color and line width can be adjusted. Changing it for some time... Now, we change line width by changing its numerical value whose range is 0 to 10. We change it to different values to obtain different linewidths. - The top view is closed and the tutorial ends. \ No newline at end of file + The top view is closed and the tutorial ends. diff --git a/docs/tutorials/tutorial_mscolab.rst b/docs/tutorials/tutorial_mscolab.rst index c4164fa9c..7d9cb4a92 100644 --- a/docs/tutorials/tutorial_mscolab.rst +++ b/docs/tutorials/tutorial_mscolab.rst @@ -5,7 +5,7 @@ Using the different views of the MSUI with a fictitious flight path and demo dat In comparison to the standalone mode of the MSUI an example setup of users is shown on a MSColab server and the possibilities of interactions. - .. video:: ../_static/mp4/tutorial_mscolab.mp4 + .. video:: ../videos/mp4/tutorial_mscolab.mp4 :alt: MSColab stores data in an online server, and can be used to access the data remotely as also working in a team where everyone contributes his part. It is used for collaborating with the users as a team together and working on a shared MSColab operation. diff --git a/docs/tutorials/tutorial_msui_views.rst b/docs/tutorials/tutorial_msui_views.rst index 7e1ba7dcd..27910e9ab 100644 --- a/docs/tutorials/tutorial_msui_views.rst +++ b/docs/tutorials/tutorial_msui_views.rst @@ -4,7 +4,7 @@ Introduction to MSUI Using the different views of the MSUI with a fictitious flight path and demo data. - .. video:: ../_static/mp4//tutorial_views.mp4 + .. video:: ../videos/mp4/tutorial_views.mp4 :alt: Lets look at the tutorial of the various views required for flight planning: Top View (CTRL+H), Side View (CTRL+V), Linear View (CTRL+L) and Table View (CTRL+T). At first, lets open Top View and Side View. diff --git a/docs/tutorials/tutorial_performance_settings.rst b/docs/tutorials/tutorial_performance_settings.rst index 82954355a..59820e2c2 100644 --- a/docs/tutorials/tutorial_performance_settings.rst +++ b/docs/tutorials/tutorial_performance_settings.rst @@ -3,7 +3,7 @@ Table View and Aircraft Performance Data The range-specific data of an aircraft can be taken into account in Tableview for flight planning. - .. video:: ../_static/mp4/tutorial_performancesettings.mp4 + .. video:: ../videos/mp4/tutorial_performancesettings.mp4 :alt: This is the Performance Settings dockwidget opened in the Table View (CTRL+T) of the MSS software where by putting and changing some parameters, we can evaluate the performance of the aircraft. Parameters like Flight Altitude, Aviation fuel, Aircraft weight, Maximum take off weight diff --git a/docs/tutorials/tutorial_remotesensing.rst b/docs/tutorials/tutorial_remotesensing.rst index e1a1d1253..65d1dbf4e 100644 --- a/docs/tutorials/tutorial_remotesensing.rst +++ b/docs/tutorials/tutorial_remotesensing.rst @@ -4,7 +4,7 @@ Top View and Remotesensing Tools In order to be able to take into account the viewing angle and solar level for measuring instruments, the remotesensing tools are used - .. video:: ../_static/mp4/tutorial_remotesensing.mp4 + .. video:: ../videos/mp4/tutorial_remotesensing.mp4 :alt: This is the Remote Sensing Section of the Top View. It shows the position and angle of the flight from any particular object in the sky. Azimuth is the forward direction line of the flight. If we go above tHE AZIMUTH, angle is in positive, diff --git a/docs/tutorials/tutorial_satellitetrack.rst b/docs/tutorials/tutorial_satellitetrack.rst index 04561d811..7c6b55550 100644 --- a/docs/tutorials/tutorial_satellitetrack.rst +++ b/docs/tutorials/tutorial_satellitetrack.rst @@ -4,7 +4,7 @@ Top View and Satellite Overflight To combine a flight path with a satellite overflight, the remotesensing widget is used. - .. video:: ../_static/mp4/tutorial_satellitetrack.mp4 + .. video:: ../videos/mp4/tutorial_satellitetrack.mp4 :alt: This is Satellite Tracking Prediction System that can be used to check the accuracy of the path travelled by a Satellite by the help of data collected from different space agencies and planning a flight accordingly. diff --git a/docs/tutorials/tutorial_waypoints.rst b/docs/tutorials/tutorial_waypoints.rst index e43d21f3d..87938a01c 100644 --- a/docs/tutorials/tutorial_waypoints.rst +++ b/docs/tutorials/tutorial_waypoints.rst @@ -3,7 +3,7 @@ Top View Drawing Waypoints Waypoints for a flight path are defined, shifted and deleted. - .. video:: ../_static/mp4/tutorial_waypoints.mp4 + .. video:: ../videos/mp4/tutorial_waypoints.mp4 :alt: This is the waypoints tutorial,i.e., whenever we are going to plan a flight track, we have to place the waypoints in some places and, form a flight path. diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 3088dfce2..9e2b52fdc 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -77,7 +77,7 @@ requirements: - metpy - pycountry - websocket-client - - libtiff <4.5.0 + - libtiff - flask-wtf - email_validator - keyring diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index e1d35624e..0d0c92bdb 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -38,16 +38,16 @@ class ChatManager: def __init__(self): pass - def add_message(self, user, text, operation_name, message_type=MessageType.TEXT, reply_id=None): + def add_message(self, user, text, op_id, message_type=MessageType.TEXT, reply_id=None): """ - text: message to be emitted to operation and saved to db - operation_name: operation-name(op_id) to which message is emitted, user: User object, one which emits the message + text: message to be emitted to operation and saved to db + op_id: operation id to which message is emitted, message_type: Enum of type MessageType. values: TEXT, SYSTEM_MESSAGE, IMAGE, DOCUMENT """ if reply_id == -1: reply_id = None - message = Message(operation_name, user.id, text, message_type, reply_id) + message = Message(op_id, user.id, text, message_type, reply_id) db.session.add(message) db.session.commit() return message diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 5b41d3a50..9db717a44 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -36,6 +36,7 @@ import mimetypes from werkzeug.utils import secure_filename from sqlalchemy.exc import IntegrityError +from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.mscolab.models import db, Operation, Permission, User, Change, Message from mslib.mscolab.conf import mscolab_settings @@ -58,9 +59,19 @@ def _get_operation_lock(self, op_id): def create_operation(self, path, description, user, last_used=None, content=None, category="default", active=True): """ - path: path to the operation - description: description of the operation + Creates a new operation in the mscolab system. + + :param path: The path of the operation. + :param description: The description of the operation. + :param user: The user object creating the operation. + :param last_used: The last used datetime of the operation. Default is None. + :param content: The content of the operation. Default is None. + :param category: The category of the operation. Default is 'default'. + :param active: The activity status of the operation. Default is True. + :return: True if the operation is created successfully, False otherwise. """ + if content is not None and not verify_waypoint_data(content): + return False # set codes on these later if path.find("/") != -1 or path.find("\\") != -1 or (" " in path): logging.debug("malicious request: %s", user) @@ -339,14 +350,14 @@ def update_operation(self, op_id, attribute, value, user): if value.find("/") != -1 or value.find("\\") != -1 or (" " in value): logging.debug("malicious request: %s", user) return False - data = fs.open_fs(self.data_dir) - if data.exists(value): - return False - # will be move when operations are introduced - # make a directory, else movedir - data.makedir(value) - data.movedir(operation.path, value) - # when renamed to a Group operation + with fs.open_fs(self.data_dir) as data: + if data.exists(value): + return False + # will be move when operations are introduced + # make a directory, else movedir + data.makedir(value) + data.movedir(operation.path, value) + # when renamed to a Group operation if value.endswith(mscolab_settings.GROUP_POSTFIX): # getting the category category = value.split(mscolab_settings.GROUP_POSTFIX)[0] @@ -394,6 +405,8 @@ def save_file(self, op_id, content, user, comment=""): content: content of the file to be saved # ToDo save change in schema """ + if not verify_waypoint_data(content): + return False # ToDo use comment operation = Operation.query.filter_by(id=op_id).first() if not operation: diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index abac5406c..742583fb7 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -67,14 +67,14 @@ class User(db.Model): def __init__(self, emailid, username, password, profile_image_path=None, confirmed=False, confirmed_on=None, authentication_backend='local'): - self.username = username - self.emailid = emailid + self.username = str(username) + self.emailid = str(emailid) self.hash_password(password) self.profile_image_path = profile_image_path self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc) - self.confirmed = confirmed + self.confirmed = bool(confirmed) self.confirmed_on = confirmed_on - self.authentication_backend = authentication_backend + self.authentication_backend = str(authentication_backend) def __repr__(self): return f'' @@ -137,9 +137,9 @@ def __init__(self, u_id, op_id, access_level): op_id: process-id access_level: the type of authorization to the operation """ - self.u_id = u_id - self.op_id = op_id - self.access_level = access_level + self.u_id = int(u_id) + self.op_id = int(op_id) + self.access_level = str(access_level) def __repr__(self): return f'' @@ -161,10 +161,10 @@ def __init__(self, path, description, last_used=None, category="default", active description: small description of operation category: name of category """ - self.path = path - self.description = description - self.category = category - self.active = active + self.path = str(path) + self.description = str(description) + self.category = str(category) + self.active = bool(active) if self.last_used is None: self.last_used = datetime.datetime.now(tz=datetime.timezone.utc) else: @@ -190,9 +190,9 @@ class Message(db.Model): replies = db.relationship('Message', cascade='all,delete,delete-orphan', single_parent=True) def __init__(self, op_id, u_id, text, message_type=MessageType.TEXT, reply_id=None): - self.op_id = op_id - self.u_id = u_id - self.text = text + self.op_id = int(op_id) + self.u_id = int(u_id) + self.text = str(text) self.message_type = message_type self.reply_id = reply_id @@ -213,8 +213,8 @@ class Change(db.Model): user = db.relationship('User') def __init__(self, op_id, u_id, commit_hash, version_name=None, comment=None): - self.op_id = op_id - self.u_id = u_id - self.commit_hash = commit_hash - self.version_name = version_name - self.comment = comment + self.op_id = int(op_id) + self.u_id = int(u_id) + self.commit_hash = str(commit_hash) + self.version_name = str(version_name) + self.comment = str(comment) diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index 1d3ad2b15..cc6d51467 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -48,6 +48,7 @@ from mslib.utils.units import units from mslib.utils.coordinate import find_location, path_points, get_distance from mslib.utils import thermolib +from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.utils.config import config_loader, save_settings_qsettings, load_settings_qsettings from mslib.utils.config import MSUIDefaultConfig as mss_default from mslib.utils.qt import variant_to_string, variant_to_float @@ -649,8 +650,11 @@ def load_from_ftml(self, filename): def load_from_xml_data(self, xml_content, name="Flight track"): self.name = name - _waypoints_list = load_from_xml_data(xml_content, name) - self.replace_waypoints(_waypoints_list) + if verify_waypoint_data(xml_content): + _waypoints_list = load_from_xml_data(xml_content, name) + self.replace_waypoints(_waypoints_list) + else: + raise SyntaxError(f"Invalid flight track filename: {name}") def get_filename(self): return self.filename diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index af57d1822..3848854af 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -61,6 +61,7 @@ from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring from mslib.utils.verify_user_token import verify_user_token +from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog from mslib.msui.qt5 import ui_add_operation_dialog as add_operation_ui @@ -2039,10 +2040,12 @@ def handle_import_msc(self, file_path, extension, function, pickertype): return dir_path, file_name = fs.path.split(file_path) file_name = fs.path.basename(file_path) - name, file_ext = fs.path.splitext(file_name) if function is None: with open_fs(dir_path) as file_dir: xml_content = file_dir.readtext(file_name) + if not verify_waypoint_data(xml_content): + show_popup(self.ui, "Import Failed", f"The file - {file_name}, does not contain valid XML") + return try: model = ft.WaypointsTableModel(xml_content=xml_content) except SyntaxError: @@ -2054,6 +2057,9 @@ def handle_import_msc(self, file_path, extension, function, pickertype): model = ft.WaypointsTableModel(waypoints=new_waypoints) xml_doc = self.waypoints_model.get_xml_doc() xml_content = xml_doc.toprettyxml(indent=" ", newl="\n") + if not verify_waypoint_data(xml_content): + show_popup(self.ui, "Import Failed", f"The file - {file_name}, was not imported!", 0) + return self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) self.waypoints_model = model self.handle_waypoints_changed() diff --git a/mslib/static/docs/help.md b/mslib/static/docs/help.md index 5ea50caa7..c0a2c0777 100644 --- a/mslib/static/docs/help.md +++ b/mslib/static/docs/help.md @@ -8,7 +8,7 @@ software that simplifies the process for planning a scientific flight. # Drawing waypoints in the MSUI Topview The example shows defining of waypoints for a flight path, moved and deleted. -![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_static/mp4/tutorial_waypoints.mp4) +![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_images/tutorial_waypoints.mp4) Further tutorials about the Mission Support System Software on: diff --git a/mslib/static/docs/installation.md b/mslib/static/docs/installation.md index 8c8051e33..b31e57473 100644 --- a/mslib/static/docs/installation.md +++ b/mslib/static/docs/installation.md @@ -7,7 +7,7 @@ The Mission Support System (MSS) including a Web Map Service a Collaboration Ser [conda-forge](https://anaconda.org/conda-forge/mss) package. -We strongly recommend to start from [Mambaforge](https://mamba.readthedocs.io/en/latest/installation.html) +We strongly recommend to start from [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) a community project of the conda-forge community. You can install it either automatically with the help of a script or manually. @@ -31,8 +31,8 @@ You can install it either automatically with the help of a script or manually. ### Manually -As **Beginner** start with an installation of Mambaforge -Get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) for your Operation System +As **Beginner** start with an installation of Miniforge3 +Get [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) for your Operation System You must install mss into a new environment to ensure the most recent @@ -65,10 +65,10 @@ user for the apache2 wsgi script. We suggest to create a mss user. - login as mss user - create a *src* directory in /home/mss - cd src -- get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) +- get [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) - set execute bit on install script - execute script, enable environment in .bashrc -- login again or export PATH="/home/mss/mambaforge/bin:\$PATH" +- start your shell again (new login) - python --version should tell Python 3.X.X - mamba create -n mssenv - mamba activate mssenv diff --git a/mslib/utils/verify_waypoint_data.py b/mslib/utils/verify_waypoint_data.py new file mode 100644 index 000000000..455912bfe --- /dev/null +++ b/mslib/utils/verify_waypoint_data.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.verify_waypoint_data + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + basic checks for xml waypoint data. + + This file is part of MSS. + + :copyright: Copyright 2024 Reimar Bauer + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + + +import defusedxml.minidom +import xml.parsers.expat + + +def verify_waypoint_data(xml_content): + try: + doc = defusedxml.minidom.parseString(xml_content) + except xml.parsers.expat.ExpatError: + return False + + ft_el = doc.getElementsByTagName("FlightTrack")[0] + waypoints = ft_el.getElementsByTagName("Waypoint") + if (len(waypoints)) < 2: + return False + + for wp_el in ft_el.getElementsByTagName("Waypoint"): + try: + wp_el.getAttribute("location") + float(wp_el.getAttribute("lat")) + float(wp_el.getAttribute("lon")) + float(wp_el.getAttribute("flightlevel")) + wp_el.getElementsByTagName("Comments")[0] + except ValueError: + return False + + return True diff --git a/mslib/version.py b/mslib/version.py index a179012a9..9181d64ab 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'9.1.0' +__version__ = u'9.2.0' diff --git a/requirements.d/development.txt b/requirements.d/development.txt index 232063ce6..d6403b27c 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -20,4 +20,3 @@ eventlet>0.30.2 dnspython>=2.0.0, <2.3.0 gsl==2.7.0 boa -xmlschema<2.5.0 diff --git a/tests/_test_mscolab/test_chat_manager.py b/tests/_test_mscolab/test_chat_manager.py index 4cc1691c4..0405f04b8 100644 --- a/tests/_test_mscolab/test_chat_manager.py +++ b/tests/_test_mscolab/test_chat_manager.py @@ -27,7 +27,7 @@ import pytest from mslib.mscolab.models import Message, MessageType -from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation, get_operation class Test_Chat_Manager: @@ -42,20 +42,21 @@ def setup(self, mscolab_app, mscolab_managers): assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) + self.operation = get_operation(self.operation_name) with self.app.app_context(): yield def test_add_message(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some message', - self.operation_name, message_type=MessageType.TEXT, + self.operation.id, message_type=MessageType.TEXT, reply_id=None) assert message.text == 'some message' def test_edit_messages(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some test message', - self.operation_name, message_type=MessageType.TEXT, + self.operation.id, message_type=MessageType.TEXT, reply_id=None) new_message_text = "Wonderland" self.cm.edit_message(message.id, new_message_text) @@ -65,7 +66,7 @@ def test_edit_messages(self): def test_delete_messages(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some test example message', - self.operation_name, message_type=MessageType.TEXT, + self.operation.id, message_type=MessageType.TEXT, reply_id=None) assert 'some test example message' in message.text self.cm.delete_message(message.id) diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index 1710ec9d5..35f0da5d2 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -32,6 +32,7 @@ from mslib.mscolab.models import User, Operation, Permission, Change, Message from mslib.mscolab.seed import add_user, get_user from mslib.mscolab.utils import get_recent_op_id +from tests.utils import XML_CONTENT1, XML_CONTENT2, XML_CONTENT3 class Test_Files: @@ -94,25 +95,54 @@ def test_is_creator(self): def test_file_save(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="operation77") - assert self.fm.save_file(operation.id, "beta", self.user) - assert self.fm.get_file(operation.id, self.user) == "beta" - assert self.fm.save_file(operation.id, "gamma", self.user) - assert self.fm.get_file(operation.id, self.user) == "gamma" + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) + assert self.fm.get_file(operation.id, self.user) == XML_CONTENT1 + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) + assert self.fm.get_file(operation.id, self.user) == XML_CONTENT2 # check if change is saved properly changes = self.fm.get_all_changes(operation.id, self.user) assert len(changes) == 2 + def test_cant_save(self): + with self.app.test_client(): + flight_path, operation = self._create_operation(flight_path="operation911") + assert self.fm.save_file(operation.id, "text", self.user) is False + incomplete = """ + + + """ + assert self.fm.save_file(operation.id, incomplete, self.user) is False + incomplete = """ + + + + + + + + + + """ + assert self.fm.save_file(operation.id, incomplete, self.user) is False + + def test_stub_data(self): + with self.app.test_client(): + flight_path, operation = self._create_operation(flight_path="operationstub") + content = self.fm.get_file(operation.id, self.user) + assert flight_path == "operationstub" + assert content == mscolab_settings.STUB_CODE + def test_undo(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="operation7", content="alpha") - assert self.fm.save_file(operation.id, "beta", self.user) - assert self.fm.save_file(operation.id, "gamma", self.user) + flight_path, operation = self._create_operation(flight_path="operation7", content=XML_CONTENT1) + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT3, self.user) changes = Change.query.filter_by(op_id=operation.id).all() assert changes is not None assert changes[0].id == 1 assert self.fm.undo_changes(changes[0].id, self.user) is True assert len(self.fm.get_all_changes(operation.id, self.user)) == 3 - assert "beta" in self.fm.get_file(operation.id, self.user) + assert XML_CONTENT2 == self.fm.get_file(operation.id, self.user) def test_get_operation(self): with self.app.test_client(): @@ -156,8 +186,7 @@ def test_delete_operation(self): assert len(messages) == 0 def _example_data(self): - self.content1 = """\ - + self.content1 = """ new flight track (1) @@ -178,8 +207,7 @@ def _example_data(self): """ - self.content2 = """\ - + self.content2 = """ new flight track (1) diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 43dcb4864..14e4b871e 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -29,6 +29,7 @@ from mslib.mscolab.models import Operation from mslib.mscolab.seed import add_user, get_user +from tests.utils import XML_CONTENT1, XML_CONTENT2, XML_CONTENT3 class Test_Files: @@ -170,8 +171,8 @@ def test_delete_operation(self): def test_get_all_changes(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V11") - assert self.fm.save_file(operation.id, "content1", self.user) - assert self.fm.save_file(operation.id, "content2", self.user) + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) # the newest change is on index 0, because it has a recent created_at time assert len(all_changes) == 2 @@ -181,20 +182,20 @@ def test_get_all_changes(self): def test_get_change_content(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="V12", content='initial') - assert self.fm.save_file(operation.id, "content1", self.user) - assert self.fm.save_file(operation.id, "content2", self.user) - assert self.fm.save_file(operation.id, "content3", self.user) + flight_path, operation = self._create_operation(flight_path="V12", content=XML_CONTENT3) + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) + assert self.fm.save_file(operation.id, XML_CONTENT3, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) previous_change = self.fm.get_change_content(all_changes[2]["id"], self.user) - assert previous_change == "content1" + assert previous_change == XML_CONTENT1 previous_change = self.fm.get_change_content(all_changes[1]["id"], self.user) - assert previous_change == "content2" + assert previous_change == XML_CONTENT2 def test_set_version_name(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="V13", content='initial') - assert self.fm.save_file(operation.id, "content1", self.user) + flight_path, operation = self._create_operation(flight_path="V13", content=XML_CONTENT3) + assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) ch_id = all_changes[-1]["id"] self.fm.set_version_name(ch_id, operation.id, self.user.id, "berlin") diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index 8b0a2140f..bf38a4894 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -30,7 +30,7 @@ from zoneinfo import ZoneInfo from mslib.mscolab.server import register_user -from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, Change +from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, MessageType, Change def test_aware_datetime_conversion(): @@ -90,12 +90,12 @@ def test_operation_repr(): def test_message_creation(): - message = Message(1, 1, "Hello, this is a test message", "TEXT", None) + message = Message(1, 1, "Hello, this is a test message", MessageType.TEXT, None) assert message.op_id == 1 assert message.u_id == 1 assert message.text == "Hello, this is a test message" - assert message.message_type == "TEXT" + assert message.message_type == MessageType.TEXT assert message.reply_id is None diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 9ffb47a0c..f7085e450 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -33,12 +33,14 @@ from mslib.mscolab.server import check_login, register_user from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user +from tests.utils import XML_CONTENT1, XML_CONTENT2 class Test_Server: @pytest.fixture(autouse=True) - def setup(self, mscolab_app): + def setup(self, mscolab_app, mscolab_managers): self.app = mscolab_app + self.sockio, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' with self.app.app_context(): yield @@ -194,6 +196,17 @@ def test_create_operation(self): assert operation.active is False assert token is not None + def test_dont_create_operation(self): + content = """ + + + +""" + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) + with self.app.test_client() as test_client: + operation, token = self._create_operation(test_client, self.userdata, content=content) + assert operation is None + def test_get_operation_by_id(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: @@ -232,8 +245,16 @@ def test_get_all_changes(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - fm, user = self._save_content(operation, self.userdata) - fm.save_file(operation.id, "content2", user) + self._save_content(operation, self.userdata) + sio = self.sockio.test_client(self.app) + # ToDo implement storing comment + sio.emit('file-save', { + "op_id": operation.id, + "token": token, + "content": XML_CONTENT2, + "comment": "XML_CONTENT2"}) + sio.emit('disconnect') + # the newest change is on index 0, because it has a recent created_at time response = test_client.get('/get_all_changes', data={"token": token, "op_id": operation.id}) @@ -249,22 +270,35 @@ def test_get_change_content(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - fm, user = self._save_content(operation, self.userdata) - fm.save_file(operation.id, "content2", user) - all_changes = fm.get_all_changes(operation.id, user) + user = self._save_content(operation, self.userdata) + sio = self.sockio.test_client(self.app) + # ToDo implement storing comment + sio.emit('file-save', { + "op_id": operation.id, + "token": token, + "content": XML_CONTENT2, + "comment": "XML_CONTENT2"}) + sio.emit('disconnect') + all_changes = self.fm.get_all_changes(operation.id, user) response = test_client.get('/get_change_content', data={"token": token, "ch_id": all_changes[1]["id"]}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) - assert data == {'content': 'content1'} + assert data == {'content': XML_CONTENT1} def test_set_version_name(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - fm, user = self._save_content(operation, self.userdata) - fm.save_file(operation.id, "content2", user) - all_changes = fm.get_all_changes(operation.id, user) + user = self._save_content(operation, self.userdata) + sio = self.sockio.test_client(self.app) + sio.emit('file-save', { + "op_id": operation.id, + "token": token, + "content": XML_CONTENT2, + "comment": "XML_CONTENT2"}) + sio.emit("disconnect") + all_changes = self.fm.get_all_changes(operation.id, user) ch_id = all_changes[1]["id"] version_name = "THIS" response = test_client.post('/set_version_name', data={"token": token, @@ -380,7 +414,8 @@ def test_import_permissions(self): # creator is not listed assert data["success"] is True - def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True): + def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True, + content=None): if userdata is None: userdata = self.userdata response = test_client.post('/token', data={"email": userdata[0], "password": userdata[2]}) @@ -389,9 +424,9 @@ def _create_operation(self, test_client, userdata=None, path="firstflight", desc response = test_client.post('/create_operation', data={"token": token, "path": path, "description": description, + "content": content, "active": str(active)}) assert response.status_code == 200 - assert response.data.decode('utf-8') == "True" operation = Operation.query.filter_by(path=path).first() return operation, token @@ -409,6 +444,5 @@ def _save_content(self, operation, userdata=None): if userdata is None: userdata = self.userdata user = get_user(userdata[0]) - fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) - fm.save_file(operation.id, "content1", user) - return fm, user + self.fm.save_file(operation.id, XML_CONTENT1, user) + return user diff --git a/tests/_test_utils/test_verify_waypoint_data.py b/tests/_test_utils/test_verify_waypoint_data.py new file mode 100644 index 000000000..c87d802e5 --- /dev/null +++ b/tests/_test_utils/test_verify_waypoint_data.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +""" + + tests._test_utils.test_verify_xml_waypoint + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This tests for valid xml data of waypoint data. + + This file is part of MSS. + + :copyright: Copyright 2024 Reimar Bauer + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import pytest + +from mslib.utils.verify_waypoint_data import verify_waypoint_data + + +flight_track_with_waypoints = """ + + + + + + + + + + + + + + + + """ + +flight_track_with_waypoints_and_broken_by_linebreak = """ + + + + + + + + + + + + + + + + + """ + +flight_track_empty = """ + + + + """ + +flight_track_incomplete = """ + + + + + + + + + + + + + + + + """ + +flight_track_with_typo = """ + + + + + + + + + + + + + """ # typo is "233.0"" + +cases = [ + (flight_track_with_waypoints, True), + (flight_track_with_waypoints_and_broken_by_linebreak, False), + (flight_track_empty, False), + (flight_track_incomplete, False), + (flight_track_with_typo, False), +] + + +@pytest.mark.parametrize("xml_content, verification_result", cases) +def test_verify_xml_waypoint(xml_content, verification_result): + """Test xml verification.""" + assert verify_waypoint_data(xml_content) is verification_result diff --git a/tests/utils.py b/tests/utils.py index 7e0083f5d..b7a47b7e9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -34,6 +34,57 @@ from tests.constants import MSUI_CONFIG_PATH +XML_CONTENT1 = """ + + + + + + + + + + + + + + + + """ + + +XML_CONTENT2 = """ + + + + + + + + + + """ + + +XML_CONTENT3 = """ + + + + + + + + + + + + + + + + """ + + def callback_ok_image(status, response_headers): assert status == "200 OK" assert response_headers[0] == ('Content-type', 'image/png') From 56f6c9e222d31ed25207c118bb6e8e439a6b589b Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Wed, 28 Aug 2024 11:44:04 +0200 Subject: [PATCH 14/29] Fix #2478 (#2479) --- mslib/msui/mscolab.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 3848854af..a0655a56e 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -654,6 +654,14 @@ def after_login(self, emailid, url, r): self.user = _json["user"] self.mscolab_server_url = url + if config_loader(dataset="MSCOLAB_skip_archived_operations"): + self.ui.pbOpenOperationArchive.setEnabled(False) + self.ui.pbOpenOperationArchive.setToolTip( + "This button is disabled to the config option 'MSCOLAB_skip_archived_operations'") + else: + self.ui.pbOpenOperationArchive.setEnabled(True) + self.ui.pbOpenOperationArchive.setToolTip("") + # create socket connection here try: self.conn = sc.ConnectionManager(self.token, user=self.user, mscolab_server_url=self.mscolab_server_url) From 0f294d9a93f16089203a0bac6d2ca1f94c13d0cc Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Thu, 29 Aug 2024 10:24:17 +0200 Subject: [PATCH 15/29] Revert "Merge stable to develop (#2482)" (#2490) This reverts commit 2c89c795684500fc846d131c2e273ccfab4a2a6a. --- .github/workflows/testing-all-oses.yml | 4 +- CHANGES.rst | 10 -- docs/conf.py | 2 +- docs/environment.yml | 2 +- docs/mss_theme/css/mss.css | 4 - docs/samples/automation/retriever.py | 3 +- docs/tutorial.rst | 2 +- docs/tutorials/tutorial_hexagoncontrol.rst | 2 +- .../tutorial_introduction_topview.rst | 2 +- docs/tutorials/tutorial_kml.rst | 4 +- docs/tutorials/tutorial_mscolab.rst | 2 +- docs/tutorials/tutorial_msui_views.rst | 2 +- .../tutorial_performance_settings.rst | 2 +- docs/tutorials/tutorial_remotesensing.rst | 2 +- docs/tutorials/tutorial_satellitetrack.rst | 2 +- docs/tutorials/tutorial_waypoints.rst | 2 +- localbuild/meta.yaml | 2 +- mslib/mscolab/chat_manager.py | 8 +- mslib/mscolab/file_manager.py | 33 ++--- mslib/mscolab/models.py | 38 +++--- mslib/msui/flighttrack.py | 8 +- mslib/msui/mscolab.py | 8 +- mslib/static/docs/help.md | 2 +- mslib/static/docs/installation.md | 10 +- mslib/utils/verify_waypoint_data.py | 53 -------- mslib/version.py | 2 +- requirements.d/development.txt | 1 + tests/_test_mscolab/test_chat_manager.py | 9 +- tests/_test_mscolab/test_files.py | 52 ++------ tests/_test_mscolab/test_files_api.py | 21 ++-- tests/_test_mscolab/test_models.py | 6 +- tests/_test_mscolab/test_server.py | 64 +++------- .../_test_utils/test_verify_waypoint_data.py | 119 ------------------ tests/utils.py | 51 -------- 34 files changed, 106 insertions(+), 428 deletions(-) delete mode 100644 mslib/utils/verify_waypoint_data.py delete mode 100644 tests/_test_utils/test_verify_waypoint_data.py diff --git a/.github/workflows/testing-all-oses.yml b/.github/workflows/testing-all-oses.yml index 58df9ccdb..86fb996fc 100644 --- a/.github/workflows/testing-all-oses.yml +++ b/.github/workflows/testing-all-oses.yml @@ -48,4 +48,6 @@ jobs: # I have no idea yet on why this happens and how to fix it. # Even a module level skip is not enough, they need to be completely ignored. # TODO: fix those tests and drop the ignores - run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib tests + run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib + --ignore=tests/_test_msui/test_sideview.py --ignore=tests/_test_msui/test_topview.py --ignore=tests/_test_msui/test_wms_control.py + tests diff --git a/CHANGES.rst b/CHANGES.rst index b1aef8422..a92d20429 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,16 +1,6 @@ Changelog ========= -Version 9.2.0 -~~~~~~~~~~~~~ - -Bug fix release and minor enhancements: -We added a verification for xml data and changed the startup of the SocketsManager. - -All changes: -https://github.com/Open-MSS/MSS/milestone/106?closed=1 - - Version 9.1.0 ~~~~~~~~~~~~~ diff --git a/docs/conf.py b/docs/conf.py index 80e5e185d..bbd933540 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -226,7 +226,7 @@ def get_tutorial_images(): html_style = 'css/mss.css' else: htmls_static_path = ['_static'] - html_css_files = ['css/mss.css'] + html_css_files = ['mss.css'] html_context = { 'display_github': False, # Add 'Edit on Bitbucket' link instead of 'View page source' 'last_updated': True, diff --git a/docs/environment.yml b/docs/environment.yml index 2a0fbaf92..8e703ec7b 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -15,7 +15,7 @@ dependencies: - xstatic - defusedxml - sphinx_rtd_theme - - sphinxcontrib-video>=0.2.1 + - sphinxcontrib-video - sphinx - fs - netCDF4 diff --git a/docs/mss_theme/css/mss.css b/docs/mss_theme/css/mss.css index 58fa4d1b7..de441f52a 100644 --- a/docs/mss_theme/css/mss.css +++ b/docs/mss_theme/css/mss.css @@ -14,7 +14,3 @@ margin-bottom: auto; margin-left: auto; } - -video { - max-width: 100%; -} diff --git a/docs/samples/automation/retriever.py b/docs/samples/automation/retriever.py index 989faa95f..0226c9c6c 100644 --- a/docs/samples/automation/retriever.py +++ b/docs/samples/automation/retriever.py @@ -31,7 +31,6 @@ import io import os import xml -import defusedxml.minidom import requests from fs import open_fs import PIL.Image @@ -59,7 +58,7 @@ def load_from_ftml(filename): _fs = open_fs(_dirname) datasource = _fs.open(_name) try: - doc = defusedxml.minidom.parse(datasource) + doc = xml.dom.minidom.parse(datasource) except xml.parsers.expat.ExpatError as ex: raise SyntaxError(str(ex)) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 8fe213c4b..a188127e3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -9,7 +9,7 @@ a `public share `_ Get familiar by some videos about the Mission Support System (MSS). - .. video:: videos/mp4/tutorial_waypoints.mp4 + .. video:: _static/mp4/tutorial_waypoints.mp4 :autoplay: :loop: :alt: This is the waypoints tutorial,i.e., whenever we are going to plan diff --git a/docs/tutorials/tutorial_hexagoncontrol.rst b/docs/tutorials/tutorial_hexagoncontrol.rst index a212a70e1..0c3dae428 100644 --- a/docs/tutorials/tutorial_hexagoncontrol.rst +++ b/docs/tutorials/tutorial_hexagoncontrol.rst @@ -3,7 +3,7 @@ Table View and Hexagon Flight Patterns For tomographic imaging, a hexagonal flight pattern can be integrated by a docking widget of Table View - .. video:: ../videos/mp4/tutorial_hexagoncontrol.mp4 + .. video:: ../_static/mp4/tutorial_hexagoncontrol.mp4 :alt: Top View windows is opened (CTRL+H). We select the "global (cyl)" for the world map. Zooming in for the required region. diff --git a/docs/tutorials/tutorial_introduction_topview.rst b/docs/tutorials/tutorial_introduction_topview.rst index b72815f05..b4edd8a1f 100644 --- a/docs/tutorials/tutorial_introduction_topview.rst +++ b/docs/tutorials/tutorial_introduction_topview.rst @@ -3,7 +3,7 @@ Top View and Selecting of Layers Selection and display of different data in the Top View with the help of the layer chooser. - .. video:: ../videos/mp4/tutorial_wms.mp4 + .. video:: ../_static/mp4/tutorial_wms.mp4 :alt: When we open the Top View (CTRL+H) of the map, the Web Map Service is already opened by default. It collects its data from the server: "open-mss dot org" that provides demodata for the meteorological or atmospheric information as layer lists. diff --git a/docs/tutorials/tutorial_kml.rst b/docs/tutorials/tutorial_kml.rst index 68a85903a..5d8c3d951 100644 --- a/docs/tutorials/tutorial_kml.rst +++ b/docs/tutorials/tutorial_kml.rst @@ -3,7 +3,7 @@ Top View and KML Data es can be displayed in the Top View. Color and line width can be adjusted. - .. video:: ../videos/mp4/tutorial_kml.mp4 + .. video:: ../_static/mp4/tutorial_kml.mp4 :alt: Open the TopView (CTRL+H) After clicking on "(select to open control)", click on KML OVERLAY. The UI will look as shown. KML files can be used to show the geographical boundary which helps in planning the WAY POINTS. @@ -23,4 +23,4 @@ es can be displayed in the Top View. Color and line width can be adjusted. Changing it for some time... Now, we change line width by changing its numerical value whose range is 0 to 10. We change it to different values to obtain different linewidths. - The top view is closed and the tutorial ends. + The top view is closed and the tutorial ends. \ No newline at end of file diff --git a/docs/tutorials/tutorial_mscolab.rst b/docs/tutorials/tutorial_mscolab.rst index 7d9cb4a92..c4164fa9c 100644 --- a/docs/tutorials/tutorial_mscolab.rst +++ b/docs/tutorials/tutorial_mscolab.rst @@ -5,7 +5,7 @@ Using the different views of the MSUI with a fictitious flight path and demo dat In comparison to the standalone mode of the MSUI an example setup of users is shown on a MSColab server and the possibilities of interactions. - .. video:: ../videos/mp4/tutorial_mscolab.mp4 + .. video:: ../_static/mp4/tutorial_mscolab.mp4 :alt: MSColab stores data in an online server, and can be used to access the data remotely as also working in a team where everyone contributes his part. It is used for collaborating with the users as a team together and working on a shared MSColab operation. diff --git a/docs/tutorials/tutorial_msui_views.rst b/docs/tutorials/tutorial_msui_views.rst index 27910e9ab..7e1ba7dcd 100644 --- a/docs/tutorials/tutorial_msui_views.rst +++ b/docs/tutorials/tutorial_msui_views.rst @@ -4,7 +4,7 @@ Introduction to MSUI Using the different views of the MSUI with a fictitious flight path and demo data. - .. video:: ../videos/mp4/tutorial_views.mp4 + .. video:: ../_static/mp4//tutorial_views.mp4 :alt: Lets look at the tutorial of the various views required for flight planning: Top View (CTRL+H), Side View (CTRL+V), Linear View (CTRL+L) and Table View (CTRL+T). At first, lets open Top View and Side View. diff --git a/docs/tutorials/tutorial_performance_settings.rst b/docs/tutorials/tutorial_performance_settings.rst index 59820e2c2..82954355a 100644 --- a/docs/tutorials/tutorial_performance_settings.rst +++ b/docs/tutorials/tutorial_performance_settings.rst @@ -3,7 +3,7 @@ Table View and Aircraft Performance Data The range-specific data of an aircraft can be taken into account in Tableview for flight planning. - .. video:: ../videos/mp4/tutorial_performancesettings.mp4 + .. video:: ../_static/mp4/tutorial_performancesettings.mp4 :alt: This is the Performance Settings dockwidget opened in the Table View (CTRL+T) of the MSS software where by putting and changing some parameters, we can evaluate the performance of the aircraft. Parameters like Flight Altitude, Aviation fuel, Aircraft weight, Maximum take off weight diff --git a/docs/tutorials/tutorial_remotesensing.rst b/docs/tutorials/tutorial_remotesensing.rst index 65d1dbf4e..e1a1d1253 100644 --- a/docs/tutorials/tutorial_remotesensing.rst +++ b/docs/tutorials/tutorial_remotesensing.rst @@ -4,7 +4,7 @@ Top View and Remotesensing Tools In order to be able to take into account the viewing angle and solar level for measuring instruments, the remotesensing tools are used - .. video:: ../videos/mp4/tutorial_remotesensing.mp4 + .. video:: ../_static/mp4/tutorial_remotesensing.mp4 :alt: This is the Remote Sensing Section of the Top View. It shows the position and angle of the flight from any particular object in the sky. Azimuth is the forward direction line of the flight. If we go above tHE AZIMUTH, angle is in positive, diff --git a/docs/tutorials/tutorial_satellitetrack.rst b/docs/tutorials/tutorial_satellitetrack.rst index 7c6b55550..04561d811 100644 --- a/docs/tutorials/tutorial_satellitetrack.rst +++ b/docs/tutorials/tutorial_satellitetrack.rst @@ -4,7 +4,7 @@ Top View and Satellite Overflight To combine a flight path with a satellite overflight, the remotesensing widget is used. - .. video:: ../videos/mp4/tutorial_satellitetrack.mp4 + .. video:: ../_static/mp4/tutorial_satellitetrack.mp4 :alt: This is Satellite Tracking Prediction System that can be used to check the accuracy of the path travelled by a Satellite by the help of data collected from different space agencies and planning a flight accordingly. diff --git a/docs/tutorials/tutorial_waypoints.rst b/docs/tutorials/tutorial_waypoints.rst index 87938a01c..e43d21f3d 100644 --- a/docs/tutorials/tutorial_waypoints.rst +++ b/docs/tutorials/tutorial_waypoints.rst @@ -3,7 +3,7 @@ Top View Drawing Waypoints Waypoints for a flight path are defined, shifted and deleted. - .. video:: ../videos/mp4/tutorial_waypoints.mp4 + .. video:: ../_static/mp4/tutorial_waypoints.mp4 :alt: This is the waypoints tutorial,i.e., whenever we are going to plan a flight track, we have to place the waypoints in some places and, form a flight path. diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 9e2b52fdc..3088dfce2 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -77,7 +77,7 @@ requirements: - metpy - pycountry - websocket-client - - libtiff + - libtiff <4.5.0 - flask-wtf - email_validator - keyring diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index 0d0c92bdb..e1d35624e 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -38,16 +38,16 @@ class ChatManager: def __init__(self): pass - def add_message(self, user, text, op_id, message_type=MessageType.TEXT, reply_id=None): + def add_message(self, user, text, operation_name, message_type=MessageType.TEXT, reply_id=None): """ - user: User object, one which emits the message text: message to be emitted to operation and saved to db - op_id: operation id to which message is emitted, + operation_name: operation-name(op_id) to which message is emitted, + user: User object, one which emits the message message_type: Enum of type MessageType. values: TEXT, SYSTEM_MESSAGE, IMAGE, DOCUMENT """ if reply_id == -1: reply_id = None - message = Message(op_id, user.id, text, message_type, reply_id) + message = Message(operation_name, user.id, text, message_type, reply_id) db.session.add(message) db.session.commit() return message diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 9db717a44..5b41d3a50 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -36,7 +36,6 @@ import mimetypes from werkzeug.utils import secure_filename from sqlalchemy.exc import IntegrityError -from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.mscolab.models import db, Operation, Permission, User, Change, Message from mslib.mscolab.conf import mscolab_settings @@ -59,19 +58,9 @@ def _get_operation_lock(self, op_id): def create_operation(self, path, description, user, last_used=None, content=None, category="default", active=True): """ - Creates a new operation in the mscolab system. - - :param path: The path of the operation. - :param description: The description of the operation. - :param user: The user object creating the operation. - :param last_used: The last used datetime of the operation. Default is None. - :param content: The content of the operation. Default is None. - :param category: The category of the operation. Default is 'default'. - :param active: The activity status of the operation. Default is True. - :return: True if the operation is created successfully, False otherwise. + path: path to the operation + description: description of the operation """ - if content is not None and not verify_waypoint_data(content): - return False # set codes on these later if path.find("/") != -1 or path.find("\\") != -1 or (" " in path): logging.debug("malicious request: %s", user) @@ -350,14 +339,14 @@ def update_operation(self, op_id, attribute, value, user): if value.find("/") != -1 or value.find("\\") != -1 or (" " in value): logging.debug("malicious request: %s", user) return False - with fs.open_fs(self.data_dir) as data: - if data.exists(value): - return False - # will be move when operations are introduced - # make a directory, else movedir - data.makedir(value) - data.movedir(operation.path, value) - # when renamed to a Group operation + data = fs.open_fs(self.data_dir) + if data.exists(value): + return False + # will be move when operations are introduced + # make a directory, else movedir + data.makedir(value) + data.movedir(operation.path, value) + # when renamed to a Group operation if value.endswith(mscolab_settings.GROUP_POSTFIX): # getting the category category = value.split(mscolab_settings.GROUP_POSTFIX)[0] @@ -405,8 +394,6 @@ def save_file(self, op_id, content, user, comment=""): content: content of the file to be saved # ToDo save change in schema """ - if not verify_waypoint_data(content): - return False # ToDo use comment operation = Operation.query.filter_by(id=op_id).first() if not operation: diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 742583fb7..abac5406c 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -67,14 +67,14 @@ class User(db.Model): def __init__(self, emailid, username, password, profile_image_path=None, confirmed=False, confirmed_on=None, authentication_backend='local'): - self.username = str(username) - self.emailid = str(emailid) + self.username = username + self.emailid = emailid self.hash_password(password) self.profile_image_path = profile_image_path self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc) - self.confirmed = bool(confirmed) + self.confirmed = confirmed self.confirmed_on = confirmed_on - self.authentication_backend = str(authentication_backend) + self.authentication_backend = authentication_backend def __repr__(self): return f'' @@ -137,9 +137,9 @@ def __init__(self, u_id, op_id, access_level): op_id: process-id access_level: the type of authorization to the operation """ - self.u_id = int(u_id) - self.op_id = int(op_id) - self.access_level = str(access_level) + self.u_id = u_id + self.op_id = op_id + self.access_level = access_level def __repr__(self): return f'' @@ -161,10 +161,10 @@ def __init__(self, path, description, last_used=None, category="default", active description: small description of operation category: name of category """ - self.path = str(path) - self.description = str(description) - self.category = str(category) - self.active = bool(active) + self.path = path + self.description = description + self.category = category + self.active = active if self.last_used is None: self.last_used = datetime.datetime.now(tz=datetime.timezone.utc) else: @@ -190,9 +190,9 @@ class Message(db.Model): replies = db.relationship('Message', cascade='all,delete,delete-orphan', single_parent=True) def __init__(self, op_id, u_id, text, message_type=MessageType.TEXT, reply_id=None): - self.op_id = int(op_id) - self.u_id = int(u_id) - self.text = str(text) + self.op_id = op_id + self.u_id = u_id + self.text = text self.message_type = message_type self.reply_id = reply_id @@ -213,8 +213,8 @@ class Change(db.Model): user = db.relationship('User') def __init__(self, op_id, u_id, commit_hash, version_name=None, comment=None): - self.op_id = int(op_id) - self.u_id = int(u_id) - self.commit_hash = str(commit_hash) - self.version_name = str(version_name) - self.comment = str(comment) + self.op_id = op_id + self.u_id = u_id + self.commit_hash = commit_hash + self.version_name = version_name + self.comment = comment diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index cc6d51467..1d3ad2b15 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -48,7 +48,6 @@ from mslib.utils.units import units from mslib.utils.coordinate import find_location, path_points, get_distance from mslib.utils import thermolib -from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.utils.config import config_loader, save_settings_qsettings, load_settings_qsettings from mslib.utils.config import MSUIDefaultConfig as mss_default from mslib.utils.qt import variant_to_string, variant_to_float @@ -650,11 +649,8 @@ def load_from_ftml(self, filename): def load_from_xml_data(self, xml_content, name="Flight track"): self.name = name - if verify_waypoint_data(xml_content): - _waypoints_list = load_from_xml_data(xml_content, name) - self.replace_waypoints(_waypoints_list) - else: - raise SyntaxError(f"Invalid flight track filename: {name}") + _waypoints_list = load_from_xml_data(xml_content, name) + self.replace_waypoints(_waypoints_list) def get_filename(self): return self.filename diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index a0655a56e..a1ee04642 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -61,7 +61,6 @@ from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring from mslib.utils.verify_user_token import verify_user_token -from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog from mslib.msui.qt5 import ui_add_operation_dialog as add_operation_ui @@ -2048,12 +2047,10 @@ def handle_import_msc(self, file_path, extension, function, pickertype): return dir_path, file_name = fs.path.split(file_path) file_name = fs.path.basename(file_path) + name, file_ext = fs.path.splitext(file_name) if function is None: with open_fs(dir_path) as file_dir: xml_content = file_dir.readtext(file_name) - if not verify_waypoint_data(xml_content): - show_popup(self.ui, "Import Failed", f"The file - {file_name}, does not contain valid XML") - return try: model = ft.WaypointsTableModel(xml_content=xml_content) except SyntaxError: @@ -2065,9 +2062,6 @@ def handle_import_msc(self, file_path, extension, function, pickertype): model = ft.WaypointsTableModel(waypoints=new_waypoints) xml_doc = self.waypoints_model.get_xml_doc() xml_content = xml_doc.toprettyxml(indent=" ", newl="\n") - if not verify_waypoint_data(xml_content): - show_popup(self.ui, "Import Failed", f"The file - {file_name}, was not imported!", 0) - return self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) self.waypoints_model = model self.handle_waypoints_changed() diff --git a/mslib/static/docs/help.md b/mslib/static/docs/help.md index c0a2c0777..5ea50caa7 100644 --- a/mslib/static/docs/help.md +++ b/mslib/static/docs/help.md @@ -8,7 +8,7 @@ software that simplifies the process for planning a scientific flight. # Drawing waypoints in the MSUI Topview The example shows defining of waypoints for a flight path, moved and deleted. -![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_images/tutorial_waypoints.mp4) +![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_static/mp4/tutorial_waypoints.mp4) Further tutorials about the Mission Support System Software on: diff --git a/mslib/static/docs/installation.md b/mslib/static/docs/installation.md index b31e57473..8c8051e33 100644 --- a/mslib/static/docs/installation.md +++ b/mslib/static/docs/installation.md @@ -7,7 +7,7 @@ The Mission Support System (MSS) including a Web Map Service a Collaboration Ser [conda-forge](https://anaconda.org/conda-forge/mss) package. -We strongly recommend to start from [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) +We strongly recommend to start from [Mambaforge](https://mamba.readthedocs.io/en/latest/installation.html) a community project of the conda-forge community. You can install it either automatically with the help of a script or manually. @@ -31,8 +31,8 @@ You can install it either automatically with the help of a script or manually. ### Manually -As **Beginner** start with an installation of Miniforge3 -Get [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) for your Operation System +As **Beginner** start with an installation of Mambaforge +Get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) for your Operation System You must install mss into a new environment to ensure the most recent @@ -65,10 +65,10 @@ user for the apache2 wsgi script. We suggest to create a mss user. - login as mss user - create a *src* directory in /home/mss - cd src -- get [Miniforge3](https://github.com/conda-forge/miniforge?tab=readme-ov-file#miniforge3) +- get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) - set execute bit on install script - execute script, enable environment in .bashrc -- start your shell again (new login) +- login again or export PATH="/home/mss/mambaforge/bin:\$PATH" - python --version should tell Python 3.X.X - mamba create -n mssenv - mamba activate mssenv diff --git a/mslib/utils/verify_waypoint_data.py b/mslib/utils/verify_waypoint_data.py deleted file mode 100644 index 455912bfe..000000000 --- a/mslib/utils/verify_waypoint_data.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - mslib.utils.verify_waypoint_data - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - basic checks for xml waypoint data. - - This file is part of MSS. - - :copyright: Copyright 2024 Reimar Bauer - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" - - -import defusedxml.minidom -import xml.parsers.expat - - -def verify_waypoint_data(xml_content): - try: - doc = defusedxml.minidom.parseString(xml_content) - except xml.parsers.expat.ExpatError: - return False - - ft_el = doc.getElementsByTagName("FlightTrack")[0] - waypoints = ft_el.getElementsByTagName("Waypoint") - if (len(waypoints)) < 2: - return False - - for wp_el in ft_el.getElementsByTagName("Waypoint"): - try: - wp_el.getAttribute("location") - float(wp_el.getAttribute("lat")) - float(wp_el.getAttribute("lon")) - float(wp_el.getAttribute("flightlevel")) - wp_el.getElementsByTagName("Comments")[0] - except ValueError: - return False - - return True diff --git a/mslib/version.py b/mslib/version.py index 9181d64ab..a179012a9 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'9.2.0' +__version__ = u'9.1.0' diff --git a/requirements.d/development.txt b/requirements.d/development.txt index d6403b27c..232063ce6 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -20,3 +20,4 @@ eventlet>0.30.2 dnspython>=2.0.0, <2.3.0 gsl==2.7.0 boa +xmlschema<2.5.0 diff --git a/tests/_test_mscolab/test_chat_manager.py b/tests/_test_mscolab/test_chat_manager.py index 0405f04b8..4cc1691c4 100644 --- a/tests/_test_mscolab/test_chat_manager.py +++ b/tests/_test_mscolab/test_chat_manager.py @@ -27,7 +27,7 @@ import pytest from mslib.mscolab.models import Message, MessageType -from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation, get_operation +from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation class Test_Chat_Manager: @@ -42,21 +42,20 @@ def setup(self, mscolab_app, mscolab_managers): assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - self.operation = get_operation(self.operation_name) with self.app.app_context(): yield def test_add_message(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some message', - self.operation.id, message_type=MessageType.TEXT, + self.operation_name, message_type=MessageType.TEXT, reply_id=None) assert message.text == 'some message' def test_edit_messages(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some test message', - self.operation.id, message_type=MessageType.TEXT, + self.operation_name, message_type=MessageType.TEXT, reply_id=None) new_message_text = "Wonderland" self.cm.edit_message(message.id, new_message_text) @@ -66,7 +65,7 @@ def test_edit_messages(self): def test_delete_messages(self): with self.app.test_client(): message = self.cm.add_message(self.user, 'some test example message', - self.operation.id, message_type=MessageType.TEXT, + self.operation_name, message_type=MessageType.TEXT, reply_id=None) assert 'some test example message' in message.text self.cm.delete_message(message.id) diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index 35f0da5d2..1710ec9d5 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -32,7 +32,6 @@ from mslib.mscolab.models import User, Operation, Permission, Change, Message from mslib.mscolab.seed import add_user, get_user from mslib.mscolab.utils import get_recent_op_id -from tests.utils import XML_CONTENT1, XML_CONTENT2, XML_CONTENT3 class Test_Files: @@ -95,54 +94,25 @@ def test_is_creator(self): def test_file_save(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="operation77") - assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) - assert self.fm.get_file(operation.id, self.user) == XML_CONTENT1 - assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) - assert self.fm.get_file(operation.id, self.user) == XML_CONTENT2 + assert self.fm.save_file(operation.id, "beta", self.user) + assert self.fm.get_file(operation.id, self.user) == "beta" + assert self.fm.save_file(operation.id, "gamma", self.user) + assert self.fm.get_file(operation.id, self.user) == "gamma" # check if change is saved properly changes = self.fm.get_all_changes(operation.id, self.user) assert len(changes) == 2 - def test_cant_save(self): - with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="operation911") - assert self.fm.save_file(operation.id, "text", self.user) is False - incomplete = """ - - - """ - assert self.fm.save_file(operation.id, incomplete, self.user) is False - incomplete = """ - - - - - - - - - - """ - assert self.fm.save_file(operation.id, incomplete, self.user) is False - - def test_stub_data(self): - with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="operationstub") - content = self.fm.get_file(operation.id, self.user) - assert flight_path == "operationstub" - assert content == mscolab_settings.STUB_CODE - def test_undo(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="operation7", content=XML_CONTENT1) - assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) - assert self.fm.save_file(operation.id, XML_CONTENT3, self.user) + flight_path, operation = self._create_operation(flight_path="operation7", content="alpha") + assert self.fm.save_file(operation.id, "beta", self.user) + assert self.fm.save_file(operation.id, "gamma", self.user) changes = Change.query.filter_by(op_id=operation.id).all() assert changes is not None assert changes[0].id == 1 assert self.fm.undo_changes(changes[0].id, self.user) is True assert len(self.fm.get_all_changes(operation.id, self.user)) == 3 - assert XML_CONTENT2 == self.fm.get_file(operation.id, self.user) + assert "beta" in self.fm.get_file(operation.id, self.user) def test_get_operation(self): with self.app.test_client(): @@ -186,7 +156,8 @@ def test_delete_operation(self): assert len(messages) == 0 def _example_data(self): - self.content1 = """ + self.content1 = """\ + new flight track (1) @@ -207,7 +178,8 @@ def _example_data(self): """ - self.content2 = """ + self.content2 = """\ + new flight track (1) diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 14e4b871e..43dcb4864 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -29,7 +29,6 @@ from mslib.mscolab.models import Operation from mslib.mscolab.seed import add_user, get_user -from tests.utils import XML_CONTENT1, XML_CONTENT2, XML_CONTENT3 class Test_Files: @@ -171,8 +170,8 @@ def test_delete_operation(self): def test_get_all_changes(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V11") - assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) - assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) + assert self.fm.save_file(operation.id, "content1", self.user) + assert self.fm.save_file(operation.id, "content2", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) # the newest change is on index 0, because it has a recent created_at time assert len(all_changes) == 2 @@ -182,20 +181,20 @@ def test_get_all_changes(self): def test_get_change_content(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="V12", content=XML_CONTENT3) - assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) - assert self.fm.save_file(operation.id, XML_CONTENT2, self.user) - assert self.fm.save_file(operation.id, XML_CONTENT3, self.user) + flight_path, operation = self._create_operation(flight_path="V12", content='initial') + assert self.fm.save_file(operation.id, "content1", self.user) + assert self.fm.save_file(operation.id, "content2", self.user) + assert self.fm.save_file(operation.id, "content3", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) previous_change = self.fm.get_change_content(all_changes[2]["id"], self.user) - assert previous_change == XML_CONTENT1 + assert previous_change == "content1" previous_change = self.fm.get_change_content(all_changes[1]["id"], self.user) - assert previous_change == XML_CONTENT2 + assert previous_change == "content2" def test_set_version_name(self): with self.app.test_client(): - flight_path, operation = self._create_operation(flight_path="V13", content=XML_CONTENT3) - assert self.fm.save_file(operation.id, XML_CONTENT1, self.user) + flight_path, operation = self._create_operation(flight_path="V13", content='initial') + assert self.fm.save_file(operation.id, "content1", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) ch_id = all_changes[-1]["id"] self.fm.set_version_name(ch_id, operation.id, self.user.id, "berlin") diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index bf38a4894..8b0a2140f 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -30,7 +30,7 @@ from zoneinfo import ZoneInfo from mslib.mscolab.server import register_user -from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, MessageType, Change +from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, Change def test_aware_datetime_conversion(): @@ -90,12 +90,12 @@ def test_operation_repr(): def test_message_creation(): - message = Message(1, 1, "Hello, this is a test message", MessageType.TEXT, None) + message = Message(1, 1, "Hello, this is a test message", "TEXT", None) assert message.op_id == 1 assert message.u_id == 1 assert message.text == "Hello, this is a test message" - assert message.message_type == MessageType.TEXT + assert message.message_type == "TEXT" assert message.reply_id is None diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index f7085e450..9ffb47a0c 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -33,14 +33,12 @@ from mslib.mscolab.server import check_login, register_user from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user -from tests.utils import XML_CONTENT1, XML_CONTENT2 class Test_Server: @pytest.fixture(autouse=True) - def setup(self, mscolab_app, mscolab_managers): + def setup(self, mscolab_app): self.app = mscolab_app - self.sockio, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' with self.app.app_context(): yield @@ -196,17 +194,6 @@ def test_create_operation(self): assert operation.active is False assert token is not None - def test_dont_create_operation(self): - content = """ - - - -""" - assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) - with self.app.test_client() as test_client: - operation, token = self._create_operation(test_client, self.userdata, content=content) - assert operation is None - def test_get_operation_by_id(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: @@ -245,16 +232,8 @@ def test_get_all_changes(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - self._save_content(operation, self.userdata) - sio = self.sockio.test_client(self.app) - # ToDo implement storing comment - sio.emit('file-save', { - "op_id": operation.id, - "token": token, - "content": XML_CONTENT2, - "comment": "XML_CONTENT2"}) - sio.emit('disconnect') - + fm, user = self._save_content(operation, self.userdata) + fm.save_file(operation.id, "content2", user) # the newest change is on index 0, because it has a recent created_at time response = test_client.get('/get_all_changes', data={"token": token, "op_id": operation.id}) @@ -270,35 +249,22 @@ def test_get_change_content(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - user = self._save_content(operation, self.userdata) - sio = self.sockio.test_client(self.app) - # ToDo implement storing comment - sio.emit('file-save', { - "op_id": operation.id, - "token": token, - "content": XML_CONTENT2, - "comment": "XML_CONTENT2"}) - sio.emit('disconnect') - all_changes = self.fm.get_all_changes(operation.id, user) + fm, user = self._save_content(operation, self.userdata) + fm.save_file(operation.id, "content2", user) + all_changes = fm.get_all_changes(operation.id, user) response = test_client.get('/get_change_content', data={"token": token, "ch_id": all_changes[1]["id"]}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) - assert data == {'content': XML_CONTENT1} + assert data == {'content': 'content1'} def test_set_version_name(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) - user = self._save_content(operation, self.userdata) - sio = self.sockio.test_client(self.app) - sio.emit('file-save', { - "op_id": operation.id, - "token": token, - "content": XML_CONTENT2, - "comment": "XML_CONTENT2"}) - sio.emit("disconnect") - all_changes = self.fm.get_all_changes(operation.id, user) + fm, user = self._save_content(operation, self.userdata) + fm.save_file(operation.id, "content2", user) + all_changes = fm.get_all_changes(operation.id, user) ch_id = all_changes[1]["id"] version_name = "THIS" response = test_client.post('/set_version_name', data={"token": token, @@ -414,8 +380,7 @@ def test_import_permissions(self): # creator is not listed assert data["success"] is True - def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True, - content=None): + def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True): if userdata is None: userdata = self.userdata response = test_client.post('/token', data={"email": userdata[0], "password": userdata[2]}) @@ -424,9 +389,9 @@ def _create_operation(self, test_client, userdata=None, path="firstflight", desc response = test_client.post('/create_operation', data={"token": token, "path": path, "description": description, - "content": content, "active": str(active)}) assert response.status_code == 200 + assert response.data.decode('utf-8') == "True" operation = Operation.query.filter_by(path=path).first() return operation, token @@ -444,5 +409,6 @@ def _save_content(self, operation, userdata=None): if userdata is None: userdata = self.userdata user = get_user(userdata[0]) - self.fm.save_file(operation.id, XML_CONTENT1, user) - return user + fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) + fm.save_file(operation.id, "content1", user) + return fm, user diff --git a/tests/_test_utils/test_verify_waypoint_data.py b/tests/_test_utils/test_verify_waypoint_data.py deleted file mode 100644 index c87d802e5..000000000 --- a/tests/_test_utils/test_verify_waypoint_data.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - tests._test_utils.test_verify_xml_waypoint - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This tests for valid xml data of waypoint data. - - This file is part of MSS. - - :copyright: Copyright 2024 Reimar Bauer - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" -import pytest - -from mslib.utils.verify_waypoint_data import verify_waypoint_data - - -flight_track_with_waypoints = """ - - - - - - - - - - - - - - - - """ - -flight_track_with_waypoints_and_broken_by_linebreak = """ - - - - - - - - - - - - - - - - - """ - -flight_track_empty = """ - - - - """ - -flight_track_incomplete = """ - - - - - - - - - - - - - - - - """ - -flight_track_with_typo = """ - - - - - - - - - - - - - """ # typo is "233.0"" - -cases = [ - (flight_track_with_waypoints, True), - (flight_track_with_waypoints_and_broken_by_linebreak, False), - (flight_track_empty, False), - (flight_track_incomplete, False), - (flight_track_with_typo, False), -] - - -@pytest.mark.parametrize("xml_content, verification_result", cases) -def test_verify_xml_waypoint(xml_content, verification_result): - """Test xml verification.""" - assert verify_waypoint_data(xml_content) is verification_result diff --git a/tests/utils.py b/tests/utils.py index b7a47b7e9..7e0083f5d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -34,57 +34,6 @@ from tests.constants import MSUI_CONFIG_PATH -XML_CONTENT1 = """ - - - - - - - - - - - - - - - - """ - - -XML_CONTENT2 = """ - - - - - - - - - - """ - - -XML_CONTENT3 = """ - - - - - - - - - - - - - - - - """ - - def callback_ok_image(status, response_headers): assert status == "200 OK" assert response_headers[0] == ('Content-type', 'image/png') From e04757105c07bef5ec4ca1f6345dd5d736727c9b Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Thu, 29 Aug 2024 17:13:44 +0530 Subject: [PATCH 16/29] replace depracated antialias with lanczos (#2488) --- mslib/msui/wms_control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 9bece6451..d3fdb0502 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -1495,7 +1495,7 @@ def append_multiple_images(self, imgs): current_height += image.height if max_height < result.height: - result.thumbnail((result.width, max_height), Image.ANTIALIAS) + result.thumbnail((result.width, max_height), Image.LANCZOS) return result From 0b95679f64f5ddb81e4d29846db4e84c8b32b0d7 Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Thu, 22 Aug 2024 10:54:10 +0200 Subject: [PATCH 17/29] Fix #2476 --- mslib/mscolab/server.py | 4 ++++ mslib/msui/mscolab.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 04bbd42b0..76f7fad9b 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -676,6 +676,10 @@ def set_last_used(): op_id = request.form.get('op_id', None) user = g.user days_ago = int(request.form.get('days', 0)) + if days_ago > 99999: + days_ago = 99999 + elif days_ago < -99999: + days_ago = -99999 fm.update_operation(int(op_id), 'last_used', datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=days_ago), user) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index a0655a56e..cf05cd856 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -1797,7 +1797,7 @@ def archive_operation(self): "token": self.token, "op_id": self.active_op_id, # when a user archives an operation we set the max “natural” integer in days - "days": sys.maxsize, + "days": 99999, # larger values run into a lot of issues } url = urljoin(self.mscolab_server_url, 'set_last_used') try: From d58f5fc903b3f47e1a13698ad874d7beaa4ca7c9 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Wed, 4 Sep 2024 15:36:52 +0200 Subject: [PATCH 18/29] Gsoc2024 aryan gupta (#2501) Enhancing the functionality of the MSColab server to allow users to upload small images which would replace generic initials as identifiers during login. This feature personalizes user accounts and significantly enhances the visual aspect of user identification and interaction within the MSColab platform. This includes real-time tracking of active users per operation, enhancing the chat window, seeing who performed what changes and separating system message from user messages. --------- Co-authored-by: Aryan Gupta --- mslib/mscolab/file_manager.py | 3 +- mslib/mscolab/server.py | 10 +- mslib/mscolab/sockets_manager.py | 79 ++++++++++- mslib/mscolab/utils.py | 8 ++ mslib/msui/flighttrack.py | 20 ++- mslib/msui/hexagon_dockwidget.py | 4 +- mslib/msui/mscolab.py | 40 +++++- mslib/msui/mscolab_chat.py | 77 +++++++++-- mslib/msui/msui_mainwindow.py | 1 + mslib/msui/qt5/ui_mainwindow.py | 69 ++++++---- mslib/msui/qt5/ui_mscolab_operation_window.py | 38 ++++-- mslib/msui/socket_control.py | 22 +++- mslib/msui/ui/ui_mainwindow.ui | 124 +++++++++++++----- mslib/msui/ui/ui_mscolab_operation_window.ui | 52 +++++++- mslib/msui/wms_control.py | 2 +- tests/_test_mscolab/test_file_manager.py | 27 +++- tests/_test_mscolab/test_files_api.py | 2 +- tests/_test_mscolab/test_server.py | 67 +++++++++- tests/_test_mscolab/test_sockets_manager.py | 42 ++++++ tests/_test_mscolab/test_utils.py | 6 +- tests/_test_msui/test_mscolab.py | 61 +++++++++ 21 files changed, 649 insertions(+), 105 deletions(-) diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 9db717a44..a80333f89 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -396,7 +396,8 @@ def get_authorized_users(self, op_id): users = [] for permission in permissions: user = User.query.filter_by(id=permission.u_id).first() - users.append({"username": user.username, "access_level": permission.access_level}) + users.append({"username": user.username, "access_level": permission.access_level, + "id": permission.u_id}) return users def save_file(self, op_id, content, user, comment=""): diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 76f7fad9b..fa057d95b 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -449,7 +449,7 @@ def get_user(): @APP.route('/upload_profile_image', methods=["POST"]) @verify_user def upload_profile_image(): - user_id = request.form['user_id'] + user_id = g.user.id file = request.files['image'] if not file: return jsonify({'message': 'No file provided or invalid file type'}), 400 @@ -623,6 +623,13 @@ def authorized_users(): return json.dumps({"users": fm.get_authorized_users(int(op_id))}) +@APP.route('/active_users', methods=["GET"]) +@verify_user +def active_users(): + op_id = request.args.get('op_id', request.form.get('op_id', None)) + return jsonify(active_users=list(sockio.sm.active_users_per_operation[int(op_id)])) + + @APP.route('/operations', methods=['GET']) @verify_user def get_operations(): @@ -785,6 +792,7 @@ def delete_bulk_permissions(): success = fm.delete_bulk_permission(op_id, user, u_ids) if success: for u_id in u_ids: + sockio.sm.remove_active_user_id_from_specific_operation(u_id, op_id) sockio.sm.emit_revoke_permission(u_id, op_id) sockio.sm.emit_operation_permissions_updated(user.id, op_id) return jsonify({"success": True, "message": "User permissions successfully deleted!"}) diff --git a/mslib/mscolab/sockets_manager.py b/mslib/mscolab/sockets_manager.py index 20b1e6b82..a51db4199 100644 --- a/mslib/mscolab/sockets_manager.py +++ b/mslib/mscolab/sockets_manager.py @@ -33,7 +33,7 @@ from mslib.mscolab.file_manager import FileManager from mslib.mscolab.models import MessageType, Permission, User from mslib.mscolab.utils import get_message_dict -from mslib.mscolab.utils import get_session_id +from mslib.mscolab.utils import get_session_id, get_user_id from mslib.mscolab.conf import mscolab_settings socketio = SocketIO(logger=mscolab_settings.SOCKETIO_LOGGER, engineio_logger=mscolab_settings.ENGINEIO_LOGGER, @@ -51,12 +51,33 @@ def __init__(self, chat_manager, file_manager): """ super(SocketsManager, self).__init__() self.sockets = [] + self.active_users_per_operation = {} self.cm = chat_manager self.fm = file_manager def handle_connect(self): logging.debug(request.sid) + def handle_operation_selected(self, json_config): + logging.debug("Operation selected: {}".format(json_config)) + token = json_config['token'] + op_id = json_config['op_id'] + user = User.verify_auth_token(token) + if user is None: + return + + # Remove the active user_id from any other operations first + self.update_active_users(user.id) + + # Add the user to the new operation + if op_id not in self.active_users_per_operation: + self.active_users_per_operation[op_id] = set() + self.active_users_per_operation[op_id].add(user.id) + + # Emit the updated count to all users + active_count = len(self.active_users_per_operation[op_id]) + socketio.emit('active-user-update', {'op_id': op_id, 'count': active_count}) + def update_operation_list(self, json_config): """ json_config has: @@ -64,7 +85,7 @@ def update_operation_list(self, json_config): """ token = json_config["token"] user = User.verify_auth_token(token) - if not user: + if user is None: return socketio.emit('operation-list-update') @@ -76,7 +97,7 @@ def join_creator_to_operation(self, json_config): """ token = json_config['token'] user = User.verify_auth_token(token) - if not user: + if user is None: return op_id = json_config['op_id'] join_room(str(op_id)) @@ -104,7 +125,7 @@ def handle_start_event(self, json_config): # authenticate socket token = json_config['token'] user = User.verify_auth_token(token) - if not user: + if user is None: return # fetch operations @@ -132,11 +153,52 @@ def handle_start_event(self, json_config): self.sockets.append(socket_storage) def handle_disconnect(self): - logging.info("disconnected") - logging.info(request.sid) + logging.debug("Handling disconnect.") + + # remove the user from any active operations + user_id = get_user_id(self.sockets, request.sid) + if user_id: + self.update_active_users(user_id) + + logging.debug(f"Disconnected: {request.sid}") # remove socket from socket_storage self.sockets[:] = [d for d in self.sockets if d['s_id'] != request.sid] + def update_active_users(self, user_id): + """ + Remove the given user_id from all operations and emit updates for active user counts. + """ + for op_id, user_ids in list(self.active_users_per_operation.items()): + if user_id in user_ids: + user_ids.remove(user_id) + active_count = len(user_ids) + logging.debug(f"Updated {op_id}: {active_count} active users") + if user_ids: + # Emit update if there are still active users + socketio.emit('active-user-update', {'op_id': op_id, 'count': active_count}) + else: + # If no users left, delete the operation key + del self.active_users_per_operation[op_id] + socketio.emit('active-user-update', {'op_id': op_id, 'count': 0}) + + def remove_active_user_id_from_specific_operation(self, user_id, op_id): + """ + Remove the given user_id from a specific operation in active_users_per_operation + and emit updates for active user counts. + """ + if op_id in self.active_users_per_operation: + if user_id in self.active_users_per_operation[op_id]: + self.active_users_per_operation[op_id].remove(user_id) + active_count = len(self.active_users_per_operation[op_id]) + + if self.active_users_per_operation[op_id]: + # Emit update if there are still active users + socketio.emit('active-user-update', {'op_id': op_id, 'count': active_count}) + else: + # If no users left, delete the operation key + del self.active_users_per_operation[op_id] + socketio.emit('active-user-update', {'op_id': op_id, 'count': 0}) + def handle_message(self, _json): """ json is a dictionary version of data sent to back-end @@ -213,6 +275,7 @@ def handle_file_save(self, json_req): op_id = json_req['op_id'] content = json_req['content'] comment = json_req.get('comment', "") + messageText = json_req.get('messageText') user = User.verify_auth_token(json_req['token']) if user is not None: # when the socket connection is expired this in None and also on wrong tokens @@ -220,7 +283,7 @@ def handle_file_save(self, json_req): # if permission is correct and file saved properly if perm and self.fm.save_file(int(op_id), content, user, comment): # send service message - message_ = f"[service message] **{user.username}** saved changes" + message_ = f"[service message] **{user.username}** saved changes. {messageText}" new_message = self.cm.add_message(user, message_, str(op_id), message_type=MessageType.SYSTEM_MESSAGE) new_message_dict = get_message_dict(new_message) socketio.emit('chat-message-client', json.dumps(new_message_dict)) @@ -283,6 +346,8 @@ def _setup_managers(app): socketio.on_event('file-save', sm.handle_file_save) socketio.on_event('add-user-to-operation', sm.join_creator_to_operation) socketio.on_event('update-operation-list', sm.update_operation_list) + # Register the 'operation-selected' event to update active user tracking when an operation is selected + socketio.on_event('operation-selected', sm.handle_operation_selected) socketio.sm = sm return socketio, cm, fm diff --git a/mslib/mscolab/utils.py b/mslib/mscolab/utils.py index 865297939..bdbf3545b 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -46,6 +46,14 @@ def get_session_id(sockets, u_id): return s_id +def get_user_id(sockets, s_id): + u_id = None + for ss in sockets: + if ss["s_id"] == s_id: + u_id = ss["u_id"] + return u_id + + def get_message_dict(message): return { "id": message.id, diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index cc6d51467..3ca4760bc 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -178,6 +178,9 @@ class WaypointsTableModel(QtCore.QAbstractTableModel): flight performance calculations. """ + # Signal emitted when a waypoint is moved, inserted or deleted + changeMessageSignal = QtCore.pyqtSignal(str) + def __init__(self, name="", filename=None, waypoints=None, mscolab_mode=False, data_dir=mss_default.mss_dir, xml_content=None): super().__init__() @@ -353,6 +356,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): waypoint.location = loc[1] # A change of position requires an update of the distances. if update: + self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) # Notify the views that items between the edited item and # the distance item of the corresponding waypoint have been @@ -379,6 +383,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): waypoint.lat, waypoint.lon = loc[0] waypoint.location = loc[1] if update: + self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) index2 = self.createIndex(index.row(), LOCATION) elif column == FLIGHTLEVEL: @@ -395,6 +400,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): waypoint.flightlevel = flightlevel waypoint.pressure = pressure if update: + self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) # need to notify view of the second item that has been # changed as well. @@ -416,6 +422,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): waypoint.pressure = pressure waypoint.flightlevel = flightlevel if update: + self.changeMessageSignal.emit(f'Moved waypoint {index.row()}') self.update_distances(index.row()) index2 = self.createIndex(index.row(), FLIGHTLEVEL) else: @@ -428,7 +435,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole, update=True): return False def insertRows(self, position, rows=1, index=QtCore.QModelIndex(), - waypoints=None): + waypoints=None, hexagonCreated=False): """ Insert waypoint; overrides the corresponding QAbstractTableModel method. @@ -438,6 +445,9 @@ def insertRows(self, position, rows=1, index=QtCore.QModelIndex(), assert len(waypoints) == rows, (waypoints, rows) + savedChangeMessage = "Hexagon created." if hexagonCreated else ("Inserted a new waypoint.") + self.changeMessageSignal.emit(savedChangeMessage) + self.beginInsertRows(QtCore.QModelIndex(), position, position + rows - 1) for row, wp in enumerate(waypoints): @@ -448,11 +458,17 @@ def insertRows(self, position, rows=1, index=QtCore.QModelIndex(), self.modified = True return True - def removeRows(self, position, rows=1, index=QtCore.QModelIndex()): + def removeRows(self, position, rows=1, index=QtCore.QModelIndex(), hexagonDeleted=False): """ Remove waypoint; overrides the corresponding QAbstractTableModel method. """ + if hexagonDeleted: + savedChangeMessage = f"Deleted waypoints {position}-{position + rows - 1}." + else: + savedChangeMessage = f"Deleted waypoint {position}." + self.changeMessageSignal.emit(savedChangeMessage) + # beginRemoveRows emits rowsAboutToBeRemoved(index, first, last). self.beginRemoveRows(QtCore.QModelIndex(), position, position + rows - 1) diff --git a/mslib/msui/hexagon_dockwidget.py b/mslib/msui/hexagon_dockwidget.py index 493148ac7..5373703dd 100644 --- a/mslib/msui/hexagon_dockwidget.py +++ b/mslib/msui/hexagon_dockwidget.py @@ -114,7 +114,7 @@ def _add_hexagon(self): waypoints.append( ft.Waypoint(lon=float(point[1]), lat=float(point[0]), flightlevel=float(flightlevel), comments=f"Hexagon {(i + 1):d}")) - waypoints_model.insertRows(row, rows=len(waypoints), waypoints=waypoints) + waypoints_model.insertRows(row, rows=len(waypoints), waypoints=waypoints, hexagonCreated=True) index = waypoints_model.index(row, 0) table_view.setCurrentIndex(index) table_view.resizeRowsToContents() @@ -156,7 +156,7 @@ def _remove_hexagon(self): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) if sel == QtWidgets.QMessageBox.Yes: - waypoints_model.removeRows(row_min, rows=7) + waypoints_model.removeRows(row_min, rows=7, hexagonDeleted=True) else: raise HexagonException("Cannot remove hexagon, please select a hexagon " "waypoint ('Hexagon x' in comments field)") diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index cf05cd856..f5e517ddd 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -576,6 +576,9 @@ def __init__(self, parent=None, data_dir=None): # Gravatar image path self.gravatar = None + # Service message text for flight-track changes (waypoints inserted, moved or deleted) + self.lastChangeMessage = "" + # set data dir, uri if data_dir is None: self.data_dir = config_loader(dataset="mss_dir") @@ -677,6 +680,7 @@ def after_login(self, emailid, url, r): self.conn.signal_update_permission.connect(self.handle_update_permission) self.conn.signal_revoke_permission.connect(self.handle_revoke_permission) self.conn.signal_operation_deleted.connect(self.handle_operation_deleted) + self.conn.signal_active_user_update.connect(self.update_active_user_label) self.ui.connectBtn.hide() self.ui.openOperationsGb.show() @@ -861,7 +865,7 @@ def upload_image(self): try: # Resize the image and set profile image pixmap image = Image.open(file_name) - image = image.resize((64, 64), Image.ANTIALIAS) + image = image.resize((64, 64), Image.LANCZOS) img_byte_arr = io.BytesIO() image.save(img_byte_arr, format=file_format) img_byte_arr.seek(0) @@ -1165,6 +1169,7 @@ def update_views(self): initial_waypoints = [ft.Waypoint(location=locations[0]), ft.Waypoint(location=locations[1])] waypoints_model = ft.WaypointsTableModel(name="", waypoints=initial_waypoints) self.waypoints_model = waypoints_model + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.reload_view_windows() def close_external_windows(self): @@ -1449,6 +1454,7 @@ def create_local_operation_file(self): def reload_local_wp(self): self.waypoints_model = ft.WaypointsTableModel(filename=self.local_ftml_file, data_dir=self.data_dir) + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() @@ -1490,6 +1496,7 @@ def fetch_wp_mscolab(self): xml_content = self.merge_dialog.get_values() if xml_content is not None: self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.waypoints_model.save_to_ftml(self.local_ftml_file) self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() @@ -1512,6 +1519,7 @@ def save_wp_mscolab(self, comment=None): if xml_content is not None: self.conn.save_file(self.token, self.active_op_id, xml_content, comment=comment) self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.waypoints_model.save_to_ftml(self.local_ftml_file) self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() @@ -1662,8 +1670,11 @@ def delete_operation_from_list(self, op_id): @QtCore.pyqtSlot(int, int) def handle_revoke_permission(self, op_id, u_id): if u_id == self.user["id"]: + revoked_operation_currently_active = True if self.active_op_id == op_id else False operation_name = self.delete_operation_from_list(op_id) if operation_name is not None: + if revoked_operation_currently_active: + self.ui.userCountLabel.hide() show_popup(self.ui, "Permission Revoked", f'Your access to operation - "{operation_name}" was revoked!', icon=1) # on import permissions revoked name can not taken from the operation list, @@ -1685,6 +1696,16 @@ def handle_operation_deleted(self, op_id): operation_name = old_operation_name show_popup(self.ui, "Success", f'Operation "{operation_name}" was deleted!', icon=1) + @QtCore.pyqtSlot(int, int) + def update_active_user_label(self, op_id, count): + # Update UI component which displays the number of active users + if self.active_op_id == op_id: + self.ui.userCountLabel.setText(f"Active Users: {count}") + + @QtCore.pyqtSlot(str) + def handle_change_message(self, message): + self.lastChangeMessage = message + def show_categories_to_ui(self, ops=None): """ adds the list of operation categories to the UI @@ -1881,6 +1902,12 @@ def set_active_op_id(self, item): window.enable_navbar_action_buttons() self.ui.switch_to_mscolab() + + # Enable the active user count label + self.ui.userCountLabel.show() + + # call select operation method from connection manager to emit signal + self.conn.select_operation(item.op_id) else: if self.mscolab_server_url is not None: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") @@ -1994,6 +2021,7 @@ def load_wps_from_server(self): xml_content = self.request_wps_from_server() if xml_content is not None: self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.waypoints_model.name = self.active_operation_name self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) @@ -2018,7 +2046,10 @@ def handle_waypoints_changed(self): self.waypoints_model.save_to_ftml(self.local_ftml_file) else: xml_content = self.waypoints_model.get_xml_content() - self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None) + self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None, + messageText=self.lastChangeMessage) + # Reset the last change message to make sure that it is used only once + self.lastChangeMessage = "" else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -2070,6 +2101,7 @@ def handle_import_msc(self, file_path, extension, function, pickertype): return self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) self.waypoints_model = model + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) self.handle_waypoints_changed() self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() @@ -2176,10 +2208,14 @@ def logout(self): self.operation_archive_browser.hide() + # reset profile image pixmap if hasattr(self, 'profile_dialog'): del self.profile_dialog self.profile_dialog = None + # reset the user count label to 0 + self.ui.userCountLabel.setText("Active Users: 0") + # activate first local flighttrack after logging out self.ui.listFlightTracks.setCurrentRow(0) self.ui.activate_selected_flight_track() diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index bb74e314a..97aa899c2 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -117,6 +117,7 @@ def __init__(self, token, op_id, user, operation_name, access_level, conn, paren self.conn.signal_message_reply_receive.connect(self.handle_incoming_message_reply) self.conn.signal_message_edited.connect(self.handle_message_edited) self.conn.signal_message_deleted.connect(self.handle_deleted_message) + self.conn.signal_update_collaborator_list.connect(self.update_user_list) # Set Label text self.set_label_text() # Hide Edit Message section @@ -327,19 +328,64 @@ def edit_message(self): # API REQUESTS def load_users(self): # load users to side-tab here - # make request to get users + # make requests to get all users and active users of the operation data = { "token": self.token, "op_id": self.op_id } - url = urljoin(self.mscolab_server_url, 'authorized_users') - r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text != "False": + users_url = urljoin(self.mscolab_server_url, 'authorized_users') + active_users_url = urljoin(self.mscolab_server_url, 'active_users') + + # Fetch both authorized and active users + users_response = requests.get(users_url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + active_response = requests.get(active_users_url, data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + + if users_response != "False": self.collaboratorsList.clear() - users = r.json()["users"] + users = users_response.json()["users"] + active_users = set(active_response.json()["active_users"]) for user in users: - item = QtWidgets.QListWidgetItem(f'{user["username"]} - {user["access_level"]}', - parent=self.collaboratorsList) + display_text = f'{user["username"]} - {user["access_level"]}' + item = QtWidgets.QListWidgetItem(display_text, parent=self.collaboratorsList) + + # Pixmap for icon i.e. profile image + url = urljoin(self.mscolab_server_url, 'fetch_profile_image') + data = { + "user_id": str(user["id"]), + "token": self.token + } + response = requests.get(url, data=data) + pixmap = QtGui.QPixmap() + if response.status_code == 200: + # pixmap = QtGui.QPixmap() + pixmap.loadFromData(response.content) + else: + first_alphabet = user["username"][0].lower() if user["username"] else "default" + default_avatar_path = f":/gravatars/default-gravatars/{first_alphabet}.png" + pixmap.load(default_avatar_path) + + # Scale pixmap to a standard size + icon_size = QtCore.QSize(50, 50) + pixmap = pixmap.scaled(icon_size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) + + # Load avatar and overlay green dot on profile image pixmap if user is active + if user["id"] in active_users: + painter = QtGui.QPainter(pixmap) + painter.setBrush(QtGui.QColor(0, 230, 0, 230)) # RGBA + # Set a thin pen for the border around green dot + pen = QtGui.QPen(QtGui.QColor(0, 0, 0), 3) # (border color, width) + painter.setPen(pen) + # Draw circle at bottom-right corner + diameter = 13 # Size of the dot + margin = -2 # Distance from the edges + position = QtCore.QPoint(pixmap.width() - diameter - margin, pixmap.height() - diameter - margin) + painter.drawEllipse(position, diameter, diameter) + painter.end() + + # Set the icon + icon = QtGui.QIcon(pixmap) + item.setIcon(icon) self.collaboratorsList.addItem(item) else: show_popup(self, "Error", "Session expired, new login required") @@ -363,17 +409,24 @@ def load_all_messages(self): for message in messages: self.render_new_message(message, scroll=False) self.messageList.scrollToBottom() + self.serviceMessageList.scrollToBottom() else: show_popup(self, "Error", "Session expired, new login required") def render_new_message(self, message, scroll=True): message_item = MessageItem(message, self) - list_widget_item = QtWidgets.QListWidgetItem(self.messageList) + list_widget_item = QtWidgets.QListWidgetItem() list_widget_item.setSizeHint(message_item.sizeHint()) - self.messageList.addItem(list_widget_item) - self.messageList.setItemWidget(list_widget_item, message_item) + # Check if the message is a service message or a normal message and add to its corresponding list + if message['message_type'] == MessageType.SYSTEM_MESSAGE: + self.serviceMessageList.addItem(list_widget_item) + self.serviceMessageList.setItemWidget(list_widget_item, message_item) + else: + self.messageList.addItem(list_widget_item) + self.messageList.setItemWidget(list_widget_item, message_item) if scroll: self.messageList.scrollToBottom() + self.serviceMessageList.scrollToBottom() # SOCKET HANDLERS @QtCore.pyqtSlot(int) @@ -437,6 +490,10 @@ def handle_deleted_message(self, message): self.messageList.takeItem(i) break + @QtCore.pyqtSlot() + def update_user_list(self): + self.load_users() + def closeEvent(self, event): self.viewCloses.emit() diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index f1ec9768b..176ad6830 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -792,6 +792,7 @@ def activate_flight_track(self, item): self.listFlightTracks.item(i).setFont(font) font.setBold(True) item.setFont(font) + self.userCountLabel.hide() self.menu_handler() self.signal_activate_flighttrack.emit(self.active_flight_track) diff --git a/mslib/msui/qt5/ui_mainwindow.py b/mslib/msui/qt5/ui_mainwindow.py index 2b8056f50..1ba2ca0fd 100644 --- a/mslib/msui/qt5/ui_mainwindow.py +++ b/mslib/msui/qt5/ui_mainwindow.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'mslib/msui/ui/ui_mainwindow.ui' # -# Created by: PyQt5 UI code generator 5.15.7 +# Created by: PyQt5 UI code generator 5.15.9 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -80,6 +80,7 @@ def setupUi(self, MSUIMainWindow): self.openFlightTracksLabel.setObjectName("openFlightTracksLabel") self.verticalLayout.addWidget(self.openFlightTracksLabel) self.listFlightTracks = QtWidgets.QListWidget(self.openFlightTracksGb) + self.listFlightTracks.setFrameShadow(QtWidgets.QFrame.Plain) self.listFlightTracks.setObjectName("listFlightTracks") self.verticalLayout.addWidget(self.listFlightTracks) self.verticalLayout_5.addWidget(self.openFlightTracksGb) @@ -112,19 +113,35 @@ def setupUi(self, MSUIMainWindow): self.gridLayout_3 = QtWidgets.QGridLayout(self.openOperationsGb) self.gridLayout_3.setContentsMargins(8, 8, 8, 8) self.gridLayout_3.setObjectName("gridLayout_3") + self.pbOpenOperationArchive = QtWidgets.QPushButton(self.openOperationsGb) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.pbOpenOperationArchive.sizePolicy().hasHeightForWidth()) + self.pbOpenOperationArchive.setSizePolicy(sizePolicy) + self.pbOpenOperationArchive.setObjectName("pbOpenOperationArchive") + self.gridLayout_3.addWidget(self.pbOpenOperationArchive, 11, 0, 1, 2) self.workingStatusLabel = QtWidgets.QLabel(self.openOperationsGb) self.workingStatusLabel.setWordWrap(True) self.workingStatusLabel.setObjectName("workingStatusLabel") self.gridLayout_3.addWidget(self.workingStatusLabel, 6, 0, 1, 2) + self.categoryLabel = QtWidgets.QLabel(self.openOperationsGb) + self.categoryLabel.setObjectName("categoryLabel") + self.gridLayout_3.addWidget(self.categoryLabel, 9, 0, 1, 1) + self.workLocallyCheckbox = QtWidgets.QCheckBox(self.openOperationsGb) + self.workLocallyCheckbox.setObjectName("workLocallyCheckbox") + self.gridLayout_3.addWidget(self.workLocallyCheckbox, 10, 0, 1, 1) + self.listOperationsMSC = QtWidgets.QListWidget(self.openOperationsGb) + self.listOperationsMSC.setObjectName("listOperationsMSC") + self.gridLayout_3.addWidget(self.listOperationsMSC, 4, 0, 1, 2) self.activeOperationDesc = QtWidgets.QLabel(self.openOperationsGb) + self.activeOperationDesc.setMaximumSize(QtCore.QSize(300, 16777215)) + self.activeOperationDesc.setLineWidth(1) self.activeOperationDesc.setObjectName("activeOperationDesc") - self.gridLayout_3.addWidget(self.activeOperationDesc, 1, 0, 1, 2) + self.gridLayout_3.addWidget(self.activeOperationDesc, 1, 0, 1, 1) self.activeOperationsLabel = QtWidgets.QLabel(self.openOperationsGb) self.activeOperationsLabel.setObjectName("activeOperationsLabel") self.gridLayout_3.addWidget(self.activeOperationsLabel, 2, 0, 1, 1) - self.workLocallyCheckbox = QtWidgets.QCheckBox(self.openOperationsGb) - self.workLocallyCheckbox.setObjectName("workLocallyCheckbox") - self.gridLayout_3.addWidget(self.workLocallyCheckbox, 10, 0, 1, 1) self.filterCategoryCb = QtWidgets.QComboBox(self.openOperationsGb) self.filterCategoryCb.setAutoFillBackground(False) self.filterCategoryCb.setEditable(False) @@ -137,27 +154,29 @@ def setupUi(self, MSUIMainWindow): self.serverOptionsCb.addItem("") self.serverOptionsCb.addItem("") self.gridLayout_3.addWidget(self.serverOptionsCb, 10, 1, 1, 1) - self.categoryLabel = QtWidgets.QLabel(self.openOperationsGb) - self.categoryLabel.setObjectName("categoryLabel") - self.gridLayout_3.addWidget(self.categoryLabel, 9, 0, 1, 1) - self.listOperationsMSC = QtWidgets.QListWidget(self.openOperationsGb) - self.listOperationsMSC.setObjectName("listOperationsMSC") - self.gridLayout_3.addWidget(self.listOperationsMSC, 4, 0, 1, 2) - self.pbOpenOperationArchive = QtWidgets.QPushButton(self.openOperationsGb) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + self.userCountLabel = QtWidgets.QLabel(self.openOperationsGb) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.pbOpenOperationArchive.sizePolicy().hasHeightForWidth()) - self.pbOpenOperationArchive.setSizePolicy(sizePolicy) - self.pbOpenOperationArchive.setObjectName("pbOpenOperationArchive") - self.gridLayout_3.addWidget(self.pbOpenOperationArchive, 11, 0, 1, 2) + sizePolicy.setHeightForWidth(self.userCountLabel.sizePolicy().hasHeightForWidth()) + self.userCountLabel.setSizePolicy(sizePolicy) + self.userCountLabel.setMinimumSize(QtCore.QSize(120, 0)) + self.userCountLabel.setMaximumSize(QtCore.QSize(120, 16777215)) + self.userCountLabel.setLayoutDirection(QtCore.Qt.LeftToRight) + self.userCountLabel.setAutoFillBackground(False) + self.userCountLabel.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.userCountLabel.setFrameShadow(QtWidgets.QFrame.Plain) + self.userCountLabel.setScaledContents(False) + self.userCountLabel.setAlignment(QtCore.Qt.AlignCenter) + self.userCountLabel.setObjectName("userCountLabel") + self.gridLayout_3.addWidget(self.userCountLabel, 1, 1, 1, 1) self.horizontalLayout.addWidget(self.openOperationsGb) self.verticalLayout_2.addLayout(self.horizontalLayout) self.gridLayout.addLayout(self.verticalLayout_2, 0, 0, 1, 2) self.gridLayout.setColumnStretch(0, 1) MSUIMainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MSUIMainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 22)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 26)) self.menubar.setNativeMenuBar(False) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) @@ -302,13 +321,18 @@ def retranslateUi(self, MSUIMainWindow): self.listFlightTracks.setToolTip(_translate("MSUIMainWindow", "List of open flight tracks.\n" "Double-click a flight track to activate it.\n" "Save a flight track to name it.")) + self.listFlightTracks.setSortingEnabled(False) self.openViewsLabel.setText(_translate("MSUIMainWindow", "Open Views:")) self.listViews.setToolTip(_translate("MSUIMainWindow", "Double-click a view to bring it to the front.")) + self.pbOpenOperationArchive.setText(_translate("MSUIMainWindow", "Operation Archive")) self.workingStatusLabel.setText(_translate("MSUIMainWindow", "No operations selected")) - self.activeOperationDesc.setText(_translate("MSUIMainWindow", "Select Operation to View Description")) - self.activeOperationsLabel.setText(_translate("MSUIMainWindow", "Operations")) + self.categoryLabel.setText(_translate("MSUIMainWindow", "Category:")) self.workLocallyCheckbox.setToolTip(_translate("MSUIMainWindow", "Check to work asynchronously from the server")) self.workLocallyCheckbox.setText(_translate("MSUIMainWindow", "Work Asynchronously")) + self.listOperationsMSC.setToolTip(_translate("MSUIMainWindow", "List of mscolab operations.\n" +"Double click a operation to activate and view its description.")) + self.activeOperationDesc.setText(_translate("MSUIMainWindow", "Select Operation to View Description")) + self.activeOperationsLabel.setText(_translate("MSUIMainWindow", "Operations")) self.filterCategoryCb.setWhatsThis(_translate("MSUIMainWindow", "filter by operation category")) self.filterCategoryCb.setCurrentText(_translate("MSUIMainWindow", "ANY")) self.filterCategoryCb.setItemText(0, _translate("MSUIMainWindow", "ANY")) @@ -316,10 +340,7 @@ def retranslateUi(self, MSUIMainWindow): self.serverOptionsCb.setItemText(0, _translate("MSUIMainWindow", "Server Options")) self.serverOptionsCb.setItemText(1, _translate("MSUIMainWindow", "Fetch From Server")) self.serverOptionsCb.setItemText(2, _translate("MSUIMainWindow", "Save To Server")) - self.categoryLabel.setText(_translate("MSUIMainWindow", "Category:")) - self.listOperationsMSC.setToolTip(_translate("MSUIMainWindow", "List of mscolab operations.\n" -"Double click a operation to activate and view its description.")) - self.pbOpenOperationArchive.setText(_translate("MSUIMainWindow", "Operation Archive")) + self.userCountLabel.setText(_translate("MSUIMainWindow", "Active Users: 0")) self.menuFile.setTitle(_translate("MSUIMainWindow", "&File")) self.menuImportFlightTrack.setTitle(_translate("MSUIMainWindow", "Import Flight Track")) self.menuExportActiveFlightTrack.setTitle(_translate("MSUIMainWindow", "Export Flight Track")) diff --git a/mslib/msui/qt5/ui_mscolab_operation_window.py b/mslib/msui/qt5/ui_mscolab_operation_window.py index 0b23730bf..2abd37a8f 100644 --- a/mslib/msui/qt5/ui_mscolab_operation_window.py +++ b/mslib/msui/qt5/ui_mscolab_operation_window.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'ui_mscolab_operation_window.ui' +# Form implementation generated from reading ui file 'ui/ui_mscolab_operation_window.ui' # -# Created by: PyQt5 UI code generator 5.12.3 +# Created by: PyQt5 UI code generator 5.15.9 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtGui, QtWidgets @@ -15,7 +16,7 @@ def setupUi(self, MscolabOperation): MscolabOperation.setObjectName("MscolabOperation") MscolabOperation.setWindowModality(QtCore.Qt.NonModal) MscolabOperation.setEnabled(True) - MscolabOperation.resize(867, 687) + MscolabOperation.resize(1066, 687) MscolabOperation.setMinimumSize(QtCore.QSize(600, 400)) self.centralwidget = QtWidgets.QWidget(MscolabOperation) self.centralwidget.setObjectName("centralwidget") @@ -25,6 +26,7 @@ def setupUi(self, MscolabOperation): self.verticalLayout_4.setObjectName("verticalLayout_4") self.horizontalLayout_3 = QtWidgets.QHBoxLayout() self.horizontalLayout_3.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize) + self.horizontalLayout_3.setSpacing(7) self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.user_info = QtWidgets.QLabel(self.centralwidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Preferred) @@ -42,13 +44,22 @@ def setupUi(self, MscolabOperation): self.proj_info.setSizePolicy(sizePolicy) self.proj_info.setObjectName("proj_info") self.horizontalLayout_3.addWidget(self.proj_info) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem = QtWidgets.QSpacerItem(330, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_3.addItem(spacerItem) + self.changes_info = QtWidgets.QLabel(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.changes_info.sizePolicy().hasHeightForWidth()) + self.changes_info.setSizePolicy(sizePolicy) + self.changes_info.setMinimumSize(QtCore.QSize(266, 0)) + self.changes_info.setObjectName("changes_info") + self.horizontalLayout_3.addWidget(self.changes_info) self.horizontalLayout_3.setStretch(0, 1) self.horizontalLayout_3.setStretch(1, 1) - self.horizontalLayout_3.setStretch(2, 2) self.verticalLayout_4.addLayout(self.horizontalLayout_3) self.horizontalLayout_4 = QtWidgets.QHBoxLayout() + self.horizontalLayout_4.setSpacing(7) self.horizontalLayout_4.setObjectName("horizontalLayout_4") self.collaboratorsList = QtWidgets.QListWidget(self.centralwidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Expanding) @@ -56,7 +67,7 @@ def setupUi(self, MscolabOperation): sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.collaboratorsList.sizePolicy().hasHeightForWidth()) self.collaboratorsList.setSizePolicy(sizePolicy) - self.collaboratorsList.setMinimumSize(QtCore.QSize(256, 300)) + self.collaboratorsList.setMinimumSize(QtCore.QSize(200, 300)) self.collaboratorsList.setObjectName("collaboratorsList") self.horizontalLayout_4.addWidget(self.collaboratorsList) self.verticalLayout_3 = QtWidgets.QVBoxLayout() @@ -130,6 +141,16 @@ def setupUi(self, MscolabOperation): self.verticalLayout_3.setStretch(1, 4) self.verticalLayout_3.setStretch(2, 1) self.horizontalLayout_4.addLayout(self.verticalLayout_3) + self.serviceMessageList = QtWidgets.QListWidget(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.serviceMessageList.sizePolicy().hasHeightForWidth()) + self.serviceMessageList.setSizePolicy(sizePolicy) + self.serviceMessageList.setMinimumSize(QtCore.QSize(275, 0)) + self.serviceMessageList.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + self.serviceMessageList.setObjectName("serviceMessageList") + self.horizontalLayout_4.addWidget(self.serviceMessageList) self.horizontalLayout_4.setStretch(1, 1) self.verticalLayout_4.addLayout(self.horizontalLayout_4) self.gridLayout.addLayout(self.verticalLayout_4, 0, 0, 1, 1) @@ -139,7 +160,7 @@ def setupUi(self, MscolabOperation): MscolabOperation.addAction(self.actionCloseWindow) self.retranslateUi(MscolabOperation) - self.actionCloseWindow.triggered.connect(MscolabOperation.close) + self.actionCloseWindow.triggered.connect(MscolabOperation.close) # type: ignore QtCore.QMetaObject.connectSlotsByName(MscolabOperation) def retranslateUi(self, MscolabOperation): @@ -147,6 +168,7 @@ def retranslateUi(self, MscolabOperation): MscolabOperation.setWindowTitle(_translate("MscolabOperation", "Mscolab Operation Chat")) self.user_info.setText(_translate("MscolabOperation", "Logged In: ")) self.proj_info.setText(_translate("MscolabOperation", "Operation:")) + self.changes_info.setText(_translate("MscolabOperation", "Change Log:")) self.searchMessageLineEdit.setPlaceholderText(_translate("MscolabOperation", "Search Message")) self.searchPrevBtn.setText(_translate("MscolabOperation", "Previous")) self.searchNextBtn.setText(_translate("MscolabOperation", "Next")) diff --git a/mslib/msui/socket_control.py b/mslib/msui/socket_control.py index 2717eafff..7bfc8c4b1 100644 --- a/mslib/msui/socket_control.py +++ b/mslib/msui/socket_control.py @@ -46,6 +46,8 @@ class ConnectionManager(QtCore.QObject): signal_operation_permissions_updated = QtCore.pyqtSignal(int, name="operation permissions updated") signal_operation_list_updated = QtCore.pyqtSignal(name="operation list updated") signal_operation_deleted = QtCore.pyqtSignal(int, name="operation deleted") + signal_active_user_update = QtCore.pyqtSignal(int, int) + signal_update_collaborator_list = QtCore.pyqtSignal() def __init__(self, token, user, mscolab_server_url=mss_default.mscolab_server_url): super(ConnectionManager, self).__init__() @@ -78,9 +80,20 @@ def __init__(self, token, user, mscolab_server_url=mss_default.mscolab_server_ur self.sio.on('operation-deleted', handler=self.handle_operation_deleted) # On New Operation self.sio.on('operation-list-update', handler=self.handle_operation_list_update) + # On active user update + self.sio.on('active-user-update', handler=self.handle_active_user_update) self.sio.emit('start', {'token': token}) + def handle_active_user_update(self, data): + """Handle the update for the number of active users on an operation.""" + if isinstance(data, str): + data = json.loads(data) # Safely decode in case of string + op_id = data['op_id'] + count = data['count'] + self.signal_active_user_update.emit(op_id, count) + self.signal_update_collaborator_list.emit() + def handle_update_permission(self, message): """ signal update of permission affected @@ -181,7 +194,11 @@ def delete_message(self, message_id, op_id): # this triggers disconnect self.signal_reload.emit(op_id) - def save_file(self, token, op_id, content, comment=None): + def select_operation(self, op_id): + # Emit an event to notify the server of the operation selection. + self.sio.emit('operation-selected', {'token': self.token, 'op_id': op_id}) + + def save_file(self, token, op_id, content, comment=None, messageText=""): # ToDo refactor API if verify_user_token(self.mscolab_server_url, self.token): logging.debug("saving file") @@ -189,7 +206,8 @@ def save_file(self, token, op_id, content, comment=None): "op_id": op_id, "token": self.token, "content": content, - "comment": comment}) + "comment": comment, + "messageText": messageText}) else: # this triggers disconnect self.signal_reload.emit(op_id) diff --git a/mslib/msui/ui/ui_mainwindow.ui b/mslib/msui/ui/ui_mainwindow.ui index 9f6209063..7ef77ca87 100644 --- a/mslib/msui/ui/ui_mainwindow.ui +++ b/mslib/msui/ui/ui_mainwindow.ui @@ -164,6 +164,12 @@ Double-click a flight track to activate it. Save a flight track to name it. + + QFrame::Plain + + + false + @@ -233,6 +239,19 @@ Save a flight track to name it. 8 + + + + + 0 + 0 + + + + Operation Archive + + + @@ -243,17 +262,10 @@ Save a flight track to name it. - - - - Select Operation to View Description - - - - - + + - Operations + Category: @@ -267,6 +279,40 @@ Save a flight track to name it. + + + + List of mscolab operations. +Double click a operation to activate and view its description. + + + + + + + + 300 + 16777215 + + + + 1 + + + Select Operation to View Description + + + 1 + + + + + + + Operations + + + @@ -313,31 +359,49 @@ Save a flight track to name it. - - - - Category: - - - - - - - List of mscolab operations. -Double click a operation to activate and view its description. - - - - - + + - + 0 0 + + + 120 + 0 + + + + + 120 + 16777215 + + + + Qt::LeftToRight + + + false + + + QFrame::StyledPanel + + + QFrame::Plain + - Operation Archive + Active Users: 0 + + + false + + + Qt::AlignCenter + + + 1 @@ -356,7 +420,7 @@ Double click a operation to activate and view its description. 0 0 738 - 22 + 26 diff --git a/mslib/msui/ui/ui_mscolab_operation_window.ui b/mslib/msui/ui/ui_mscolab_operation_window.ui index 76dc0dccc..c4266cf67 100644 --- a/mslib/msui/ui/ui_mscolab_operation_window.ui +++ b/mslib/msui/ui/ui_mscolab_operation_window.ui @@ -12,7 +12,7 @@ 0 0 - 867 + 1066 687 @@ -30,9 +30,9 @@ - + - -1 + 7 QLayout::SetMinimumSize @@ -70,18 +70,37 @@ - 40 + 330 20 + + + + + 0 + 0 + + + + + 266 + 0 + + + + Change Log: + + + - + - -1 + 7 @@ -93,7 +112,7 @@ - 256 + 200 300 @@ -259,6 +278,25 @@ + + + + + 0 + 0 + + + + + 275 + 0 + + + + QAbstractItemView::NoSelection + + + diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 9bece6451..d3fdb0502 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -1495,7 +1495,7 @@ def append_multiple_images(self, imgs): current_height += image.height if max_height < result.height: - result.thumbnail((result.width, max_height), Image.ANTIALIAS) + result.thumbnail((result.width, max_height), Image.LANCZOS) return result diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index a80286984..00c03aea1 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -32,6 +32,7 @@ from mslib.mscolab.models import Operation, User from mslib.mscolab.seed import add_user, get_user, add_operation +from mslib.mscolab.conf import mscolab_settings class Test_FileManager: @@ -240,7 +241,7 @@ def test_get_authorized_users(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path='operation5') assert self.fm.get_authorized_users(operation.id) == [{'access_level': 'creator', - 'username': self.userdata[1]}] + 'username': self.userdata[1], 'id': 1}] def test_save_file(self): with self.app.test_client(): @@ -268,6 +269,30 @@ def test_upload_chat_attachment(self): assert name in static_path assert static_path.endswith(ext) + def test_upload_file(self): + sample_file_path = os.path.join(os.path.dirname(__file__), "..", "data") + filename = "example.txt" + _, ext = filename.split('.') + + open_txt = os.path.join(sample_file_path, "example.txt") + with open(open_txt, 'rb') as fp: + file_content = fp.read() + fp.seek(0) # reset file pointer + file = FileStorage(fp, filename=filename, content_type="text/plain") + subfolder = 'test_subfolder' + identifier = 'unique_identifier' + relative_path = self.fm.upload_file(file, subfolder=subfolder, identifier=identifier) + full_path = os.path.join(mscolab_settings.UPLOAD_FOLDER, relative_path) + + assert os.path.isfile(full_path) + assert identifier in relative_path + assert subfolder in relative_path + assert relative_path.endswith(ext) + # comparing content of uploaded file with sample file + with open(full_path, 'rb') as uploaded_file: + uploaded_file_content = uploaded_file.read() + assert uploaded_file_content == file_content + def test_get_file(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="operation7") diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 14e4b871e..fea89f4fd 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -80,7 +80,7 @@ def test_get_authorized_users(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V1") users = self.fm.get_authorized_users(operation.id) - assert users[0] == {'username': 'UV10', 'access_level': 'creator'} + assert users[0] == {'username': 'UV10', 'access_level': 'creator', 'id': 1} def test_fetch_users_without_permission(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index f7085e450..33960915f 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -27,6 +27,9 @@ import pytest import json import io +import os + +from PIL import Image from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import User, Operation @@ -132,14 +135,50 @@ def test_get_user(self): def test_delete_user(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: + # Case 1 : The user has no profile image set token = self._get_token(test_client, self.userdata) response = test_client.post('/delete_own_account', data={"token": token}) assert response.status_code == 200 - data = json.loads(response.data.decode('utf-8')) - assert data["success"] is True - response = test_client.post('/delete_own_account', data={"token": "dsdsds"}) + assert response.get_json()["success"] is True + # ToDo: Check if user token was cleared after deleting account as assert returns True instead of False + # assert verify_user_token(config_loader(dataset="mscolab_server_url"), token) is False + + # Case 2 : The user has a custom profile image set + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) + token = self._get_token(test_client, self.userdata) + response = self._upload_profile_image(test_client, token, self.userdata[0]) + assert response.status_code == 200 # this will ensure image was uploaded + + user = get_user(self.userdata[0]) + relative_image_path = user.profile_image_path # Capture the path before deletion + full_image_path = os.path.join(mscolab_settings.UPLOAD_FOLDER, relative_image_path) + response = test_client.post('/delete_own_account', data={"token": token}) assert response.status_code == 200 - assert response.data.decode('utf-8') == "False" + assert response.get_json()["success"] is True + assert not os.path.exists(full_image_path) + # ToDo: Check if user token was cleared after deleting account as assert returns True instead of False + # assert verify_user_token(config_loader(dataset="mscolab_server_url"), token) is False + + # ToDo: Add a test for an oversized image/file ( > MAX_UPLOAD_SIZE) for chat attachments and profile image. + # Currently, flask is unable to raise exception for an oversized file. + + def test_unauthorized_profile_image_upload(self): + other_user_data = 'other@ex.com', 'other', 'other' + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) + assert add_user(other_user_data[0], other_user_data[1], other_user_data[2]) + with self.app.test_client() as test_client: + # Case 1: Unauthenticated upload attempt + user = get_user(self.userdata[0]) + assert user.profile_image_path is None + self._upload_profile_image(test_client, token="random-string", email=self.userdata[0]) + user = get_user(self.userdata[0]) + assert user.profile_image_path is None # profile-image-path should remain None after failed upload + + # Case 2: Authenticated as another user trying to upload for main user + token_of_other_user = self._get_token(test_client, other_user_data) + self._upload_profile_image(test_client, token_of_other_user, self.userdata[0]) + user = get_user(self.userdata[0]) + assert user.profile_image_path is None # User should not be able to upload an image for another user def test_messages(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -317,7 +356,7 @@ def test_authorized_users(self): "op_id": operation.id}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) - assert data["users"] == [{'access_level': 'creator', 'username': self.userdata[1]}] + assert data["users"] == [{'access_level': 'creator', 'username': self.userdata[1], 'id': 1}] def test_delete_operation(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -446,3 +485,21 @@ def _save_content(self, operation, userdata=None): user = get_user(userdata[0]) self.fm.save_file(operation.id, XML_CONTENT1, user) return user + + def _upload_profile_image(self, test_client, token, email): + # Creating a dummy image + img = Image.new('RGB', (64, 64), color='yellow') + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format='JPEG') + img_byte_arr.seek(0) + filename = "test.jpeg" + + # Post request for uploading the image + user = get_user(email) + data = { + "user_id": str(user.id), + "token": token, + 'image': (img_byte_arr, filename, 'image/jpeg') + } + response = test_client.post('/upload_profile_image', data=data) + return response diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index 84152f219..57dce21b1 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -107,6 +107,48 @@ def test_remove_collaborator_from_operation(self): perms = Permission(self.anotheruser.id, operation.id, "collaborator") assert perms is None + def test_active_user_tracking_and_emissions_on_operation_selection(self): + """ + Test that selecting an operation tracks the active user count appropriately + and verifies that the correct events are emitted. + """ + sio = self._connect() + + # Initial state: no active users for the operation + assert self.operation.id not in self.sm.active_users_per_operation + + # User selects an operation + sio.emit("operation-selected", {"token": self.token, "op_id": self.operation.id}) + + # Check internal server tracking + assert self.operation.id in self.sm.active_users_per_operation + assert self.user.id in self.sm.active_users_per_operation[self.operation.id] + assert len(self.sm.active_users_per_operation[self.operation.id]) == 1 + + # Verify that the correct event is emitted + received_messages = sio.get_received() + assert len(received_messages) == 1 + received_message_args = received_messages[0]["args"][0] + assert received_message_args["op_id"] == self.operation.id + assert received_message_args["count"] == 1 + + # Testing with multiple users + add_user_to_operation(path=self.operation_name, emailid=self.anotheruserdata[0]) + another_sio = self._connect() + another_sio.emit("operation-selected", + {"token": self.anotheruser.generate_auth_token(), "op_id": self.operation.id}) + + # Check internal server tracking + assert self.anotheruser.id in self.sm.active_users_per_operation[self.operation.id] + assert len(self.sm.active_users_per_operation[self.operation.id]) == 2 + + # Verify that the active user count is updated for both clients + updated_messages = another_sio.get_received() + assert len(updated_messages) == 1 + updated_message_args = updated_messages[0]["args"][0] + assert updated_message_args["op_id"] == self.operation.id + assert updated_message_args["count"] == 2 + def test_handle_start_event(self): pytest.skip("unknown how to verify") sio = self._connect() diff --git a/tests/_test_mscolab/test_utils.py b/tests/_test_mscolab/test_utils.py index 72696596b..991b0ea60 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -34,7 +34,7 @@ from mslib.mscolab.seed import add_user, get_user from mslib.mscolab.utils import (get_recent_op_id, get_session_id, get_message_dict, create_files, - os_fs_create_dir) + os_fs_create_dir, get_user_id) class Test_Utils: @@ -63,6 +63,10 @@ def test_get_session_id(self): sockets = [{"u_id": 5, "s_id": 100}] assert get_session_id(sockets, 5) == 100 + def test_get_user_id(self): + sockets = [{"u_id": 9, "s_id": 101}] + assert get_user_id(sockets, 101) == 9 + def test_get_message_dict(self): message = Message(0, 0, "Moin") message.user = User(*self.userdata) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 4fa1fb3b1..010aed788 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -25,6 +25,7 @@ limitations under the License. """ import os +import io import sys import fs import fs.errors @@ -33,6 +34,8 @@ import mock import pytest +from PIL import Image + import mslib.utils.auth from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Permission, User @@ -901,6 +904,64 @@ def test_profile_dialog(self, qtbot): critbox.assert_called_once() assert not self.window.mscolab.profile_dialog.gravatarLabel.pixmap().isNull() + def test_upload_and_fetch_profile_image(self, qtbot, tmp_path): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, self.userdata[0], self.userdata[2]) + self.window.mscolab.profile_action.trigger() + initial_pixmap = self.window.mscolab.profile_dialog.gravatarLabel.pixmap().toImage() + + # Creating a new image and storing it at a temporary path + new_image = Image.new('RGB', (64, 64), color='cyan') + img_byte_arr = io.BytesIO() + new_image.save(img_byte_arr, format='JPEG') + img_byte_arr.seek(0) + temp_image_path = tmp_path / 'new_profile_image.jpg' + with open(temp_image_path, 'wb') as f: + f.write(img_byte_arr.getvalue()) + + # Mocking the QFileDialog to select the image + with mock.patch('PyQt5.QtWidgets.QFileDialog.getOpenFileName', + return_value=(str(temp_image_path), 'Image (*.jpg)')): + with mock.patch.object(QtWidgets.QMessageBox, 'information'): + self.window.mscolab.upload_image() + + def pixmap_updated(): + updated_pixmap = self.window.mscolab.profile_dialog.gravatarLabel.pixmap().toImage() + assert initial_pixmap != updated_pixmap + qtbot.wait_until(pixmap_updated) + uploaded_pixmap = self.window.mscolab.profile_dialog.gravatarLabel.pixmap().toImage() + + self.window.mscolab.fetch_profile_image(refresh=True) + qtbot.wait_until(pixmap_updated) + fetched_pixmap = self.window.mscolab.profile_dialog.gravatarLabel.pixmap().toImage() + + assert initial_pixmap != fetched_pixmap + assert uploaded_pixmap == fetched_pixmap + + def test_activate_operation_updates_active_users(self, qtbot): + """ + Test that selecting an operation updates the active users label correctly. + """ + # Login and activate an operation + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + self._activate_operation_at_index(0) + assert self.window.mscolab.active_op_id is not None + assert self.window.mscolab.active_operation_name == self.operation_name + + # Testing that the label is updated after an operation is selected + def assert_active_users_label_updated(): + assert self.window.mscolab.ui.userCountLabel.text() == "Active Users: 1" + qtbot.wait_until(assert_active_users_label_updated) + + # Testing that the label is indeed hidden after we select a flight track and vice versa + self._activate_flight_track_at_index(0) + assert not self.window.mscolab.ui.userCountLabel.isVisible() + self._activate_operation_at_index(0) + assert self.window.mscolab.ui.userCountLabel.isVisible() + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window From ee9e8321a1f00f96230c987f66db4bd079a87c33 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 6 Sep 2024 08:17:09 +0200 Subject: [PATCH 19/29] remove the conda/mamba based updater. (#2495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show in About a new version available. --------- Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> --- docs/installation.rst | 16 +-- mslib/mscolab/app/__init__.py | 7 +- mslib/mscolab/mscolab.py | 12 --- mslib/msui/msui.py | 12 --- mslib/msui/msui_mainwindow.py | 7 +- mslib/msui/qt5/ui_about_dialog.py | 47 +++++---- mslib/msui/qt5/ui_mainwindow.py | 3 +- mslib/msui/qt5/ui_updater_dialog.py | 65 ------------ mslib/msui/ui/ui_about_dialog.ui | 43 ++++---- mslib/msui/ui/ui_mainwindow.ui | 1 - mslib/msui/ui/ui_updater_dialog.ui | 107 ------------------- mslib/msui/updater.py | 81 -------------- mslib/mswms/app/__init__.py | 8 +- mslib/mswms/mswms.py | 12 --- mslib/utils/qt.py | 158 +--------------------------- mslib/utils/release_info.py | 77 ++++++++++++++ tests/_test_msui/test_updater.py | 135 ------------------------ 17 files changed, 147 insertions(+), 644 deletions(-) delete mode 100644 mslib/msui/qt5/ui_updater_dialog.py delete mode 100644 mslib/msui/ui/ui_updater_dialog.ui delete mode 100644 mslib/msui/updater.py create mode 100644 mslib/utils/release_info.py delete mode 100644 tests/_test_msui/test_updater.py diff --git a/docs/installation.rst b/docs/installation.rst index 9075d450a..234d6b6c6 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -124,23 +124,9 @@ versions for dependencies. :: (mssenv) $ msui -Update ------- - -Builtin Update +Update Methods .............. -Since version 5.0 we provide a feature for updating MSS by the UI or the command line -After you started the MSS UI it informs you after a while if there is a new update available. -From the command line you can trigger this update feature by :: - - (mssenv) $ msui --update - - - -Other Methods -............. - For updating an existing MSS installation to the current version, it is best to install it into a new environment. If your current version is not far behind the new version you could try the `mamba update mss` as described. diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py index 374383e1c..1e9fe719e 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -25,6 +25,7 @@ """ import os +import logging import sqlalchemy from flask_migrate import Migrate @@ -34,7 +35,11 @@ from flask import Flask, url_for from mslib.mscolab.conf import mscolab_settings from flask_sqlalchemy import SQLAlchemy -from mslib.utils import prefix_route +from mslib.utils import prefix_route, release_info + +message, update = release_info.check_for_new_release() +if update: + logging.warning(message) DOCS_SERVER_PATH = os.path.dirname(os.path.abspath(mslib.__file__)) diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index 445dc2f7c..e3b521872 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -45,7 +45,6 @@ from mslib.mscolab.server import APP from mslib.mscolab.utils import create_files from mslib.utils import setup_logging -from mslib.utils.qt import Worker, Updater def handle_start(args): @@ -358,7 +357,6 @@ def handle_sso_metadata_init(repo_exists): def main(): parser = argparse.ArgumentParser() parser.add_argument("-v", "--version", help="show version", action="store_true", default=False) - parser.add_argument("--update", help="Updates MSS to the newest version", action="store_true", default=False) subparsers = parser.add_subparsers(help='Available actions', dest='action') @@ -407,16 +405,6 @@ def main(): except git.exc.InvalidGitRepositoryError: repo_exists = False - updater = Updater() - if args.update: - updater.on_update_available.connect(lambda old, new: updater.update_mss()) - updater.on_log_update.connect(lambda s: print(s.replace("\n", ""))) - updater.on_status_update.connect(lambda s: print(s.replace("\n", ""))) - updater.run() - while Worker.workers: - list(Worker.workers)[0].wait() - sys.exit() - if args.action == "start": handle_start(args) diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index 0a4c4108e..b15bab1aa 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -42,7 +42,6 @@ from mslib.msui import constants from mslib.utils import setup_logging from mslib.msui.icons import icons -from mslib.utils.qt import Worker, Updater from mslib.utils.config import read_config_file from PyQt5 import QtGui, QtCore, QtWidgets @@ -62,7 +61,6 @@ def main(tutorial_mode=False): parser.add_argument("--debug", help="show debugging log messages on console", action="store_true", default=False) parser.add_argument("--logfile", help="Specify logfile location. Set to empty string to disable.", action="store", default=os.path.join(constants.MSUI_CONFIG_PATH, "msui.log")) - parser.add_argument("--update", help="Updates MSS to the newest version", action="store_true", default=False) args = parser.parse_args() @@ -74,16 +72,6 @@ def main(tutorial_mode=False): print("Version:", __version__) sys.exit() - if args.update: - updater = Updater() - updater.on_update_available.connect(lambda old, new: updater.update_mss()) - updater.on_log_update.connect(lambda s: print(s.replace("\n", ""))) - updater.on_status_update.connect(lambda s: print(s.replace("\n", ""))) - updater.run() - while Worker.workers: - list(Worker.workers)[0].wait() - sys.exit() - setup_logging(args) logging.info("MSS Version: %s", __version__) diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index 176ad6830..a32f5cdf4 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -46,11 +46,11 @@ from mslib.msui import flighttrack as ft from mslib.msui import tableview, topview, sideview, linearview from mslib.msui import constants, editor, mscolab -from mslib.msui.updater import UpdaterUI from mslib.plugins.io.csv import load_from_csv, save_to_csv from mslib.msui.icons import icons, python_powered from mslib.utils.qt import get_open_filenames, get_save_filename, show_popup from mslib.utils.config import read_config_file, config_loader +from mslib.utils import release_info from PyQt5 import QtGui, QtCore, QtWidgets from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas @@ -408,6 +408,7 @@ def __init__(self, parent=None): super().__init__(parent) self.setupUi(self) self.lblVersion.setText(f"Version: {__version__}") + self.lblNewVersion.setText(f"{release_info.check_for_new_release()[0]}") self.milestone_url = f'https://github.com/Open-MSS/MSS/issues?q=is%3Aclosed+milestone%3A{__version__[:-1]}' self.lblChanges.setText(f'New Features and Changes') blub = QtGui.QPixmap(python_powered()) @@ -532,10 +533,6 @@ def __init__(self, mscolab_data_dir=None, tutorial_mode=False, *args): self.mscolab.signal_render_new_permission.connect( lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) - # Don't start the updater during a test run of msui - if "pytest" not in sys.modules: - self.updater = UpdaterUI(self) - self.actionUpdater.triggered.connect(self.updater.show) self.openOperationsGb.hide() def bring_main_window_to_front(self): diff --git a/mslib/msui/qt5/ui_about_dialog.py b/mslib/msui/qt5/ui_about_dialog.py index 32d6a9f15..46725b0ff 100644 --- a/mslib/msui/qt5/ui_about_dialog.py +++ b/mslib/msui/qt5/ui_about_dialog.py @@ -2,9 +2,10 @@ # Form implementation generated from reading ui file 'ui_about_dialog.ui' # -# Created by: PyQt5 UI code generator 5.12.3 +# Created by: PyQt5 UI code generator 5.15.9 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtGui, QtWidgets @@ -13,7 +14,7 @@ class Ui_AboutMSUIDialog(object): def setupUi(self, AboutMSUIDialog): AboutMSUIDialog.setObjectName("AboutMSUIDialog") - AboutMSUIDialog.resize(1052, 600) + AboutMSUIDialog.resize(1052, 771) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -82,6 +83,9 @@ def setupUi(self, AboutMSUIDialog): self.lblChanges.setObjectName("lblChanges") self.horizontalLayout_2.addWidget(self.lblChanges) self.verticalLayout_2.addLayout(self.horizontalLayout_2) + self.lblNewVersion = QtWidgets.QLabel(AboutMSUIDialog) + self.lblNewVersion.setObjectName("lblNewVersion") + self.verticalLayout_2.addWidget(self.lblNewVersion) spacerItem2 = QtWidgets.QSpacerItem(20, 10, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) self.verticalLayout_2.addItem(spacerItem2) self.lblLicense = QtWidgets.QLabel(AboutMSUIDialog) @@ -109,7 +113,7 @@ def setupUi(self, AboutMSUIDialog): self.verticalLayout.addLayout(self.verticalLayout_2) self.retranslateUi(AboutMSUIDialog) - self.btOK.clicked.connect(AboutMSUIDialog.accept) + self.btOK.clicked.connect(AboutMSUIDialog.accept) # type: ignore QtCore.QMetaObject.connectSlotsByName(AboutMSUIDialog) def retranslateUi(self, AboutMSUIDialog): @@ -120,25 +124,26 @@ def retranslateUi(self, AboutMSUIDialog): self.textBrowser.setHtml(_translate("AboutMSUIDialog", "\n" "\n" -"

Please read the reference documentation:

\n" -"


\n" -"

Bauer, R., Grooß, J.-U., Ungermann, J., Bär, M., Geldenhuys, M., and Hoffmann, L.: The Mission Support

\n" -"

System (MSS v7.0.4) and its use in planning for the SouthTRAC aircraft campaign, Geosci.

\n" -"

Model Dev., 15, 8983–8997, https://doi.org/10.5194/gmd-15-8983-2022, 2022.

\n" -"


\n" -"


\n" -"

Rautenhaus, M., Bauer, G., and Doernbrack, A.: A web service based tool to plan

\n" -"

atmospheric research flights, Geosci. Model Dev., 5,55-71, https://doi.org/10.5194/gmd-5-55-2012, 2012.

\n" -"


\n" -"

and the paper\'s Supplement (which includes a tutorial) before using the application. The documents are available at:

\n" -"


\n" -"

* http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012.pdf

\n" -"

* http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012-supplement.pdf

\n" -"

\n" -"

When using this software, please be so kind and acknowledge its use by citing the above mentioned reference documentation in publications, presentations, reports, etc. that you create. Thank you very much.

")) +"\n" +"

Please read the reference documentation:

\n" +"


\n" +"

Bauer, R., Grooß, J.-U., Ungermann, J., Bär, M., Geldenhuys, M., and Hoffmann, L.: The Mission Support

\n" +"

System (MSS v7.0.4) and its use in planning for the SouthTRAC aircraft campaign, Geosci.

\n" +"

Model Dev., 15, 8983–8997, https://doi.org/10.5194/gmd-15-8983-2022, 2022.

\n" +"


\n" +"


\n" +"

Rautenhaus, M., Bauer, G., and Doernbrack, A.: A web service based tool to plan

\n" +"

atmospheric research flights, Geosci. Model Dev., 5,55-71, https://doi.org/10.5194/gmd-5-55-2012, 2012.

\n" +"


\n" +"

and the paper\'s Supplement (which includes a tutorial) before using the application. The documents are available at:

\n" +"


\n" +"

* http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012.pdf

\n" +"

* http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012-supplement.pdf

\n" +"

\n" +"

When using this software, please be so kind and acknowledge its use by citing the above mentioned reference documentation in publications, presentations, reports, etc. that you create. Thank you very much.

")) self.lblVersion.setText(_translate("AboutMSUIDialog", "Version: --VERSION--")) self.lblChanges.setText(_translate("AboutMSUIDialog", "Changes: --CHANGES--")) + self.lblNewVersion.setText(_translate("AboutMSUIDialog", "Check for new Version: --NEW VERSION--")) self.lblLicense.setText(_translate("AboutMSUIDialog", "License: Apache License Version 2.0")) self.lblCopyright.setText(_translate("AboutMSUIDialog", "Copyright 2008-2014: Deutsches Zentrum fuer Luft- und Raumfahrt e.V.\n" "Copyright 2011-2014: Marc Rautenhaus\n" diff --git a/mslib/msui/qt5/ui_mainwindow.py b/mslib/msui/qt5/ui_mainwindow.py index 1ba2ca0fd..f1c386c69 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.15.9 # @@ -272,7 +272,6 @@ def setupUi(self, MSUIMainWindow): self.menuFile.addAction(self.actionQuit) self.menuHelp.addAction(self.actionShortcuts) self.menuHelp.addAction(self.actionMSColabHelp) - self.menuHelp.addAction(self.actionUpdater) self.menuHelp.addAction(self.actionOnlineHelp) self.menuHelp.addAction(self.actionAboutMSUI) self.menuHelp.addAction(self.actionSearch) diff --git a/mslib/msui/qt5/ui_updater_dialog.py b/mslib/msui/qt5/ui_updater_dialog.py deleted file mode 100644 index bf60607af..000000000 --- a/mslib/msui/qt5/ui_updater_dialog.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'ui_updater_dialog.ui' -# -# Created by: PyQt5 UI code generator 5.12.3 -# -# WARNING! All changes made in this file will be lost! - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_Updater(object): - def setupUi(self, Updater): - Updater.setObjectName("Updater") - Updater.setWindowModality(QtCore.Qt.NonModal) - Updater.resize(854, 338) - self.verticalLayout = QtWidgets.QVBoxLayout(Updater) - self.verticalLayout.setObjectName("verticalLayout") - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.labelVersion = QtWidgets.QLabel(Updater) - self.labelVersion.setObjectName("labelVersion") - self.horizontalLayout.addWidget(self.labelVersion) - self.btUpdate = QtWidgets.QPushButton(Updater) - self.btUpdate.setEnabled(False) - self.btUpdate.setObjectName("btUpdate") - self.horizontalLayout.addWidget(self.btUpdate) - self.btRestart = QtWidgets.QPushButton(Updater) - self.btRestart.setEnabled(False) - self.btRestart.setObjectName("btRestart") - self.horizontalLayout.addWidget(self.btRestart) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem) - self.label = QtWidgets.QLabel(Updater) - self.label.setOpenExternalLinks(True) - self.label.setObjectName("label") - self.horizontalLayout.addWidget(self.label) - self.verticalLayout.addLayout(self.horizontalLayout) - self.statusLabel = QtWidgets.QLabel(Updater) - self.statusLabel.setObjectName("statusLabel") - self.verticalLayout.addWidget(self.statusLabel) - self.output = QtWidgets.QPlainTextEdit(Updater) - font = QtGui.QFont() - font.setFamily("Sans Serif") - font.setStyleStrategy(QtGui.QFont.PreferDefault) - self.output.setFont(font) - self.output.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) - self.output.setReadOnly(True) - self.output.setPlainText("") - self.output.setCenterOnScroll(False) - self.output.setObjectName("output") - self.verticalLayout.addWidget(self.output) - - self.retranslateUi(Updater) - QtCore.QMetaObject.connectSlotsByName(Updater) - - def retranslateUi(self, Updater): - _translate = QtCore.QCoreApplication.translate - Updater.setWindowTitle(_translate("Updater", "Updater")) - self.labelVersion.setText(_translate("Updater", "Newest Version: x.x.x")) - self.btUpdate.setText(_translate("Updater", "Update")) - self.btRestart.setText(_translate("Updater", "Restart MSUI")) - self.label.setText(_translate("Updater", "

Manual update instructions

")) - self.statusLabel.setText(_translate("Updater", "Nothing to do")) diff --git a/mslib/msui/ui/ui_about_dialog.ui b/mslib/msui/ui/ui_about_dialog.ui index c82ba442f..484e6912f 100644 --- a/mslib/msui/ui/ui_about_dialog.ui +++ b/mslib/msui/ui/ui_about_dialog.ui @@ -7,7 +7,7 @@ 0 0 1052 - 600 + 771 @@ -139,23 +139,23 @@ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';">Please read the reference documentation:</span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'Sans Serif';"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';">Bauer, R., Grooß, J.-U., Ungermann, J., Bär, M., Geldenhuys, M., and Hoffmann, L.: The Mission Support</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';">System (MSS v7.0.4) and its use in planning for the SouthTRAC aircraft campaign, Geosci.</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';">Model Dev., 15, 8983–8997, https://doi.org/10.5194/gmd-15-8983-2022, 2022.</span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'Sans Serif';"><br /></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'Sans Serif';"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';">Rautenhaus, M., Bauer, G., and Doernbrack, A.: A web service based tool to plan</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';">atmospheric research flights, Geosci. Model Dev., 5,55-71, https://doi.org/10.5194/gmd-5-55-2012, 2012.</span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'Sans Serif';"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';">and the paper's Supplement (which includes a tutorial) before using the application. The documents are available at:</span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'Sans Serif';"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';"> * http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012.pdf</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';"> * http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012-supplement.pdf</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';"> </span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Sans Serif';"> When using this software, please be so kind and acknowledge its use by citing the above mentioned reference documentation in publications, presentations, reports, etc. that you create. Thank you very much.</span></p></body></html> +</style></head><body style=" font-family:'Sans Serif'; font-size:9pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;">Please read the reference documentation:</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:11pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;">Bauer, R., Grooß, J.-U., Ungermann, J., Bär, M., Geldenhuys, M., and Hoffmann, L.: The Mission Support</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;">System (MSS v7.0.4) and its use in planning for the SouthTRAC aircraft campaign, Geosci.</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;">Model Dev., 15, 8983–8997, https://doi.org/10.5194/gmd-15-8983-2022, 2022.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:11pt;"><br /></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:11pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;">Rautenhaus, M., Bauer, G., and Doernbrack, A.: A web service based tool to plan</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;">atmospheric research flights, Geosci. Model Dev., 5,55-71, https://doi.org/10.5194/gmd-5-55-2012, 2012.</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:11pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;">and the paper's Supplement (which includes a tutorial) before using the application. The documents are available at:</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:11pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;"> * http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012.pdf</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;"> * http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012-supplement.pdf</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;"> </span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt;"> When using this software, please be so kind and acknowledge its use by citing the above mentioned reference documentation in publications, presentations, reports, etc. that you create. Thank you very much.</span></p></body></html> @@ -193,6 +193,13 @@ p, li { white-space: pre-wrap; } + + + + Check for new Version: --NEW VERSION-- + + + diff --git a/mslib/msui/ui/ui_mainwindow.ui b/mslib/msui/ui/ui_mainwindow.ui index 7ef77ca87..3ef27614e 100644 --- a/mslib/msui/ui/ui_mainwindow.ui +++ b/mslib/msui/ui/ui_mainwindow.ui @@ -467,7 +467,6 @@ Double click a operation to activate and view its description. - diff --git a/mslib/msui/ui/ui_updater_dialog.ui b/mslib/msui/ui/ui_updater_dialog.ui deleted file mode 100644 index 611b55719..000000000 --- a/mslib/msui/ui/ui_updater_dialog.ui +++ /dev/null @@ -1,107 +0,0 @@ - - - Updater - - - Qt::NonModal - - - - 0 - 0 - 854 - 338 - - - - Updater - - - - - - - - Newest Version: x.x.x - - - - - - - false - - - Update - - - - - - - false - - - Restart MSUI - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - <html><head/><body><p><a href="https://mss.readthedocs.io/en/stable/installation.html#install"><span style=" text-decoration: underline; color:#0000ff;">Manual update instructions</span></a></p></body></html> - - - true - - - - - - - - - Nothing to do - - - - - - - - Sans Serif - PreferDefault - - - - QPlainTextEdit::NoWrap - - - true - - - - - - false - - - - - - - - diff --git a/mslib/msui/updater.py b/mslib/msui/updater.py deleted file mode 100644 index b33822e94..000000000 --- a/mslib/msui/updater.py +++ /dev/null @@ -1,81 +0,0 @@ -""" - mslib.msui.updater - ~~~~~~~~~~~~~~~~~~~ - - This UI interface for the updater util, handles detection of an outdated mss version and automatic updating. - - This file is part of MSS. - - :copyright: Copyright 2021 May Bär - :copyright: Copyright 2021-2024 by the MSS team, see AUTHORS. - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" -import logging - -from PyQt5 import QtCore, QtWidgets, QtGui - -from mslib.utils.qt import Updater -from mslib.msui.qt5 import ui_updater_dialog -from mslib import __version__ - - -class UpdaterUI(QtWidgets.QDialog, ui_updater_dialog.Ui_Updater): - """ - Checks for a newer versions of MSS and installs it. - Only works if conda is installed and MSS isn't inside a git repo. - """ - on_update_available = QtCore.pyqtSignal([str, str]) - - def __init__(self, parent=None): - super().__init__(parent) - self.setupUi(self) - self.hide() - self.labelVersion.setText(f"Newest Version: {__version__}") - self.updater = None - try: - self.updater = Updater() - except ModuleNotFoundError as ex: - # https://github.com/Open-MSS/MSS/issues/1409#issuecomment-1200946622 - logging.error("unexpected error in updater: %s %s in version: %s", type(ex), ex, __version__) - - if self.updater is not None: - monospace = QtGui.QFont("non-existent") - monospace.setStyleHint(QtGui.QFont.Monospace) - self.output.setFont(monospace) - self.updater.on_log_update.connect(lambda s: (self.output.insertPlainText(s), - self.output.verticalScrollBar().setSliderPosition( - self.output.verticalScrollBar().maximum()))) - self.updater.on_status_update.connect(self.statusLabel.setText) - self.updater.on_update_available.connect(self.notify_on_update) - self.updater.on_update_finished.connect(lambda: self.btRestart.setEnabled(True)) - self.btUpdate.clicked.connect(lambda: (self.updater.update_mss(), self.btUpdate.setEnabled(False))) - self.btRestart.clicked.connect(self.updater._restart_msui) - self.updater.run() - - def notify_on_update(self, old, new): - """ - Asks the user if they want to update MSS - """ - self.btUpdate.setEnabled(True) - self.labelVersion.setText(f"Newest Version: {new}") - if not self.updater.is_git_env: - ret = QtWidgets.QMessageBox.information( - self, "Mission Support System", - f"MSS can be updated from {old} to {new}\nDo you want to update?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) - if ret == QtWidgets.QMessageBox.Yes: - self.show() - self.btUpdate.click() diff --git a/mslib/mswms/app/__init__.py b/mslib/mswms/app/__init__.py index dd7885619..bcc1d7dd1 100644 --- a/mslib/mswms/app/__init__.py +++ b/mslib/mswms/app/__init__.py @@ -25,11 +25,17 @@ """ import os +import logging import mslib from flask import Flask, url_for from mslib.mswms.gallery_builder import STATIC_LOCATION -from mslib.utils import prefix_route +from mslib.utils import prefix_route, release_info + + +message, update = release_info.check_for_new_release() +if update: + logging.warning(message) DOCS_SERVER_PATH = os.path.dirname(os.path.abspath(mslib.__file__)) diff --git a/mslib/mswms/mswms.py b/mslib/mswms/mswms.py index 987d6a7a2..b73fe9d5b 100644 --- a/mslib/mswms/mswms.py +++ b/mslib/mswms/mswms.py @@ -31,7 +31,6 @@ from mslib import __version__ from mslib.utils import setup_logging -from mslib.utils.qt import Updater, Worker from mslib.mswms.wms import app as application @@ -45,7 +44,6 @@ def main(): parser.add_argument("--debug", help="show debugging log messages on console", action="store_true", default=False) parser.add_argument("--logfile", help="If set to a name log output goes to that file", dest="logfile", default=None) - parser.add_argument("--update", help="Updates MSS to the newest version", action="store_true", default=False) subparsers = parser.add_subparsers(help='Available actions', dest='action') gallery = subparsers.add_parser("gallery", help="Subcommands surrounding the gallery") @@ -87,16 +85,6 @@ def main(): print("Version:", __version__) sys.exit() - updater = Updater() - if args.update: - updater.on_update_available.connect(lambda old, new: updater.update_mss()) - updater.on_log_update.connect(lambda s: print(s.replace("\n", ""))) - updater.on_status_update.connect(lambda s: print(s.replace("\n", ""))) - updater.run() - while Worker.workers: - list(Worker.workers)[0].wait() - sys.exit() - setup_logging(args) # keep the import after the version check. This creates all layers. diff --git a/mslib/utils/qt.py b/mslib/utils/qt.py index 8503b80c4..fd6441fb3 100644 --- a/mslib/utils/qt.py +++ b/mslib/utils/qt.py @@ -2,7 +2,7 @@ """ mslib.utils.msui_qt - ~~~~~~~~~~~~~~~~~ + ~~~~~~~~~~~~~~~~~~~ This module helps with qt @@ -30,14 +30,13 @@ import re import platform import sys -import subprocess import traceback from fslib.fs_filepicker import getSaveFileName, getOpenFileName, getExistingDirectory from PyQt5 import QtCore, QtWidgets, QtGui # noqa from mslib.utils.config import config_loader -from mslib.utils import FatalUserError, subprocess_startupinfo +from mslib.utils import FatalUserError def get_open_filename_qt(*args): @@ -431,159 +430,6 @@ def _update_gui(): window.requestUpdate() -class Updater(QtCore.QObject): - """ - Checks for a newer versions of MSS and provide functions to install it asynchronously. - Only works if conda is installed. - """ - on_update_available = QtCore.pyqtSignal([str, str]) - on_update_finished = QtCore.pyqtSignal() - on_log_update = QtCore.pyqtSignal([str]) - on_status_update = QtCore.pyqtSignal([str]) - - def __init__(self, parent=None): - super().__init__(parent) - self.is_git_env = False - self.new_version = None - self.old_version = None - # we are using the installer version of the env - self.conda_prefix = os.getenv("CONDA_PREFIX") - if self.conda_prefix is not None: - self.command = os.path.join(self.conda_prefix, 'bin', "conda") - mamba_cmd = os.path.join(self.conda_prefix, 'bin', 'mamba') - # Check if mamba is installed in the env - try: - subprocess.run([mamba_cmd], startupinfo=subprocess_startupinfo(), - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - self.command = mamba_cmd - except FileNotFoundError: - pass - else: - self.command = "conda" - - # pyqtSignals don't work without an application eventloop running - if QtCore.QCoreApplication.startingUp(): - self.on_update_available = NonQtCallback() - self.on_update_finished = NonQtCallback() - self.on_log_update = NonQtCallback() - self.on_status_update = NonQtCallback() - - def run(self): - """ - Starts the updater process - """ - Worker.create(self._check_version) - - def _check_version(self): - """ - Checks if conda search has a newer version of MSS - """ - # Don't notify on updates if mss is in a git repo, as you are most likely a developer - try: - git = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], - startupinfo=subprocess_startupinfo(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, encoding="utf8") - if "true" in git.stdout: - self.is_git_env = True - except FileNotFoundError: - pass - - # Return if conda is not installed. conda is fallback of mamba - try: - subprocess.run([self.command], startupinfo=subprocess_startupinfo(), - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - except FileNotFoundError: - return - - self.on_status_update.emit("Checking for updates...") - - # Check if "search mss" yields a higher version than the currently running one - search = self._execute_command(f"{self.command} search mss") - self.new_version = search.split("\n")[-2].split()[1] - c_list = self._execute_command(f"{self.command} list -f mss") - self.old_version = c_list.split("\n")[-2].split()[1] - if any(c.isdigit() for c in self.new_version): - if self.new_version > self.old_version: - self.on_status_update.emit("Your version of MSS is outdated!") - self.on_update_available.emit(self.old_version, self.new_version) - else: - self.on_status_update.emit("Your MSS is up to date.") - - def _restart_msui(self): - """ - Restart msui with all the same parameters, not entirely - safe in case parameters change in higher versions, or while debugging - """ - command = [sys.executable.split(os.sep)[-1]] + sys.argv - if os.name == "nt" and not command[1].endswith(".py"): - command[1] += "-script.py" - os.execv(sys.executable, command) - - def _try_updating(self): - """ - Execute 'conda/mamba install mss=newest python -y' and return if it worked or not - """ - self.on_status_update.emit("Trying to update MSS...") - self._execute_command(f"{self.command} install mss={self.new_version} python -y") - if self._verify_newest_mss(): - return True - - return False - - def _update_mss(self): - """ - Try to install MSS' newest version - """ - if not self._try_updating(): - self.on_status_update.emit("Update failed. Please try it manually or by creating a new environment!") - else: - self.on_update_finished.emit() - self.on_status_update.emit("Update successful. Please restart MSS.") - - def _verify_newest_mss(self): - """ - Return if the newest mss exists in the environment or not - """ - verify = self._execute_command(f"{self.command} list -f mss") - if self.new_version in verify: - return True - - return False - - def _execute_command(self, command): - """ - Handles proper execution of conda subprocesses and logging - """ - process = subprocess.Popen(command.split(), - startupinfo=subprocess_startupinfo(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - encoding="utf8") - self.on_log_update.emit(" ".join(process.args) + "\n") - - text = "" - for line in process.stdout: - self.on_log_update.emit(line) - text += line - - # Happens e.g. on connection errors during installation attempts - if "An unexpected error has occurred. Conda has prepared the above report" in text: - raise RuntimeError("Something went wrong! Can't safely continue to update.") - else: - return text - - def update_mss(self): - """ - Installs the newest mss version - """ - def on_failure(e: Exception): - self.on_status_update.emit("Update failed, please do it manually.") - self.on_log_update.emit(str(e)) - - Worker.create(self._update_mss, on_failure=on_failure) - - class NonQtCallback: """ Small mock of pyqtSignal to work without the QT eventloop. diff --git a/mslib/utils/release_info.py b/mslib/utils/release_info.py new file mode 100644 index 000000000..c2a5a64ea --- /dev/null +++ b/mslib/utils/release_info.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.release_info + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Shows by a github API call information about the latest release + + This file is part of MSS. + + :copyright: Copyright 2024 Reimar Bauer + :copyright: Copyright 2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import datetime +import logging +import requests + +from mslib.version import __version__ as installed_version + + +def get_latest_release(): + # GitHub API URL for the MSS Release + url = "https://api.github.com/repos/Open-MSS/MSS/releases/latest" + + try: + # Make a GET request to the GitHub API + response = requests.get(url, timeout=(1, 1)) + response.raise_for_status() # Raise an error for non-200 status codes + + # Extract the JSON response + release_data = response.json() + + # Extract the latest release tag and name + latest_release = { + 'tag_name': release_data['tag_name'], + 'release_name': release_data['name'], + 'published_at': release_data['published_at'], + 'url': release_data['html_url'] + } + return latest_release + + except requests.exceptions.RequestException as e: + logging.debug(f"Error fetching release data: {e}") + return None + + +def check_for_new_release(): + no_new_release_found = f"{datetime.date.today()}: No new release found." + try: + latest_release = get_latest_release() + except TimeoutError as e: + logging.debug(f"Error fetching release data: {e}") + latest_release = None + + if latest_release is None or latest_release['tag_name'] == installed_version: + return no_new_release_found, False + + github_url = f'{latest_release["url"]}' + return ' | '.join([ + f"New release found: {latest_release['release_name']} ({latest_release['tag_name']})", + f"Published at: {latest_release['published_at']}", + f"Release URL: {github_url}", + ]), True diff --git a/tests/_test_msui/test_updater.py b/tests/_test_msui/test_updater.py deleted file mode 100644 index 452ee88d7..000000000 --- a/tests/_test_msui/test_updater.py +++ /dev/null @@ -1,135 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - tests._test_msui.test_updater - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This module provides pytest functions to tests msui.updater - - This file is part of MSS. - - :copyright: Copyright 2021 May Bär - :copyright: Copyright 2021-2024 by the MSS team, see AUTHORS. - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" -import mock -import pytest -from PyQt5 import QtWidgets - -from mslib.msui.updater import UpdaterUI, Updater -from mslib.utils.qt import Worker - - -def no_conda(args=None, **named_args): - raise FileNotFoundError - - -class SubprocessDifferentVersionMock: - def __init__(self, args=None, **named_args): - self.returncode = 0 - self.args = args - if args and "list" in args and "mss" in args: - self.stdout = "*mss 0.0.0\n" - else: - self.stdout = "*mss 999.999.999\n" - - -class SubprocessSameMock: - def __init__(self, args=None, **named_args): - self.stdout = "*mss 999.999.999\n" - self.returncode = 0 - self.args = args - - -@mock.patch("mslib.utils.qt.Worker.start", Worker.run) -class Test_MSS_ShortcutDialog: - @pytest.fixture(autouse=True) - def setup(self, qtbot): - self.updater = Updater() - self.status = "" - self.update_available = False - self.update_finished = False - - def update_signal(old, new): - self.update_available = True - - def update_finished_signal(): - self.update_finished = True - - def status_signal(s): - self.status = s - - self.updater.on_update_available.connect(update_signal) - self.updater.on_status_update.connect(status_signal) - self.updater.on_update_finished.connect(update_finished_signal) - yield - - @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) - @mock.patch("subprocess.run", new=SubprocessDifferentVersionMock) - def test_update_recognised(self): - self.updater.run() - - assert self.updater.new_version == "999.999.999" - assert self.update_available - self.updater.new_version = "0.0.0" - - self.updater.update_mss() - assert self.status == "Update successful. Please restart MSS." - assert self.update_finished - - @mock.patch("subprocess.Popen", new=SubprocessSameMock) - @mock.patch("subprocess.run", new=SubprocessSameMock) - def test_no_update(self): - self.updater.run() - assert self.status == "Your MSS is up to date." - assert not self.update_available - assert not self.update_finished - - @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) - @mock.patch("subprocess.run", new=SubprocessDifferentVersionMock) - def test_update_failed(self): - self.updater.run() - assert self.updater.new_version == "999.999.999" - assert self.update_available - self.updater.new_version = "1000.1000.1000" - self.updater.update_mss() - assert self.status == "Update failed. Please try it manually or " \ - "by creating a new environment!" - - @mock.patch("subprocess.Popen", new=no_conda) - @mock.patch("subprocess.run", new=no_conda) - def test_no_conda(self): - self.updater.run() - assert self.updater.new_version is None and self.updater.old_version is None - assert not self.update_available - assert not self.update_finished - - @mock.patch("subprocess.Popen", new=no_conda) - @mock.patch("subprocess.run", new=no_conda) - def test_exception(self): - self.updater.new_version = "999.999.999" - self.updater.old_version = "999.999.999" - self.updater.update_mss() - assert self.status == "Update failed, please do it manually." - assert not self.update_finished - - @mock.patch("subprocess.Popen", new=SubprocessSameMock) - @mock.patch("subprocess.run", new=SubprocessSameMock) - @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Yes) - def test_ui(self, mock): - ui = UpdaterUI() - ui.updater.on_update_available.emit("", "") - assert ui.statusLabel.text() == "Update successful. Please restart MSS." - assert ui.btRestart.isEnabled() From 863f052d37e31fbd26680ab10410dc709e340c62 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Fri, 6 Sep 2024 10:47:27 +0200 Subject: [PATCH 20/29] renamed and refactored names for mscolab (#2499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removed the xmlsec binary selector based on CONDA_PREFIX provided separate config for msidp separate dir for mscolab server data and work asynchronously by msolab ui --------- Co-authored-by: Matthias Riße <9308656+matrss@users.noreply.github.com> --- conftest.py | 6 +-- docs/conf_sso_test_msscolab.rst | 6 +++ .../config/mscolab/mscolab_settings.py.sample | 18 +++---- .../mscolab/setup_saml2_backend.py.sample | 10 ++-- .../config/msidp/msidp_settings.py.sample | 36 ++++++++++++++ docs/sso_via_saml_mscolab.rst | 2 +- mslib/mscolab/app/__init__.py | 2 +- mslib/mscolab/conf.py | 32 +++++++------ mslib/mscolab/mscolab.py | 26 +++++----- mslib/mscolab/seed.py | 6 +-- mslib/mscolab/sockets_manager.py | 2 +- mslib/mscolab/utils.py | 4 +- mslib/msidp/conf.py | 48 +++++++++++++++++++ mslib/msidp/idp.py | 4 +- mslib/msidp/idp_conf.py | 32 +++++-------- mslib/msui/mscolab.py | 10 ++-- mslib/msui/msui_mainwindow.py | 13 ++++- tests/_test_mscolab/test_files.py | 4 +- tests/_test_mscolab/test_mscolab.py | 8 ++-- tests/_test_mscolab/test_server.py | 4 +- .../test_server_auth_required.py | 2 +- tests/_test_mscolab/test_utils.py | 2 +- tests/_test_msui/test_mscolab.py | 12 ++--- tests/_test_msui/test_mscolab_admin_window.py | 4 +- .../test_mscolab_merge_waypoints.py | 6 +-- tests/_test_msui/test_mscolab_operation.py | 4 +- .../test_mscolab_version_history.py | 4 +- tests/fixtures.py | 2 +- 28 files changed, 202 insertions(+), 107 deletions(-) create mode 100644 docs/samples/config/msidp/msidp_settings.py.sample create mode 100644 mslib/msidp/conf.py diff --git a/conftest.py b/conftest.py index b9bf7f543..13a0a5528 100644 --- a/conftest.py +++ b/conftest.py @@ -111,9 +111,9 @@ def generate_initial_config(): root_fs.makedir('colabTestData') BASE_DIR = ROOT_DIR DATA_DIR = fs.path.join(ROOT_DIR, 'colabTestData') -# mscolab data directory -MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') -MSCOLAB_SSO_DIR = fs.path.join(DATA_DIR, 'datasso') +# mscolab data directory for operation git repositories +OPERATIONS_DATA = fs.path.join(DATA_DIR, 'filedata') +SSO_DIR = fs.path.join(DATA_DIR, 'datasso') # In the unit days when Operations get archived because not used ARCHIVE_THRESHOLD = 30 diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst index ad9ecaf19..9ecbd4097 100644 --- a/docs/conf_sso_test_msscolab.rst +++ b/docs/conf_sso_test_msscolab.rst @@ -30,7 +30,13 @@ To set up a local identity provider with the mscolab server, you'll first need t ---------------- Before getting started, you should correctly activate the environments, set the correct Python path as explained in the mss instructions : https://github.com/Open-MSS/MSS/tree/develop#readme +Setup your +**msidp_settings.py** + + .. literalinclude:: samples/config/msidp/msidp_settings.py.sample + +.. _configuration-mscolab: 2. Generate Keys, Certificates, and backend_saml files ------------------------------------------------------ diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index 7249ee5fd..64eb16e66 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -44,8 +44,16 @@ BASE_DIR = os.path.abspath(os.path.dirname(__file__)) # Directory in which all data related to Mscolab is stored DATA_DIR = os.path.join(BASE_DIR, "colabdata") -# Where mscolab project files are stored on the server -MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') +# mscolab data directory for operation git repositories +OPERATIONS_DATA = os.path.join(DATA_DIR, 'filedata') + +# SSO by SAML2 is optional + +# dir where mscolab single sign-on process files are stored +SSO_DIR = os.path.join(DATA_DIR, 'datasso') + +# Enable SSL certificate verification during SSO between MSColab and IdP +SSO_SSL_CERT_VERIFICATION = True # Directory where uploaded images and documents in the chat are stored UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') @@ -99,9 +107,3 @@ USE_SAML2 = False # all users in that Group are set to the operations of that category # having the roles in the TexGroup GROUP_POSTFIX = "Group" - -# Enable SSL certificate verification during SSO between MSColab and IdP -ENABLE_SSO_SSL_CERT_VERIFICATION = True - -# dir where mscolab single sign process files are stored -MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') diff --git a/docs/samples/config/mscolab/setup_saml2_backend.py.sample b/docs/samples/config/mscolab/setup_saml2_backend.py.sample index d2bc963e6..941a2a4cc 100644 --- a/docs/samples/config/mscolab/setup_saml2_backend.py.sample +++ b/docs/samples/config/mscolab/setup_saml2_backend.py.sample @@ -22,19 +22,19 @@ class setup_saml2_backend: ] - if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml"): - with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: + if os.path.exists(f"{mscolab_settings.SSO_DIR}/mss_saml2_backend.yaml"): + with open(f"{mscolab_settings.SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: yaml_data = yaml.safe_load(fobj) # go through configured IDPs and set conf file paths for particular files for configured_idp in CONFIGURED_IDPS: # set CRTs and metadata paths for the localhost_test_idp if 'localhost_test_idp' == configured_idp['idp_identity_name']: yaml_data["config"]["localhost_test_idp"]["key_file"] = \ - f'{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key' # set path to your mscolab key file + f'{mscolab_settings.SSO_DIR}/key_mscolab.key' # set path to your mscolab key file yaml_data["config"]["localhost_test_idp"]["cert_file"] = \ - f'{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt' # set path to your mscolab certificate file + f'{mscolab_settings.SSO_DIR}/crt_mscolab.crt' # set path to your mscolab certificate file yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0] = \ - f'{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml' # set path to your idp metadata xml file + f'{mscolab_settings.SSO_DIR}/idp.xml' # set path to your idp metadata xml file # configuration localhost_test_idp Saml2Client try: diff --git a/docs/samples/config/msidp/msidp_settings.py.sample b/docs/samples/config/msidp/msidp_settings.py.sample new file mode 100644 index 000000000..138e22634 --- /dev/null +++ b/docs/samples/config/msidp/msidp_settings.py.sample @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msidp.conf.py.example + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + config for msidp + + This file is part of mss. + + :copyright: Copyright 2023 Nilupul Manodya + :copyright: Copyright 2023-2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import os + + +# our default dir for mss content +BASE_DIR = os.path.join(os.path.expanduser("~"), 'mss') + +DATA_DIR = os.path.join(BASE_DIR, "colabdata") + +# dir where mscolab single sign-on process files are stored +SSO_DIR = os.path.join(DATA_DIR, 'datasso') diff --git a/docs/sso_via_saml_mscolab.rst b/docs/sso_via_saml_mscolab.rst index 19f5fabe8..87413c163 100644 --- a/docs/sso_via_saml_mscolab.rst +++ b/docs/sso_via_saml_mscolab.rst @@ -471,7 +471,7 @@ Configuration in MSColab settings for Keycloak Configuration mss_saml2_backend.yaml file - Create your mss_saml2_backend.yaml file in your ``MSCOLAB_SSO_DIR``. + Create your mss_saml2_backend.yaml file in your ``SSO_DIR``. .. code:: text diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py index 1e9fe719e..7ed623e63 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -52,7 +52,7 @@ APP.config.from_object(__name__) APP.route = prefix_route(APP.route, SCRIPT_NAME) -APP.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR +APP.config['OPERATIONS_DATA'] = mscolab_settings.OPERATIONS_DATA APP.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI APP.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False APP.config['SQLALCHEMY_ECHO'] = mscolab_settings.SQLALCHEMY_ECHO diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 4321f7ab2..e88bc6d57 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -53,12 +53,20 @@ class default_mscolab_settings: CORS_ORIGINS = ["*"] # dir where msui output files are stored - BASE_DIR = os.path.expanduser("~") + BASE_DIR = os.path.join(os.path.expanduser("~"), 'mss') DATA_DIR = os.path.join(BASE_DIR, "colabdata") - # mscolab data directory - MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') + # mscolab data directory for operation git repositories + OPERATIONS_DATA = os.path.join(DATA_DIR, 'filedata') + + # SSO by SAML2 is optional + + # dir where mscolab single sign-on process files are stored + SSO_DIR = os.path.join(DATA_DIR, 'datasso') + + # Enable SSL certificate verification during SSO between MSColab and IdP + SSO_SSL_CERT_VERIFICATION = True # MYSQL CONNECTION STRING: "mysql+pymysql://:@:/?charset=utf8mb4" SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') @@ -126,12 +134,6 @@ class default_mscolab_settings: # accounts on a database on the server DIRECT_LOGIN = True - # Enable SSL certificate verification during SSO between MSColab and IdP - ENABLE_SSO_SSL_CERT_VERIFICATION = True - - # dir where mscolab single sign process files are stored - MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') - mscolab_settings = default_mscolab_settings() @@ -165,19 +167,19 @@ class setup_saml2_backend: # } # }, ] - if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml"): - with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: + if os.path.exists(f"{mscolab_settings.SSO_DIR}/mss_saml2_backend.yaml"): + with open(f"{mscolab_settings.SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: yaml_data = yaml.safe_load(fobj) # go through configured IDPs and set conf file paths for particular files for configured_idp in CONFIGURED_IDPS: # set CRTs and metadata paths for the localhost_test_idp if 'localhost_test_idp' == configured_idp['idp_identity_name']: yaml_data["config"]["localhost_test_idp"]["key_file"] = \ - f'{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key' + f'{mscolab_settings.SSO_DIR}/key_mscolab.key' yaml_data["config"]["localhost_test_idp"]["cert_file"] = \ - f'{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt' + f'{mscolab_settings.SSO_DIR}/crt_mscolab.crt' yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0] = \ - f'{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml' + f'{mscolab_settings.SSO_DIR}/idp.xml' # configuration localhost_test_idp Saml2Client try: @@ -187,7 +189,7 @@ class setup_saml2_backend: Ignore this warning when you initialize metadata.") localhost_test_idp = SPConfig().load(yaml_data["config"]["localhost_test_idp"]) - localhost_test_idp.verify_ssl_cert = mscolab_settings.ENABLE_SSO_SSL_CERT_VERIFICATION + localhost_test_idp.verify_ssl_cert = mscolab_settings.SSO_SSL_CERT_VERIFICATION sp_localhost_test_idp = Saml2Client(localhost_test_idp) configured_idp['idp_data']['saml2client'] = sp_localhost_test_idp diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index e3b521872..d802dcbb4 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -102,9 +102,9 @@ def handle_mscolab_certificate_init(): try: cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", - os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "key_mscolab.key"), + os.path.join(mscolab_settings.SSO_DIR, "key_mscolab.key"), "-nodes", "-x509", "-days", "365", "-batch", "-subj", - "/CN=localhost", "-out", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, + "/CN=localhost", "-out", os.path.join(mscolab_settings.SSO_DIR, "crt_mscolab.crt")] subprocess.run(cmd, check=True) logging.info("generated CRTs for the mscolab server.") @@ -119,9 +119,9 @@ def handle_local_idp_certificate_init(): try: cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", - os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "key_local_idp.key"), + os.path.join(mscolab_settings.SSO_DIR, "key_local_idp.key"), "-nodes", "-x509", "-days", "365", "-batch", "-subj", - "/CN=localhost", "-out", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "crt_local_idp.crt")] + "/CN=localhost", "-out", os.path.join(mscolab_settings.SSO_DIR, "crt_local_idp.crt")] subprocess.run(cmd, check=True) logging.info("generated CRTs for the local identity provider") return True @@ -252,7 +252,7 @@ def handle_mscolab_backend_yaml_init(): # name_id_format_allow_create: true """ try: - file_path = os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "mss_saml2_backend.yaml") + file_path = os.path.join(mscolab_settings.SSO_DIR, "mss_saml2_backend.yaml") with open(file_path, "w", encoding="utf-8") as file: file.write(saml_2_backend_yaml_content) return True @@ -278,7 +278,7 @@ def handle_mscolab_metadata_init(repo_exists): process = subprocess.Popen(command) cmd_curl = ["curl", "--retry", "5", "--retry-connrefused", "--retry-delay", "3", "http://localhost:8083/metadata/localhost_test_idp", - "-o", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "metadata_sp.xml")] + "-o", os.path.join(mscolab_settings.SSO_DIR, "metadata_sp.xml")] subprocess.run(cmd_curl, check=True) process.terminate() logging.info('mscolab metadata file generated succesfully') @@ -293,8 +293,8 @@ def handle_local_idp_metadata_init(repo_exists): print('generating metadata for localhost identity provider') try: - if os.path.exists(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")): - os.remove(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")) + if os.path.exists(os.path.join(mscolab_settings.SSO_DIR, "idp.xml")): + os.remove(os.path.join(mscolab_settings.SSO_DIR, "idp.xml")) idp_conf_path = os.path.join("mslib", "msidp", "idp_conf.py") @@ -305,15 +305,15 @@ def handle_local_idp_metadata_init(repo_exists): cmd = ["make_metadata", idp_conf_path] - with open(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml"), + with open(os.path.join(mscolab_settings.SSO_DIR, "idp.xml"), "w", encoding="utf-8") as output_file: subprocess.run(cmd, stdout=output_file, check=True) logging.info("idp metadata file generated successfully") return True except subprocess.CalledProcessError as error: # Delete the idp.xml file when the subprocess fails - if os.path.exists(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")): - os.remove(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")) + if os.path.exists(os.path.join(mscolab_settings.SSO_DIR, "idp.xml")): + os.remove(os.path.join(mscolab_settings.SSO_DIR, "idp.xml")) print(f"Error while generating metadata for localhost identity provider: {error}") return False @@ -323,8 +323,8 @@ def handle_sso_crts_init(): This will generate necessary CRTs files for sso in mscolab through localhost idp """ print("\n\nmscolab sso conf initiating......") - if os.path.exists(mscolab_settings.MSCOLAB_SSO_DIR): - shutil.rmtree(mscolab_settings.MSCOLAB_SSO_DIR) + if os.path.exists(mscolab_settings.SSO_DIR): + shutil.rmtree(mscolab_settings.SSO_DIR) create_files() if not handle_mscolab_certificate_init(): print('Error while handling mscolab certificate.') diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index 8d9f23457..8a740a7dd 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -62,7 +62,7 @@ def add_all_users_default_operation(path='TEMPLATE', description="Operation to k operation = Operation(path, description) db.session.add(operation) db.session.commit() - with fs.open_fs(mscolab_settings.MSCOLAB_DATA_DIR) as file_dir: + with fs.open_fs(mscolab_settings.OPERATIONS_DATA) as file_dir: if not file_dir.exists(path): file_dir.makedir(path) file_dir.writetext(f'{path}/main.ftml', mscolab_settings.STUB_CODE) @@ -150,7 +150,7 @@ def add_operation(operation_name, description): operation = Operation(operation_name, description) db.session.add(operation) db.session.commit() - with fs.open_fs(mscolab_settings.MSCOLAB_DATA_DIR) as file_dir: + with fs.open_fs(mscolab_settings.OPERATIONS_DATA) as file_dir: if not file_dir.exists(operation_name): file_dir.makedir(operation_name) file_dir.writetext(f'{operation_name}/main.ftml', mscolab_settings.STUB_CODE) @@ -396,7 +396,7 @@ def seed_data(): db.session.commit() db.session.close() - with fs.open_fs(mscolab_settings.MSCOLAB_DATA_DIR) as file_dir: + with fs.open_fs(mscolab_settings.OPERATIONS_DATA) as file_dir: file_paths = ['one', 'two', 'three', 'four', 'Admin_Test', 'test_mscolab'] for file_path in file_paths: file_dir.makedir(file_path) diff --git a/mslib/mscolab/sockets_manager.py b/mslib/mscolab/sockets_manager.py index a51db4199..719d28b45 100644 --- a/mslib/mscolab/sockets_manager.py +++ b/mslib/mscolab/sockets_manager.py @@ -334,7 +334,7 @@ def _setup_managers(app): """ cm = ChatManager() - fm = FileManager(app.config["MSCOLAB_DATA_DIR"]) + fm = FileManager(app.config["OPERATIONS_DATA"]) sm = SocketsManager(cm, fm) # sockets related handlers socketio.on_event('connect', sm.handle_connect) diff --git a/mslib/mscolab/utils.py b/mslib/mscolab/utils.py index bdbf3545b..6f5db86e9 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -82,6 +82,6 @@ def os_fs_create_dir(directory_path): def create_files(): - os_fs_create_dir(mscolab_settings.MSCOLAB_DATA_DIR) + os_fs_create_dir(mscolab_settings.OPERATIONS_DATA) os_fs_create_dir(mscolab_settings.UPLOAD_FOLDER) - os_fs_create_dir(mscolab_settings.MSCOLAB_SSO_DIR) + os_fs_create_dir(mscolab_settings.SSO_DIR) diff --git a/mslib/msidp/conf.py b/mslib/msidp/conf.py new file mode 100644 index 000000000..751781b44 --- /dev/null +++ b/mslib/msidp/conf.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msidp.conf + ~~~~~~~~~~~~~~~~ + + config for msidp. + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :copyright: Copyright 2023-2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import os +import logging + + +class default_msidp_settings: + # our default dir for mss content + BASE_DIR = os.path.join(os.path.expanduser("~"), 'mss') + + DATA_DIR = os.path.join(BASE_DIR, "colabdata") + + # dir where mscolab single sign-on process files are stored + SSO_DIR = os.path.join(DATA_DIR, 'datasso') + + +msidp_settings = default_msidp_settings() + +try: + import msidp_settings as user_settings + logging.info("Using user defined settings") + msidp_settings.__dict__.update(user_settings.__dict__) +except ImportError as ex: + logging.warning(u"Couldn't import msidp_settings (ImportError:'%s'), using dummy config.", ex) diff --git a/mslib/msidp/idp.py b/mslib/msidp/idp.py index a6f1f78de..521b36486 100644 --- a/mslib/msidp/idp.py +++ b/mslib/msidp/idp.py @@ -83,7 +83,7 @@ from mslib.msidp.idp_user import EXTRA from mslib.msidp.idp_user import USERS, PASSWD from mako.lookup import TemplateLookup -from mslib.mscolab.conf import mscolab_settings +from mslib.msidp.conf import msidp_settings logger = logging.getLogger("saml2.idp") logger.setLevel(logging.WARNING) @@ -91,7 +91,7 @@ DOCS_SERVER_PATH = os.path.dirname(os.path.abspath(msidp.__file__)) LOOKUP = TemplateLookup( directories=[os.path.join(DOCS_SERVER_PATH, "templates"), os.path.join(DOCS_SERVER_PATH, "htdocs")], - module_directory=os.path.join(mscolab_settings.DATA_DIR, 'msidp_modules'), + module_directory=os.path.join(msidp_settings.DATA_DIR, 'msidp_modules'), input_encoding="utf-8", output_encoding="utf-8", ) diff --git a/mslib/msidp/idp_conf.py b/mslib/msidp/idp_conf.py index 21d726a4e..a545713db 100644 --- a/mslib/msidp/idp_conf.py +++ b/mslib/msidp/idp_conf.py @@ -24,8 +24,7 @@ limitations under the License. """ -# Parts of the code -import logging + import os.path from saml2 import BINDING_HTTP_ARTIFACT @@ -36,28 +35,21 @@ from saml2.saml import NAME_FORMAT_URI from saml2.saml import NAMEID_FORMAT_PERSISTENT from saml2.saml import NAMEID_FORMAT_TRANSIENT +from saml2.sigver import get_xmlsec_binary + +from mslib.msidp.conf import msidp_settings -XMLSEC_PATH = os.path.join(os.environ["CONDA_PREFIX"], "bin", "xmlsec1") -if not os.path.exists(XMLSEC_PATH): - logging.warning("%s not found", XMLSEC_PATH) # CRTs and metadata files can be generated through the mscolab server. # if configured that way CRTs DIRs should be same in both IDP and mscolab server. -BASE_DIR = os.path.expanduser("~") -DATA_DIR = os.path.join(BASE_DIR, "colabdata") -MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') - -BASEDIR = os.path.abspath(os.path.dirname(__file__)) - - -def full_path(local_file): - """Return the full path by joining the BASEDIR and local_file.""" - return os.path.join(BASEDIR, local_file) +BASE_DIR = msidp_settings.BASE_DIR +DATA_DIR = msidp_settings.DATA_DIR +SSO_DIR = msidp_settings.SSO_DIR def sso_dir_path(local_file): - """Return the full path by joining the MSCOLAB_SSO_DIR and local_file.""" - return os.path.join(MSCOLAB_SSO_DIR, local_file) + """Return the full path by joining the SSO_DIR and local_file.""" + return os.path.join(SSO_DIR, local_file) HOST = 'localhost' @@ -71,8 +63,8 @@ def sso_dir_path(local_file): BASE = f"http://{HOST}:{PORT}" # HTTPS cert information -SERVER_CERT = f"{MSCOLAB_SSO_DIR}/crt_local_idp.crt" -SERVER_KEY = f"{MSCOLAB_SSO_DIR}/key_local_idp.key" +SERVER_CERT = f"{SSO_DIR}/crt_local_idp.crt" +SERVER_KEY = f"{SSO_DIR}/key_local_idp.key" CERT_CHAIN = "" SIGN_ALG = None DIGEST_ALG = None @@ -170,7 +162,7 @@ def sso_dir_path(local_file): ], # This database holds the map between a subject's local identifier and # the identifier returned to a SP - "xmlsec_binary": XMLSEC_PATH, + "xmlsec_binary": get_xmlsec_binary(), # "attribute_map_dir": "../attributemaps", "logging": { "version": 1, diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index f5e517ddd..4cdf5be64 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -481,7 +481,7 @@ class MSUIMscolab(QtCore.QObject): signal_permission_revoked = QtCore.pyqtSignal(int) signal_render_new_permission = QtCore.pyqtSignal(int, str) - def __init__(self, parent=None, data_dir=None): + def __init__(self, parent=None, local_operations_data=None): super().__init__(parent) self.ui = parent @@ -580,10 +580,10 @@ def __init__(self, parent=None, data_dir=None): self.lastChangeMessage = "" # set data dir, uri - if data_dir is None: + if local_operations_data is None: self.data_dir = config_loader(dataset="mss_dir") else: - self.data_dir = data_dir + self.data_dir = local_operations_data self.create_dir() def view_description(self): @@ -1416,7 +1416,7 @@ def handle_work_locally_toggle(self): self.local_ftml_file = fs.path.combine( self.data_dir, fs.path.join( - "local_mscolab_data", self.user["username"], + "local_colabdata", self.user["username"], self.active_operation_name, "mscolab_operation.ftml"), ) self.ui.workingStatusLabel.setText( @@ -1444,7 +1444,7 @@ def handle_work_locally_toggle(self): def create_local_operation_file(self): with open_fs(self.data_dir) as mss_dir: - rel_file_path = fs.path.join('local_mscolab_data', self.user['username'], + rel_file_path = fs.path.join('local_colabdata', self.user['username'], self.active_operation_name, 'mscolab_operation.ftml') if mss_dir.exists(rel_file_path) is True: return diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index a32f5cdf4..0d725a75b 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -431,7 +431,16 @@ class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): signal_permission_revoked = QtCore.pyqtSignal(int) signal_render_new_permission = QtCore.pyqtSignal(int, str) - def __init__(self, mscolab_data_dir=None, tutorial_mode=False, *args): + def __init__(self, local_operations_data=None, tutorial_mode=False, *args): + """ + This method initializes the main window of the application. + It sets up the user interface, icons, menu actions, and connects signals to slots. + + :param local_operations_data: Base path used by "work asynchronously" to store operations. + :param tutorial_mode: Whether to run the application in tutorial mode. Default is False. + :param args: Additional arguments to pass to the parent class. + + """ super().__init__(*args) self.tutorial_mode = tutorial_mode self.setupUi(self) @@ -507,7 +516,7 @@ def __init__(self, mscolab_data_dir=None, tutorial_mode=False, *args): self.statusBar.showMessage(self.status()) # Create MSColab instance to handle all MSColab functionalities - self.mscolab = mscolab.MSUIMscolab(parent=self, data_dir=mscolab_data_dir) + self.mscolab = mscolab.MSUIMscolab(parent=self, local_operations_data=local_operations_data) # Setting up MSColab Tab self.connectBtn.clicked.connect(self.mscolab.open_connect_window) diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index 35f0da5d2..10d3b8baa 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -64,7 +64,7 @@ def test_create_operation(self): # test for '/' in path assert self.fm.create_operation('test/path', 'sth', self.user) is False # check file existence - assert os.path.exists(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, 'test_path')) is True + assert os.path.exists(os.path.join(mscolab_settings.OPERATIONS_DATA, 'test_path')) is True # check creation in db p = Operation.query.filter_by(path="test_path").first() assert p is not None @@ -166,7 +166,7 @@ def test_modify_operation(self): assert self.fm.update_operation(op_id, 'path', 'dummy wrong', self.user) is False assert self.fm.update_operation(op_id, 'path', 'dummy/wrong', self.user) is False assert self.fm.update_operation(op_id, 'path', 'dummy', self.user) is True - assert os.path.exists(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, 'dummy')) + assert os.path.exists(os.path.join(mscolab_settings.OPERATIONS_DATA, 'dummy')) assert self.fm.update_operation(op_id, 'description', 'dummy', self.user) is True def test_delete_operation(self): diff --git a/tests/_test_mscolab/test_mscolab.py b/tests/_test_mscolab/test_mscolab.py index ca395d9f6..4bd93114c 100644 --- a/tests/_test_mscolab/test_mscolab.py +++ b/tests/_test_mscolab/test_mscolab.py @@ -73,20 +73,20 @@ def test_initial_state(self): def test_handle_db_reset(self): assert os.path.isdir(mscolab_settings.UPLOAD_FOLDER) - assert os.path.isdir(mscolab_settings.MSCOLAB_DATA_DIR) + assert os.path.isdir(mscolab_settings.OPERATIONS_DATA) all_operations = Operation.query.all() assert all_operations == [] operation_name = "Example" assert add_operation(operation_name, "Test Example") - assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) + assert os.path.isdir(os.path.join(mscolab_settings.OPERATIONS_DATA, operation_name)) operation = Operation.query.filter_by(path=operation_name).first() assert operation.description == "Test Example" all_operations = Operation.query.all() assert len(all_operations) == 1 handle_db_reset() # check operation dir name removed - assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) is False - assert os.path.isdir(mscolab_settings.MSCOLAB_DATA_DIR) + assert os.path.isdir(os.path.join(mscolab_settings.OPERATIONS_DATA, operation_name)) is False + assert os.path.isdir(mscolab_settings.OPERATIONS_DATA) assert os.path.isdir(mscolab_settings.UPLOAD_FOLDER) # query db for operation_name operation = Operation.query.filter_by(path=operation_name).first() diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 33960915f..b80582e5b 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -50,7 +50,7 @@ def setup(self, mscolab_app, mscolab_managers): def test_initialized_managers(self, mscolab_managers): sockio, cm, fm = mscolab_managers - assert self.app.config['MSCOLAB_DATA_DIR'] == mscolab_settings.MSCOLAB_DATA_DIR + assert self.app.config['OPERATIONS_DATA'] == mscolab_settings.OPERATIONS_DATA assert 'Create a Flask-SocketIO server.' in sockio.__doc__ assert 'Class with handler functions for chat related functionalities' in cm.__doc__ assert 'Class with handler functions for file related functionalities' in fm.__doc__ @@ -442,7 +442,7 @@ def test_import_permissions(self): import_operation, token = self._create_operation(test_client, self.userdata, path="import") user = get_user(self.userdata[0]) another = get_user(another_user[0]) - fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) + fm = FileManager(self.app.config["OPERATIONS_DATA"]) fm.add_bulk_permission(import_operation.id, user, [another.id], "viewer") current_operation, token = self._create_operation(test_client, self.userdata, path="current") response = test_client.post('/import_permissions', data={"token": token, diff --git a/tests/_test_mscolab/test_server_auth_required.py b/tests/_test_mscolab/test_server_auth_required.py index 14ab148e6..b9bbbc213 100644 --- a/tests/_test_mscolab/test_server_auth_required.py +++ b/tests/_test_mscolab/test_server_auth_required.py @@ -45,7 +45,7 @@ def setup(self, mscolab_app): def test_initialize_managers(self): app, sockio, cm, fm = initialize_managers(self.app) - assert app.config['MSCOLAB_DATA_DIR'] == mscolab_settings.MSCOLAB_DATA_DIR + assert app.config['OPERATIONS_DATA'] == mscolab_settings.OPERATIONS_DATA assert 'Create a Flask-SocketIO server.' in sockio.__doc__ assert 'Class with handler functions for chat related functionalities' in cm.__doc__ assert 'Class with handler functions for file related functionalities' in fm.__doc__ diff --git a/tests/_test_mscolab/test_utils.py b/tests/_test_mscolab/test_utils.py index 991b0ea60..2c8b1805e 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -83,7 +83,7 @@ def test_os_fs_create_dir(self): def test_create_file(self): create_files() # ToDo refactor to fs - assert os.path.exists(mscolab_settings.MSCOLAB_DATA_DIR) + assert os.path.exists(mscolab_settings.OPERATIONS_DATA) assert os.path.exists(mscolab_settings.UPLOAD_FOLDER) def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test"): diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 010aed788..affc96de2 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -36,8 +36,8 @@ from PIL import Image +from tests.constants import ROOT_DIR import mslib.utils.auth -from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Permission, User from mslib.msui.flighttrack import WaypointsTableModel from PyQt5 import QtCore, QtTest, QtWidgets @@ -59,7 +59,7 @@ def setup(self, qtbot, mscolab_server): assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - self.main_window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.main_window = msui.MSUIMainWindow(local_operations_data=ROOT_DIR) self.main_window.create_new_flight_track() self.main_window.show() self.window = mscolab.MSColab_ConnectDialog(parent=self.main_window, mscolab=self.main_window.mscolab) @@ -281,7 +281,7 @@ def setup(self, qtbot, mscolab_app, mscolab_server): assert add_user(self.userdata3[0], self.userdata3[1], self.userdata3[2]) assert add_user_to_operation(path=self.operation_name3, access_level="collaborator", emailid=self.userdata3[0]) - self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window = msui.MSUIMainWindow(local_operations_data=ROOT_DIR) self.window.create_new_flight_track() self.window.show() @@ -554,7 +554,7 @@ def assert_logout_text(): # ToDo verify all operations disabled again without a visual check @mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", - return_value=(fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, 'test_export.ftml'), + return_value=(fs.path.join(ROOT_DIR, 'test_export.ftml'), "Flight track (*.ftml)")) def test_handle_export(self, mockbox, qtbot): self._connect_to_mscolab(qtbot) @@ -671,7 +671,7 @@ def test_handle_delete_operation(self, mocktext, qtbot): operation_name = "flight7" self._create_operation(qtbot, operation_name, "Description flight7") # check for operation dir is created on server - assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) + assert os.path.isdir(os.path.join(ROOT_DIR, 'colabTestData', 'filedata', operation_name)) self._activate_operation_at_index(0) op_id = self.window.mscolab.get_recent_op_id() assert op_id is not None @@ -684,7 +684,7 @@ def test_handle_delete_operation(self, mocktext, qtbot): op_id = self.window.mscolab.get_recent_op_id() assert op_id is None # check operation dir name removed - assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) is False + assert os.path.isdir(os.path.join(ROOT_DIR, operation_name)) is False @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_handle_leave_operation(self, mockmessage, qtbot): diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index 515868e3e..961c65879 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -27,8 +27,8 @@ import mock import pytest -from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets +from tests.constants import ROOT_DIR from mslib.msui import mscolab from mslib.msui import msui from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation @@ -58,7 +58,7 @@ def setup(self, qtbot, mscolab_server): assert add_operation("tokyo", "test tokyo") assert add_user_to_operation(path="tokyo", emailid=self.userdata[0], access_level="creator") - self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window = msui.MSUIMainWindow(local_operations_data=ROOT_DIR) self.window.create_new_flight_track() self.window.show() # connect and login to mscolab diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 1d0fb9340..ebc95fdd3 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -29,8 +29,8 @@ import pytest import mslib.utils.auth +from tests.constants import ROOT_DIR from mslib.msui import flighttrack as ft -from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest from tests.utils import (mscolab_register_and_login, mscolab_create_operation, mscolab_delete_all_operations, mscolab_delete_user) @@ -44,7 +44,7 @@ class Test_Mscolab_Merge_Waypoints: def setup(self, qtbot, mscolab_app, mscolab_server): self.app = mscolab_app self.url = mscolab_server - self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window = msui.MSUIMainWindow(local_operations_data=ROOT_DIR) self.window.create_new_flight_track() self.emailid = 'merge@alpha.org' yield @@ -53,7 +53,7 @@ def setup(self, qtbot, mscolab_app, mscolab_server): 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') - with fs.open_fs(mscolab_settings.MSCOLAB_DATA_DIR) as mss_dir: + with fs.open_fs(ROOT_DIR) as mss_dir: if mss_dir.exists('local_mscolab_data'): mss_dir.removetree('local_mscolab_data') assert mss_dir.exists('local_mscolab_data') is False diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 76e5ae334..76136a7a9 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -27,7 +27,7 @@ import pytest import datetime -from mslib.mscolab.conf import mscolab_settings +from tests.constants import ROOT_DIR from mslib.mscolab.models import Message, MessageType from PyQt5 import QtCore, QtTest, QtWidgets from mslib.msui import mscolab @@ -56,7 +56,7 @@ def setup(self, qtbot, mscolab_app, mscolab_server): assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window = msui.MSUIMainWindow(local_operations_data=ROOT_DIR) self.window.create_new_flight_track() self.window.show() # connect and login to mscolab diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 72f218c58..6e672e053 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -27,8 +27,8 @@ import pytest import mock -from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets +from tests.constants import ROOT_DIR from mslib.msui import mscolab from mslib.msui import msui from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation @@ -45,7 +45,7 @@ def setup(self, qtbot, mscolab_server): assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window = msui.MSUIMainWindow(local_operations_data=ROOT_DIR) self.window.create_new_flight_track() self.window.show() # connect and login to mscolab diff --git a/tests/fixtures.py b/tests/fixtures.py index 0a4e03f9c..5859c631b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -92,7 +92,7 @@ def mscolab_session_app(): """ _app = APP _app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - _app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR + _app.config['OPERATIONS_DATA'] = mscolab_settings.OPERATIONS_DATA _app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER return _app From 52c7a045eae9bfd12be1587a2947fa60a3abc4b1 Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Thu, 22 Aug 2024 13:36:20 +0200 Subject: [PATCH 21/29] rework active+archiving --- docs/mscolab.rst | 2 ++ mslib/mscolab/file_manager.py | 18 ++++--------- mslib/mscolab/seed.py | 8 +----- mslib/mscolab/server.py | 13 +++------- mslib/msui/mscolab.py | 29 +++++++++------------ tests/_test_mscolab/test_server.py | 41 ++++++++++++++++++++++++++++++ tests/_test_msui/test_mscolab.py | 19 +++++++++++--- 7 files changed, 80 insertions(+), 50 deletions(-) diff --git a/docs/mscolab.rst b/docs/mscolab.rst index 80459d6ad..5e44bc19d 100644 --- a/docs/mscolab.rst +++ b/docs/mscolab.rst @@ -190,6 +190,8 @@ Operation based features - All the operations the user has created or has been added to can be found in Mscolab's main window along with the user's access level. - To start working on an operation the user needs to select it which enables all the operation related buttons. - Any change made to an operation by a user will be shared with everyone in real-time unless `Work Locally` is turned on.(More on this later) + - Operations can be manually archived to remove them from normal view without having to delete them. + They can be found again in the Archive view, where they can be unarchived for further use. Operation Permissions ..................... diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index a80333f89..2b06fff4c 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -135,18 +135,7 @@ def list_operations(self, user, skip_archived=False): permissions = Permission.query.filter_by(u_id=user.id).all() for permission in permissions: operation = Operation.query.filter_by(id=permission.op_id).first() - if operation.last_used is not None and ( - datetime.datetime.now(tz=datetime.timezone.utc) - operation.last_used - ).days > mscolab_settings.ARCHIVE_THRESHOLD: - # outdated OPs get archived - self.update_operation(permission.op_id, "active", False, user) - # new query to get up-to-date data - if skip_archived: - operation = Operation.query.filter_by(id=permission.op_id, active=skip_archived).first() - else: - operation = Operation.query.filter_by(id=permission.op_id).first() - - if operation is not None: + if operation is not None and (operation.active or not skip_archived): operations.append({ "op_id": permission.op_id, "access_level": permission.access_level, @@ -367,6 +356,9 @@ def update_operation(self, op_id, attribute, value, user): # the user changing the {category}{mscolab_settings.GROUP_POSTFIX} needs to have rights in the op # then members of this op gets added to all others of same category self.import_permissions(op_id, ops.id, user.id) + elif attribute == "active": + if isinstance(value, str): + value = value.upper() == "TRUE" setattr(operation, attribute, value) db.session.commit() return True @@ -376,7 +368,7 @@ def delete_operation(self, op_id, user): op_id: operation id user: logged in user """ - if self.auth_type(user.id, op_id) != "creator": + if not self.is_creator(user.id, op_id): return False Permission.query.filter_by(op_id=op_id).delete() Change.query.filter_by(op_id=op_id).delete() diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index 8a740a7dd..dc9c17439 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -27,8 +27,6 @@ import logging import fs import git -import datetime -import dateutil.relativedelta from sqlalchemy.exc import IntegrityError from mslib.mscolab.conf import mscolab_settings @@ -215,13 +213,9 @@ def archive_operation(path=None, emailid=None): perm = Permission.query.filter_by(u_id=user.id, op_id=operation.id).first() if perm is None: return False - elif perm.access_level != "creator": + elif perm.access_level not in ["admin", "creator"]: return False operation.active = False - operation.last_used = ( - datetime.datetime.now(tz=datetime.timezone.utc) - - dateutil.relativedelta.relativedelta(days=mscolab_settings.ARCHIVE_THRESHOLD) - ) db.session.commit() diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index fa057d95b..19013ea89 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -658,12 +658,12 @@ def update_operation(): attribute = request.form['attribute'] value = request.form['value'] user = g.user - r = str(fm.update_operation(int(op_id), attribute, value, user)) - if r == "True": + r = fm.update_operation(int(op_id), attribute, value, user) + if r is True: token = request.args.get('token', request.form.get('token', False)) json_config = {"token": token} sockio.sm.update_operation_list(json_config) - return r + return str(r) @APP.route('/operation_details', methods=["GET"]) @@ -690,13 +690,6 @@ def set_last_used(): fm.update_operation(int(op_id), 'last_used', datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=days_ago), user) - if days_ago > mscolab_settings.ARCHIVE_THRESHOLD: - fm.update_operation(int(op_id), "active", False, user) - else: - fm.update_operation(int(op_id), "active", True, user) - token = request.args.get('token', request.form.get('token', False)) - json_config = {"token": token} - sockio.sm.update_operation_list(json_config) return jsonify({"success": True}), 200 diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 4cdf5be64..c84f45ce3 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -87,7 +87,7 @@ def __init__(self, parent=None, mscolab=None): def select_archived_operation(self, item): logging.debug('select_inactive_operation') - if item.access_level == "creator": + if item.access_level in ["creator", "admin"]: self.archived_op_id = item.op_id self.pbUnarchiveOperation.setEnabled(True) else: @@ -101,20 +101,19 @@ def unarchive_operation(self): data = { "token": self.mscolab.token, "op_id": self.archived_op_id, + "attribute": "active", + "value": "True" } - url = urljoin(self.mscolab.mscolab_server_url, 'set_last_used') + url = urljoin(self.mscolab.mscolab_server_url, 'update_operation') try: res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.debug(e) show_popup(self.parent, "Error", "Some error occurred! Could not unarchive operation.") + self.logout() else: - if res.text != "False": - res = res.json() - if res["success"]: - self.mscolab.reload_operations() - else: - show_popup(self.parent, "Error", "Some error occurred! Could not activate operation") + if res.text == "True": + self.mscolab.reload_operations() else: show_popup(self.parent, "Error", "Session expired, new login required") self.mscolab.logout() @@ -1818,9 +1817,10 @@ def archive_operation(self): "token": self.token, "op_id": self.active_op_id, # when a user archives an operation we set the max “natural” integer in days - "days": 99999, # larger values run into a lot of issues + "attribute": "active", + "value": "False" } - url = urljoin(self.mscolab_server_url, 'set_last_used') + url = urljoin(self.mscolab_server_url, 'update_operation') try: res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: @@ -1833,6 +1833,7 @@ def archive_operation(self): logging.debug("activate local") self.ui.listFlightTracks.setCurrentRow(0) self.ui.activate_selected_flight_track() + self.active_op_id = None else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1866,12 +1867,6 @@ def set_active_op_id(self, item): self.signal_unarchive_operation.emit(self.active_op_id) - self.inactive_op_id = None - font = QtGui.QFont() - for i in range(self.ui.listOperationsMSC.count()): - self.ui.listOperationsMSC.item(i).setFont(font) - font.setBold(False) - # Set active operation description self.set_operation_desc_label(self.active_operation_description) # set active flightpath here @@ -1970,6 +1965,7 @@ def show_operation_options(self): self.ui.actionChangeDescription.setEnabled(True) self.ui.filterCategoryCb.setEnabled(True) self.ui.actionRenameOperation.setEnabled(True) + self.ui.actionArchiveOperation.setEnabled(True) else: if self.admin_window is not None: self.admin_window.close() @@ -1977,7 +1973,6 @@ def show_operation_options(self): if self.access_level in ["creator"]: self.ui.actionDeleteOperation.setEnabled(True) self.ui.actionLeaveOperation.setEnabled(False) - self.ui.actionArchiveOperation.setEnabled(True) self.ui.menuImportFlightTrack.setEnabled(True) diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index b80582e5b..94b04159c 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -24,6 +24,7 @@ See the License for the specific language governing permissions and limitations under the License. """ +import datetime import pytest import json import io @@ -402,11 +403,51 @@ def test_set_last_used(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) + old = operation.last_used response = test_client.post('/set_last_used', data={"token": token, "op_id": operation.id}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) assert data["success"] is True + new = operation.last_used + assert old != new + response = test_client.post('/set_last_used', data={"token": token, + "op_id": operation.id, + "days": 10}) + assert response.status_code == 200 + data = json.loads(response.data.decode('utf-8')) + assert data["success"] is True + new = operation.last_used + assert datetime.timedelta(days=11) > old - new > datetime.timedelta(days=9) + + def test_set_active(self): + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) + with self.app.test_client() as test_client: + operation, token = self._create_operation(test_client, self.userdata) + assert operation.active is True + response = test_client.post('/update_operation', data={ + "token": token, + "op_id": operation.id, "attribute": "active", "value": "False"}) + assert response.status_code == 200 + data = response.data.decode('utf-8') + assert data == "True" + assert operation.active is False + + response = test_client.post('/update_operation', data={ + "token": token, + "op_id": operation.id, "attribute": "active", "value": "True"}) + assert response.status_code == 200 + data = response.data.decode('utf-8') + assert data == "True" + assert operation.active is True + + response = test_client.post('/update_operation', data={ + "token": token, + "op_id": operation.id, "attribute": "active", "value": False}) + assert response.status_code == 200 + data = response.data.decode('utf-8') + assert data == "True" + assert operation.active is False def test_get_users_without_permission(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index affc96de2..95188f4c8 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -660,8 +660,7 @@ def test_add_operation(self, qtbot): self._activate_operation_at_index(1) assert self.window.mscolab.active_operation_name == "reproduce-test" - @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)) - def test_handle_delete_operation(self, mocktext, qtbot): + def test_handle_delete_operation(self, qtbot): self._connect_to_mscolab(qtbot) modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) self._create_user(qtbot, "berta", "berta@something.org", "something") @@ -676,11 +675,13 @@ def test_handle_delete_operation(self, mocktext, qtbot): op_id = self.window.mscolab.get_recent_op_id() assert op_id is not None assert self.window.listOperationsMSC.model().rowCount() == 1 - with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m, \ + mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)): self.window.actionDeleteOperation.trigger() qtbot.wait_until( lambda: m.assert_called_once_with(self.window, "Success", 'Operation "flight7" was deleted!') ) + assert self.window.mscolab.active_op_id is None op_id = self.window.mscolab.get_recent_op_id() assert op_id is None # check operation dir name removed @@ -744,6 +745,18 @@ def test_update_description(self, mocktext, qtbot): assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_description == "new_description" + def test_archive_operation(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") + assert self.window.listOperationsMSC.model().rowCount() == 1 + self._activate_operation_at_index(0) + assert self.window.mscolab.active_op_id is not None + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window.actionArchiveOperation.trigger() + assert self.window.mscolab.active_op_id is None + @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_category", True)) def test_update_category(self, mocktext, qtbot): self._connect_to_mscolab(qtbot) From c485163aa9c2f30e254dbe27e6c1cad7f0cc3e70 Mon Sep 17 00:00:00 2001 From: Joern Ungermann Date: Fri, 30 Aug 2024 17:15:09 +0200 Subject: [PATCH 22/29] Use decorator to make msui/mscolab code more readable --- mslib/msui/mscolab.py | 1283 ++++++++++++++++++++--------------------- tests/fixtures.py | 2 +- 2 files changed, 617 insertions(+), 668 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index c84f45ce3..713acb1dd 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -37,6 +37,7 @@ import logging import types import fs +import functools import requests import re import webbrowser @@ -60,7 +61,7 @@ from PyQt5.QtGui import QPixmap from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring -from mslib.utils.verify_user_token import verify_user_token +from mslib.utils.verify_user_token import verify_user_token as _verify_user_token from mslib.utils.verify_waypoint_data import verify_waypoint_data from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog @@ -73,6 +74,18 @@ from mslib.utils.config import config_loader, modify_config_file +def verify_user_token(func): + @functools.wraps(func) + def wrapper(*args, **vargs): + self = args[0] + if _verify_user_token(self.mscolab_server_url, self.token): + return func(*args, **vargs) + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() + return wrapper + + class MSColab_OperationArchiveBrowser(QtWidgets.QDialog, ui_opar.Ui_OperationArchiveBrowser): def __init__(self, parent=None, mscolab=None): super().__init__(parent) @@ -95,8 +108,8 @@ def select_archived_operation(self, item): self.pbUnarchiveOperation.setEnabled(False) def unarchive_operation(self): - logging.debug('unarchive_operation') - if verify_user_token(self.mscolab.mscolab_server_url, self.mscolab.token): + if _verify_user_token(self.mscolab.mscolab_server_url, self.mscolab.token): + logging.debug('unarchive_operation') # set last used date for operation data = { "token": self.mscolab.token, @@ -118,7 +131,7 @@ def unarchive_operation(self): show_popup(self.parent, "Error", "Session expired, new login required") self.mscolab.logout() else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + show_popup(self.parent, "Error", "Your Connection is expired. New Login required!") self.mscolab.logout() @@ -898,87 +911,81 @@ def upload_image(self): QMessageBox.critical(self.prof_diag, "Error", f'Cannot identify image file. Please check the file format. Error: {e}') - def delete_account(self): + @verify_user_token + def delete_account(self, _=None): # ToDo rename to delete_own_account - if verify_user_token(self.mscolab_server_url, self.token): - w = QtWidgets.QWidget() - qm = QtWidgets.QMessageBox - reply = qm.question(w, self.tr('Continue?'), - self.tr("You're about to delete your account. You cannot undo this operation!"), - qm.Yes, qm.No) - if reply == QtWidgets.QMessageBox.No: - return - data = { - "token": self.token - } + w = QtWidgets.QWidget() + qm = QtWidgets.QMessageBox + reply = qm.question(w, self.tr('Continue?'), + self.tr("You're about to delete your account. You cannot undo this operation!"), + qm.Yes, qm.No) + if reply == QtWidgets.QMessageBox.No: + return + data = { + "token": self.token + } - try: - url = urljoin(self.mscolab_server_url, "delete_own_account") - r = requests.post(url, data=data, - timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() - else: - if r.status_code == 200 and json.loads(r.text)["success"] is True: - self.logout() - else: - show_popup(self, "Error", "Your Connection is expired. New Login required!") + try: + url = urljoin(self.mscolab_server_url, "delete_own_account") + r = requests.post(url, data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") self.logout() + else: + if r.status_code == 200 and json.loads(r.text)["success"] is True: + self.logout() - def add_operation_handler(self): - if verify_user_token(self.mscolab_server_url, self.token): - def check_and_enable_operation_accept(): - if (self.add_proj_dialog.path.text() != "" and - self.add_proj_dialog.description.toPlainText() != "" and - self.add_proj_dialog.category.text() != ""): - self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True) + @verify_user_token + def add_operation_handler(self, _=None): + def check_and_enable_operation_accept(): + if (self.add_proj_dialog.path.text() != "" and + self.add_proj_dialog.description.toPlainText() != "" and + self.add_proj_dialog.category.text() != ""): + self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True) + else: + self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) + + def browse(): + import_type = self.add_proj_dialog.cb_ImportType.currentText() + file_type = ["Flight track (*.ftml)"] + if import_type != 'FTML': + file_type = [f"Flight track (*.{self.ui.import_plugins[import_type][1]})"] + + file_path = get_open_filename( + self.ui, "Open Flighttrack file", "", ';;'.join(file_type)) + if file_path is not None: + file_name = fs.path.basename(file_path) + if file_path.endswith('ftml'): + with open_fs(fs.path.dirname(file_path)) as file_dir: + file_content = file_dir.readtext(file_name) else: - self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) - - def browse(): - import_type = self.add_proj_dialog.cb_ImportType.currentText() - file_type = ["Flight track (*.ftml)"] - if import_type != 'FTML': - file_type = [f"Flight track (*.{self.ui.import_plugins[import_type][1]})"] - - file_path = get_open_filename( - self.ui, "Open Flighttrack file", "", ';;'.join(file_type)) - if file_path is not None: - file_name = fs.path.basename(file_path) - if file_path.endswith('ftml'): - with open_fs(fs.path.dirname(file_path)) as file_dir: - file_content = file_dir.readtext(file_name) - else: - function = self.ui.import_plugins[import_type][0] - ft_name, waypoints = function(file_path) - model = ft.WaypointsTableModel(waypoints=waypoints) - xml_doc = model.get_xml_doc() - file_content = xml_doc.toprettyxml(indent=" ", newl="\n") - self.add_proj_dialog.f_content = file_content - self.add_proj_dialog.selectedFile.setText(file_name) - - self.proj_diag = QtWidgets.QDialog() - self.add_proj_dialog = add_operation_ui.Ui_addOperationDialog() - self.add_proj_dialog.setupUi(self.proj_diag) - self.add_proj_dialog.f_content = None - self.add_proj_dialog.buttonBox.accepted.connect(self.add_operation) - self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) - self.add_proj_dialog.path.textChanged.connect(check_and_enable_operation_accept) - self.add_proj_dialog.description.textChanged.connect(check_and_enable_operation_accept) - self.add_proj_dialog.category.textChanged.connect(check_and_enable_operation_accept) - self.add_proj_dialog.browse.clicked.connect(browse) - self.add_proj_dialog.category.setText(config_loader(dataset="MSCOLAB_category")) - - # sets types from defined import menu - import_menu = self.ui.menuImportFlightTrack - for im_action in import_menu.actions(): - self.add_proj_dialog.cb_ImportType.addItem(im_action.text()) - self.proj_diag.show() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + function = self.ui.import_plugins[import_type][0] + ft_name, waypoints = function(file_path) + model = ft.WaypointsTableModel(waypoints=waypoints) + xml_doc = model.get_xml_doc() + file_content = xml_doc.toprettyxml(indent=" ", newl="\n") + self.add_proj_dialog.f_content = file_content + self.add_proj_dialog.selectedFile.setText(file_name) + + self.proj_diag = QtWidgets.QDialog() + self.add_proj_dialog = add_operation_ui.Ui_addOperationDialog() + self.add_proj_dialog.setupUi(self.proj_diag) + self.add_proj_dialog.f_content = None + self.add_proj_dialog.buttonBox.accepted.connect(self.add_operation) + self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) + self.add_proj_dialog.path.textChanged.connect(check_and_enable_operation_accept) + self.add_proj_dialog.description.textChanged.connect(check_and_enable_operation_accept) + self.add_proj_dialog.category.textChanged.connect(check_and_enable_operation_accept) + self.add_proj_dialog.browse.clicked.connect(browse) + self.add_proj_dialog.category.setText(config_loader(dataset="MSCOLAB_category")) + + # sets types from defined import menu + import_menu = self.ui.menuImportFlightTrack + for im_action in import_menu.actions(): + self.add_proj_dialog.cb_ImportType.addItem(im_action.text()) + self.proj_diag.show() def add_operation(self): logging.debug("add_operation") @@ -1035,33 +1042,30 @@ def add_operation(self): self.error_dialog = QtWidgets.QErrorMessage() self.error_dialog.showMessage('The path already exists') + @verify_user_token def get_recent_op_id(self): + """ + get most recent operation's op_id + """ logging.debug('get_recent_op_id') - if verify_user_token(self.mscolab_server_url, self.token): - """ - get most recent operation's op_id - """ - skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") - data = { - "token": self.token, - "skip_archived": skip_archived - } - url = urljoin(self.mscolab_server_url, "operations") - r = requests.get(url, data=data) - if r.text != "False": - _json = json.loads(r.text) - operations = _json["operations"] - op_id = None - if operations: - op_id = operations[-1]["op_id"] - logging.debug("recent op_id %s", op_id) - return op_id - else: - show_popup(self.ui, "Error", "Session expired, new login required") - self.signal_logout_mscolab() + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") + data = { + "token": self.token, + "skip_archived": skip_archived + } + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data) + if r.text != "False": + _json = json.loads(r.text) + operations = _json["operations"] + op_id = None + if operations: + op_id = operations[-1]["op_id"] + logging.debug("recent op_id %s", op_id) + return op_id else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + show_popup(self.ui, "Error", "Session expired, new login required") + self.signal_logout_mscolab() def operation_options_handler(self): if self.sender() == self.ui.actionChat: @@ -1075,86 +1079,77 @@ def operation_options_handler(self): elif self.sender() == self.ui.actionLeaveOperation: self.handle_leave_operation() + @verify_user_token def open_chat_window(self): - if verify_user_token(self.mscolab_server_url, self.token): - if self.active_op_id is None: - return + if self.active_op_id is None: + return - if self.chat_window is not None: - self.chat_window.activateWindow() - return + if self.chat_window is not None: + self.chat_window.activateWindow() + return - self.chat_window = mc.MSColabChatWindow( - self.token, - self.active_op_id, - self.user, - self.active_operation_name, - self.access_level, - self.conn, - mscolab_server_url=self.mscolab_server_url, - ) - self.chat_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.chat_window.viewCloses.connect(self.close_chat_window) - self.chat_window.reloadWindows.connect(self.reload_windows_slot) - self.chat_window.show() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + self.chat_window = mc.MSColabChatWindow( + self.token, + self.active_op_id, + self.user, + self.active_operation_name, + self.access_level, + self.conn, + mscolab_server_url=self.mscolab_server_url, + ) + self.chat_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.chat_window.viewCloses.connect(self.close_chat_window) + self.chat_window.reloadWindows.connect(self.reload_windows_slot) + self.chat_window.show() def close_chat_window(self): self.chat_window.close() self.chat_window = None + @verify_user_token def open_admin_window(self): - if verify_user_token(self.mscolab_server_url, self.token): - if self.active_op_id is None: - return + if self.active_op_id is None: + return - if self.admin_window is not None: - self.admin_window.activateWindow() - return + if self.admin_window is not None: + self.admin_window.activateWindow() + return - operations = [operation for operation in self.operations if operation["active"] is True] + operations = [operation for operation in self.operations if operation["active"] is True] - self.admin_window = maw.MSColabAdminWindow( - self.token, - self.active_op_id, - self.user, - self.active_operation_name, - operations, - self.conn, - mscolab_server_url=self.mscolab_server_url, - ) - self.admin_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.admin_window.viewCloses.connect(self.close_admin_window) - self.admin_window.show() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + self.admin_window = maw.MSColabAdminWindow( + self.token, + self.active_op_id, + self.user, + self.active_operation_name, + operations, + self.conn, + mscolab_server_url=self.mscolab_server_url, + ) + self.admin_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.admin_window.viewCloses.connect(self.close_admin_window) + self.admin_window.show() def close_admin_window(self): self.admin_window.close() self.admin_window = None + @verify_user_token def open_version_history_window(self): - if verify_user_token(self.mscolab_server_url, self.token): - if self.active_op_id is None: - return + if self.active_op_id is None: + return - if self.version_window is not None: - self.version_window.activateWindow() - return + if self.version_window is not None: + self.version_window.activateWindow() + return - self.version_window = mvh.MSColabVersionHistory(self.token, self.active_op_id, self.user, - self.active_operation_name, self.conn, - mscolab_server_url=self.mscolab_server_url) - self.version_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.version_window.viewCloses.connect(self.close_version_history_window) - self.version_window.reloadWindows.connect(self.reload_windows_slot) - self.version_window.show() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + self.version_window = mvh.MSColabVersionHistory(self.token, self.active_op_id, self.user, + self.active_operation_name, self.conn, + mscolab_server_url=self.mscolab_server_url) + self.version_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.version_window.viewCloses.connect(self.close_version_history_window) + self.version_window.reloadWindows.connect(self.reload_windows_slot) + self.version_window.show() def close_version_history_window(self): self.version_window.close() @@ -1185,42 +1180,39 @@ def close_external_windows(self): self.version_window.close() self.version_window = None + @verify_user_token def handle_delete_operation(self): logging.debug("handle_delete_operation") - if verify_user_token(self.mscolab_server_url, self.token): - entered_operation_name, ok = QtWidgets.QInputDialog.getText( - self.ui, - self.ui.tr("Delete Operation"), - self.ui.tr( - f"You're about to delete the operation - '{self.active_operation_name}'. " - f"Enter the operation name to confirm: " - ), - ) - if ok: - if entered_operation_name == self.active_operation_name: - data = { - "token": self.token, - "op_id": self.active_op_id - } - url = urljoin(self.mscolab_server_url, 'delete_operation') - try: - res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.debug(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() - else: - res.raise_for_status() - self.reload_operations() - self.signal_operation_removed.emit(self.active_op_id) - logging.debug("activate local") - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() + entered_operation_name, ok = QtWidgets.QInputDialog.getText( + self.ui, + self.ui.tr("Delete Operation"), + self.ui.tr( + f"You're about to delete the operation - '{self.active_operation_name}'. " + f"Enter the operation name to confirm: " + ), + ) + if ok: + if entered_operation_name == self.active_operation_name: + data = { + "token": self.token, + "op_id": self.active_op_id + } + url = urljoin(self.mscolab_server_url, 'delete_operation') + try: + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.debug(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") + self.logout() else: - show_popup(self.ui, "Error", "Entered operation name did not match!") - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + res.raise_for_status() + self.reload_operations() + self.signal_operation_removed.emit(self.active_op_id) + logging.debug("activate local") + self.ui.listFlightTracks.setCurrentRow(0) + self.ui.activate_selected_flight_track() + else: + show_popup(self.ui, "Error", "Entered operation name did not match!") def handle_leave_operation(self): logging.debug("handle_leave_operation") @@ -1230,7 +1222,7 @@ def handle_leave_operation(self): self.tr("Do you want to leave this operation?"), qm.Yes, qm.No) if reply == QtWidgets.QMessageBox.Yes: - if verify_user_token(self.mscolab_server_url, self.token): + if _verify_user_token(self.mscolab_server_url, self.token): data = { "token": self.token, "op_id": self.active_op_id, @@ -1269,177 +1261,165 @@ def set_operation_desc_label(self, op_desc): "Description is too long to show here, for long descriptions go " "to operations menu.") - def change_category_handler(self): + @verify_user_token + def change_category_handler(self, _=None): # only after login - if verify_user_token(self.mscolab_server_url, self.token): - entered_operation_category, ok = QtWidgets.QInputDialog.getText( - self.ui, - self.ui.tr(f"{self.active_operation_name} - Change Category"), - self.ui.tr( - "You're about to change the operation category\n" - "Enter new operation category: " - ), - text=self.active_operation_category - ) - if ok: - data = { - "token": self.token, - "op_id": self.active_op_id, - "attribute": 'category', - "value": entered_operation_category - } - url = urljoin(self.mscolab_server_url, 'update_operation') - try: - r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() + entered_operation_category, ok = QtWidgets.QInputDialog.getText( + self.ui, + self.ui.tr(f"{self.active_operation_name} - Change Category"), + self.ui.tr( + "You're about to change the operation category\n" + "Enter new operation category: " + ), + text=self.active_operation_category + ) + if ok: + data = { + "token": self.token, + "op_id": self.active_op_id, + "attribute": 'category', + "value": entered_operation_category + } + url = urljoin(self.mscolab_server_url, 'update_operation') + try: + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") + self.logout() + else: + if r.text == "True": + self.active_operation_category = entered_operation_category + self.reload_operation_list() + QtWidgets.QMessageBox.information( + self.ui, + "Update successful", + "Category is updated successfully.", + ) else: - if r.text == "True": - self.active_operation_category = entered_operation_category - self.reload_operation_list() - QtWidgets.QMessageBox.information( - self.ui, - "Update successful", - "Category is updated successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() - def change_description_handler(self): + @verify_user_token + def change_description_handler(self, _=None): # 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} - Change Description"), - self.ui.tr( - "You're about to change the operation description\n" - "Enter new operation description: " - ), - text=self.active_operation_description - ) - if ok: - data = { - "token": self.token, - "op_id": self.active_op_id, - "attribute": 'description', - "value": entered_operation_desc - } + entered_operation_desc, ok = QtWidgets.QInputDialog.getText( + self.ui, + self.ui.tr(f"{self.active_operation_name} - Change Description"), + self.ui.tr( + "You're about to change the operation description\n" + "Enter new operation description: " + ), + text=self.active_operation_description + ) + if ok: + data = { + "token": self.token, + "op_id": self.active_op_id, + "attribute": 'description', + "value": entered_operation_desc + } - url = urljoin(self.mscolab_server_url, 'update_operation') - try: - r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() + url = urljoin(self.mscolab_server_url, 'update_operation') + try: + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") + self.logout() + else: + if r.text == "True": + # Update active operation description label + self.set_operation_desc_label(entered_operation_desc) + + self.reload_operation_list() + QtWidgets.QMessageBox.information( + self.ui, + "Update successful", + "Description is updated successfully.", + ) else: - if r.text == "True": - # Update active operation description label - self.set_operation_desc_label(entered_operation_desc) - - self.reload_operation_list() - QtWidgets.QMessageBox.information( - self.ui, - "Update successful", - "Description is updated successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() - def rename_operation_handler(self): + @verify_user_token + def rename_operation_handler(self, _=None): # 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: " - ), - text=f"{self.active_operation_name}", - ) - if ok: - data = { - "token": self.token, - "op_id": self.active_op_id, - "attribute": 'path', - "value": entered_operation_name - } - url = urljoin(self.mscolab_server_url, 'update_operation') - try: - r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() + 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: " + ), + text=f"{self.active_operation_name}", + ) + if ok: + data = { + "token": self.token, + "op_id": self.active_op_id, + "attribute": 'path', + "value": entered_operation_name + } + url = urljoin(self.mscolab_server_url, 'update_operation') + try: + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.error(e) + show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") + self.logout() + else: + if r.text == "True": + # Update active operation name + self.active_operation_name = entered_operation_name + + # Update active operation description + self.set_operation_desc_label(self.active_operation_description) + self.reload_operation_list() + self.reload_windows_slot() + # Update other user's operation list + self.conn.signal_operation_list_updated.connect(self.reload_operation_list) + + QtWidgets.QMessageBox.information( + self.ui, + "Rename successful", + "Operation is renamed successfully.", + ) else: - if r.text == "True": - # Update active operation name - self.active_operation_name = entered_operation_name - - # Update active operation description - self.set_operation_desc_label(self.active_operation_description) - self.reload_operation_list() - self.reload_windows_slot() - # Update other user's operation list - self.conn.signal_operation_list_updated.connect(self.reload_operation_list) - - QtWidgets.QMessageBox.information( - self.ui, - "Rename successful", - "Operation is renamed successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + 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(): - if self.version_window is not None: - self.version_window.close() - self.create_local_operation_file() - self.local_ftml_file = fs.path.combine( - self.data_dir, - fs.path.join( - "local_colabdata", self.user["username"], - self.active_operation_name, "mscolab_operation.ftml"), - ) - self.ui.workingStatusLabel.setText( - self.ui.tr( - "Working Asynchronously.\nYour changes are only available to you. " - "Use the 'Server Options' drop-down menu below to Save to or Fetch from the server.") - ) - self.ui.serverOptionsCb.show() - self.reload_local_wp() - else: - self.local_ftml_file = None - self.ui.workingStatusLabel.setText( - self.ui.tr( - "Working Online.\nAll your changes will be shared with everyone. " - "You can work on the operation asynchronously by checking the 'Work Asynchronously' box.") - ) - self.ui.serverOptionsCb.hide() - self.waypoints_model = None - self.load_wps_from_server() - self.show_operation_options() - self.reload_view_windows() + @verify_user_token + def handle_work_locally_toggle(self, _=None): + if self.ui.workLocallyCheckbox.isChecked(): + if self.version_window is not None: + self.version_window.close() + self.create_local_operation_file() + self.local_ftml_file = fs.path.combine( + self.data_dir, + fs.path.join( + "local_colabdata", self.user["username"], + self.active_operation_name, "mscolab_operation.ftml"), + ) + self.ui.workingStatusLabel.setText( + self.ui.tr( + "Working Asynchronously.\nYour changes are only available to you. " + "Use the 'Server Options' drop-down menu below to Save to or Fetch from the server.") + ) + self.ui.serverOptionsCb.show() + self.reload_local_wp() else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + self.local_ftml_file = None + self.ui.workingStatusLabel.setText( + self.ui.tr( + "Working Online.\nAll your changes will be shared with everyone. " + "You can work on the operation asynchronously by checking the 'Work Asynchronously' box.") + ) + self.ui.serverOptionsCb.hide() + self.waypoints_model = None + self.load_wps_from_server() + self.show_operation_options() + self.reload_view_windows() def create_local_operation_file(self): with open_fs(self.data_dir) as mss_dir: @@ -1485,73 +1465,64 @@ def server_options_handler(self, index): elif selected_option == "Save To Server": self.save_wp_mscolab() + @verify_user_token def fetch_wp_mscolab(self): - if verify_user_token(self.mscolab_server_url, self.token): - server_xml = self.request_wps_from_server() - server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) - self.merge_dialog = MscolabMergeWaypointsDialog(self.waypoints_model, server_waypoints_model, True, self.ui) - self.merge_dialog.saveBtn.setDisabled(True) - if self.merge_dialog.exec_(): - xml_content = self.merge_dialog.get_values() - if xml_content is not None: - self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) - self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) - self.waypoints_model.save_to_ftml(self.local_ftml_file) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) - self.reload_view_windows() - show_popup(self.ui, "Success", "New Waypoints Fetched To Local File!", icon=1) - self.merge_dialog.close() - self.merge_dialog = None - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + server_xml = self.request_wps_from_server() + server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) + self.merge_dialog = MscolabMergeWaypointsDialog(self.waypoints_model, server_waypoints_model, True, self.ui) + self.merge_dialog.saveBtn.setDisabled(True) + if self.merge_dialog.exec_(): + xml_content = self.merge_dialog.get_values() + if xml_content is not None: + self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) + self.waypoints_model.save_to_ftml(self.local_ftml_file) + self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.reload_view_windows() + show_popup(self.ui, "Success", "New Waypoints Fetched To Local File!", icon=1) + self.merge_dialog.close() + self.merge_dialog = None + @verify_user_token def save_wp_mscolab(self, comment=None): - if verify_user_token(self.mscolab_server_url, self.token): - server_xml = self.request_wps_from_server() - server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) - self.merge_dialog = MscolabMergeWaypointsDialog(self.waypoints_model, - server_waypoints_model, parent=self.ui) - self.merge_dialog.saveBtn.setDisabled(True) - if self.merge_dialog.exec_(): - xml_content = self.merge_dialog.get_values() - if xml_content is not None: - self.conn.save_file(self.token, self.active_op_id, xml_content, comment=comment) - self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) - self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) - self.waypoints_model.save_to_ftml(self.local_ftml_file) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) - self.reload_view_windows() - show_popup(self.ui, "Success", "New Waypoints Saved To Server!", icon=1) - self.merge_dialog.close() - self.merge_dialog = None - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + server_xml = self.request_wps_from_server() + server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) + self.merge_dialog = MscolabMergeWaypointsDialog(self.waypoints_model, + server_waypoints_model, parent=self.ui) + self.merge_dialog.saveBtn.setDisabled(True) + if self.merge_dialog.exec_(): + xml_content = self.merge_dialog.get_values() + if xml_content is not None: + self.conn.save_file(self.token, self.active_op_id, xml_content, comment=comment) + self.waypoints_model = ft.WaypointsTableModel(xml_content=xml_content) + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) + self.waypoints_model.save_to_ftml(self.local_ftml_file) + self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.reload_view_windows() + show_popup(self.ui, "Success", "New Waypoints Saved To Server!", icon=1) + self.merge_dialog.close() + self.merge_dialog = None + @verify_user_token def get_recent_operation(self): """ get most recent operation """ logging.debug('get_recent_operation') - if verify_user_token(self.mscolab_server_url, self.token): - data = { - "token": self.token - } - url = urljoin(self.mscolab_server_url, "operations") - r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text != "False": - _json = json.loads(r.text) - operations = _json["operations"] - recent_operation = None - if operations: - recent_operation = operations[-1] - return recent_operation - else: - show_popup(self.ui, "Error", "Session expired, new login required") + data = { + "token": self.token + } + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + if r.text != "False": + _json = json.loads(r.text) + operations = _json["operations"] + recent_operation = None + if operations: + recent_operation = operations[-1] + return recent_operation else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + show_popup(self.ui, "Error", "Session expired, new login required") @QtCore.pyqtSlot() def reload_operation_list(self): @@ -1705,97 +1676,94 @@ def update_active_user_label(self, op_id, count): def handle_change_message(self, message): self.lastChangeMessage = message + @verify_user_token def show_categories_to_ui(self, ops=None): """ adds the list of operation categories to the UI """ logging.debug('show_categories_to_ui') - if verify_user_token(self.mscolab_server_url, self.token) or ops: - r = None - if ops is not None: - r = ops - else: - data = { - "token": self.token - } - url = urljoin(self.mscolab_server_url, "operations") - try: - r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.MissingSchema: - show_popup(self.ui, "Error", "Session expired, new login required") - if r is not None and r.text != "False": - _json = json.loads(r.text) - operations = _json["operations"] - self.ui.filterCategoryCb.currentIndexChanged.disconnect(self.operation_category_handler) - self.ui.filterCategoryCb.clear() - categories = set(["*ANY*"]) - for operation in operations: - categories.add(operation["category"]) - categories.remove("*ANY*") - categories = ["*ANY*"] + sorted(categories) - category = config_loader(dataset="MSCOLAB_category") - self.ui.filterCategoryCb.addItems(categories) - if category in categories: - index = categories.index(category) - self.ui.filterCategoryCb.setCurrentIndex(index) - self.operation_category_handler(update_operations=False) - self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) - - def add_operations_to_ui(self): - logging.debug('add_operations_to_ui') r = None - if verify_user_token(self.mscolab_server_url, self.token): - skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") + if ops is not None: + r = ops + else: data = { - "token": self.token, - "skip_archived": skip_archived + "token": self.token } url = urljoin(self.mscolab_server_url, "operations") - r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text != "False": - _json = json.loads(r.text) - self.operations = _json["operations"] - logging.debug("adding operations to ui") - operations = sorted(self.operations, key=lambda k: k["path"].lower()) - self.ui.listOperationsMSC.clear() - self.operation_archive_browser.listArchivedOperations.clear() - new_operation = None - active_operation = None - for operation in operations: - operation_desc = f'{operation["path"]} - {operation["access_level"]}' - widgetItem = QtWidgets.QListWidgetItem(operation_desc) - widgetItem.op_id = operation["op_id"] - widgetItem.operation_category = operation["category"] - widgetItem.operation_path = operation["path"] - widgetItem.access_level = operation["access_level"] - widgetItem.active_operation_description = operation["description"] - try: - # compatibility to 7.x - # a newer server can distinguish older operations and move those into inactive state - widgetItem.active = operation["active"] - except KeyError: - widgetItem.active = True - if widgetItem.active: - self.ui.listOperationsMSC.addItem(widgetItem) - if widgetItem.op_id == self.active_op_id: - active_operation = widgetItem - if widgetItem.op_id == self.new_op_id: - new_operation = widgetItem - else: - self.operation_archive_browser.listArchivedOperations.addItem(widgetItem) - if new_operation is not None: - logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) - self.ui.listOperationsMSC.itemActivated.emit(new_operation) - elif active_operation is not None: - logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) - self.ui.listOperationsMSC.itemActivated.emit(active_operation) - self.ui.listOperationsMSC.itemActivated.connect(self.set_active_op_id) - self.new_op_id = None - else: + try: + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.MissingSchema: show_popup(self.ui, "Error", "Session expired, new login required") - self.logout() + if r is not None and r.text != "False": + _json = json.loads(r.text) + operations = _json["operations"] + self.ui.filterCategoryCb.currentIndexChanged.disconnect(self.operation_category_handler) + self.ui.filterCategoryCb.clear() + categories = set(["*ANY*"]) + for operation in operations: + categories.add(operation["category"]) + categories.remove("*ANY*") + categories = ["*ANY*"] + sorted(categories) + category = config_loader(dataset="MSCOLAB_category") + self.ui.filterCategoryCb.addItems(categories) + if category in categories: + index = categories.index(category) + self.ui.filterCategoryCb.setCurrentIndex(index) + self.operation_category_handler(update_operations=False) + self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) + + @verify_user_token + def add_operations_to_ui(self): + logging.debug('add_operations_to_ui') + r = None + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") + data = { + "token": self.token, + "skip_archived": skip_archived + } + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + if r.text != "False": + _json = json.loads(r.text) + self.operations = _json["operations"] + logging.debug("adding operations to ui") + operations = sorted(self.operations, key=lambda k: k["path"].lower()) + self.ui.listOperationsMSC.clear() + self.operation_archive_browser.listArchivedOperations.clear() + new_operation = None + active_operation = None + for operation in operations: + operation_desc = f'{operation["path"]} - {operation["access_level"]}' + widgetItem = QtWidgets.QListWidgetItem(operation_desc) + widgetItem.op_id = operation["op_id"] + widgetItem.operation_category = operation["category"] + widgetItem.operation_path = operation["path"] + widgetItem.access_level = operation["access_level"] + widgetItem.active_operation_description = operation["description"] + try: + # compatibility to 7.x + # a newer server can distinguish older operations and move those into inactive state + widgetItem.active = operation["active"] + except KeyError: + widgetItem.active = True + if widgetItem.active: + self.ui.listOperationsMSC.addItem(widgetItem) + if widgetItem.op_id == self.active_op_id: + active_operation = widgetItem + if widgetItem.op_id == self.new_op_id: + new_operation = widgetItem + else: + self.operation_archive_browser.listArchivedOperations.addItem(widgetItem) + if new_operation is not None: + logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) + self.ui.listOperationsMSC.itemActivated.emit(new_operation) + elif active_operation is not None: + logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) + self.ui.listOperationsMSC.itemActivated.emit(active_operation) + self.ui.listOperationsMSC.itemActivated.connect(self.set_active_op_id) + self.new_op_id = None else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + show_popup(self.ui, "Error", "Session expired, new login required") self.logout() return r @@ -1804,115 +1772,108 @@ def show_operation_options_in_inactivated_state(self, access_level): if access_level in ["creator", "admin"]: self.ui.actionUnarchiveOperation.setEnabled(True) - def archive_operation(self): + @verify_user_token + def archive_operation(self, _): logging.debug("handle_archive_operation") - if verify_user_token(self.mscolab_server_url, self.token): - ret = QtWidgets.QMessageBox.warning( - self.ui, self.tr("Mission Support System"), - self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) - if ret == QtWidgets.QMessageBox.Yes: - data = { - "token": self.token, - "op_id": self.active_op_id, - # when a user archives an operation we set the max “natural” integer in days - "attribute": "active", - "value": "False" - } - url = urljoin(self.mscolab_server_url, 'update_operation') - try: - res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.debug(e) - show_popup(self.ui, "Error", "Some error occurred! Could not archive operation.") - else: - res.raise_for_status() - self.reload_operations() - self.signal_operation_removed.emit(self.active_op_id) - logging.debug("activate local") - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() - self.active_op_id = None - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + ret = QtWidgets.QMessageBox.warning( + self.ui, self.tr("Mission Support System"), + self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + if ret == QtWidgets.QMessageBox.Yes: + data = { + "token": self.token, + "op_id": self.active_op_id, + # when a user archives an operation we set the max “natural” integer in days + "attribute": "active", + "value": "False" + } + url = urljoin(self.mscolab_server_url, 'update_operation') + try: + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as e: + logging.debug(e) + show_popup(self.ui, "Error", "Some error occurred! Could not archive operation.") + else: + res.raise_for_status() + self.reload_operations() + self.signal_operation_removed.emit(self.active_op_id) + logging.debug("activate local") + self.ui.listFlightTracks.setCurrentRow(0) + self.ui.activate_selected_flight_track() + self.active_op_id = None + @verify_user_token def set_active_op_id(self, item): logging.debug('set_active_op_id %s %s %s', item, item.op_id, self.active_op_id) - if verify_user_token(self.mscolab_server_url, self.token): - if not self.ui.local_active: - if item.op_id == self.active_op_id: - font = QtGui.QFont() - font.setBold(True) - item.setFont(font) - return + if not self.ui.local_active: + if item.op_id == self.active_op_id: + font = QtGui.QFont() + font.setBold(True) + item.setFont(font) + return - # close all hanging window - self.close_external_windows() - self.hide_operation_options() + # close all hanging window + self.close_external_windows() + self.hide_operation_options() - # Turn off work locally toggle - self.ui.workLocallyCheckbox.blockSignals(True) - self.ui.workLocallyCheckbox.setChecked(False) - self.ui.workLocallyCheckbox.blockSignals(False) - - # set active_op_id here - self.active_op_id = item.op_id - self.access_level = item.access_level - self.active_operation_name = item.operation_path - self.active_operation_description = item.active_operation_description - self.active_operation_category = item.operation_category - self.waypoints_model = None + # Turn off work locally toggle + self.ui.workLocallyCheckbox.blockSignals(True) + self.ui.workLocallyCheckbox.setChecked(False) + self.ui.workLocallyCheckbox.blockSignals(False) - self.signal_unarchive_operation.emit(self.active_op_id) + # set active_op_id here + self.active_op_id = item.op_id + self.access_level = item.access_level + self.active_operation_name = item.operation_path + self.active_operation_description = item.active_operation_description + self.active_operation_category = item.operation_category + self.waypoints_model = None - # Set active operation description - self.set_operation_desc_label(self.active_operation_description) - # set active flightpath here - self.load_wps_from_server() - # display working status - self.ui.workingStatusLabel.setText( - self.ui.tr( - "Working Online.\nAll your changes will be shared with everyone. " - "You can work on the operation asynchronously by checking the 'Work Asynchronously' box.") - ) - # self.ui.workingStatusLabel.show() - # enable access level specific widgets - self.show_operation_options() + self.signal_unarchive_operation.emit(self.active_op_id) - # change font style for selected - font = QtGui.QFont() - for i in range(self.ui.listOperationsMSC.count()): - self.ui.listOperationsMSC.item(i).setFont(font) - font.setBold(True) - item.setFont(font) + # Set active operation description + self.set_operation_desc_label(self.active_operation_description) + # set active flightpath here + self.load_wps_from_server() + # display working status + self.ui.workingStatusLabel.setText( + self.ui.tr( + "Working Online.\nAll your changes will be shared with everyone. " + "You can work on the operation asynchronously by checking the 'Work Asynchronously' box.") + ) + # self.ui.workingStatusLabel.show() + # enable access level specific widgets + self.show_operation_options() - # set new waypoints model to open views - for window in self.ui.get_active_views(): - window.setFlightTrackModel(self.waypoints_model) - if self.access_level == "viewer": - window.disable_navbar_action_buttons() - else: - window.enable_navbar_action_buttons() + # change font style for selected + font = QtGui.QFont() + for i in range(self.ui.listOperationsMSC.count()): + self.ui.listOperationsMSC.item(i).setFont(font) + font.setBold(True) + item.setFont(font) + + # set new waypoints model to open views + for window in self.ui.get_active_views(): + window.setFlightTrackModel(self.waypoints_model) + if self.access_level == "viewer": + window.disable_navbar_action_buttons() + else: + window.enable_navbar_action_buttons() - self.ui.switch_to_mscolab() + self.ui.switch_to_mscolab() - # Enable the active user count label - self.ui.userCountLabel.show() + # Enable the active user count label + self.ui.userCountLabel.show() - # call select operation method from connection manager to emit signal - self.conn.select_operation(item.op_id) - else: - if self.mscolab_server_url is not None: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + # call select operation method from connection manager to emit signal + self.conn.select_operation(item.op_id) def switch_to_local(self): logging.debug('switch_to_local') self.ui.local_active = True if self.active_op_id is not None: - if verify_user_token(self.mscolab_server_url, self.token): + if _verify_user_token(self.mscolab_server_url, self.token): # change font style for selected font = QtGui.QFont() @@ -1993,22 +1954,19 @@ def hide_operation_options(self): # change working status label self.ui.workingStatusLabel.setText(self.ui.tr("\n\nNo Operation Selected")) + @verify_user_token def request_wps_from_server(self): - if verify_user_token(self.mscolab_server_url, self.token): - data = { - "token": self.token, - "op_id": self.active_op_id - } - url = urljoin(self.mscolab_server_url, "get_operation_by_id") - r = requests.get(url, data=data) - if r.text != "False": - xml_content = json.loads(r.text)["content"] - return xml_content - else: - show_popup(self.ui, "Error", "Session expired, new login required") + data = { + "token": self.token, + "op_id": self.active_op_id + } + url = urljoin(self.mscolab_server_url, "get_operation_by_id") + r = requests.get(url, data=data) + if r.text != "False": + xml_content = json.loads(r.text)["content"] + return xml_content else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + show_popup(self.ui, "Error", "Session expired, new login required") def load_wps_from_server(self): if self.ui.workLocallyCheckbox.isChecked(): @@ -2034,20 +1992,17 @@ def reload_wps_from_server(self): self.load_wps_from_server() self.reload_view_windows() - def handle_waypoints_changed(self): + @verify_user_token + def handle_waypoints_changed(self, _1=None, _2=None, _3=None): logging.debug("handle_waypoints_changed") - if verify_user_token(self.mscolab_server_url, self.token): - if self.ui.workLocallyCheckbox.isChecked(): - self.waypoints_model.save_to_ftml(self.local_ftml_file) - else: - xml_content = self.waypoints_model.get_xml_content() - self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None, - messageText=self.lastChangeMessage) - # Reset the last change message to make sure that it is used only once - self.lastChangeMessage = "" + if self.ui.workLocallyCheckbox.isChecked(): + self.waypoints_model.save_to_ftml(self.local_ftml_file) else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + xml_content = self.waypoints_model.get_xml_content() + self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None, + messageText=self.lastChangeMessage) + # Reset the last change message to make sure that it is used only once + self.lastChangeMessage = "" def reload_view_windows(self): logging.debug("reload_view_windows") @@ -2065,72 +2020,66 @@ def reload_view_windows(self): except AttributeError as err: logging.error("%s" % err) + @verify_user_token def handle_import_msc(self, file_path, extension, function, pickertype): logging.debug("handle_import_msc") - if verify_user_token(self.mscolab_server_url, self.token): - if self.active_op_id is None: - return - if file_path is None: - return - dir_path, file_name = fs.path.split(file_path) - file_name = fs.path.basename(file_path) - if function is None: - with open_fs(dir_path) as file_dir: - xml_content = file_dir.readtext(file_name) - if not verify_waypoint_data(xml_content): - show_popup(self.ui, "Import Failed", f"The file - {file_name}, does not contain valid XML") - return - try: - model = ft.WaypointsTableModel(xml_content=xml_content) - except SyntaxError: + if self.active_op_id is None: + return + if file_path is None: + return + dir_path, file_name = fs.path.split(file_path) + file_name = fs.path.basename(file_path) + if function is None: + with open_fs(dir_path) as file_dir: + xml_content = file_dir.readtext(file_name) + if not verify_waypoint_data(xml_content): show_popup(self.ui, "Import Failed", f"The file - {file_name}, does not contain valid XML") return - else: - # _function = self.ui.import_plugins[file_ext[1:]] - _, new_waypoints = function(file_path) - model = ft.WaypointsTableModel(waypoints=new_waypoints) - xml_doc = self.waypoints_model.get_xml_doc() - xml_content = xml_doc.toprettyxml(indent=" ", newl="\n") - if not verify_waypoint_data(xml_content): - show_popup(self.ui, "Import Failed", f"The file - {file_name}, was not imported!", 0) + try: + model = ft.WaypointsTableModel(xml_content=xml_content) + except SyntaxError: + show_popup(self.ui, "Import Failed", f"The file - {file_name}, does not contain valid XML") return - self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) - self.waypoints_model = model - self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) - self.handle_waypoints_changed() - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) - self.reload_view_windows() - show_popup(self.ui, "Import Success", f"The file - {file_name}, was imported successfully!", 1) else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + # _function = self.ui.import_plugins[file_ext[1:]] + _, new_waypoints = function(file_path) + model = ft.WaypointsTableModel(waypoints=new_waypoints) + xml_doc = self.waypoints_model.get_xml_doc() + xml_content = xml_doc.toprettyxml(indent=" ", newl="\n") + if not verify_waypoint_data(xml_content): + show_popup(self.ui, "Import Failed", f"The file - {file_name}, was not imported!", 0) + return + self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) + self.waypoints_model = model + self.waypoints_model.changeMessageSignal.connect(self.handle_change_message) + self.handle_waypoints_changed() + self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.reload_view_windows() + show_popup(self.ui, "Import Success", f"The file - {file_name}, was imported successfully!", 1) + @verify_user_token def handle_export_msc(self, extension, function, pickertype): logging.debug("handle_export_msc") - if verify_user_token(self.mscolab_server_url, self.token): - if self.active_op_id is None: - return + if self.active_op_id is None: + return - # Setting default filename path for filedialogue - default_filename = f'{self.active_operation_name}.{extension}' - file_name = get_save_filename( - self.ui, "Export From Server", - default_filename, f"Flight track (*.{extension})", - pickertype=pickertype) - if file_name is None: - return - if function is None: - xml_doc = self.waypoints_model.get_xml_doc() - dir_path, file_name = fs.path.split(file_name) - with open_fs(dir_path).open(file_name, 'w') as file: - xml_doc.writexml(file, indent=" ", addindent=" ", newl="\n", encoding="utf-8") - else: - name = fs.path.basename(file_name) - function(file_name, name, self.waypoints_model.waypoints) - show_popup(self.ui, "Export Success", f"The file - {file_name}, was exported successfully!", 1) + # Setting default filename path for filedialogue + default_filename = f'{self.active_operation_name}.{extension}' + file_name = get_save_filename( + self.ui, "Export From Server", + default_filename, f"Flight track (*.{extension})", + pickertype=pickertype) + if file_name is None: + return + if function is None: + xml_doc = self.waypoints_model.get_xml_doc() + dir_path, file_name = fs.path.split(file_name) + with open_fs(dir_path).open(file_name, 'w') as file: + xml_doc.writexml(file, indent=" ", addindent=" ", newl="\n", encoding="utf-8") else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + name = fs.path.basename(file_name) + function(file_name, name, self.waypoints_model.waypoints) + show_popup(self.ui, "Export Success", f"The file - {file_name}, was exported successfully!", 1) def listFlighttrack_itemDoubleClicked(self): logging.debug("listFlighttrack_itemDoubleClicked") diff --git a/tests/fixtures.py b/tests/fixtures.py index 5859c631b..9077612ac 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -124,7 +124,7 @@ def mscolab_session_server(mscolab_session_app, mscolab_session_managers): with _running_eventlet_server(mscolab_session_app) as url: # Wait until the Flask-SocketIO server is ready for connections sio = socketio.Client() - sio.connect(url, retry=True) + sio.connect(url)#, retry=True) sio.disconnect() del sio yield url From c013f01804a9102a4ed928415c77a3f496064160 Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Wed, 11 Sep 2024 02:44:55 +0530 Subject: [PATCH 23/29] fix:remove dead msui webbrowser (#2519) --- mslib/msui/msui_web_browser.py | 105 -------------------------------- mslib/msui/qt5/ui_webbrowser.py | 40 ------------ mslib/msui/ui/ui_webbrowser.ui | 50 --------------- 3 files changed, 195 deletions(-) delete mode 100644 mslib/msui/msui_web_browser.py delete mode 100644 mslib/msui/qt5/ui_webbrowser.py delete mode 100644 mslib/msui/ui/ui_webbrowser.ui diff --git a/mslib/msui/msui_web_browser.py b/mslib/msui/msui_web_browser.py deleted file mode 100644 index 6347ad155..000000000 --- a/mslib/msui/msui_web_browser.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - mslib.msui.msui_web_browser.py - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - MSUIWebBrowser can be used for localhost usage and testing purposes. - - This file is part of MSS. - - :copyright: Copyright 2023 Nilupul Manodya - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" - -import os -import sys - -from PyQt5.QtCore import QUrl, QTimer -from PyQt5.QtWidgets import QMainWindow, QPushButton, QToolBar, QApplication -from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile - -from mslib.msui.constants import MSUI_CONFIG_PATH - - -class MSUIWebBrowser(QMainWindow): - def __init__(self, url: str): - super().__init__() - - self.web_view = QWebEngineView(self) - self.setCentralWidget(self.web_view) - - self._url = url - self.profile = QWebEngineProfile().defaultProfile() - self.profile.setPersistentCookiesPolicy(QWebEngineProfile.ForcePersistentCookies) - self.browser_storage_folder = os.path.join(MSUI_CONFIG_PATH, 'webbrowser', '.cookies') - self.profile.setPersistentStoragePath(self.browser_storage_folder) - - self.back_button = QPushButton("← Back", self) - self.forward_button = QPushButton("→ Forward", self) - self.refresh_button = QPushButton("🔄 Refresh", self) - - self.back_button.clicked.connect(self.web_view.back) - self.forward_button.clicked.connect(self.web_view.forward) - self.refresh_button.clicked.connect(self.web_view.reload) - - toolbar = QToolBar() - toolbar.addWidget(self.back_button) - toolbar.addWidget(self.forward_button) - toolbar.addWidget(self.refresh_button) - self.addToolBar(toolbar) - - self.web_view.load(QUrl(self._url)) - self.setWindowTitle("MSS Web Browser") - self.resize(800, 600) - self.show() - - def closeEvent(self, event): - """ - Delete all cookies when closing the web browser - """ - self.profile.cookieStore().deleteAllCookies() - - -if __name__ == "__main__": - ''' - This function will be moved to handle accordingly the test cases. - The 'connection' variable determines when the web browser should be - closed, typically after the user logged in and establishes a connection - ''' - - CONNECTION = False - - def close_qtwebengine(): - """ - Close the main window - """ - main.close() - - def check_connection(): - """ - Schedule the close_qtwebengine function to be called asynchronously - """ - if CONNECTION: - QTimer.singleShot(0, close_qtwebengine) - - # app = QApplication(sys.argv) - app = QApplication(['', '--no-sandbox']) - WEB_URL = "https://www.google.com/" - main = MSUIWebBrowser(WEB_URL) - - QTimer.singleShot(0, check_connection) - - sys.exit(app.exec_()) diff --git a/mslib/msui/qt5/ui_webbrowser.py b/mslib/msui/qt5/ui_webbrowser.py deleted file mode 100644 index cb51c9c1c..000000000 --- a/mslib/msui/qt5/ui_webbrowser.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'ui/ui_webbrowser.ui' -# -# Created by: PyQt5 UI code generator 5.12.3 -# -# WARNING! All changes made in this file will be lost! - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_WebBrowser(object): - def setupUi(self, WebBrowser): - WebBrowser.setObjectName("WebBrowser") - WebBrowser.resize(800, 600) - self.centralwidget = QtWidgets.QWidget(WebBrowser) - self.centralwidget.setObjectName("centralwidget") - self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) - self.verticalLayout.setObjectName("verticalLayout") - self.webEngineView = QtWebEngineWidgets.QWebEngineView(self.centralwidget) - self.webEngineView.setUrl(QtCore.QUrl("about:blank")) - self.webEngineView.setObjectName("webEngineView") - self.verticalLayout.addWidget(self.webEngineView) - WebBrowser.setCentralWidget(self.centralwidget) - self.menubar = QtWidgets.QMenuBar(WebBrowser) - self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 20)) - self.menubar.setObjectName("menubar") - WebBrowser.setMenuBar(self.menubar) - self.statusbar = QtWidgets.QStatusBar(WebBrowser) - self.statusbar.setObjectName("statusbar") - WebBrowser.setStatusBar(self.statusbar) - - self.retranslateUi(WebBrowser) - QtCore.QMetaObject.connectSlotsByName(WebBrowser) - - def retranslateUi(self, WebBrowser): - _translate = QtCore.QCoreApplication.translate - WebBrowser.setWindowTitle(_translate("WebBrowser", "MainWindow")) -from PyQt5 import QtWebEngineWidgets diff --git a/mslib/msui/ui/ui_webbrowser.ui b/mslib/msui/ui/ui_webbrowser.ui deleted file mode 100644 index 08db45046..000000000 --- a/mslib/msui/ui/ui_webbrowser.ui +++ /dev/null @@ -1,50 +0,0 @@ - - - WebBrowser - - - - 0 - 0 - 800 - 600 - - - - MainWindow - - - - - - - - about:blank - - - - - - - - - - 0 - 0 - 800 - 20 - - - - - - - - QWebEngineView - QWidget -
QtWebEngineWidgets/QWebEngineView
-
-
- - -
From 8e2952647a72d4747f755b605ad7d9c496d05850 Mon Sep 17 00:00:00 2001 From: Nilupul Manodya Date: Thu, 12 Sep 2024 20:04:04 +0530 Subject: [PATCH 24/29] fix: remove unused code idp_uswsgi.py (#2523) --- mslib/msidp/idp_uwsgi.py | 1106 -------------------------------------- 1 file changed, 1106 deletions(-) delete mode 100644 mslib/msidp/idp_uwsgi.py diff --git a/mslib/msidp/idp_uwsgi.py b/mslib/msidp/idp_uwsgi.py deleted file mode 100644 index 8a6097f96..000000000 --- a/mslib/msidp/idp_uwsgi.py +++ /dev/null @@ -1,1106 +0,0 @@ -# pylint: skip-file -# -*- coding: utf-8 -*- -""" - mslib.msidp.idp_uwsgi.py - ~~~~~~~~~~~~~~~~~~~~~~~~ - - WSGI application for IDP - - This file is part of MSS. - - :copyright: Copyright 2023 Nilupul Manodya - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" -# Additional Info: -# This file is imported from -# https://github.com/IdentityPython/pysaml2/blob/master/example/idp2/idp_uwsgi.py -# and customized as MSS requirements. Pylint has been disabled for this imported file. - -# Parts of the code - -import argparse -import base64 -from hashlib import sha1 -import importlib -import logging -import os -import re -import time -import socket - -from Cookie import SimpleCookie -from urlparse import parse_qs -from saml2 import ( - BINDING_HTTP_ARTIFACT, - BINDING_HTTP_POST, - BINDING_HTTP_REDIRECT, - BINDING_PAOS, - BINDING_SOAP, - BINDING_URI, - server, - time_util -) -from saml2.authn import is_equal -from saml2.authn_context import PASSWORD, UNSPECIFIED, AuthnBroker, authn_context_class_ref -from saml2.httputil import ( - BadRequest, - NotFound, - Redirect, - Response, - ServiceError, - Unauthorized, - get_post, - geturl -) -from saml2.ident import Unknown -from saml2.metadata import create_metadata_string -from saml2.profile import ecp -from saml2.s_utils import PolicyError, UnknownPrincipal, exception_trace, UnsupportedBinding, rndstr -from saml2.sigver import encrypt_cert_from_item, verify_redirect_signature - -from mslib.msidp.idp_user import EXTRA, USERS, PASSWD -from mako.lookup import TemplateLookup - - -logger = logging.getLogger("saml2.idp") - - -class Cache: - """ - A cache class for mapping users to UIDs and vice versa. - """ - def __init__(self): - self.user2uid = {} - self.uid2user = {} - - -def _expiration(timeout, tformat="%a, %d-%b-%Y %H:%M:%S GMT"): - """ - :param timeout: - :param tformat: - :return: - """ - if timeout == "now": - return time_util.instant(tformat) - elif timeout == "dawn": - return time.strftime(tformat, time.gmtime(0)) - else: - # validity time should match lifetime of assertions - return time_util.in_a_while(minutes=timeout, format=tformat) - - -def get_eptid(idp, req_info, session): - """ - Get the EPTID (Entity-Participant Target ID) based on the provided parameters. - """ - return idp.eptid.get(idp.config.entityid, req_info.sender(), - session["permanent_id"], session["authn_auth"]) - - -def dict2list_of_tuples(dictionary): - """ - Convert a dictionary to a list of tuples. - """ - return [(k, v) for k, v in dictionary.items()] - - -class Service: - """ - Service class for handling SAML operations - """ - def __init__(self, environ, start_response, user=None): - self.environ = environ - logger.debug("ENVIRON: %s", environ) - self.start_response = start_response - self.user = user - - def unpack_redirect(self): - """ - Unpacks and parses a HTTP-redirect request - """ - if "QUERY_STRING" in self.environ: - _qs = self.environ["QUERY_STRING"] - return {k: v[0] for k, v in parse_qs(_qs).items()} - return None - - def unpack_post(self): - """ - Unpacks and parses a HTTP-POST request. - """ - _dict = parse_qs(get_post(self.environ)) - logger.debug("unpack_post:: %s", _dict) - try: - return {k: v[0] for k, v in _dict.items()} - except Exception: - return None - - def unpack_soap(self): - """ - Unpacks and parses a SOAP request. - """ - try: - query = get_post(self.environ) - return {"SAMLRequest": query, "RelayState": ""} - except Exception: - return None - - def unpack_either(self): - """ - Unpacks and retrieves data from either a GET or POST request. - """ - if self.environ["REQUEST_METHOD"] == "GET": - _dict = self.unpack_redirect() - elif self.environ["REQUEST_METHOD"] == "POST": - _dict = self.unpack_post() - else: - _dict = None - logger.debug("_dict: %s", _dict) - return _dict - - def operation(self, saml_msg, binding): - """ - Performs the SAML operation based on the provided SAML message and binding. - """ - logger.debug("_operation: %s", saml_msg) - if not saml_msg or "SAMLRequest" not in saml_msg: - resp = BadRequest("Error parsing request or no request") - return resp(self.environ, self.start_response) - else: - try: - _encrypt_cert = encrypt_cert_from_item(saml_msg["req_info"].message) - return self.do(saml_msg["SAMLRequest"], binding, - saml_msg["RelayState"], encrypt_cert=_encrypt_cert) - except KeyError: - # Can live with no relay state - return self.do(saml_msg["SAMLRequest"], binding) - - def artifact_operation(self, saml_msg): - """ - Handles artifact-based operations. - """ - if not saml_msg: - resp = BadRequest("Missing query") - return resp(self.environ, self.start_response) - else: - # exchange artifact for request - request = IDP.artifact2message(saml_msg["SAMLart"], "spsso") - try: - return self.do(request, BINDING_HTTP_ARTIFACT, saml_msg["RelayState"]) - except KeyError: - return self.do(request, BINDING_HTTP_ARTIFACT) - - def response(self, binding, http_args): - """ - Generates the response based on the specified binding and HTTP arguments. - """ - if binding == BINDING_HTTP_ARTIFACT: - resp = Redirect() - else: - resp = Response(http_args["data"], headers=http_args["headers"]) - return resp(self.environ, self.start_response) - - def do(self, query, binding, relay_state="", encrypt_cert=None): - """ - Performs the SAML operation based on the provided query - """ - pass - - def redirect(self): - """Expects a HTTP-redirect request""" - - _dict = self.unpack_redirect() - return self.operation(_dict, BINDING_HTTP_REDIRECT) - - def post(self): - """Expects a HTTP-POST request""" - - _dict = self.unpack_post() - return self.operation(_dict, BINDING_HTTP_POST) - - def artifact(self): - """ - Handles the artifact operation, which can be either through HTTP_Redirect or HTTP_POST. - """ - # Can be either by HTTP_Redirect or HTTP_POST - _dict = self.unpack_either() - return self.artifact_operation(_dict) - - def soap(self): - """ - Single log out using HTTP_SOAP binding - """ - logger.debug("- SOAP -") - _dict = self.unpack_soap() - logger.debug("_dict: %s", _dict) - return self.operation(_dict, BINDING_SOAP) - - def uri(self): - """ - Handles the URI operation. - """ - _dict = self.unpack_either() - return self.operation(_dict, BINDING_SOAP) - - def not_authn(self, key, requested_authn_context): - """ - Handles the case when the user is not authenticated. - """ - ruri = geturl(self.environ, query=False) - return do_authentication( - self.environ, self.start_response, authn_context=requested_authn_context, - key=key, redirect_uri=ruri - ) - - -# ----------------------------------------------------------------------------- - -REPOZE_ID_EQUIVALENT = "uid" -FORM_SPEC = """
- - -
""" - -# ----------------------------------------------------------------------------- -# === Single log in ==== -# ----------------------------------------------------------------------------- - - -class AuthenticationNeeded(Exception): - """ - Exception raised when authentication is required. - """ - def __init__(self, authn_context=None, *args, **kwargs): - Exception.__init__(*args, **kwargs) - self.authn_context = authn_context - - -class SSO(Service): - """ - Single Sign-On (SSO) service. - """ - def __init__(self, environ, start_response, user=None): - Service.__init__(self, environ, start_response, user) - self.binding = "" - self.response_bindings = None - self.resp_args = {} - self.binding_out = None - self.destination = None - self.req_info = None - self.op_type = "" - - def verify_request(self, query, binding): - """ - :param query: The SAML query, transport encoded - :param binding: Which binding the query came in over - """ - resp_args = {} - if not query: - logger.info("Missing QUERY") - resp = Unauthorized("Unknown user") - return resp_args, resp(self.environ, self.start_response) - - if not self.req_info: - self.req_info = IDP.parse_authn_request(query, binding) - - logger.info("parsed OK") - _authn_req = self.req_info.message - logger.debug("%s", _authn_req) - - try: - self.binding_out, self.destination = IDP.pick_binding( - "assertion_consumer_service", bindings=self.response_bindings, - entity_id=_authn_req.issuer.text - ) - except Exception as err: - logger.error("Couldn't find receiver endpoint: %s", err) - raise - - logger.debug("Binding: %s, destination: %s", self.binding_out, self.destination) - - resp_args = {} - try: - resp_args = IDP.response_args(_authn_req) - _resp = None - except UnknownPrincipal as excp: - _resp = IDP.create_error_response(_authn_req.id, self.destination, excp) - except UnsupportedBinding as excp: - _resp = IDP.create_error_response(_authn_req.id, self.destination, excp) - - return resp_args, _resp - - def do(self, query, binding_in, relay_state="", encrypt_cert=None): - """ - :param query: The request - :param binding_in: Which binding was used when receiving the query - :param relay_state: The relay state provided by the SP - :param encrypt_cert: Cert to use for encryption - :return: A response - """ - try: - resp_args, _resp = self.verify_request(query, binding_in) - except UnknownPrincipal as excp: - logger.error("UnknownPrincipal: %s", excp) - resp = ServiceError(f"UnknownPrincipal: {excp}") - return resp(self.environ, self.start_response) - except UnsupportedBinding as excp: - logger.error("UnsupportedBinding: %s", excp) - resp = ServiceError(f"UnsupportedBinding: {excp}") - return resp(self.environ, self.start_response) - - if not _resp: - identity = USERS[self.user].copy() - # identity["eduPersonTargetedID"] = get_eptid(IDP, query, session) - logger.info("Identity: %s", identity) - - if REPOZE_ID_EQUIVALENT: - identity[REPOZE_ID_EQUIVALENT] = self.user - try: - try: - method = self.environ["idp.authn"] - except KeyError: - pass - else: - resp_args["authn"] = method - - _resp = IDP.create_authn_response(identity, userid=self.user, - encrypt_cert=encrypt_cert, **resp_args) - except Exception as excp: - logging.error(exception_trace(excp)) - resp = ServiceError(f"Exception: {excp}") - return resp(self.environ, self.start_response) - - logger.info("AuthNResponse: %s", _resp) - if self.op_type == "ecp": - kwargs = {"soap_headers": [ecp.Response( - assertion_consumer_service_url=self.destination)]} - else: - kwargs = {} - - http_args = IDP.apply_binding( - self.binding_out, f"{_resp}", self.destination, relay_state, response=True, **kwargs - ) - - logger.debug("HTTPargs: %s", http_args) - return self.response(self.binding_out, http_args) - - def _store_request(self, saml_msg): - logger.debug("_store_request: %s", saml_msg) - key = sha1(saml_msg["SAMLRequest"]).hexdigest() - # store the AuthnRequest - IDP.ticket[key] = saml_msg - return key - - def redirect(self): - """This is the HTTP-redirect endpoint""" - - logger.info("--- In SSO Redirect ---") - saml_msg = self.unpack_redirect() - - try: - _key = saml_msg["key"] - saml_msg = IDP.ticket[_key] - self.req_info = saml_msg["req_info"] - del IDP.ticket[_key] - except KeyError: - try: - self.req_info = IDP.parse_authn_request( - saml_msg["SAMLRequest"], BINDING_HTTP_REDIRECT) - except KeyError: - resp = BadRequest("Message signature verification failure") - return resp(self.environ, self.start_response) - - _req = self.req_info.message - - if "SigAlg" in saml_msg and "Signature" in saml_msg: # Signed - # request - issuer = _req.issuer.text - _certs = IDP.metadata.certs(issuer, "any", "signing") - verified_ok = False - for cert in _certs: - if verify_redirect_signature(saml_msg, IDP.sec.sec_backend, cert): - verified_ok = True - break - if not verified_ok: - resp = BadRequest("Message signature verification failure") - return resp(self.environ, self.start_response) - - if self.user: - if _req.force_authn: - saml_msg["req_info"] = self.req_info - key = self._store_request(saml_msg) - return self.not_authn(key, _req.requested_authn_context) - else: - return self.operation(saml_msg, BINDING_HTTP_REDIRECT) - else: - saml_msg["req_info"] = self.req_info - key = self._store_request(saml_msg) - return self.not_authn(key, _req.requested_authn_context) - else: - return self.operation(saml_msg, BINDING_HTTP_REDIRECT) - - def post(self): - """ - The HTTP-Post endpoint - """ - logger.info("--- In SSO POST ---") - saml_msg = self.unpack_either() - self.req_info = IDP.parse_authn_request(saml_msg["SAMLRequest"], BINDING_HTTP_POST) - _req = self.req_info.message - if self.user: - if _req.force_authn: - saml_msg["req_info"] = self.req_info - key = self._store_request(saml_msg) - return self.not_authn(key, _req.requested_authn_context) - else: - return self.operation(saml_msg, BINDING_HTTP_POST) - else: - saml_msg["req_info"] = self.req_info - key = self._store_request(saml_msg) - return self.not_authn(key, _req.requested_authn_context) - - # def artifact(self): - # # Can be either by HTTP_Redirect or HTTP_POST - # _req = self._store_request(self.unpack_either()) - # if isinstance(_req, basestring): - # return self.not_authn(_req) - # return self.artifact_operation(_req) - - def ecp(self): - """ - The ECP interface - """ - logger.info("--- ECP SSO ---") - resp = None - - try: - authz_info = self.environ["HTTP_AUTHORIZATION"] - if authz_info.startswith("Basic "): - try: - _info = base64.b64decode(authz_info[6:]) - except TypeError: - resp = Unauthorized() - else: - try: - (user, passwd) = _info.split(":") - if is_equal(PASSWD[user], passwd): - resp = Unauthorized() - self.user = user - self.environ["idp.authn"] = AUTHN_BROKER.get_authn_by_accr(PASSWORD) - except ValueError: - resp = Unauthorized() - else: - resp = Unauthorized() - except KeyError: - resp = Unauthorized() - - if resp: - return resp(self.environ, self.start_response) - - _dict = self.unpack_soap() - self.response_bindings = [BINDING_PAOS] - # Basic auth ?! - self.op_type = "ecp" - return self.operation(_dict, BINDING_SOAP) - - -# ----------------------------------------------------------------------------- -# === Authentication ==== -# ----------------------------------------------------------------------------- - - -def do_authentication(environ, start_response, authn_context, key, redirect_uri): - """ - Display the login form - """ - logger.debug("Do authentication") - auth_info = AUTHN_BROKER.pick(authn_context) - - if len(auth_info) > 0: - method, reference = auth_info[0] - logger.debug("Authn chosen: %s (ref=%s)", method, reference) - return method(environ, start_response, reference, key, redirect_uri) - resp = Unauthorized("No usable authentication method") - return resp(environ, start_response) - - -# ----------------------------------------------------------------------------- - -def username_password_authn(environ, start_response, reference, key, redirect_uri): - """ - Display the login form - """ - logger.info("The login page") - headers = [] - - resp = Response(mako_template="login.mako", template_lookup=LOOKUP, headers=headers) - - argv = { - "action": "/verify", - "login": "", - "password": "", - "key": key, - "authn_reference": reference, - "redirect_uri": redirect_uri, - } - logger.info("do_authentication argv: %s", argv) - return resp(environ, start_response, **argv) - - -def verify_username_and_password(dic): - """ - Verifies the username and password stored in the dictionary. - """ - # verify username and password - if PASSWD[dic["login"][0]] == dic["password"][0]: - return True, dic["login"][0] - else: - return False, "" - - -def do_verify(environ, start_response, _): - """ - Verifies the username and password provided in the POST request. - """ - query = parse_qs(get_post(environ)) - - logger.debug("do_verify: %s", query) - - try: - _ok, user = verify_username_and_password(query) - except KeyError: - _ok = False - user = None - - if not _ok: - resp = Unauthorized("Unknown user or wrong password") - else: - uid = rndstr(24) - IDP.cache.uid2user[uid] = user - IDP.cache.user2uid[user] = uid - logger.debug("Register %s under '%s'", user, uid) - - kaka = set_cookie("idpauthn", "/", uid, query["authn_reference"][0]) - - lox = f"{query['redirect_uri'][0]}?id={uid}&key={query['key'][0]}" - logger.debug("Redirect => %s", lox) - resp = Redirect(lox, headers=[kaka], content="text/html") - - return resp(environ, start_response) - - -def not_found(environ, start_response): - """Called if no URL matches.""" - resp = NotFound() - return resp(environ, start_response) - - -# ----------------------------------------------------------------------------- -# === Single log out === -# ----------------------------------------------------------------------------- - -# def _subject_sp_info(req_info): -# # look for the subject -# subject = req_info.subject_id() -# subject = subject.text.strip() -# sp_entity_id = req_info.message.issuer.text.strip() -# return subject, sp_entity_id - - -class SLO(Service): - """ - Single Log Out Service. - """ - def do(self, request, binding, relay_state="", encrypt_cert=None): - logger.info("--- Single Log Out Service ---") - try: - _, body = request.split("\n") - logger.debug("req: '%s'", body) - req_info = IDP.parse_logout_request(body, binding) - except Exception as exc: - logger.error("Bad request: %s", exc) - resp = BadRequest(f"{exc}") - return resp(self.environ, self.start_response) - - msg = req_info.message - if msg.name_id: - lid = IDP.ident.find_local_id(msg.name_id) - logger.info("local identifier: %s", lid) - if lid in IDP.cache.user2uid: - uid = IDP.cache.user2uid[lid] - if uid in IDP.cache.uid2user: - del IDP.cache.uid2user[uid] - del IDP.cache.user2uid[lid] - # remove the authentication - try: - IDP.session_db.remove_authn_statements(msg.name_id) - except KeyError as exc: - logger.error("ServiceError: %s", exc) - resp = ServiceError(f"{exc}") - return resp(self.environ, self.start_response) - - resp = IDP.create_logout_response(msg, [binding]) - - try: - hinfo = IDP.apply_binding(binding, f"{resp}", "", relay_state) - except Exception as exc: - logger.error("ServiceError: %s", exc) - resp = ServiceError(f"{exc}") - return resp(self.environ, self.start_response) - - # _tlh = dict2list_of_tuples(hinfo["headers"]) - delco = delete_cookie(self.environ, "idpauthn") - if delco: - hinfo["headers"].append(delco) - logger.info("Header: %s", (hinfo["headers"],)) - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) - - -# ---------------------------------------------------------------------------- -# Manage Name ID service -# ---------------------------------------------------------------------------- - - -class NMI(Service): - """ - Manage Name ID Service. - """ - def do(self, query, binding, relay_state="", encrypt_cert=None): - logger.info("--- Manage Name ID Service ---") - req = IDP.parse_manage_name_id_request(query, binding) - request = req.message - - # Do the necessary stuff - name_id = IDP.ident.handle_manage_name_id_request( - request.name_id, request.new_id, request.new_encrypted_id, request.terminate - ) - - logger.debug("New NameID: %s", name_id) - - _resp = IDP.create_manage_name_id_response(request) - - # It's using SOAP binding - hinfo = IDP.apply_binding(BINDING_SOAP, f"{_resp}", "", relay_state, response=True) - - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) - - -# ---------------------------------------------------------------------------- -# === Assertion ID request === -# ---------------------------------------------------------------------------- - - -class AIDR(Service): - """ - Only URI binding - """ - def do(self, aid, binding, relay_state="", encrypt_cert=None): - logger.info("--- Assertion ID Service ---") - - try: - assertion = IDP.create_assertion_id_request_response(aid) - except Unknown: - resp = NotFound(aid) - return resp(self.environ, self.start_response) - - hinfo = IDP.apply_binding(BINDING_URI, f"{assertion}", response=True) - - logger.debug("HINFO: %s", hinfo) - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) - - def operation(self, _dict, binding, **kwargs): - logger.debug("_operation: %s", _dict) - if not _dict or "ID" not in _dict: - resp = BadRequest("Error parsing request or no request") - return resp(self.environ, self.start_response) - - return self.do(_dict["ID"], binding, **kwargs) - - -# ---------------------------------------------------------------------------- -# === Artifact resolve service === -# ---------------------------------------------------------------------------- - - -class ARS(Service): - """Artifact Resolution Service.""" - def do(self, request, binding, relay_state="", encrypt_cert=None): - _req = IDP.parse_artifact_resolve(request, binding) - - msg = IDP.create_artifact_response(_req, _req.artifact.text) - - hinfo = IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) - - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) - - -# ---------------------------------------------------------------------------- -# === Authn query service === -# ---------------------------------------------------------------------------- - - -class AQS(Service): - """ - Only SOAP binding - """ - def do(self, request, binding, relay_state="", encrypt_cert=None): - logger.info("--- Authn Query Service ---") - _req = IDP.parse_authn_query(request, binding) - _query = _req.message - - msg = IDP.create_authn_query_response(_query.subject, - _query.requested_authn_context, _query.session_index) - - logger.debug("response: %s", msg) - hinfo = IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) - - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) - - -# ---------------------------------------------------------------------------- -# === Attribute query service === -# ---------------------------------------------------------------------------- - - -class ATTR(Service): - """ - Only SOAP binding - """ - def do(self, request, binding, relay_state="", encrypt_cert=None): - logger.info("--- Attribute Query Service ---") - - _req = IDP.parse_attribute_query(request, binding) - _query = _req.message - - name_id = _query.subject.name_id - uid = name_id.text - logger.debug("Local uid: %s", uid) - identity = EXTRA[self.user] - - # Comes in over SOAP so only need to construct the response - args = IDP.response_args(_query, [BINDING_SOAP]) - msg = IDP.create_attribute_response(identity, name_id=name_id, **args) - - logger.debug("response: %s", msg) - hinfo = IDP.apply_binding(BINDING_SOAP, f"{msg}", "", "", response=True) - - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) - - -# ---------------------------------------------------------------------------- -# Name ID Mapping service -# When an entity that shares an identifier for a principal with an identity -# provider wishes to obtain a name identifier for the same principal in a -# particular format or federation namespace, it can send a request to -# the identity provider using this protocol. -# ---------------------------------------------------------------------------- - - -class NIM(Service): - """ - Name ID Mapping Service - """ - def do(self, query, binding, relay_state="", encrypt_cert=None): - req = IDP.parse_name_id_mapping_request(query, binding) - request = req.message - # Do the necessary stuff - try: - name_id = IDP.ident.handle_name_id_mapping_request(request.name_id, - request.name_id_policy) - except Unknown: - resp = BadRequest("Unknown entity") - return resp(self.environ, self.start_response) - except PolicyError: - resp = BadRequest("Unknown entity") - return resp(self.environ, self.start_response) - - info = IDP.response_args(request) - _resp = IDP.create_name_id_mapping_response(name_id, **info) - - # Only SOAP - hinfo = IDP.apply_binding(BINDING_SOAP, f"{_resp}", "", "", response=True) - - resp = Response(hinfo["data"], headers=hinfo["headers"]) - return resp(self.environ, self.start_response) - - -# ---------------------------------------------------------------------------- -# Cookie handling -# ---------------------------------------------------------------------------- -def info_from_cookie(kaka): - """ - Extracts user information and reference from the provided cookie. - """ - logger.debug("KAKA: %s", kaka) - if kaka: - cookie_obj = SimpleCookie(kaka) - morsel = cookie_obj.get("idpauthn", None) - if morsel: - try: - key, ref = base64.b64decode(morsel.value).split(":") - return IDP.cache.uid2user[key], ref - except (TypeError, KeyError): - return None, None - else: - logger.debug("No idpauthn cookie") - return None, None - - -def delete_cookie(environ, name): - """ - Deletes the specified cookie from the provided environ. - """ - kaka = environ.get("HTTP_COOKIE", "") - logger.debug("delete KAKA: %s", kaka) - if kaka: - cookie_obj = SimpleCookie(kaka) - morsel = cookie_obj.get(name, None) - cookie = SimpleCookie() - cookie[name] = "" - cookie[name]["path"] = "/" - logger.debug("Expire: %s", morsel) - cookie[name]["expires"] = _expiration("dawn") - return tuple(cookie.output().split(": ", 1)) - return None - - -def set_cookie(name, _, *args): - """ - Sets a cookie with the specified name and values. - """ - cookie = SimpleCookie() - cookie[name] = base64.b64encode(":".join(args)) - cookie[name]["path"] = "/" - cookie[name]["expires"] = _expiration(5) # 5 minutes from now - logger.debug("Cookie expires: %s", cookie[name]["expires"]) - return tuple(cookie.output().split(": ", 1)) - - -# ---------------------------------------------------------------------------- - -# map urls to functions -AUTHN_URLS = [ - # sso - (r"sso/post$", (SSO, "post")), - (r"sso/post/(.*)$", (SSO, "post")), - (r"sso/redirect$", (SSO, "redirect")), - (r"sso/redirect/(.*)$", (SSO, "redirect")), - (r"sso/art$", (SSO, "artifact")), - (r"sso/art/(.*)$", (SSO, "artifact")), - # slo - (r"slo/redirect$", (SLO, "redirect")), - (r"slo/redirect/(.*)$", (SLO, "redirect")), - (r"slo/post$", (SLO, "post")), - (r"slo/post/(.*)$", (SLO, "post")), - (r"slo/soap$", (SLO, "soap")), - (r"slo/soap/(.*)$", (SLO, "soap")), - # - (r"airs$", (AIDR, "uri")), - (r"ars$", (ARS, "soap")), - # mni - (r"mni/post$", (NMI, "post")), - (r"mni/post/(.*)$", (NMI, "post")), - (r"mni/redirect$", (NMI, "redirect")), - (r"mni/redirect/(.*)$", (NMI, "redirect")), - (r"mni/art$", (NMI, "artifact")), - (r"mni/art/(.*)$", (NMI, "artifact")), - (r"mni/soap$", (NMI, "soap")), - (r"mni/soap/(.*)$", (NMI, "soap")), - # nim - (r"nim$", (NIM, "soap")), - (r"nim/(.*)$", (NIM, "soap")), - # - (r"aqs$", (AQS, "soap")), - (r"attr$", (ATTR, "soap")), -] - -NON_AUTHN_URLS = [ - # (r'login?(.*)$', do_authentication), - (r"verify?(.*)$", do_verify), - (r"sso/ecp$", (SSO, "ecp")), -] - -# ---------------------------------------------------------------------------- - - -def metadata(environ, start_response): - """ - Generates and serves the metadata XML based on the provided environment and start_response. - """ - try: - path = args.path - if path is None or len(path) == 0: - path = os.path.dirname(os.path.abspath(__file__)) - if path[-1] != "/": - path += "/" - metadata = create_metadata_string( - path + args.config, IDP.config, args.valid, args.cert, - args.keyfile, args.id, args.name, args.sign - ) - start_response("200 OK", [("Content-Type", "text/xml")]) - return metadata - except Exception as ex: - logger.error("An error occurred while creating metadata:", ex.message) - return not_found(environ, start_response) - - -def staticfile(environ, start_response): - """ - Serves a static file based on the provided environment and start_response. - """ - try: - path = args.path - if path is None or len(path) == 0: - path = os.path.dirname(os.path.abspath(__file__)) - if path[-1] != "/": - path += "/" - path += environ.get("PATH_INFO", "").lstrip("/") - path = os.path.realpath(path) - if not path.startswith(args.path): - resp = Unauthorized() - return resp(environ, start_response) - start_response("200 OK", [("Content-Type", "text/xml")]) - return open(path).read() - except Exception as ex: - logger.error("An error occurred while creating metadata: %s", str(ex)) - return not_found(environ, start_response) - - -def application(environ, start_response): - """ - The main WSGI application. Dispatch the current request to - the functions from above and store the regular expression - captures in the WSGI environment as `myapp.url_args` so that - the functions from above can access the url placeholders. - If nothing matches, call the `not_found` function. - :param environ: The HTTP application environment - :param start_response: The application to run when the handling of the - request is done - :return: The response as a list of lines - """ - - path = environ.get("PATH_INFO", "").lstrip("/") - - if path == "metadata": - return metadata(environ, start_response) - - kaka = environ.get("HTTP_COOKIE", None) - logger.info(" PATH: %s", path) - - if kaka: - logger.info("= KAKA =") - user, authn_ref = info_from_cookie(kaka) - if authn_ref: - environ["idp.authn"] = AUTHN_BROKER[authn_ref] - else: - try: - query = parse_qs(environ["QUERY_STRING"]) - logger.debug("QUERY: %s", query) - user = IDP.cache.uid2user[query["id"][0]] - except KeyError: - user = None - - url_patterns = AUTHN_URLS - if not user: - logger.info("-- No USER --") - # insert NON_AUTHN_URLS first in case there is no user - url_patterns = NON_AUTHN_URLS + url_patterns - - for regex, callback in url_patterns: - match = re.search(regex, path) - if match is not None: - try: - environ["myapp.url_args"] = match.groups()[0] - except IndexError: - environ["myapp.url_args"] = path - - logger.debug("Callback: %s", callback) - if isinstance(callback, tuple): - cls = callback[0](environ, start_response, user) - func = getattr(cls, callback[1]) - return func() - return callback(environ, start_response, user) - - if re.search(r"static/.*", path) is not None: - return staticfile(environ, start_response) - return not_found(environ, start_response) - - -# ---------------------------------------------------------------------------- - -# allow uwsgi or gunicorn mount -# by moving some initialization out of __name__ == '__main__' section. -# uwsgi -s 0.0.0.0:8088 --protocol http --callable application --module idp - -args = type("Config", (object,), {}) -args.config = "idp_conf" -args.mako_root = "./" -args.path = None - -AUTHN_BROKER = AuthnBroker() -AUTHN_BROKER.add(authn_context_class_ref(PASSWORD), - username_password_authn, 10, f"http://{socket.gethostname()}") -AUTHN_BROKER.add(authn_context_class_ref(UNSPECIFIED), "", 0, f"http://{socket.gethostname()}") -CONFIG = importlib.import_module(args.config) -IDP = server.Server(args.config, cache=Cache()) -IDP.ticket = {} - -# ---------------------------------------------------------------------------- - -if __name__ == "__main__": - from wsgiref.simple_server import make_server - - parser = argparse.ArgumentParser() - parser.add_argument("-p", dest="path", help="Path to configuration file.") - parser.add_argument( - "-v", dest="valid", - help="How long, in days,the metadata is valid from " "the time of creation" - ) - parser.add_argument("-c", dest="cert", help="certificate") - parser.add_argument("-i", dest="id", help="The ID of the entities descriptor") - parser.add_argument("-k", dest="keyfile", help="A file with a key to sign the metadata with") - parser.add_argument("-n", dest="name") - parser.add_argument("-s", dest="sign", action="store_true", help="sign the metadata") - parser.add_argument("-m", dest="mako_root", default="./") - parser.add_argument(dest="config") - args = parser.parse_args() - - _rot = args.mako_root - LOOKUP = TemplateLookup( - directories=[f"{_rot}templates", f"{_rot}htdocs"], - module_directory=f"{_rot}modules", - input_encoding="utf-8", - output_encoding="utf-8", - ) - - HOST = CONFIG.HOST - PORT = CONFIG.PORT - - SRV = make_server(HOST, PORT, application) - print(f"IdP listening on {HOST}:{PORT}") - SRV.serve_forever() -else: - _rot = args.mako_root - LOOKUP = TemplateLookup( - directories=[f"{_rot}templates", f"{_rot}htdocs"], - module_directory=f"{_rot}modules", - input_encoding="utf-8", - output_encoding="utf-8", - ) From 457ff083b3d1b69597f8ee9d08c02a514cef9e43 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Wed, 18 Sep 2024 17:19:09 +0200 Subject: [PATCH 25/29] fix:Further refactoring to simplify control flow (#2521) * Further refactoring to simplify control flow Fewer LOC to make things more maintanable. Also Fix #2514 * Handle Exceptions at the outer level, so no unforeseen errors occur. Also catch uncaught SocketIO-Exceptions to handle them gracefully. * better solution --- mslib/msui/mscolab.py | 507 +++++++++++++++---------------- mslib/utils/verify_user_token.py | 4 +- tests/_test_msui/test_mscolab.py | 17 +- tests/fixtures.py | 2 +- 4 files changed, 263 insertions(+), 267 deletions(-) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 713acb1dd..f05b173de 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -48,6 +48,7 @@ from fs import open_fs from PIL import Image, UnidentifiedImageError from keyring.errors import NoKeyringError, PasswordSetError, InitError +import socketio from mslib.msui import flighttrack as ft from mslib.msui import mscolab_chat as mc @@ -55,9 +56,8 @@ from mslib.msui import mscolab_version_history as mvh from mslib.msui import socket_control as sc -import PyQt5 from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtWidgets import QFileDialog, QMessageBox +from PyQt5.QtWidgets import QDialog, QFileDialog, QMessageBox from PyQt5.QtGui import QPixmap from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring @@ -74,19 +74,38 @@ from mslib.utils.config import config_loader, modify_config_file +class MSColabConnectionError(RuntimeError): + pass + + def verify_user_token(func): + if not hasattr(verify_user_token, "depth"): + verify_user_token.depth = 0 + @functools.wraps(func) - def wrapper(*args, **vargs): - self = args[0] - if _verify_user_token(self.mscolab_server_url, self.token): - return func(*args, **vargs) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + def wrapper(self, *args, **vargs): + if self.mscolab_server_url is None: + # in case of a forecd logout some QT events may still trigger MSCOLAB functions + return + verify_user_token.depth += 1 + try: + if not _verify_user_token(self.mscolab_server_url, self.token): + raise MSColabConnectionError("Your Connection is expired. New Login required!") + assert self.mscolab_server_url is not None + result = func(self, *args, **vargs) + return result + except (MSColabConnectionError, socketio.exceptions.SocketIOError) as ex: + if verify_user_token.depth > 1: + raise + logging.error("%s", ex) + show_popup(self.ui, "Error", str(ex)) self.logout() + finally: + verify_user_token.depth -= 1 return wrapper -class MSColab_OperationArchiveBrowser(QtWidgets.QDialog, ui_opar.Ui_OperationArchiveBrowser): +class MSColab_OperationArchiveBrowser(QDialog, ui_opar.Ui_OperationArchiveBrowser): def __init__(self, parent=None, mscolab=None): super().__init__(parent) self.setupUi(self) @@ -135,7 +154,7 @@ def unarchive_operation(self): self.mscolab.logout() -class MSColab_ConnectDialog(QtWidgets.QDialog, ui_conn.Ui_MSColabConnectDialog): +class MSColab_ConnectDialog(QDialog, ui_conn.Ui_MSColabConnectDialog): """MSColab connect window class. Provides user interface elements to connect/disconnect, login, add new user to an MSColab Server. Also implements HTTP Server Authentication prompt. """ @@ -276,12 +295,12 @@ def connect_handler(self): url_list = config_loader(dataset="default_MSCOLAB") if self.mscolab_server_url not in url_list: - ret = PyQt5.QtWidgets.QMessageBox.question( + ret = QMessageBox.question( self, self.tr("Update Server List"), self.tr("You are using a new MSColab server. " "Should your settings file be updated by adding the new server?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) - if ret == QtWidgets.QMessageBox.Yes: + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if ret == QMessageBox.Yes: url_list = [self.mscolab_server_url] + url_list modify_config_file({"default_MSCOLAB": url_list}) @@ -418,12 +437,12 @@ def save_user_credentials_to_config_file(self, emailid, password): logging.warning("Can't use Keyring on your system: %s" % ex) mss_auth = config_loader(dataset="MSS_auth") if mss_auth.get(self.mscolab_server_url) != emailid: - ret = QtWidgets.QMessageBox.question( + ret = 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: + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if ret == QMessageBox.Yes: mss_auth[self.mscolab_server_url] = emailid modify_config_file({"MSS_auth": mss_auth}) @@ -598,6 +617,19 @@ def __init__(self, parent=None, local_operations_data=None): self.data_dir = local_operations_data self.create_dir() + def _handle_font_bolding(self, item=None): + font = QtGui.QFont() + for i in range(self.ui.listOperationsMSC.count()): + self.ui.listOperationsMSC.item(i).setFont(font) + if item is not None: + font.setBold(True) + item.setFont(font) + + def _activate_first_local_flighttrack(self): + self.ui.listFlightTracks.setCurrentRow(0) + self.ui.activate_selected_flight_track() + self.active_op_id = None + def view_description(self): data = { "token": self.token, @@ -609,7 +641,7 @@ def view_description(self): if r.text != "False": _json = json.loads(r.text) creator_name = _json["username"] - QtWidgets.QMessageBox.information( + QMessageBox.information( self.ui, "Operation Description", f"Creator: {creator_name}

" f"Category: {self.active_operation_category}

" @@ -681,10 +713,8 @@ def after_login(self, emailid, url, r): try: self.conn = sc.ConnectionManager(self.token, user=self.user, mscolab_server_url=self.mscolab_server_url) except Exception as ex: - logging.debug("Couldn't create a socket connection: %s", ex) - show_popup(self.ui, "Error", "Couldn't create a socket connection. Maybe the MSColab server is too old. " - "New Login required!") - self.logout() + raise MSColabConnectionError("Couldn't create a socket connection. Maybe the MSColab server is too old." + f"({ex}). New Login required!") else: self.conn.signal_operation_list_updated.connect(self.reload_operation_list) self.conn.signal_reload.connect(self.reload_window) @@ -847,7 +877,7 @@ def open_profile_window(self): def on_context_menu(point): self.gravatar_menu.exec_(self.profile_dialog.gravatarLabel.mapToGlobal(point)) - self.prof_diag = QtWidgets.QDialog() + self.prof_diag = QDialog() self.profile_dialog = ui_profile.Ui_ProfileWindow() self.profile_dialog.setupUi(self.prof_diag) self.profile_dialog.buttonBox.accepted.connect(lambda: self.prof_diag.close()) @@ -914,12 +944,11 @@ def upload_image(self): @verify_user_token def delete_account(self, _=None): # ToDo rename to delete_own_account - w = QtWidgets.QWidget() - qm = QtWidgets.QMessageBox - reply = qm.question(w, self.tr('Continue?'), - self.tr("You're about to delete your account. You cannot undo this operation!"), - qm.Yes, qm.No) - if reply == QtWidgets.QMessageBox.No: + reply = QMessageBox.question( + self.ui, self.tr('Continue?'), + self.tr("You're about to delete your account. You cannot undo this operation!"), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.No: return data = { "token": self.token @@ -929,10 +958,8 @@ def delete_account(self, _=None): url = urljoin(self.mscolab_server_url, "delete_own_account") r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Please reconnect.") else: if r.status_code == 200 and json.loads(r.text)["success"] is True: self.logout() @@ -969,7 +996,7 @@ def browse(): self.add_proj_dialog.f_content = file_content self.add_proj_dialog.selectedFile.setText(file_name) - self.proj_diag = QtWidgets.QDialog() + self.proj_diag = QDialog() self.add_proj_dialog = add_operation_ui.Ui_addOperationDialog() self.add_proj_dialog.setupUi(self.proj_diag) self.add_proj_dialog.f_content = None @@ -987,6 +1014,7 @@ def browse(): self.add_proj_dialog.cb_ImportType.addItem(im_action.text()) self.proj_diag.show() + @verify_user_token def add_operation(self): logging.debug("add_operation") path = self.add_proj_dialog.path.text() @@ -1023,24 +1051,20 @@ def add_operation(self): url = urljoin(self.mscolab_server_url, "create_operation") r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Please reconnect.") + if r.text == "True": + QMessageBox.information( + self.ui, "Creation successful", + "Your operation was created successfully.", + ) + op_id = self.get_recent_op_id() + self.new_op_id = op_id + self.conn.handle_new_operation(op_id) + self.signal_operation_added.emit(op_id, path) else: - if r.text == "True": - QtWidgets.QMessageBox.information( - self.ui, - "Creation successful", - "Your operation was created successfully.", - ) - op_id = self.get_recent_op_id() - self.new_op_id = op_id - self.conn.handle_new_operation(op_id) - self.signal_operation_added.emit(op_id, path) - else: - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage('The path already exists') + self.error_dialog = QtWidgets.QErrorMessage() + self.error_dialog.showMessage('The path already exists') @verify_user_token def get_recent_op_id(self): @@ -1055,17 +1079,15 @@ def get_recent_op_id(self): } url = urljoin(self.mscolab_server_url, "operations") r = requests.get(url, data=data) - if r.text != "False": - _json = json.loads(r.text) - operations = _json["operations"] - op_id = None - if operations: - op_id = operations[-1]["op_id"] - logging.debug("recent op_id %s", op_id) - return op_id - else: - show_popup(self.ui, "Error", "Session expired, new login required") - self.signal_logout_mscolab() + if r.text == "False": + raise MSColabConnectionError("Session expired, new login required") + _json = json.loads(r.text) + operations = _json["operations"] + op_id = None + if operations: + op_id = operations[-1]["op_id"] + logging.debug("recent op_id %s", op_id) + return op_id def operation_options_handler(self): if self.sender() == self.ui.actionChat: @@ -1200,55 +1222,42 @@ def handle_delete_operation(self): url = urljoin(self.mscolab_server_url, 'delete_operation') try: res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.debug(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Please reconnect.") else: res.raise_for_status() self.reload_operations() self.signal_operation_removed.emit(self.active_op_id) - logging.debug("activate local") - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() else: show_popup(self.ui, "Error", "Entered operation name did not match!") + @verify_user_token def handle_leave_operation(self): logging.debug("handle_leave_operation") - w = QtWidgets.QWidget() - qm = QtWidgets.QMessageBox - reply = qm.question(w, self.tr('Mission Support System'), - self.tr("Do you want to leave this operation?"), - qm.Yes, qm.No) - if reply == QtWidgets.QMessageBox.Yes: - if _verify_user_token(self.mscolab_server_url, self.token): - data = { - "token": self.token, - "op_id": self.active_op_id, - "selected_userids": json.dumps([self.user["id"]]) - } - url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") - try: - res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() - if res.text != "False": - res = res.json() - if res["success"]: - for window in self.ui.get_active_views(): - window.handle_force_close() - self.reload_operations() - else: - show_popup(self.ui, "Error", "Some error occurred! Could not leave operation.") - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + reply = QMessageBox.question( + self.ui, self.tr('Mission Support System'), + self.tr("Do you want to leave this operation?"), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply == QMessageBox.Yes: + data = { + "token": self.token, + "op_id": self.active_op_id, + "selected_userids": json.dumps([self.user["id"]]) + } + url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") + try: + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Please reconnect.") + if res.text == "False": + raise MSColabConnectionError("Your Connection is expired. New Login required!") + res = res.json() + if res["success"]: + for window in self.ui.get_active_views(): + window.handle_force_close() + self.reload_operations() else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + show_popup(self.ui, "Error", "Some error occurred! Could not leave operation.") def set_operation_desc_label(self, op_desc): self.active_operation_description = op_desc @@ -1263,6 +1272,7 @@ def set_operation_desc_label(self, op_desc): @verify_user_token def change_category_handler(self, _=None): + logging.debug('change_category_handler') # only after login entered_operation_category, ok = QtWidgets.QInputDialog.getText( self.ui, @@ -1283,25 +1293,21 @@ def change_category_handler(self, _=None): url = urljoin(self.mscolab_server_url, 'update_operation') try: r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() - else: - if r.text == "True": - self.active_operation_category = entered_operation_category - self.reload_operation_list() - QtWidgets.QMessageBox.information( - self.ui, - "Update successful", - "Category is updated successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Please reconnect.") + if r.text == "False": + raise MSColabConnectionError("Your Connection is expired. New Login required!") + self.active_operation_category = entered_operation_category + self.reload_operation_list() + QMessageBox.information( + self.ui, + "Update successful", + "Category is updated successfully.", + ) @verify_user_token def change_description_handler(self, _=None): + logging.debug('change_description_handler') # only after login entered_operation_desc, ok = QtWidgets.QInputDialog.getText( self.ui, @@ -1319,31 +1325,26 @@ def change_description_handler(self, _=None): "attribute": 'description', "value": entered_operation_desc } - url = urljoin(self.mscolab_server_url, 'update_operation') try: r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() - else: - if r.text == "True": - # Update active operation description label - self.set_operation_desc_label(entered_operation_desc) - - self.reload_operation_list() - QtWidgets.QMessageBox.information( - self.ui, - "Update successful", - "Description is updated successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Please reconnect.") + if r.text == "False": + raise MSColabConnectionError("Your Connection is expired. New Login required!") + # Update active operation description label + self.set_operation_desc_label(entered_operation_desc) + + self.reload_operation_list() + QMessageBox.information( + self.ui, + "Update successful", + "Description is updated successfully.", + ) @verify_user_token def rename_operation_handler(self, _=None): + logging.debug('rename_operation_handler') # only after login entered_operation_name, ok = QtWidgets.QInputDialog.getText( self.ui, @@ -1364,30 +1365,25 @@ def rename_operation_handler(self, _=None): url = urljoin(self.mscolab_server_url, 'update_operation') try: r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() - else: - if r.text == "True": - # Update active operation name - self.active_operation_name = entered_operation_name - - # Update active operation description - self.set_operation_desc_label(self.active_operation_description) - self.reload_operation_list() - self.reload_windows_slot() - # Update other user's operation list - self.conn.signal_operation_list_updated.connect(self.reload_operation_list) - - QtWidgets.QMessageBox.information( - self.ui, - "Rename successful", - "Operation is renamed successfully.", - ) - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Please reconnect.") + if r.text == "False": + raise MSColabConnectionError("Your Connection is expired. New Login required!") + # Update active operation name + self.active_operation_name = entered_operation_name + + # Update active operation description + self.set_operation_desc_label(self.active_operation_description) + self.reload_operation_list() + self.reload_windows_slot() + # Update other user's operation list + self.conn.signal_operation_list_updated.connect(self.reload_operation_list) + + QMessageBox.information( + self.ui, + "Rename successful", + "Operation is renamed successfully.", + ) @verify_user_token def handle_work_locally_toggle(self, _=None): @@ -1438,6 +1434,7 @@ def reload_local_wp(self): self.reload_view_windows() def operation_category_handler(self, update_operations=True): + logging.debug('operation_category_handler') # only after_login if self.mscolab_server_url is not None: self.selected_category = self.ui.filterCategoryCb.currentText() @@ -1514,15 +1511,14 @@ def get_recent_operation(self): } url = urljoin(self.mscolab_server_url, "operations") r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text != "False": - _json = json.loads(r.text) - operations = _json["operations"] - recent_operation = None - if operations: - recent_operation = operations[-1] - return recent_operation - else: - show_popup(self.ui, "Error", "Session expired, new login required") + if r.text == "False": + raise MSColabConnectionError("Session expired, new login required") + _json = json.loads(r.text) + operations = _json["operations"] + recent_operation = None + if operations: + recent_operation = operations[-1] + return recent_operation @QtCore.pyqtSlot() def reload_operation_list(self): @@ -1547,6 +1543,7 @@ def render_new_permission(self, op_id, u_id): to render new permission if added """ + logging.debug('render_new_permission') data = { 'token': self.token } @@ -1580,6 +1577,7 @@ def handle_update_permission(self, op_id, u_id, access_level): function updates existing permissions and related control availability """ + logging.debug('handle_update_permission') if u_id == self.user["id"]: # update table of operations operation_name = None @@ -1639,6 +1637,7 @@ def delete_operation_from_list(self, op_id): @QtCore.pyqtSlot(int, int) def handle_revoke_permission(self, op_id, u_id): + logging.debug('handle_revoke_permission') if u_id == self.user["id"]: revoked_operation_currently_active = True if self.active_op_id == op_id else False operation_name = self.delete_operation_from_list(op_id) @@ -1653,18 +1652,18 @@ def handle_revoke_permission(self, op_id, u_id): self.signal_permission_revoked.emit(op_id) if self.active_op_id == op_id: - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() + self._activate_first_local_flighttrack() @QtCore.pyqtSlot(int) def handle_operation_deleted(self, op_id): - logging.debug('handle_operation_deleted') + logging.debug('handle_operation_deleted %s %s', op_id, self.active_op_id) old_operation_name = self.active_operation_name old_active_id = self.active_op_id operation_name = self.delete_operation_from_list(op_id) - if op_id == old_active_id and operation_name is None: - operation_name = old_operation_name - show_popup(self.ui, "Success", f'Operation "{operation_name}" was deleted!', icon=1) + if op_id == old_active_id: + if operation_name is None: + operation_name = old_operation_name + show_popup(self.ui, "Information", f'Active operation "{operation_name}" is inaccessible!', icon=1) @QtCore.pyqtSlot(int, int) def update_active_user_label(self, op_id, count): @@ -1693,7 +1692,7 @@ def show_categories_to_ui(self, ops=None): try: r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.MissingSchema: - show_popup(self.ui, "Error", "Session expired, new login required") + raise MSColabConnectionError("Session expired, new login required") if r is not None and r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1723,51 +1722,56 @@ def add_operations_to_ui(self): } url = urljoin(self.mscolab_server_url, "operations") r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - if r.text != "False": - _json = json.loads(r.text) - self.operations = _json["operations"] - logging.debug("adding operations to ui") - operations = sorted(self.operations, key=lambda k: k["path"].lower()) - self.ui.listOperationsMSC.clear() - self.operation_archive_browser.listArchivedOperations.clear() - new_operation = None - active_operation = None - for operation in operations: - operation_desc = f'{operation["path"]} - {operation["access_level"]}' - widgetItem = QtWidgets.QListWidgetItem(operation_desc) - widgetItem.op_id = operation["op_id"] - widgetItem.operation_category = operation["category"] - widgetItem.operation_path = operation["path"] - widgetItem.access_level = operation["access_level"] - widgetItem.active_operation_description = operation["description"] - try: - # compatibility to 7.x - # a newer server can distinguish older operations and move those into inactive state - widgetItem.active = operation["active"] - except KeyError: - widgetItem.active = True - if widgetItem.active: - self.ui.listOperationsMSC.addItem(widgetItem) - if widgetItem.op_id == self.active_op_id: - active_operation = widgetItem - if widgetItem.op_id == self.new_op_id: - new_operation = widgetItem - else: - self.operation_archive_browser.listArchivedOperations.addItem(widgetItem) - if new_operation is not None: - logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) - self.ui.listOperationsMSC.itemActivated.emit(new_operation) - elif active_operation is not None: - logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) - self.ui.listOperationsMSC.itemActivated.emit(active_operation) - self.ui.listOperationsMSC.itemActivated.connect(self.set_active_op_id) - self.new_op_id = None - else: - show_popup(self.ui, "Error", "Session expired, new login required") - self.logout() + if r.text == "False": + raise MSColabConnectionError("Session expired, new login required") + + _json = json.loads(r.text) + self.operations = _json["operations"] + operations = sorted(self.operations, key=lambda k: k["path"].lower()) + self.ui.listOperationsMSC.clear() + self.operation_archive_browser.listArchivedOperations.clear() + new_operation = None + active_operation = None + for operation in operations: + operation_desc = f'{operation["path"]} - {operation["access_level"]}' + widgetItem = QtWidgets.QListWidgetItem(operation_desc) + widgetItem.op_id = operation["op_id"] + widgetItem.operation_category = operation["category"] + widgetItem.operation_path = operation["path"] + widgetItem.access_level = operation["access_level"] + widgetItem.active_operation_description = operation["description"] + try: + # compatibility to 7.x + # a newer server can distinguish older operations and move those into inactive state + widgetItem.active = operation["active"] + except KeyError: + widgetItem.active = True + if widgetItem.active: + self.ui.listOperationsMSC.addItem(widgetItem) + if widgetItem.op_id == self.active_op_id: + active_operation = widgetItem + if widgetItem.op_id == self.new_op_id: + new_operation = widgetItem + else: + self.operation_archive_browser.listArchivedOperations.addItem(widgetItem) + if new_operation is not None: + logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) + self.ui.listOperationsMSC.itemActivated.emit(new_operation) + elif active_operation is not None: + logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) + self.ui.listOperationsMSC.itemActivated.emit(active_operation) + elif self.active_op_id is not None: + logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) + show_popup(self.ui, "Information", + f'Active operation "{self.active_operation_name}" is inaccessible!', icon=1) + self._activate_first_local_flighttrack() + + self.ui.listOperationsMSC.itemActivated.connect(self.set_active_op_id) + self.new_op_id = None return r def show_operation_options_in_inactivated_state(self, access_level): + logging.debug('show_operation_options_in_inactivated_state') self.ui.actionUnarchiveOperation.setEnabled(False) if access_level in ["creator", "admin"]: self.ui.actionUnarchiveOperation.setEnabled(True) @@ -1775,12 +1779,11 @@ def show_operation_options_in_inactivated_state(self, access_level): @verify_user_token def archive_operation(self, _): logging.debug("handle_archive_operation") - ret = QtWidgets.QMessageBox.warning( + ret = QMessageBox.warning( self.ui, self.tr("Mission Support System"), self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) - if ret == QtWidgets.QMessageBox.Yes: + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if ret == QMessageBox.Yes: data = { "token": self.token, "op_id": self.active_op_id, @@ -1791,27 +1794,19 @@ def archive_operation(self, _): url = urljoin(self.mscolab_server_url, 'update_operation') try: res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) - except requests.exceptions.RequestException as e: - logging.debug(e) - show_popup(self.ui, "Error", "Some error occurred! Could not archive operation.") - else: - res.raise_for_status() - self.reload_operations() - self.signal_operation_removed.emit(self.active_op_id) - logging.debug("activate local") - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() - self.active_op_id = None + except requests.exceptions.RequestException as ex: + raise MSColabConnectionError(f"Some error occurred ({ex})! Could not archive operation.") + res.raise_for_status() + self.reload_operations() + self.signal_operation_removed.emit(self.active_op_id) + logging.debug("activate local") + self._activate_first_local_flighttrack() @verify_user_token def set_active_op_id(self, item): logging.debug('set_active_op_id %s %s %s', item, item.op_id, self.active_op_id) - if not self.ui.local_active: - if item.op_id == self.active_op_id: - font = QtGui.QFont() - font.setBold(True) - item.setFont(font) - return + if not self.ui.local_active and item.op_id == self.active_op_id: + return # close all hanging window self.close_external_windows() @@ -1847,11 +1842,7 @@ def set_active_op_id(self, item): self.show_operation_options() # change font style for selected - font = QtGui.QFont() - for i in range(self.ui.listOperationsMSC.count()): - self.ui.listOperationsMSC.item(i).setFont(font) - font.setBold(True) - item.setFont(font) + self._handle_font_bolding(item) # set new waypoints model to open views for window in self.ui.get_active_views(): @@ -1873,20 +1864,13 @@ def switch_to_local(self): logging.debug('switch_to_local') self.ui.local_active = True if self.active_op_id is not None: - if _verify_user_token(self.mscolab_server_url, self.token): - # change font style for selected - font = QtGui.QFont() - - for i in range(self.ui.listOperationsMSC.count()): - self.ui.listOperationsMSC.item(i).setFont(font) + self._handle_font_bolding() - # close all hanging operation option windows - self.close_external_windows() - self.hide_operation_options() - self.ui.menu_handler() - else: - show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") - self.logout() + # close all hanging operation option windows + self.close_external_windows() + self.hide_operation_options() + self.ui.menu_handler() + self.active_op_id = None def show_operation_options(self): self.ui.actionChat.setEnabled(False) @@ -1966,7 +1950,7 @@ def request_wps_from_server(self): xml_content = json.loads(r.text)["content"] return xml_content else: - show_popup(self.ui, "Error", "Session expired, new login required") + raise MSColabConnectionError("Session expired, new login required") def load_wps_from_server(self): if self.ui.workLocallyCheckbox.isChecked(): @@ -1979,6 +1963,7 @@ def load_wps_from_server(self): self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) def reload_operations(self): + logging.debug('reload_operations') ops = self.add_operations_to_ui() selected_category = self.ui.filterCategoryCb.currentText() self.show_categories_to_ui(ops) @@ -2087,6 +2072,7 @@ def listFlighttrack_itemDoubleClicked(self): self.signal_listFlighttrack_doubleClicked.emit() def logout(self): + logging.debug('logout') if self.mscolab_server_url is None: return self.ui.local_active = True @@ -2161,11 +2147,10 @@ def logout(self): self.ui.userCountLabel.setText("Active Users: 0") # activate first local flighttrack after logging out - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() + self._activate_first_local_flighttrack() -class MscolabMergeWaypointsDialog(QtWidgets.QDialog, merge_wp_ui.Ui_MergeWaypointsDialog): +class MscolabMergeWaypointsDialog(QDialog, merge_wp_ui.Ui_MergeWaypointsDialog): def __init__(self, local_waypoints_model, server_waypoints_model, fetch=False, parent=None): super().__init__(parent) self.setupUi(self) @@ -2237,7 +2222,7 @@ def get_values(self): return self.xml_content -class MscolabHelpDialog(QtWidgets.QDialog, msc_help_dialog.Ui_mscolabHelpDialog): +class MscolabHelpDialog(QDialog, msc_help_dialog.Ui_mscolabHelpDialog): def __init__(self, parent=None): super().__init__(parent) diff --git a/mslib/utils/verify_user_token.py b/mslib/utils/verify_user_token.py index a1adbafc6..20e7b0a2a 100644 --- a/mslib/utils/verify_user_token.py +++ b/mslib/utils/verify_user_token.py @@ -46,13 +46,13 @@ def verify_user_token(mscolab_server_url, token): logging.debug("Certificate Verification Failed") return False except requests.exceptions.InvalidSchema: - logging.debug("Invalid schema of url") + logging.debug("Invalid schema of url '%s'", url) return False except requests.exceptions.ConnectionError as ex: logging.error("unexpected error: %s %s", type(ex), ex) return False except requests.exceptions.MissingSchema as ex: # self.mscolab_server_url can be None?? - logging.error("unexpected error: %s %s", type(ex), ex) + logging.error("unexpected error for url '%s': %s %s", url, type(ex), ex) return False return r.text == "True" diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 95188f4c8..ad00b1ec2 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -679,7 +679,8 @@ def test_handle_delete_operation(self, qtbot): mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)): self.window.actionDeleteOperation.trigger() qtbot.wait_until( - lambda: m.assert_called_once_with(self.window, "Success", 'Operation "flight7" was deleted!') + lambda: m.assert_called_once_with( + self.window, "Information", 'Active operation "flight7" is inaccessible!') ) assert self.window.mscolab.active_op_id is None op_id = self.window.mscolab.get_recent_op_id() @@ -707,7 +708,12 @@ def test_handle_leave_operation(self, mockmessage, qtbot): self.window.actionSideView.trigger() assert len(self.window.get_active_views()) == 2 - self.window.actionLeaveOperation.trigger() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionLeaveOperation.trigger() + qtbot.wait_until( + lambda: m.assert_called_once_with( + self.window, "Information", 'Active operation "kerala" is inaccessible!') + ) def assert_leave_operation_done(): assert self.window.mscolab.active_op_id is None @@ -753,8 +759,13 @@ def test_archive_operation(self, qtbot): assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None - with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: self.window.actionArchiveOperation.trigger() + qtbot.wait_until( + lambda: m.assert_called_once_with( + self.window, "Information", 'Active operation "flight1234" is inaccessible!') + ) assert self.window.mscolab.active_op_id is None @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_category", True)) diff --git a/tests/fixtures.py b/tests/fixtures.py index 9077612ac..5859c631b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -124,7 +124,7 @@ def mscolab_session_server(mscolab_session_app, mscolab_session_managers): with _running_eventlet_server(mscolab_session_app) as url: # Wait until the Flask-SocketIO server is ready for connections sio = socketio.Client() - sio.connect(url)#, retry=True) + sio.connect(url, retry=True) sio.disconnect() del sio yield url From e33f8e33389744d5726f5f90f14a0dc554b7d763 Mon Sep 17 00:00:00 2001 From: "J. Ungermann" Date: Wed, 18 Sep 2024 18:17:37 +0200 Subject: [PATCH 26/29] fix #2530 (#2532) --- mslib/msui/mscolab.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index f05b173de..30b5664e0 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -354,6 +354,7 @@ def disconnect_handler(self): self.set_status("Info", 'Disconnected from server.') def login_handler(self): + self.loginBtn.setEnabled(False) data = { "email": self.loginEmailLe.text(), "password": self.loginPasswordLe.text() @@ -380,6 +381,7 @@ def login_handler(self): # show status indicating about wrong credentials self.set_status("Error", 'Invalid credentials. Fix them, create a new user, or ' f'recover your password.') + self.loginBtn.setEnabled(True) else: self.save_user_credentials_to_config_file(data["email"], data["password"]) self.mscolab.after_login(data["email"], self.mscolab_server_url, r) From 2a68b2572c03c408f429fbdcb066c2391954b523 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 19 Sep 2024 08:57:39 +0200 Subject: [PATCH 27/29] add our chat to the RTFD footer and top on our README (#2512) * XML validation * Revert "XML validation" This reverts commit 6a41a4da43215f039b3ab2716e8b49be29f0ddfd. * add our chat * removed a line --- README.md | 5 +++++ docs/_templates/footer.html | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 docs/_templates/footer.html diff --git a/README.md b/README.md index f819688af..75ec70d0d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +**Chat:** +[![IRC: #mss-general on libera.chat](https://img.shields.io/badge/libera.chat-%23MSS_General-blue)](https://web.libera.chat/?channels=#mss-general) +[![IRC: #mss-gsoc on libera.chat](https://img.shields.io/badge/libera.chat-%23MSS_GSoC-brightgreen)](https://web.libera.chat/?channels=#mss-gsoc) + + Mission Support System Usage Guidelines ======================================= diff --git a/docs/_templates/footer.html b/docs/_templates/footer.html new file mode 100644 index 000000000..eb5febe74 --- /dev/null +++ b/docs/_templates/footer.html @@ -0,0 +1,21 @@ +{% extends '!footer.html' %} + +{% block extrafooter %} +


+ +{% endblock %} From e7ff0483d0b96ea0b1d92bac01f34e1aa6e05090 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 19 Sep 2024 11:05:34 +0200 Subject: [PATCH 28/29] except BrokenPipeError on github (#2535) --- tests/constants.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/constants.py b/tests/constants.py index cac933a8e..a1920bd19 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -28,6 +28,7 @@ import os import fs import tempfile +import logging from fs.tempfs import TempFS try: @@ -35,10 +36,14 @@ except ImportError: SHA = "" else: - repo = git.Repo(os.path.dirname(os.path.realpath(__file__)), search_parent_directories=True) + path = os.path.dirname(os.path.realpath(__file__)) + repo = git.Repo(path, search_parent_directories=True) + logging.debug(path) try: + # this is for local development important to get a fresh named tmpdir SHA = repo.head.object.hexsha[0:10] - except ValueError: + except (ValueError, BrokenPipeError) as ex: + logging.debug("Unknown Problem: %s", ex) # mounted dir in docker container and owner is not root SHA = "" From 22aa853875a2615fb5586139eb54444151af4fe7 Mon Sep 17 00:00:00 2001 From: ReimarBauer Date: Thu, 19 Sep 2024 13:36:43 +0200 Subject: [PATCH 29/29] setup a config for tutorials to test the mscolab refactoring (#2528) * option to enable flask debug added * remove mscolab start from batch * similiar dir as for the ui * new module for mscolab restart * linting satisfied --- mslib/mscolab/conf.py | 3 ++ mslib/mscolab/mscolab.py | 5 +- mslib/mscolab/server.py | 2 +- tutorials/tutorial_mscolab.py | 2 +- tutorials/tutorials.batch | 6 --- tutorials/utils/__init__.py | 50 ++++++++++++++++-- tutorials/utils/restart_mscolab.py | 81 ++++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 tutorials/utils/restart_mscolab.py diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index e88bc6d57..d846fcf18 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -49,6 +49,9 @@ class default_mscolab_settings: # To enable Engine.IO logging set to True or pass a logger object to use. ENGINEIO_LOGGER = False + # To enable flask socketio debugging, this sets in flask_socketio app.debug too + DEBUG = False + # Which origins are allowed to communicate with your server CORS_ORIGINS = ["*"] diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index d802dcbb4..da8f26897 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -47,9 +47,10 @@ from mslib.utils import setup_logging -def handle_start(args): +def handle_start(args=None): from mslib.mscolab.server import APP, sockio, cm, fm, start_server - setup_logging(args) + if args is not None: + setup_logging(args) logging.info("MSS Version: %s", __version__) logging.info("Python Version: %s", sys.version) logging.info("Platform: %s (%s)", platform.platform(), platform.architecture()) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 19013ea89..f43fdd154 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -1015,7 +1015,7 @@ def metadata(idp_identity_name): def start_server(app, sockio, cm, fm, port=8083): create_files() - sockio.run(app, port=port) + sockio.run(app, port=port, debug=mscolab_settings.DEBUG) def main(): diff --git a/tutorials/tutorial_mscolab.py b/tutorials/tutorial_mscolab.py index 68dbf1954..aba7f747b 100644 --- a/tutorials/tutorial_mscolab.py +++ b/tutorials/tutorial_mscolab.py @@ -553,4 +553,4 @@ def _connect_to_mscolab_url(): if __name__ == '__main__': - start(target=automate_mscolab, duration=640) + start(target=automate_mscolab, duration=640, mscolab=True, dry_run=False) diff --git a/tutorials/tutorials.batch b/tutorials/tutorials.batch index e95363dfa..e99eb4ecf 100755 --- a/tutorials/tutorials.batch +++ b/tutorials/tutorials.batch @@ -164,12 +164,6 @@ cd .. #################################################### # tutorial mscolab, 3K, because we need the menu in full screen -# start a mscolab server on standard port after you have it seeded -# we should have a seed for tutorials -$HOME/Miniforge/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py db --seed - -$HOME/Miniforge/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py start & - ~/bin/highlight-pointer -r 10 --key-quit q & $HOME/Miniforge/envs/mssdev/bin/python tutorial_mscolab.py diff --git a/tutorials/utils/__init__.py b/tutorials/utils/__init__.py index 770aef845..d347b70a5 100644 --- a/tutorials/utils/__init__.py +++ b/tutorials/utils/__init__.py @@ -82,6 +82,15 @@ def call_msui(): msui.main(tutorial_mode=True) +def call_mscolab(): + # change of config won't work when it becomes earlier imported + from mslib.mscolab import mscolab + with mscolab.APP.app_context(): + # initialize our seeded example dbase + mscolab.handle_db_seed() + mscolab.handle_start() + + def finish(close_widgets=3): """ Closes all open windows and exits the application. @@ -129,7 +138,7 @@ def finish(close_widgets=3): raise -def start(target=None, duration=120, dry_run=False): +def start(target=None, duration=120, dry_run=False, mscolab=False): """ Starts the automation process. @@ -141,8 +150,31 @@ def start(target=None, duration=120, dry_run=False): Note: Uncomment the line pag.press('q') if recording windows do not close in some cases. """ if platform.system() == 'Linux': + tutdir = "/tmp/msui_tutorials" + if not os.path.isdir(tutdir): + os.mkdir(tutdir) + os.environ["MSUI_CONFIG_PATH"] = tutdir + os.environ["XDG_CACHE_HOME"] = tutdir # makes sure the keyboard is set to US os.system("setxkbmap -layout us") + + # early + if mscolab: + mscdir = "/tmp/mscolab_tutorials" + if not os.path.isdir(mscdir): + os.makedirs(mscdir) + settings_file = os.path.join(mscdir, "mscolab_settings.py") + with open(settings_file, "w") as sf: + sf.write('import os\n') + sf.write('\n\n') + sf.write(f"BASE_DIR = '{mscdir}'\n") + sf.write('DATA_DIR = os.path.join(BASE_DIR, "colabdata")\n') + sf.write('OPERATIONS_DATA = os.path.join(DATA_DIR, "filedata")\n') + sf.write("DEBUG = True\n") + + os.environ["MSCOLAB_SETTINGS"] = settings_file + sys.path.insert(0, mscdir) + if target is None: return p1 = multiprocessing.Process(target=call_msui) @@ -150,6 +182,10 @@ def start(target=None, duration=120, dry_run=False): if not dry_run: p3 = multiprocessing.Process(target=call_recorder, kwargs={"duration": duration}) p3.start() + if mscolab is True: + print("Start and Seed MSColab server") + p4 = multiprocessing.Process(target=call_mscolab, daemon=True) + p4.start() print("\nINFO : Starting Automation.....\n") @@ -158,11 +194,15 @@ def start(target=None, duration=120, dry_run=False): p1.start() p2.start() - p2.join() - p1.join() - if not dry_run: - p3.join() + # unclear for what the join was needed + # p2.join() + # p1.join() + # if not dry_run: + # p3.join() + # if mscolab: + # p4.join() print("\n\nINFO : Automation Completes Successfully!") + # pag.press('q') # In some cases, recording windows does not closes. So it needs to ne there. sys.exit() diff --git a/tutorials/utils/restart_mscolab.py b/tutorials/utils/restart_mscolab.py new file mode 100644 index 000000000..70bc98967 --- /dev/null +++ b/tutorials/utils/restart_mscolab.py @@ -0,0 +1,81 @@ +""" + + mslib.tutorials.utils.restart_mscolab + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module has functions related to restarting the mscolab server. + + This file is part of MSS. + + :copyright: Copyright 2024 by Reimar Bauer + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import socketio +import time +from pathlib import Path + +try: + import mscolab_settings +except ImportError: + mscolab_settings = None + + +sio = socketio.Client() + + +@sio.event +def connect(): + print('connection established') + return True + + +@sio.event +def disconnect(): + print('disconnected from server') + return False + + +def restart_mscolab(): + if mscolab_settings is not None: + msc_settings_file = mscolab_settings.__file__ + Path(msc_settings_file).touch() + else: + print("mscolab settings file not found, can't restart, have you set the PYTHONPATH environment variable?") + + +def verify_mscolab_server_alive(url="http://localhost", port="8083"): + try: + sio.connect(f'{url}:{port}') + return True + except socketio.exceptions.ConnectionError: + return False + + +def wait_until_mscolab_server_alive(max_wait=10, interval=0.5): + start_time = time.time() + + while True: + # Check server status + if verify_mscolab_server_alive(): + print("MSColab server is alive!") + break + + # Check if the max wait time has passed + if time.time() - start_time > max_wait: + print(f"Waited for {max_wait} seconds, but MSColab server is still not alive.") + break + + # Wait for short interval before checking server status again + time.sleep(interval)