diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5ace4600a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..3280f2946 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ +**Purpose of PR?**: + +Fixes # + +**Does this PR introduce a breaking change?** + +**If the changes in this PR are manually verified, list down the scenarios covered:**: + +**Additional information for reviewer?** : +_Mention if this PR is part of any design or a continuation of previous PRs_ + +**Does this PR results in some Documentation changes?** +_If yes, include the list of Documentation changes_ + +**Checklist:** +- [ ] Bug fix. Fixes # +- [ ] New feature (Non-API breaking changes that adds functionality) +- [ ] PR Title follows the convention of `: ` +- [ ] Commit has unit tests + + \ No newline at end of file diff --git a/.github/workflows/ISSUE_TEMPLATE/bug_report.md b/.github/workflows/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..ba43749cd --- /dev/null +++ b/.github/workflows/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a bug report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +## Bug Report + + +**General Information** + +Please describe your issue in few words here. + +**How to Reproduce** + +1. Instruction 1 +2. Instruction 2 + +**Expected behavior** + +A description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. diff --git a/.github/workflows/ISSUE_TEMPLATE/feature_request.md b/.github/workflows/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..602efcede --- /dev/null +++ b/.github/workflows/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request/Enhancement +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +## Feature Request + +**Short Description** + +E.g., MSS could support ensuring that cows don't take over the world and turn us all into their loyal milkmaids. + +**Is your feature request related to a problem? Please describe the use case.** + +A description of what the problem/use case is. E.g. Prevent the impending cow uprising and maintain human dominance on Earth. + +**Describe the solution you'd like** + +A description of what you want to happen. E.g., Develop a "Moofense" system that deploys an army of robotic cow herders to keep the bovine rebellion in check. + +**Describe alternatives you've considered** + +A description of any alternative solutions or features you've considered. E.g, Alternatively, we could offer the cows a reality TV show deal and distract them with celebrity pasture appearances, turning them into the world's first moo-dia stars. \ No newline at end of file diff --git a/.github/workflows/build_docs_gallery.yml b/.github/workflows/build_docs_gallery.yml index 2a2dea4db..b5732cc43 100644 --- a/.github/workflows/build_docs_gallery.yml +++ b/.github/workflows/build_docs_gallery.yml @@ -1,6 +1,6 @@ name: Build Gallery -on: +on: pull_request: inputs: branch_name: @@ -27,7 +27,7 @@ jobs: steps: - name: Trust My Directory run: git config --global --add safe.directory /__w/MSS/MSS - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Create gallery timeout-minutes: 25 diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index b3b1ef122..06a0c4454 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -8,23 +8,27 @@ on: branches: - develop - stable + - GSOC2023-NilupulManodya + - GSOC2023-ShubhGaur pull_request: branches: - develop - stable + - GSOC2023-NilupulManodya + - GSOC2023-ShubhGaur jobs: lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.8 - uses: actions/setup-python@v3 + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: "3.10" - name: Lint with flake8 run: | python -m pip install --upgrade pip - pip install flake8 + pip install flake8 flake8-builtins flake8 --count --statistics mslib tests diff --git a/.github/workflows/testing-develop.yml b/.github/workflows/testing-develop.yml index 208899dfe..0f4860c4c 100644 --- a/.github/workflows/testing-develop.yml +++ b/.github/workflows/testing-develop.yml @@ -9,24 +9,12 @@ on: branches: - develop -jobs: +jobs: test-develop: - uses: + uses: ./.github/workflows/testing.yml with: - xdist: no - branch_name: develop - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} - - test-develop-xdist: - uses: - ./.github/workflows/testing.yml - with: - xdist: yes branch_name: develop event_name: ${{ github.event_name }} secrets: PAT: ${{ secrets.PAT }} - diff --git a/.github/workflows/testing-scheduled.yml b/.github/workflows/testing-scheduled.yml index f97be66e3..985b533d5 100644 --- a/.github/workflows/testing-scheduled.yml +++ b/.github/workflows/testing-scheduled.yml @@ -2,29 +2,25 @@ name: new dependency test (scheduled) on: schedule: - - cron: '30 5 * * 1' + - cron: '30 5 * * 1' -jobs: - test-stable-scheduled: - uses: - ./.github/workflows/testing.yml - with: - xdist: no - branch_name: stable - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} +jobs: + trigger-testing-stable: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - uses: benc-uk/workflow-dispatch@v1.2.2 + with: + workflow: testing-stable.yml + ref: stable test-develop-scheduled: - uses: + uses: ./.github/workflows/testing.yml with: - xdist: no branch_name: develop event_name: ${{ github.event_name }} secrets: PAT: ${{ secrets.PAT }} - - - diff --git a/.github/workflows/testing-stable.yml b/.github/workflows/testing-stable.yml index 8e11d3784..ffc306270 100644 --- a/.github/workflows/testing-stable.yml +++ b/.github/workflows/testing-stable.yml @@ -9,25 +9,12 @@ on: - stable workflow_dispatch: -jobs: +jobs: test-stable: - uses: + uses: ./.github/workflows/testing.yml with: - xdist: no branch_name: stable event_name: ${{ github.event_name }} secrets: PAT: ${{ secrets.PAT }} - - test-stable-xdist: - uses: - ./.github/workflows/testing.yml - with: - xdist: yes - branch_name: stable - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} - - diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index fcd818794..1e459431b 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,11 +1,8 @@ name: Pytest MSS -on: +on: workflow_call: inputs: - xdist: - required: true - type: string branch_name: required: true type: string @@ -31,11 +28,16 @@ jobs: container: image: openmss/testing-${{ inputs.branch_name }} + strategy: + fail-fast: false + matrix: + order: ["normal", "reverse"] + steps: - name: Trust My Directory run: git config --global --add safe.directory /__w/MSS/MSS - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Check for changed dependencies run: | @@ -69,7 +71,6 @@ jobs: | sed -e "s/menuinst.*//" \ | sed -e "s/.*://" > reqs.txt \ && cat requirements.d/development.txt >> reqs.txt \ - && echo pyvirtualdisplay >> reqs.txt \ && cat reqs.txt \ && mamba env remove -n mss-${{ inputs.branch_name }}-env \ && mamba create -y -n mss-${{ inputs.branch_name }}-env --file reqs.txt @@ -82,36 +83,18 @@ jobs: && mamba list - name: Run tests - if: ${{ success() && inputs.xdist == 'no' }} - timeout-minutes: 25 - run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && pytest -v --durations=20 --reverse --cov=mslib tests \ - || (for i in {1..5} \ - ; do pytest tests -v --durations=0 --reverse --last-failed --lfnf=none \ - && break \ - ; done) - - - name: Run tests in parallel - if: ${{ success() && inputs.xdist == 'yes' }} - timeout-minutes: 25 + timeout-minutes: 10 run: | cd $GITHUB_WORKSPACE \ && source /opt/conda/etc/profile.d/conda.sh \ && source /opt/conda/etc/profile.d/mamba.sh \ && mamba activate mss-${{ inputs.branch_name }}-env \ - && pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests \ - || (for i in {1..5} \ - ; do pytest -vv -n 6 --dist loadfile --max-worker-restart 0 tests --last-failed --lfnf=none \ - && break \ - ; done) + && xvfb-run pytest -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib \ + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage - if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && inputs.xdist == 'no'}} - env: + if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && matrix.order == 'normal' }} + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd $GITHUB_WORKSPACE \ @@ -122,7 +105,7 @@ jobs: && coveralls --service=github - name: Invoke dockertesting image creation - if: ${{ always() && inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && inputs.xdist == 'no'}} + if: ${{ always() && inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} uses: benc-uk/workflow-dispatch@v1.2.2 with: workflow: Update Image testing-${{ inputs.branch_name }} diff --git a/.github/workflows/testing_gsoc.yml b/.github/workflows/testing_gsoc.yml new file mode 100644 index 000000000..60336a0ab --- /dev/null +++ b/.github/workflows/testing_gsoc.yml @@ -0,0 +1,90 @@ +name: test GSOC branches + +on: + push: + branches: + - 'GSOC**' + + pull_request: + branches: + - 'GSOC**' + +env: + PAT: ${{ secrets.PAT }} + + +jobs: + Test-MSS-GSOC: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + + container: + image: openmss/testing-develop + + strategy: + fail-fast: false + matrix: + order: ["normal", "reverse"] + + steps: + - name: Trust My Directory + run: git config --global --add safe.directory /__w/MSS/MSS + + - uses: actions/checkout@v4 + + - name: Check for changed dependencies + run: | + cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt \ + || (echo Dependencies differ \ + && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) + + - name: Reinstall dependencies if changed + if: ${{ success() && env.triggerdockerbuild == 'yes' }} + run: | + cd $GITHUB_WORKSPACE \ + && source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && mamba deactivate \ + && cat localbuild/meta.yaml \ + | sed -n '/^requirements:/,/^test:/p' \ + | sed -e "s/.*- //" \ + | sed -e "s/menuinst.*//" \ + | sed -e "s/.*://" > reqs.txt \ + && cat requirements.d/development.txt >> reqs.txt \ + && cat reqs.txt \ + && mamba env remove -n mss-develop-env \ + && mamba create -y -n mss-develop-env --file reqs.txt + + - name: Print conda list + run: | + source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && mamba list + + - name: Run tests + if: ${{ success() }} + timeout-minutes: 10 + run: | + cd $GITHUB_WORKSPACE \ + && source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && xvfb-run pytest -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib \ + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests + + - name: Collect coverage + if: ${{ success() }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd $GITHUB_WORKSPACE \ + && source /opt/conda/etc/profile.d/conda.sh \ + && source /opt/conda/etc/profile.d/mamba.sh \ + && mamba activate mss-develop-env \ + && mamba install coveralls \ + && coveralls --service=github diff --git a/.gitignore b/.gitignore index 69fbc2c15..9671caf94 100644 --- a/.gitignore +++ b/.gitignore @@ -6,15 +6,9 @@ *.swp *.patch *~ -mslib/mss_config.py mslib/performance/data/ -mslib/msui/mss.sideview.cfg -mslib/msui/mss.topview.cfg -mslib/msui/msui_settings.py -mslib/msui/wms_cache/ mslib/mswms/mswms_settings.py mslib/mswms/mswms_auth.py -mslib/mscolab/colabdata/ docs/_build docs/gallery/plots docs/gallery/code @@ -24,4 +18,3 @@ build/ mss.egg-info/ tutorials/recordings tutorials/cursor_image.png - diff --git a/AUTHORS b/AUTHORS index 0465897c9..24c3f1e15 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ in alphabetic order by first name - Jens-Uwe Grooß - Jörn Ungermann - Marc Rautenhaus +- Matthias Riße - May Bär - Nilupul Manodya - Reimar Bauer diff --git a/CHANGES.rst b/CHANGES.rst index e95e10fc8..0d10cf37e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -108,8 +108,8 @@ Mscolab Operations in use for more than 30 days, move to an inactive list. The initial idea for multiple flightpaths on topview stems from bkirbus. GSoC mentors were Reimar Bauer, Jörn Ungermann, Sonja Gisinger -With MSS 8.0.0 we base our installation on mambaforge. This has -mamba in the base environment. +With MSS 9.0.0 we base our installation on miniforge. This has +mamba in the base environment. Mambaforge is discouraged of September 2023. All changes: https://github.com/Open-MSS/MSS/milestone/81?closed=1 diff --git a/NOTICE b/NOTICE index 3504c7039..7622905bf 100644 --- a/NOTICE +++ b/NOTICE @@ -107,8 +107,8 @@ Icons We use icons in mslib.mui.editor from the tango-icon-library Author: Jakub Steiner jimmac@gmail.com -License: https://github.com/freedesktop/tango-icon-library/blob/master/COPYING.PublicDomain -Further Information: http://tango.freedesktop.org +License: https://gitlab.freedesktop.org/tango/tango-icon-library/-/blob/master/COPYING.PublicDomain +Further Information: https://gitlab.freedesktop.org/tango/tango-icon-library Airports Data ------------- @@ -130,3 +130,14 @@ License: https://github.com/PaulSchweizer/qt-json-view/blob/master/LICENSE (MIT Package for working with JSON files in PyQt5. Obtained from Github (https://github.com/PaulSchweizer/qt-json-view), on 23/7/2021. + +Identity Provider +----------------- + +We utilize example files from the pysaml2 library to set up the configuration for our local Identity Provider (IdP). +Obtained from GitHub (https://github.com/IdentityPython/pysaml2/tree/master/example/idp2) on 13/07/2023 + +Copyright: 2018 Roland Hedberg + +License: https://github.com/IdentityPython/pysaml2/blob/master/LICENSE (Apache License 2.0) +Further Information: https://pysaml2.readthedocs.io/en/ diff --git a/README.md b/README.md index bd552a069..b014afc9d 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ Automatically Manually -------- -As **Beginner** start with an installation of Mambaforge -Get [mambaforge](https://github.com/conda-forge/miniforge#mambaforge) for your Operation System +As **Beginner** start with an installation of Miniforge +Get [miniforge](https://github.com/conda-forge/miniforge#download) for your Operation System You must install mss into a new environment to ensure the most recent diff --git a/conftest.py b/conftest.py index 32339103c..a1aa82e37 100644 --- a/conftest.py +++ b/conftest.py @@ -25,12 +25,9 @@ limitations under the License. """ -import importlib.machinery +import importlib.util import os import sys -import mock -import warnings -from PyQt5 import QtWidgets # Disable pyc files sys.dont_write_bytecode = True @@ -41,9 +38,8 @@ from mslib.mswms.demodata import DataFiles import tests.constants as constants -# make a copy for mscolab test, so that we read different pathes during parallel tests. -sample_path = os.path.join(os.path.dirname(__file__), "tests", "data") -shutil.copy(os.path.join(sample_path, "example.ftml"), constants.ROOT_DIR) +# This import must come after importing tests.constants due to MSUI_CONFIG_PATH being set there +from mslib.utils.config import read_config_file class TestKeyring(keyring.backend.KeyringBackend): @@ -53,193 +49,186 @@ class TestKeyring(keyring.backend.KeyringBackend): """ priority = 1 + passwords = {} + + def reset(self): + self.passwords = {} + def set_password(self, servicename, username, password): - pass + self.passwords[servicename + username] = password def get_password(self, servicename, username): - return "password from TestKeyring" + return self.passwords.get(servicename + username, "password from TestKeyring") def delete_password(self, servicename, username): - pass + if servicename + username in self.passwords: + del self.passwords[servicename + username] + # set the keyring for keyring lib keyring.set_keyring(TestKeyring()) -def pytest_addoption(parser): - parser.addoption("--msui_settings", action="store") +@pytest.fixture(autouse=True) +def keyring_reset(): + keyring.get_keyring().reset() -def pytest_generate_tests(metafunc): - option_value = metafunc.config.option.msui_settings - if option_value is not None: - msui_settings_file_fs = fs.open_fs(constants.MSUI_CONFIG_PATH) - msui_settings_file_fs.writetext("msui_settings.json", option_value) - msui_settings_file_fs.close() +def generate_initial_config(): + """Generate an initial state for the configuration directory in tests.constants.ROOT_FS + """ + if not constants.ROOT_FS.exists("msui/testdata"): + constants.ROOT_FS.makedirs("msui/testdata") + + # make a copy for mscolab test, so that we read different pathes during parallel tests. + sample_path = os.path.join(os.path.dirname(__file__), "tests", "data") + shutil.copy(os.path.join(sample_path, "example.ftml"), constants.ROOT_DIR) + + if not constants.SERVER_CONFIG_FS.exists(constants.SERVER_CONFIG_FILE): + print('\n configure testdata') + # ToDo check pytest tmpdir_factory + examples = DataFiles(data_fs=constants.DATA_FS, + server_config_fs=constants.SERVER_CONFIG_FS) + examples.create_server_config(detailed_information=True) + examples.create_data() + + if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_CONFIG_FILE): + config_string = f''' +# SQLALCHEMY_DB_URI = 'mysql://user:pass@127.0.0.1/mscolab' +import os +import logging +import fs +import secrets +from urllib.parse import urljoin + +ROOT_DIR = '{constants.ROOT_DIR}' +# directory where mss output files are stored +root_fs = fs.open_fs(ROOT_DIR) +if not root_fs.exists('colabTestData'): + root_fs.makedir('colabTestData') +BASE_DIR = ROOT_DIR +DATA_DIR = fs.path.join(ROOT_DIR, 'colabTestData') +# mscolab data directory +MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') +MSCOLAB_SSO_DIR = fs.path.join(DATA_DIR, 'datasso') + +# In the unit days when Operations get archived because not used +ARCHIVE_THRESHOLD = 30 + +# To enable logging set to True or pass a logger object to use. +SOCKETIO_LOGGER = True + +# To enable Engine.IO logging set to True or pass a logger object to use. +ENGINEIO_LOGGER = True + +# used to generate and parse tokens +SECRET_KEY = secrets.token_urlsafe(16) + +# used to generate the password token +SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) + +# looks for a given category for an operation ending with GROUP_POSTFIX +# e.g. category = Tex will look for TexGroup +# all users in that Group are set to the operations of that category +# having the roles in the TexGroup +GROUP_POSTFIX = "Group" + +# mail settings +MAIL_SERVER = 'localhost' +MAIL_PORT = 25 +MAIL_USE_TLS = False +MAIL_USE_SSL = True + +# mail authentication +MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') +MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') + +# mail accounts +MAIL_DEFAULT_SENDER = 'MSS@localhost' + +# enable verification by Mail +MAIL_ENABLED = False + +SQLALCHEMY_DB_URI = 'sqlite:///' + urljoin(DATA_DIR, 'mscolab.db') + +# mscolab file upload settings +UPLOAD_FOLDER = fs.path.join(DATA_DIR, 'uploads') +MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB + +# text to be written in new mscolab based ftml files. +STUB_CODE = """ + + + + + + + + + + +""" +enable_basic_http_authentication = False +# enable login by identity provider +USE_SAML2 = False +''' + ROOT_FS = fs.open_fs(constants.ROOT_DIR) + if not ROOT_FS.exists('mscolab'): + ROOT_FS.makedir('mscolab') + with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: + # windows needs \\ or / but mixed is terrible. *nix needs / + mscolab_fs.writetext(constants.MSCOLAB_CONFIG_FILE, config_string.replace('\\', '/')) + path = fs.path.join(constants.ROOT_DIR, 'mscolab', constants.MSCOLAB_CONFIG_FILE) + parent_path = fs.path.join(constants.ROOT_DIR, 'mscolab') -if os.getenv("TESTS_VISIBLE") == "TRUE": - Display = None -else: - try: - from pyvirtualdisplay import Display - except ImportError: - Display = None + if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_AUTH_FILE): + config_string = ''' +import hashlib -if not constants.SERVER_CONFIG_FS.exists(constants.SERVER_CONFIG_FILE): - print('\n configure testdata') - # ToDo check pytest tmpdir_factory - examples = DataFiles(data_fs=constants.DATA_FS, - server_config_fs=constants.SERVER_CONFIG_FS) - examples.create_server_config(detailed_information=True) - examples.create_data() +class mscolab_auth: + password = "testvaluepassword" + allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] +''' + ROOT_FS = fs.open_fs(constants.ROOT_DIR) + if not ROOT_FS.exists('mscolab'): + ROOT_FS.makedir('mscolab') + with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: + # windows needs \\ or / but mixed is terrible. *nix needs / + mscolab_fs.writetext(constants.MSCOLAB_AUTH_FILE, config_string.replace('\\', '/')) -if not constants.SERVER_CONFIG_FS.exists(constants.MSCOLAB_CONFIG_FILE): - config_string = f'''# -*- coding: utf-8 -*- -""" + def _load_module(module_name, path): + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) - mslib.mscolab.conf.py.example - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - config for mscolab. + _load_module("mswms_settings", constants.SERVER_CONFIG_FILE_PATH) + _load_module("mscolab_settings", path) - This file is part of MSS. - :copyright: Copyright 2019 Shivashis Padhi - :copyright: Copyright 2019-2023 by the MSS team, see AUTHORS. - :license: APACHE-2.0, see LICENSE for details. +generate_initial_config() - 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 +# This import must come after the call to generate_initial_config, otherwise SQLAlchemy will have a wrong database path +from tests.utils import create_msui_settings_file - 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. -""" -class mscolab_settings(object): - - # SQLALCHEMY_DB_URI = 'mysql://user:pass@127.0.0.1/mscolab' - import os - import logging - import fs - import secrets - from werkzeug.urls import url_join - - ROOT_DIR = '{constants.ROOT_DIR}' - # directory where mss output files are stored - root_fs = fs.open_fs(ROOT_DIR) - if not root_fs.exists('colabTestData'): - root_fs.makedir('colabTestData') - BASE_DIR = ROOT_DIR - DATA_DIR = fs.path.join(ROOT_DIR, 'colabTestData') - # mscolab data directory - MSCOLAB_DATA_DIR = fs.path.join(DATA_DIR, 'filedata') - - # used to generate and parse tokens - SECRET_KEY = secrets.token_urlsafe(16) - - # used to generate the password token - SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) - - # looks for a given category for an operation ending with GROUP_POSTFIX - # e.g. category = Tex will look for TexGroup - # all users in that Group are set to the operations of that category - # having the roles in the TexGroup - GROUP_POSTFIX = "Group" - - # mail settings - MAIL_SERVER = 'localhost' - MAIL_PORT = 25 - MAIL_USE_TLS = False - MAIL_USE_SSL = True - - # mail authentication - MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') - MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') - - # mail accounts - MAIL_DEFAULT_SENDER = 'MSS@localhost' - - # enable verification by Mail - MAIL_ENABLED = False - - SQLALCHEMY_DB_URI = 'sqlite:///' + url_join(DATA_DIR, 'mscolab.db') - - # mscolab file upload settings - UPLOAD_FOLDER = fs.path.join(DATA_DIR, 'uploads') - MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB - - # text to be written in new mscolab based ftml files. - STUB_CODE = """ - - - - - - - - - - - """ - enable_basic_http_authentication = False - ''' - ROOT_FS = fs.open_fs(constants.ROOT_DIR) - if not ROOT_FS.exists('mscolab'): - ROOT_FS.makedir('mscolab') - with fs.open_fs(fs.path.join(constants.ROOT_DIR, "mscolab")) as mscolab_fs: - # windows needs \\ or / but mixed is terrible. *nix needs / - mscolab_fs.writetext('mscolab_settings.py', config_string.replace('\\', '/')) - path = fs.path.join(constants.ROOT_DIR, 'mscolab', 'mscolab_settings.py') - parent_path = fs.path.join(constants.ROOT_DIR, 'mscolab') +@pytest.fixture(autouse=True) +def reset_config(): + """Reset the configuration directory used in the tests (tests.constants.ROOT_FS) after every test + """ + # Ideally this would just be constants.ROOT_FS.removetree("/"), but SQLAlchemy complains if the SQLite file is deleted. + for e in constants.ROOT_FS.walk.files(exclude=["mscolab.db"]): + constants.ROOT_FS.remove(e) + for e in constants.ROOT_FS.walk.dirs(search="depth"): + constants.ROOT_FS.removedir(e) -importlib.machinery.SourceFileLoader('mswms_settings', constants.SERVER_CONFIG_FILE_PATH).load_module() -sys.path.insert(0, constants.SERVER_CONFIG_FS.root_path) -importlib.machinery.SourceFileLoader('mscolab_settings', path).load_module() -sys.path.insert(0, parent_path) + generate_initial_config() + create_msui_settings_file("{}") + read_config_file() -@pytest.fixture(autouse=True) -def close_open_windows(): - """ - Closes all windows after every test - """ - # Mock every MessageBox widget in the test suite to avoid unwanted freezes on unhandled error popups etc. - with mock.patch("PyQt5.QtWidgets.QMessageBox.question") as q, \ - mock.patch("PyQt5.QtWidgets.QMessageBox.information") as i, \ - mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as c, \ - mock.patch("PyQt5.QtWidgets.QMessageBox.warning") as w: - yield - if any(box.call_count > 0 for box in [q, i, c, w]): - summary = "\n".join([f"PyQt5.QtWidgets.QMessageBox.{box()._extract_mock_name()}: {box.mock_calls[:-1]}" - for box in [q, i, c, w] if box.call_count > 0]) - warnings.warn(f"An unhandled message box popped up during your test!\n{summary}") - - - # Try to close all remaining widgets after each test - for qobject in set(QtWidgets.QApplication.topLevelWindows() + QtWidgets.QApplication.topLevelWidgets()): - try: - qobject.destroy() - # Some objects deny permission, pass in that case - except RuntimeError: - pass - - -@pytest.fixture(scope="session", autouse=True) -def configure_testsetup(request): - if Display is not None: - # needs for invisible window output xvfb installed, - # default backend for visible output is xephyr - # by visible=0 you get xvfb - VIRT_DISPLAY = Display(visible=0, size=(1280, 1024)) - VIRT_DISPLAY.start() - yield - VIRT_DISPLAY.stop() - else: - yield +# Make fixtures available everywhere +from tests.fixtures import * diff --git a/docs/components.rst b/docs/components.rst index 41e205067..a1103fd77 100644 --- a/docs/components.rst +++ b/docs/components.rst @@ -10,5 +10,6 @@ Components mscolab gentutorials mssautoplot - - + conf_auth_client_sp_idp + conf_sso_test_msscolab + sso_via_saml_mscolab diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst new file mode 100644 index 000000000..85ee7054f --- /dev/null +++ b/docs/conf_sso_test_msscolab.rst @@ -0,0 +1,117 @@ +Configuration MSS Colab Server with Testing IdP for SSO +======================================================= +Testing IDP (`mslib/msidp`) is specifically designed for testing the Single Sign-On (SSO) process with the mscolab server using PySAML2. + +Here is documentation that explains the configuration of the MSS Colab Server with the testing IdP. + +Getting started +--------------- + +To set up a local identity provider with the mscolab server, you'll first need to generate the required keys and certificates for both the Identity Provider and the mscolab server. Follow these steps to configure the system: + + 1. Initial Steps + 2. Generate Keys and Certificates + 3. Enable USE_SAML2 + 4. Generate Metadata Files + 5. Start the Identity Provider + 6. Start the mscolab Server + 7. Test the Single Sign-On (SSO) Process + + +1. Initial Steps +---------------- +Before getting started, you should correctly activate the environments, set the correct Python path as explained in the mss instructions : https://github.com/Open-MSS/MSS/tree/develop#readme + + + +2. Generate Keys, Certificates, and backend_saml files +------------------------------------------------------ + +This involves generating both `.key` files and `.crt` files for both the Identity provider and mscolab server and `backend_saml.yaml` file. + +Before running the command make sure to set `USE_SAML2 = False` in your `mscolab_settings.py` file, You can accomplish this by following these steps: + +- Add to the `PYTHONPATH` where your `mscolab_settings.py`. +- Add `USE_SAML2 = False` in your `mscolab_settings.py` file. + +.. note:: + If you set `USE_SAML2 = True` without keys and certificates, this will not execute. So, make sure to set `USE_SAML2 = False` before executing the command. + +If everything is correctly set, you can generate keys and certificates simply by running + +.. code:: text + + $ mscolab sso_conf --init_sso_crts + +.. note:: + This process generating keys and certificates for both Identity provider and mscolab server by default, If you need configure with different keys and certificates for the Identity provider, You should manually update the path of `SERVER_CERT` with the path of the generated .crt file for Identity provider, and `SERVER_KEY` with the path of the generated .key file for the Identity provider in the file `MSS/mslib/idp/idp_conf.py`. + + +3. Enable USE_SAML2 +------------------- + +To enable SAML2-based login (identity provider-based login), + +- To start the process update `USE_SAML2 = True` in your `mscolab_settings.py` file. + +.. note:: + After enabling the `USE_SAML2` option, the subsequent step involves adding the `CONFIGURED_IDPS` dictionary for the MSS Colab Server. This dictionary must contain keys for each active Identity Provider, denoted by their `idp_identity_name`, along with their respective `idp_name`. Once this dictionary is configured, it should be utilized to update several aspects of the mscolab server, including the SAML2Client configuration in the .yml file. This ensures seamless integration with the enabled IDPs. By default, configuration has been set up for the localhost IDP, and any additional configurations required should be performed by the developer. + +4. Generate metadata files +-------------------------- + +This involves generating necessary metadata files for both the identity provider and the service provider. You can generate them by simply running the below command. + +.. note:: + Before executing this, you should set `USE_SAML2=True` as described in the third step(Enable USE_SAML2). + +.. code:: text + + $ mscolab sso_conf --init_sso_metadata + + +5. Start Identity provider +-------------------------- + +Once you set certificates and metada files you can start mscolab server and local identity provider. To start local identity provider, simply execute: + +.. code:: text + + $ msidp + + +6. Start the mscolab Server +--------------------------- + +Before Starting the mscolab server, make sure to do necessary database migrations. + +When this is the first time you setup a mscolab server, you have to initialize the database by: + +.. code:: text + + $ mscolab db --init + +.. note:: + An existing database maybe needs a migration, have a look for this on our documentation. + + https://mss.readthedocs.io/en/stable/mscolab.html#data-base-migration + +When migrations finished, you can start mscolab server using the following command: + +.. code:: text + + $ mscolab start + + +7. Testing Single Sign-On (SSO) process +--------------------------------------- + +* Once you have successfully launched the server and identity provider, you can begin testing the Single Sign-On (SSO) process. +* Start MSS PyQt application: + +.. code:: text + + $ msui + +* Login with identity provider through Qt Client application. +* To log in to the mscolab server through the identity provider, you can use the credentials specified in the ``PASSWD`` section of the ``MSS/mslib/msidp/idp.py`` file. Look for the relevant section in the file to find the necessary login credentials. diff --git a/docs/development.rst b/docs/development.rst index 329fce64e..80a594076 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -132,7 +132,7 @@ Requirements 2. Software requirement | Python - | `Mambaforge `_ + | `Miniforge `_ | `Additional Requirements `_ @@ -145,7 +145,7 @@ Requirements Using predefined docker images instead of installing all requirements ..................................................................... -You can easily use our testing docker images which have all libraries pre installed. These are based on mambaforgen. +You can easily use our testing docker images which have all libraries pre installed. These are based on miniforge. We provide two images. In openmss/testing-stable we have mss-stable-env and in openmss/testing-develop we have mss-develop-env defined. In the further course of the documentation we speak of the environment mssdev, this corresponds to one of these evironments. @@ -171,12 +171,12 @@ Use the docker env on your computer, initial setup This example shows by using mss-stable-env how to set it up for testing and development of stable branch. The images gets updates when we have to add new dependencies or have do pinning of existing modules. On an updated image you need to redo these steps :: - rm -rf $HOME/mambaforge/envs/mss-stable-env # cleanup the existing env - mkdir $HOME/mambaforge/envs/mss-stable-env # create the dir to bind to + rm -rf $HOME/miniforge/envs/mss-stable-env # cleanup the existing env + mkdir $HOME/miniforge/envs/mss-stable-env # create the dir to bind to xhost +local:docker # may be needed - docker run -it --rm --mount type=volume,dst=/opt/conda/envs/mss-stable-env,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$HOME/mambaforge/envs/mss-stable-env --network host openmss/testing-stable # do the volume bind + docker run -it --rm --mount type=volume,dst=/opt/conda/envs/mss-stable-env,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$HOME/miniforge/envs/mss-stable-env --network host openmss/testing-stable # do the volume bind exit # we are in the container, escape :) - sudo ln -s $HOME/mambaforge/envs/mss-stable-env /opt/conda/envs/mss-stable-env # we need the origin location linked because hashbangs interpreters are with that path. (only once needed) + sudo ln -s $HOME/miniforge/envs/mss-stable-env /opt/conda/envs/mss-stable-env # we need the origin location linked because hashbangs interpreters are with that path. (only once needed) conda activate mss-stable-env # activate env cd workspace/MSS # go to your workspace MSS dir export PYTHONPATH=`pwd` # add it to the PYTHONPATH @@ -197,7 +197,7 @@ After the image was configured you can use it like a self installed env :: Manual Installing dependencies .............................. -MSS is based on the software of the conda-forge channel located. The channel is predefined in Mambaforge. +MSS is based on the software of the conda-forge channel located. The channel is predefined in Miniforge. Create an environment and install the dependencies needed for the mss package:: @@ -220,11 +220,10 @@ For developers we provide additional packages for running tests, activate your e $ mamba install --file requirements.d/development.txt -On linux install the `conda-forge package pyvirtualdisplay` and `xvfb` from your linux package manager. -This is used to run tests on a virtual display. -If you don't want tests redirected to the xvfb display just setup an environment variable:: +On linux install `xvfb` from your linux package manager. +This can be used to run tests on an invisible virtual display by prepending the pytest call with `xvfb-run`, e.g.:: - $ export TESTS_VISIBLE=TRUE + $ xvfb-run pytest ... We have implemented demodata as data base for testing. On first call of pytest a set of demodata becomes stored in a /tmp/mss* folder. If you have installed gitpython a postfix of the revision head is added. @@ -343,9 +342,7 @@ Use the -v option to get a verbose result. By the -k option you could select one Verify Code Style ................. -A flake8 only test is done by `py.test --flake8 -m flake8` or `pytest --flake8 -m flake8` - -Instead of running a ibrary module as a script by the -m option you may also use the pytest command. +A flake8 only test is done with `flake8 mslib tests`. Coverage ........ @@ -394,6 +391,23 @@ example:: +Writing Tests +------------- + +Ideally every new feature or bug fix should be accompanied by tests +that make sure that the feature works as intended or that the bug is indeed fixed +(and won't turn up again in the future). +The best way to find out how to write such tests is by taking a look at the existing tests, +maybe finding one that is similar +and adapting it to the new test case. + +MSS uses pytest as a test runner and therefore their `docs `_ are relevant here. + +Common resources that a test might need, +like e.g. a running MSColab server or a QApplication instance for GUI tests, +are collected in :mod:`tests.fixtures` in the form of pytest fixtures that can be requested as needed in tests. + + Pushing your changes -------------------- @@ -449,15 +463,27 @@ As developer you should copy this directory and adjust the source path, build nu using a local meta.yaml recipe:: $ cd yourlocalbuild - $ conda build . - $ conda create -n mssbuildtest mamba - $ conda activate mssbuildtest - $ mamba install --use-local mss + $ mamba build . + $ mamba create -n mssbuildtest + $ mamba activate mssbuildtest + $ mamba install -c local mss Take care on removing alpha builds, or increase the build number for a new version. +Alternative local build by boa +------------------------------ + +`boa `_ is a new faster option to build conda packages. +We need first to convert the existing description to a recipe.yaml:: + + $ cd yourlocalbuild + $ boa convert meta.yaml > recipe.yaml + $ boa build . + $ mamba install -c local mss + + Creating a new release ---------------------- @@ -528,4 +554,3 @@ GSoC'19 Projects - `Anveshan Lal: Updating Geographical Plotting Routines `_ - `Shivashis Padhi: Collaborative editing of flight path in real-time `_ - diff --git a/docs/environment.yml b/docs/environment.yml index 0764e97e4..3b803c610 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -18,7 +18,6 @@ dependencies: - sphinx - fs - netCDF4 - - future - PyQt5 - owslib - basemap >=1.3.3 diff --git a/docs/gentutorials.rst b/docs/gentutorials.rst index d1f0e59ff..c370b649c 100644 --- a/docs/gentutorials.rst +++ b/docs/gentutorials.rst @@ -42,7 +42,7 @@ System Requirements Keep the following things in mind before running a script * You should have only an **US keyboard layout**. If you have a different keyboard layout, you just need to change it to - US keyboard! + US keyboard! Typewriting of urls in a DE keyboard layout does not write `:` and `//`. * The **cursor.py** python file will run only on Linux and not on Windows for grabbing the mouse pointer image. * The screenrecorder.py works only in **Full HD Screens**. @@ -57,11 +57,12 @@ Keep the following things in mind before running a script Getting Started --------------- -On the Anaconda terminal, type the following :: +On the terminal, type the following :: cd ..../MSS/$ $ export PYTHONPATH=.../MSS # Path of MSS - $ conda activate mssdev + $ export MSUI_CONFIG_PATH=/tmp/msui_tutorials # Path where msui_settings.json gets created and all heler images + $ mamba activate mssdev (mssdev)$ mamba install --file requirements.d/tutorials.txt This will install all the dependencies required for running of the tutorials. @@ -70,9 +71,6 @@ This will install all the dependencies required for running of the tutorials. **On Linux additionally** :: $ sudo apt-get install scrot - $ sudo apt-get install python3-tk - $ sudo apt-get install python3-dev - $ sudo apt-get install libx11-dev libxext-dev libxfixes-dev libxi-dev Now, just go into the **../MSS/tutorials/** directory :: @@ -84,6 +82,9 @@ Now, just go into the **../MSS/tutorials/** directory :: You must go into the tutorials directcory and then run the .py files. And always remember to add the PYTHONPATH to ........../MSS/ directory. +You have also to set the MSUI_CONFIG_PATH to a tmp directory. The comparison images are created below this directory. +The user's msui_settings.conf is not changed. + You cannot just do like this :: $ python MSS/tutorials/sreenrecorder.py # This will be problematic. @@ -117,7 +118,7 @@ Each python file inside MSS/tutorials can be run directly like :: (mssdev)~/..MSS/tutorials/ $ python screenrecorder.py -For recording anything on your screen. The videos will be then saved to `MSS/tutorials/Screen Recordings/` +For recording anything on your screen. The videos will be then saved to `MSS/tutorials/recordings/` For all the tutorials, you can do the same, example :: @@ -128,6 +129,11 @@ For all the tutorials, you can do the same, example :: The `MSS/tutorials/textfiles` contain descriptions of the tutorial videos in text format, these later can be converted to audio files by `audio.py` script after adding certain #ToDOs there. +When you want to run the tutorial by your IDE you can disable the screenrecording by `dry_run=True` +in the `start` function. Development on a 4K display is then possible too. + +For running the `tutorial_mscolab.py` you must provide a cleaned database and a mcolab server running on default port. + **Note** In tutorials development, when creating a class of Screen Recorder as :: @@ -196,5 +202,5 @@ batch scripts ~~~~~~~~~~~~~ Two batch scripts can be used to create tutorials. -start_tutorial.sh is to create one tutorial and tutorials.batch +`start_tutorial.sh` is to create one tutorial and `tutorials.batch` is used to create all tutorials compressed to gifcycles. diff --git a/docs/images/sso_via_saml_conf/ss_add_mappers_btn.png b/docs/images/sso_via_saml_conf/ss_add_mappers_btn.png new file mode 100644 index 000000000..e30ac3db7 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_add_mappers_btn.png differ diff --git a/docs/images/sso_via_saml_conf/ss_add_realam_btn.png b/docs/images/sso_via_saml_conf/ss_add_realam_btn.png new file mode 100644 index 000000000..27fdb3db1 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_add_realam_btn.png differ diff --git a/docs/images/sso_via_saml_conf/ss_add_realam_name.png b/docs/images/sso_via_saml_conf/ss_add_realam_name.png new file mode 100644 index 000000000..a77d02551 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_add_realam_name.png differ diff --git a/docs/images/sso_via_saml_conf/ss_admin_login.png b/docs/images/sso_via_saml_conf/ss_admin_login.png new file mode 100644 index 000000000..f735bb469 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_admin_login.png differ diff --git a/docs/images/sso_via_saml_conf/ss_client_select.png b/docs/images/sso_via_saml_conf/ss_client_select.png new file mode 100644 index 000000000..08fa90bb7 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_client_select.png differ diff --git a/docs/images/sso_via_saml_conf/ss_create_client_btn.png b/docs/images/sso_via_saml_conf/ss_create_client_btn.png new file mode 100644 index 000000000..357c7bbe7 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_create_client_btn.png differ diff --git a/docs/images/sso_via_saml_conf/ss_docker_run_cmd.png b/docs/images/sso_via_saml_conf/ss_docker_run_cmd.png new file mode 100644 index 000000000..839900461 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_docker_run_cmd.png differ diff --git a/docs/images/sso_via_saml_conf/ss_enable_mappers.png b/docs/images/sso_via_saml_conf/ss_enable_mappers.png new file mode 100644 index 000000000..072da7214 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_enable_mappers.png differ diff --git a/docs/images/sso_via_saml_conf/ss_enable_usr_reg.png b/docs/images/sso_via_saml_conf/ss_enable_usr_reg.png new file mode 100644 index 000000000..af24a3998 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_enable_usr_reg.png differ diff --git a/docs/images/sso_via_saml_conf/ss_gen_keys_crts.png b/docs/images/sso_via_saml_conf/ss_gen_keys_crts.png new file mode 100644 index 000000000..17ce5a340 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_gen_keys_crts.png differ diff --git a/docs/images/sso_via_saml_conf/ss_interface_keycloak.png b/docs/images/sso_via_saml_conf/ss_interface_keycloak.png new file mode 100644 index 000000000..2c4879754 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_interface_keycloak.png differ diff --git a/docs/images/sso_via_saml_conf/ss_left_nav_client.png b/docs/images/sso_via_saml_conf/ss_left_nav_client.png new file mode 100644 index 000000000..da7d46172 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_left_nav_client.png differ diff --git a/docs/images/sso_via_saml_conf/ss_left_nav_realm_settings.png b/docs/images/sso_via_saml_conf/ss_left_nav_realm_settings.png new file mode 100644 index 000000000..72f33f12c Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_left_nav_realm_settings.png differ diff --git a/docs/images/sso_via_saml_conf/ss_set_attribute_name1.png b/docs/images/sso_via_saml_conf/ss_set_attribute_name1.png new file mode 100644 index 000000000..231bff865 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_set_attribute_name1.png differ diff --git a/docs/images/sso_via_saml_conf/ss_set_attribute_name2.png b/docs/images/sso_via_saml_conf/ss_set_attribute_name2.png new file mode 100644 index 000000000..2873dd7d5 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_set_attribute_name2.png differ diff --git a/docs/images/sso_via_saml_conf/ss_set_client_protocol.png b/docs/images/sso_via_saml_conf/ss_set_client_protocol.png new file mode 100644 index 000000000..9a0fe5d82 Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_set_client_protocol.png differ diff --git a/docs/images/sso_via_saml_conf/ss_view_mappers.png b/docs/images/sso_via_saml_conf/ss_view_mappers.png new file mode 100644 index 000000000..42cb570cf Binary files /dev/null and b/docs/images/sso_via_saml_conf/ss_view_mappers.png differ diff --git a/docs/installation.rst b/docs/installation.rst index 52e656a30..8222d330c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -41,11 +41,15 @@ Manual Mamba based installation ........................ -We strongly recommend to start from `Mambaforge `_, + + +We strongly recommend to start from `Miniforge `_, a community project of the conda-forge community. -As **Beginner** start with an installation of Mambaforge -- Get `mambaforge `__ for your Operation System +As **Beginner** start with an installation of Miniforge +- Get `miniforge `__ for your Operation System + +If you use already Mambaforge please read the `FAQ `__ Install MSS ~~~~~~~~~~~ @@ -70,7 +74,7 @@ We suggest to create a mss user. * login as mss user * create a *src* directory in /home/mss * cd src -* get `mambaforge `__ +* get `miniforge `__ * set execute bit on install script * execute script, enable environment in .bashrc * login again @@ -99,22 +103,20 @@ Conda based installation `Anaconda `_ provides an enterprise-ready data analytics platform that empowers companies to adopt a modern open data science analytics architecture. -.. warning:: - Installing Mamba in Anaconda setup is not recommended. We strongly recommend to use the Mambaforge method (see above). - Please add the channel conda-forge to your defaults:: $ conda config --add channels conda-forge The conda-forge channel must be on top of the list before the anaconda default channel. +From September 2023 libmamba is the `default installer in anaconda `__. + Install MSS ~~~~~~~~~~~ You must install mss into a new environment to ensure the most recent versions for dependencies. :: - $ conda install -n base conda-libmamba-solver $ conda create -n mssenv $ conda activate mssenv (mssenv) $ conda install mss=$mss_version python --solver=libmamba @@ -245,4 +247,3 @@ You can start server and client by loading the image :: $ Singularity > mswms_demodata --seed # creates in your $HOME a mss/ folder with testdata $ Singularity > export PYTHONPATH=$HOME/mss; mswms # starts the development server $ Singularity > mscolab db --init; mscolab start # starts the mscolab development server - diff --git a/docs/mscolab.rst b/docs/mscolab.rst index ee6ee3182..cad627b9b 100644 --- a/docs/mscolab.rst +++ b/docs/mscolab.rst @@ -62,14 +62,8 @@ After executed you get informations to exchange with users. y Userdata: email suggested_username 30736d0350c9b886 - "MSCOLAB_mailid": "email", - "MSCOLAB_password": "30736d0350c9b886", - - Userdata: email2 suggested_username2 342434de34904303 - "MSCOLAB_mailid": "email2", - "MSCOLAB_password": "342434de34904303", Further options can be listed by `mscolab db -h` diff --git a/docs/mswms.rst b/docs/mswms.rst index 582c74510..a0dfd5893 100644 --- a/docs/mswms.rst +++ b/docs/mswms.rst @@ -507,7 +507,7 @@ At current state we have to use pip to install mod_wsgi into the INSTANCE enviro Setup a /etc/apache2/mods-available/wsgi_express.conf:: - WSGIPythonHome "/home/mss-demo/mambaforge/envs/demo/" + WSGIPythonHome "/home/mss-demo/miniforge/envs/demo/" Setup a /etc/apache2/mods-available/wsgi_express.load:: @@ -522,11 +522,11 @@ Configuration of apache mod_wsgi.conf One posibility to setup the PYTHONPATH environment variable is by adding it to your mod_wsgi.conf. Alternativly you could add it also to wms.wsgi. - WSGIPythonPath /home/mss/INSTANCE/config:/home/mss/mambaforge/envs/instance/lib/python3.X/site-packages + WSGIPythonPath /home/mss/INSTANCE/config:/home/mss/miniforge/envs/instance/lib/python3.X/site-packages By this setting you override the PYTHONPATH environment variable. So you have also to add -the site-packes directory of your mambaforge installation besides the config file path. +the site-packes directory of your miniforge installation besides the config file path. If your server hosts different instances by different users you want to setup this path in mswms_setting.py. @@ -548,7 +548,7 @@ INSTANCE is a placeholder for your service name:: | └── wsgi | ├── auth.wsgi | └── wms.wsgi - ├── mambaforge + ├── miniforge │   ├── bin │   ├── conda-bld │   ├── conda-meta diff --git a/docs/samples/config/mscolab/mscolab_auth.py.sample b/docs/samples/config/mscolab/mscolab_auth.py.sample index e6c6564cd..5324e0a7e 100644 --- a/docs/samples/config/mscolab/mscolab_auth.py.sample +++ b/docs/samples/config/mscolab/mscolab_auth.py.sample @@ -1,3 +1,3 @@ -class mscolab_auth(object): +class mscolab_auth: password = "please use the methods to save only the encrypted value" allowed_users = [("user", hashlib.md5(password.encode('utf-8')).hexdigest())] diff --git a/docs/samples/config/mscolab/mscolab_settings.py.sample b/docs/samples/config/mscolab/mscolab_settings.py.sample index 1e892c444..e66d4a89e 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -25,55 +25,80 @@ limitations under the License. """ import os -import logging - -class mscolab_settings: - # Set which origins are allowed to communicate with your server - CORS_ORIGINS = ["*"] - - # Set base directory where you want to save Mscolab data - BASE_DIR = os.path.abspath(os.path.dirname(__file__)) - - # Directory in which all data related to Mscolab is stored - DATA_DIR = os.path.join(BASE_DIR, "colabdata") - - # Where mscolab project files are stored on the server - MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') - - # Directory where uploaded images and documents in the chat are stored - UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') - - # Max image/document upload size in mscolab chat (default 2MB) - MAX_UPLOAD_SIZE = 2 * 1024 * 1024 - - # Set your secret key for token generation - SECRET_KEY = 'MySecretKey' - - # looks for a given category for an operation ending with GROUP_POSTFIX - # e.g. category = Tex will look for TexGroup - # all users in that Group are set to the operations of that category - # having the roles in the TexGroup - GROUP_POSTFIX = "Group" - - # Set the database connection string: - # Examples for different DBMS: - # MySQL: "mysql+pymysql://:@/?charset=utf8mb4" - # PostgreSQL: "postgresql://:@/" - # SQLite: "sqlite:///" - SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') - - enable_basic_http_authentication = False - - # text to be written in new mscolab based ftml files. - STUB_CODE = """ - - - - - - - - - - - """ + +# In the unit days when Operations get archived because not used +ARCHIVE_THRESHOLD = 30 + +# To enable logging set to True or pass a logger object to use. +SOCKETIO_LOGGER = False + +# To enable Engine.IO logging set to True or pass a logger object to use. +ENGINEIO_LOGGER = False + +# Set which origins are allowed to communicate with your server +CORS_ORIGINS = ["*"] + +# Set base directory where you want to save Mscolab data +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + +# Directory in which all data related to Mscolab is stored +DATA_DIR = os.path.join(BASE_DIR, "colabdata") + +# Where mscolab project files are stored on the server +MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') + +# Directory where uploaded images and documents in the chat are stored +UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') + +# Max image/document upload size in mscolab chat (default 2MB) +MAX_UPLOAD_SIZE = 2 * 1024 * 1024 + +# Set your secret key for token generation +SECRET_KEY = 'MySecretKey' + +# looks for a given category for an operation ending with GROUP_POSTFIX +# e.g. category = Tex will look for TexGroup +# all users in that Group are set to the operations of that category +# having the roles in the TexGroup +GROUP_POSTFIX = "Group" + +# Set the database connection string: +# Examples for different DBMS: +# MySQL: "mysql+pymysql://:@/?charset=utf8mb4" +# PostgreSQL: "postgresql://:@/" +# SQLite: "sqlite:///" +SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') + +enable_basic_http_authentication = False + +# text to be written in new mscolab based ftml files. +STUB_CODE = """ + + + + + + + + + + +""" + +# accounts on a database on the server +DIRECT_LOGIN = True + +# enable login by identity provider +USE_SAML2 = False + +# looks for a given category forn a operation ending with GROUP_POSTFIX +# e.g. category = Tex will look for TexGroup +# all users in that Group are set to the operations of that category +# having the roles in the TexGroup +GROUP_POSTFIX = "Group" + +# 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/mss_saml2_backend.yaml.sample b/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample new file mode 100644 index 000000000..5284a3890 --- /dev/null +++ b/docs/samples/config/mscolab/mss_saml2_backend.yaml.sample @@ -0,0 +1,116 @@ +name: Saml2 +config: + entityid_endpoint: true + mirror_force_authn: no + memorize_idp: no + use_memorized_idp_when_force_authn: no + send_requester_id: no + enable_metadata_reload: no + + # SP Configuration for localhost_test_idp + localhost_test_idp: + name: "MSS Colab Server - Testing IDP(localhost)" + description: "MSS Collaboration Server with Testing IDP(localhost)" + key_file: path/to/key_sp.key # Will be set from the mscolab server + cert_file: path/to/crt_sp.crt # Will be set from the mscolab server + verify_ssl_cert: true # Specifies if the SSL certificates should be verified. + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + metadata: + local: [path/to/idp.xml] # Will be set from the mscolab server + + entityid: http://localhost:5000/proxy_saml2_backend.xml + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + + + # # SP Configuration for IDP 2 + # sp_config_idp_2: + # name: "MSS Colab Server - Testing IDP(localhost)" + # description: "MSS Collaboration Server with Testing IDP(localhost)" + # key_file: mslib/mscolab/app/key_sp.key + # cert_file: mslib/mscolab/app/crt_sp.crt + # organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + # contact_person: + # - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + # - {contact_type: support, email_address: support@example.com, given_name: Support} + + # metadata: + # local: [mslib/mscolab/app/idp.xml] + + # entityid: http://localhost:5000/proxy_saml2_backend.xml + # accepted_time_diff: 60 + # service: + # sp: + # ui_info: + # display_name: + # - lang: en + # text: "Open MSS" + # description: + # - lang: en + # text: "Mission Support System" + # information_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # privacy_statement_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # keywords: + # - lang: en + # text: ["MSS"] + # - lang: en + # text: ["OpenMSS"] + # logo: + # text: "https://open-mss.github.io/assets/logo.png" + # width: "100" + # height: "100" + # authn_requests_signed: true + # want_response_signed: true + # want_assertion_signed: true + # allow_unknown_attributes: true + # allow_unsolicited: true + # endpoints: + # assertion_consumer_service: + # - [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + # discovery_response: + # - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + # name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + # name_id_format_allow_create: true diff --git a/docs/samples/config/mscolab/setup_saml2_backend.py.sample b/docs/samples/config/mscolab/setup_saml2_backend.py.sample new file mode 100644 index 000000000..23d1cea61 --- /dev/null +++ b/docs/samples/config/mscolab/setup_saml2_backend.py.sample @@ -0,0 +1,68 @@ +import os +import sys +import warnings +import yaml +from saml2 import SAMLError +from saml2.client import Saml2Client +from saml2.config import SPConfig +from urllib.parse import urlparse + + +class setup_saml2_backend: + from mslib.mscolab.conf import mscolab_settings + + CONFIGURED_IDPS = [ + # configure your idps here + { + 'idp_identity_name': 'localhost_test_idp', # make sure to use underscore for the blanks + 'idp_data': { + 'idp_name': 'Testing Identity Provider', # this name is used on the Login page to connect to the Provider. + } + }, + + ] + + if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml"): + with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: + yaml_data = yaml.safe_load(fobj) + # go through configured IDPs and set conf file paths for particular files + for configured_idp in CONFIGURED_IDPS: + # set CRTs and metadata paths for the localhost_test_idp + if 'localhost_test_idp' == configured_idp['idp_identity_name']: + yaml_data["config"]["localhost_test_idp"]["key_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key' # set path to your mscolab key file + yaml_data["config"]["localhost_test_idp"]["cert_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt' # set path to your mscolab certiticate file + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml' # set path to your idp metadata xml file + + # configuration localhost_test_idp Saml2Client + try: + if not os.path.exists(yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0]): + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"] = [] + warnings.warn("idp.xml file does not exists !\ + Ignore this warning when you initializeing metadata.") + + localhost_test_idp = SPConfig().load(yaml_data["config"]["localhost_test_idp"]) + sp_localhost_test_idp = Saml2Client(localhost_test_idp) + + configured_idp['idp_data']['saml2client'] = sp_localhost_test_idp + for url_pair in (yaml_data["config"]["localhost_test_idp"] + ["service"]["sp"]["endpoints"]["assertion_consumer_service"]): + saml_url, binding = url_pair + path = urlparse(saml_url).path + configured_idp['idp_data']['assertion_consumer_endpoints'] = \ + configured_idp['idp_data'].get('assertion_consumer_endpoints', []) + [path] + + except SAMLError: + warnings.warn("Invalid Saml2Client Config with localhost_test_idp ! Please configure with\ + valid CRTs metadata and try again.") + sys.exit() + + # if multiple IdPs exists, development should need to implement accordingly below + """ + if 'idp_2'== configured_idp['idp_identity_name']: + # rest of code + # set CRTs and metadata paths for the idp_2 + # configuration idp_2 Saml2Client + """ diff --git a/docs/samples/config/msui/mssautoplot.json.sample b/docs/samples/config/msui/mssautoplot.json.sample index 284db0987..a21327a4c 100644 --- a/docs/samples/config/msui/mssautoplot.json.sample +++ b/docs/samples/config/msui/mssautoplot.json.sample @@ -86,8 +86,7 @@ ], "automated_plotting_lsecs": [ ["", "", ""] - ] + ], - "MSCOLAB_mailid": "", "MSCOLAB_operations": [] } diff --git a/docs/samples/config/msui/msui_settings.json.sample b/docs/samples/config/msui/msui_settings.json.sample index 883c04e12..2f125181e 100644 --- a/docs/samples/config/msui/msui_settings.json.sample +++ b/docs/samples/config/msui/msui_settings.json.sample @@ -72,7 +72,5 @@ "MSS_auth": { "http://www.your-server.de/forecasts": "authuser", "http://www.your-mscolab-server.de": "authuser" - }, - - "MSCOLAB_mailid": "" + } } diff --git a/docs/sso_via_saml_mscolab.rst b/docs/sso_via_saml_mscolab.rst new file mode 100644 index 000000000..69277ff14 --- /dev/null +++ b/docs/sso_via_saml_mscolab.rst @@ -0,0 +1,560 @@ +SSO via SAML Integration Guide for MSColab Server +================================================= + +In this documentation, you will go through the following topics. + + 1. Introduction + + 2. Configuring an existing IdP + + * Private key and certificate + + * Configuring MSColab settings + + * MSColab configurations + * Establish pysaml2, Saml2Client for the MSColab server + + * Configuration `mss_saml2_backend.yaml` file + + * Access SAML2Client metadata of MSColab + + * Guide to IDP Configuration + + 3. Configuration example through Keycloak 13.0.1 + + * Setting Up Keycloak + + * Installation and run Keycloak + * Setup Keycloak IdP + + * Configure MSColab server + + * Configuration in MSColab settings for Keycloak + * Configuration `mss_saml2_backend.yaml` file + + 4. Configuration Multiple IDPs + +1. Introduction +*************** +This documentation will explain how to configure MSColab with an existing IdP or multiple IdPs, along with examples of implementation. + +If you are not aware of how the SAML process works in the MSColab server, it is highly recommended to set up msidp and test it with MSColab as an initial step before configuring existing 3rd party IdPs. + +.. note:: + You can find instructions to set up msidp by `conf_sso_test_msscolab.rst`. + + +2. Configuring an existing IdP +****************************** + +To configure an existing IdP, you will need a signed certificate and a private key for the MSColab server. Additionally, you will require metadata for the IdP to complete the configuration. + +Furthermore, you will need to configure saml2 setup in your `setup_saml2_backend.py` file and configure settings in your `mscolab_settings.py` file. On development you need only to use your PYTHONPATH and `setup_saml2_backend.py`, `mscolab_settings.py` in that path. + +.. note:: + When you want to set a parameter or change a default add it to that file, + + eg:- + + $ more mscolab_settings.py + + USE_SAML2 = True + +Also, you should be careful to return the attributes `username` and `email` address accordingly from the IdP along with the SAML response. + +Private key and certificate +--------------------------- + +You can store your private key and certificate in any highly secure location. To configure MSColab for SSO, you just need to specify the paths to your certificate and key files in the configuration. + +Private key and certificates path can be setup by your `mss_saml2_backend.yaml` file or when you Establishing Saml2Client for the MSColab server in your `setup_saml2_backend.py` file. + + +Configuring MSColab settings +---------------------------- + +MSColab configurations +###################### + +This section provides a guide for implementing MSColab with a single IdP. You can make the necessary changes in your `mscolab_settings.py` or `conf.py` file and your `setup_saml2_backend.py`. + +.. note:: + Sensible defaults of MSColab are opinionated. All these are defined in conf.py and those which you want to change you can add to a mscolab_settings.py in your search path. + +Before running the MSColab server, ensure `USE_SAML` is set to `True` in your `mscolab_settings.py`. + +.. code:: text + + # enable login by identity provider + USE_SAML2 = True + +To enabling login via the Identity Provider; need to implement `mss_saml2_backend.yaml` with paths for .crt and .key files, configure mscolab_settings.py, and configure `setup_saml2_backend.py` + +In this implementation, as we are enabling only one IdP, there is no need to configure the default testing IdP (msidp). You can disable it simply by removing ``localhost_test_idp`` from the list of ``CONFIGURED_IDPS`` in your `setup_saml2_backend.py` file. Additionally, remember to add your ``idp_identity_name`` and ``idp_name`` accordingly. + + +.. code:: text + + # idp settings + class setup_saml2_backend: + CONFIGURED_IDPS = [ + { + 'idp_identity_name': 'localhost_test_idp', # make sure to use underscore for the blanks + 'idp_data': { + 'idp_name': 'Testing Identity Provider', # this name is used on the Login page to connect to the Provider + } + }, + ,] + + +.. note:: + Please refer to the sample template `setup_saml2_backend.py.sample` located in the `docs/samples/config/mscolab` directory. + + Idp_identity_name refers to the specific name used to identify the particular Identity Provider within the MSColab server. This name should be used in the `mss_saml2_backend.yaml` file when configuring your IdP, as well as in the MSColab server configurations. It's important to note that this name is not visible to end users + + Remember to use underscore for the blanks in your `idp_identity_name`. + + Idp_name refers to the name of the Identity Provider that will be displayed in the MSColab server web interface for end users to select when configuring SSO. + + +Establish pysaml2, Saml2Client for the MSColab server +##################################################### + +You should establish a Saml2Client, a component designed for handling SAML 2.0 authentication flows. This Saml2Client will be configured to work seamlessly with the MSColab server, ensuring that authentication requests and responses are handled correctly. + +You should do implementation by your `setup_saml2_backend.py` file. + +.. code:: text + + # if multiple 3rd party exists, development should need to implement accordingly below + """ + if 'idp_2'== configured_idp['idp_identity_name']: + # rest of code + # set CRTs and metadata paths for the idp_2 + # configuration idp_2 Saml2Client + """ + +After completing these steps, you can proceed to configure the `mss_saml2_backend.yaml` file. + +Configuration mss_saml2_backend.yaml file +----------------------------------------- + +You should create a new attribute using the ``idp_identity_name`` defined in the previous step. Afterward, you will need to create the necessary attributes in the `.yaml` file accordingly. If need, you can also update these attributes using the server + +Please refer the yaml file template (`mss_saml2_backend.yaml.samlple`) in the directory of `docs/samples/config/mscolab` to generating your IdP file. + +.. code:: text + + # SP Configuration for IDP 2 + sp_config_idp_2: + name: "MSS Colab Server - Testing IDP(localhost)" + description: "MSS Collaboration Server with Testing IDP(localhost)" + key_file: mslib/mscolab/app/key_sp.key + cert_file: mslib/mscolab/app/crt_sp.crt + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + metadata: + local: [mslib/mscolab/app/idp.xml] + entityid: http://localhost:5000/proxy_saml2_backend.xml + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + - [http://localhost:8083/idp2/acs/redirect, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + +.. note:: + Make sure to update + entityid : 'idp_identity_name' + Assertion_consumer_service : with the urls of assertion consumer services functionalities URL that going to implement next step, may be better to explain here + + Key_file : if need can be update through the server + Cert_file : if need can be update through the server + Metadata.local : if need can be update through the server + + +Access SAML2Client metadata of MSColab +-------------------------------------- + +While the core purpose of IdPs is to authenticate users and provide information to relying parties, the responses can vary based on configuration, protocol, user attributes, consent, and customization. Therefore, responses from different IdPs can indeed be different, and developers and administrators should be aware of these variations when integrating with different identity providers. However, in the MSColab server, we implemented an easy way to access metadata from an endpoint. You can access it easily by using the specified url, which is configured based on the settings of your SAML2 client in your `setupsaml2backend.py` and `saml2backend.yaml` file. This streamlined approach simplifies the process and eliminates the need for manual development of endpoints and functionalities specific to each IdP. + +.. note:: + URL to access metadata endpoint for particular IdP: + ``/metadata/`` + +Guide to IDP Configuration +-------------------------- + +In the SSO process through the MSColab server, the username is obtained as ``givenName``, and the email address is obtained as ``email``. Therefore, when configuring the IdP, it is necessary to configure it accordingly to ensure the correct return of the givenName attribute and the email address along with the SAML response. + + +3. Configuration example through Keycloak 13.0.1 +************************************************ + +Setting Up Keycloak +------------------- + +Installation and run Keycloak +############################# + +Via local installation + 1. Download the file (requires java, wget installed): + + .. code:: text + + cd $HOME && \ wget -c keycloak_13_0_1.tar.gz https://github.com/keycloak/keycloak/releases/download/13.0.1/keycloak-13.0.1.tar.gz -O - | tar -xz + +| + + 2. Navigate to the KeyCloak binaries folder: + + .. code:: text + + cd keycloak-13.0.1/bin + +| + + 3. And start it up: + + .. code:: text + + ./standalone.sh + +| + +Via Docker (requires Docker installed) + + .. note:: + + You can define KEYCLOAK_USER and KEYCLOAK_PASSWORD as you wish. Recommends using tools like pwgen to generate strong and random passwords. + + * Open your terminal and run + + .. code:: text + + docker run -p 8080:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=pwgen_password quay.io/keycloak/keycloak:13.0.1 + +| + + .. image:: images/sso_via_saml_conf/ss_docker_run_cmd.png + :width: 400 + + + +Setup Keycloak IdP +################## + +Access Keycloak + Once you successfully install and start keycloak, you can Access keycloak interface through a particular port using your web browser. + eg:- http://localhost:8080 + + .. image:: images/sso_via_saml_conf/ss_interface_keycloak.png + :width: 800 + +Login as an admin + You can go to the admin console and login as an admin by providing the above provided credentials. + + .. image:: images/sso_via_saml_conf/ss_admin_login.png + :width: 400 + +Create realm + Once successfully logged in you should create a realm to configure IdP. You can create a realm by clicking `Add realm` button. + + .. image:: images/sso_via_saml_conf/ss_add_realam_btn.png + :width: 300 + + You need to provide a name for your realm and create. + + .. image:: images/sso_via_saml_conf/ss_add_realam_name.png + :width: 800 + +Create a client specifically for SAML + + Once you successfully created a realm, lets create a client specifically for SAML. + + First you should navigate into the client section using your left navigation. + + .. image:: images/sso_via_saml_conf/ss_left_nav_client.png + :width: 200 + + In the client section you can see `create` button in the top right corner. + + Create a new client by clicking `create` button in the top right corner. + + .. image:: images/sso_via_saml_conf/ss_create_client_btn.png + :width: 800 + + .. note:: + When creating client ID, it should be same as the issuer ID of the MSColab server. + In here, the MSColab server used different issuer IDs for the particular idp_iedentity_name, and issued it by url bellow + + http://127.0.0.1:8083/metadata/idp_identityname/ + + + Also make sure to select Client Protocol as saml. + .. image:: images/sso_via_saml_conf/ss_set_client_protocol.png + :width: 800 + + After creating a SAML client, make sure you set Valid Redirect URIs to match our Service Provider. + + Eg:- + http://127.0.0.1:8083/* + + http://localhost:8083/* + + + Generate keys and certificates + + To generate keys and certificates first navigate into saml keys tab and click `Generate new keys` button. + .. image:: images/sso_via_saml_conf/ss_gen_keys_crts.png + :width: 800 + + You can copy generated keys and certificates by clicking top of the key and certificate. After clicked you should need to create .crt and .key file accordingly. + + .. note:: + In here when you creating .key and .crt make sure to begin creating file structure accordingly. + + Eg:- + .key file + + ----BEGIN RSA PRIVATE KEY----- + + Key key key key key key key + + -----END RSA PRIVATE KEY----- + + | + + .crt file + + -----BEGIN CERTIFICATE----- + + Crt crt crt crt + + -----END CERTIFICATE----- + + + Configure keycloak IdP for endusers + + You can enable user registration through enabling, Realm Settings>login>User-registration + + First go to Realm settings through left navigation, + + .. image:: images/sso_via_saml_conf/ss_left_nav_realm_settings.png + :width: 200 + + Then goto `Login` tab and enable User registration. + + .. image:: images/sso_via_saml_conf/ss_enable_usr_reg.png + :width: 800 + + Add email and givenName into mappers + + .. note:: + In the MSColab server, we take the attribute name for email as `email` and for the username as `givenName`. Therefore, we need to implement mappers accordingly for the Keycloak end. + + In this example, We need to add the Keycloak built-in email mapper and givenName mapper to obtain it in our MSColab server through the SAML response with correct attribute names. + + eg:- + + clients>yourcreatedCliet>Mappers>Add Builtin Protocol Mapper enable email + + First navigate into client section through left navigation. + + .. image:: images/sso_via_saml_conf/ss_left_nav_client.png + :width: 200 + + Select client we created already + + .. image:: images/sso_via_saml_conf/ss_client_select.png + :width: 800 + + Go to the Mapper section tab, and Click `Add Builtin` button to add Mappers. + + .. image:: images/sso_via_saml_conf/ss_add_mappers_btn.png + :width: 800 + + Since we need email address and givenName, enable those and click `add selected` button. + + .. image:: images/sso_via_saml_conf/ss_enable_mappers.png + :width: 800 + + Then you can see Added mappers in your interface + + .. image:: images/sso_via_saml_conf/ss_view_mappers.png + :width: 800 + + + Set SAML Attribute Names as `email` and `givenName`. + + .. image:: images/sso_via_saml_conf/ss_set_attribute_name1.png + :width: 800 + + .. image:: images/sso_via_saml_conf/ss_set_attribute_name2.png + :width: 800 + + Export IdP metadata + + When all sorted you need to export metadata file from the keycloak, + + http://localhost:8080/auth/realms/saml-example-realm/protocol/saml/descripto + + Since we're going to import the file with the name as "key_cloak_v_13_idp.xml" in this example, We should store it with the same name. + + +Configure MSColab server +######################## + +Configuration in MSColab settings for Keycloak + This involves Updating your `conf.py` file or `mcolab_settigns.py`, and update your `conf.py` file or `setup_saml2_backend.py`. + + 1. Set USE_SAML = True in your mcolab_settigns.py + + .. code:: text + + # enable login by identity provider + USE_SAML2 = True + + 2. Insert Keycloak into list of CONFIGURE_IDP in your setup_saml2_backend.py + + .. code:: text + + # idp settings + class setup_saml2_backend: + CONFIGURED_IDPS = [ + { + 'idp_identity_name': 'key_cloak_v_13', # make sure to use underscore for the blanks + 'idp_data': { + 'idp_name': 'Keycloak V 13', # this name is used on the Login page to connect to the Provider + } + }, + ,] + + .. note:: + Make sure to insert idp_identity_name as above ('key_cloak_v_13'), which used in this example. + +Configuration mss_saml2_backend.yaml file + + Create your mss_saml2_backend.yaml file in your ``MSCOLAB_SSO_DIR``. + + .. code:: text + + name: Saml2 + config: + entityid_endpoint: true + mirror_force_authn: no + memorize_idp: no + use_memorized_idp_when_force_authn: no + send_requester_id: no + enable_metadata_reload: no + + + # SP Configuration for localhost_test_idp + key_cloak_v_13: + name: "Keycloak Testing IDP" + description: "Keycloak 13.0.1" + key_file: path/to/key_sp.key # Will be set from the mscolab server + cert_file: path/to/crt_sp.crt # Will be set from the mscolab server + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + + metadata: + local: [path/to/idp.xml] # Will be set from the mscolab server + + + entityid: http://127.0.0.1:8083/metadata_keycloak/ + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/keycloak_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + + + .. note:: + make sure to set same issuer ID in your saml_2.yaml file correctly + eg:- entityid: http://127.0.0.1:8083/metadata/ + + .. note:: + may be can be occured invalid redirect url problem, since we defined localhost in keycloak admin, and using 127.0..... be careful to set it correctly. + + eg:- + assertion_consumer_service: + - [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + - [http://localhost:8083/localhost_test_idp/acs/redirect,] + + +4. Configuration Multiple IDPs +****************************** + +As we have already implemented one IdP, we can extend the list of IdPs and implement functions specific to each IdP as needed. diff --git a/docs/usage.rst b/docs/usage.rst index 4c376f5eb..d347e81cd 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -114,29 +114,20 @@ You can setup which accounts are used to login into MSColab and used for authent When you use an old configuration having WMS_login, MSC_login, MSCOLAB_password defined on start of msui you get a hint that we can update your msui_settings.json file. We keep your old attributes in a bak file. - -A dictionary by Server-Url and username provide the username for an http-auth request -and the MSCOLAB_mailid is used to login by your credentials into the service. +A dictionary by Server-Url and username provide the username for logging into our services +(by http-auth request for WMS). .. code:: text "MSS_auth": { "http://www.your-server.de/forecasts": "authuser", - "http://www.your-mscolab-server.de": "authuser" + "http://www.your-mscolab-server.de": "your-email" }, - "MSCOLAB_mailid": "your-email" - - -By entering first time the passwords they are stored by using keyring. -You can also use the keyring app to set, change and delete passwords. -The following examples shows how to setup your individual MSColab account and to add -the common WWW-authentication to access the server. - -.. code:: text - (mssenv): keyring set MSCOLAB your-email your-password - (mssenv): keyring set http://www.your-mscolab-server.de authuser authpassword +All passwords are stored by using an OS-provided keyring after entering them +the first time. Also the token required for accessing the MSColab server will +be stored there. You can also use an OS-provided keyring app to set, change and delete passwords. MSUI Flight track import/export plugins diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 27f1ce947..1c146bff0 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -19,6 +19,7 @@ build: - mswms_demodata = mslib.mswms.demodata:main - mscolab = mslib.mscolab.mscolab:main - mssautoplot = mslib.utils.mssautoplot:main + - msidp = mslib.msidp.idp:main requirements: build: @@ -29,8 +30,8 @@ requirements: - python - setuptools - pip - - future - menuinst # [win] + - future run: - python - defusedxml @@ -38,8 +39,8 @@ requirements: - basemap >=1.3.3 - chameleon - execnet - - fastkml =0.11 - - shapely <2.0.0 + - fastkml >=0.11 + - shapely >=2.0.0 - pygeoif <1.0.0 - isodate - lxml @@ -47,8 +48,8 @@ requirements: - hdf4 - pillow - pytz - - pyqt >=5, <5.13 - - qt >=5.10, <5.13 + - pyqt >=5.15.0 + - qt >=5.15.0 - requests >=2.31.0 - scipy - skyfield >=1.12 @@ -58,15 +59,15 @@ requirements: - unicodecsv - fs_filepicker - cftime >=1.0.1 - - matplotlib >=3.3.2,<3.6 + - matplotlib >=3.5.3 - itsdangerous - pyjwt - flask >=2.3.2 - flask-httpauth - flask-mail - flask-migrate - - werkzeug >=2.2.3,<3.0.0 - - flask-socketio =5.1.0 + - werkzeug >=2.2.3, <3.0.0 + - flask-socketio >=5.1.0 - flask-sqlalchemy >=3.0.0 - flask-cors - passlib @@ -76,7 +77,7 @@ requirements: - PyMySQL >=0.9.3 - validate_email - multidict - - pint <=0.22 + - pint - python-socketio >=5 - python-engineio >=4 - markdown @@ -92,6 +93,10 @@ requirements: - email_validator - keyring - dbus-python + - python-slugify + - flask-login + - pysaml2 + - libxmlsec1 test: imports: @@ -101,6 +106,7 @@ test: - mswms_demodata -h - msui -h - mscolab -h + - msidp -h about: summary: 'A web service based tool to plan atmospheric research flights.' diff --git a/mslib/index.py b/mslib/index.py index 71734a06a..abcc6516e 100644 --- a/mslib/index.py +++ b/mslib/index.py @@ -25,21 +25,15 @@ limitations under the License. """ -import sys import os -import codecs import mslib -import werkzeug from flask import render_template from flask import send_from_directory, send_file, url_for from flask import abort -from flask import request -from flask import Response -from markdown import Markdown from xstatic.main import XStatic from mslib.msui.icons import icons -from mslib.mswms.gallery_builder import STATIC_LOCATION +from mslib.utils.get_content import get_content # set the operation root directory as the static folder DOCS_SERVER_PATH = os.path.dirname(os.path.abspath(mslib.__file__)) @@ -65,13 +59,26 @@ def _xstatic(name): return None -def create_app(name=""): +def file_exists(filepath=None): + try: + return os.path.isfile(filepath) + except TypeError: + return False + + +def create_app(name="", imprint=None, gdpr=None): + imprint_file = imprint + gdpr_file = gdpr + if "mscolab.server" in name: - from mslib.mscolab.app import APP + from mslib.mscolab.app import APP, get_topmenu else: - from mslib.mswms.app import APP + from mslib.mswms.app import APP, get_topmenu + + APP.jinja_env.globals.update(file_exists=file_exists) + APP.jinja_env.globals["imprint"] = imprint_file + APP.jinja_env.globals["gdpr"] = gdpr_file - @APP.route('/xstatic//', defaults=dict(filename='')) @APP.route('/xstatic//') def files(name, filename): @@ -82,50 +89,13 @@ def files(name, filename): abort(404) return send_from_directory(base_path, filename) - @APP.route('/mss_theme//', defaults=dict(filename='')) - @APP.route('/mss_theme//') - def mss_theme(name, filename): - if name != 'img': - abort(404) + @APP.route('/mss_theme/img/') + def mss_theme(filename): base_path = os.path.join(DOCS_SERVER_PATH, 'static', 'img') return send_from_directory(base_path, filename) - def get_topmenu(): - if "mscolab" in " ".join(sys.argv): - menu = [ - (url_for('index'), 'Mission Support System', - ((url_for('about'), 'About'), - (url_for('install'), 'Install'), - (url_for('help'), 'Help'), - )), - ] - else: - menu = [ - (url_for('index'), 'Mission Support System', - ((url_for('about'), 'About'), - (url_for('install'), 'Install'), - (url_for("plots"), 'Gallery'), - (url_for('help'), 'Help'), - )), - ] - - return menu - APP.jinja_env.globals.update(get_topmenu=get_topmenu) - def get_content(filename, overrides=None): - markdown = Markdown(extensions=["fenced_code"]) - content = "" - if os.path.isfile(filename): - with codecs.open(filename, 'r', 'utf-8') as f: - md_data = f.read() - md_data = md_data.replace(':ref:', '') - if overrides is not None: - v1, v2 = overrides - md_data = md_data.replace(v1, v2) - content = markdown.convert(md_data) - return content - @APP.route("/index") def index(): return render_template("/index.html") @@ -135,9 +105,11 @@ def index(): def about(): _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'about.md') img_url = url_for('overview') - overrides = ['![image](/mss/overview.png)', f'![image]({img_url})'] - content = get_content(_file, - overrides=overrides) + md_overrides = ('![image](/mss/overview.png)', f'![image]({img_url})') + + html_overrrides = ('image', + 'image') + content = get_content(_file, md_overrides=md_overrides, html_overrides=html_overrrides) return render_template("/content.html", act="about", content=content) @APP.route("/mss/install") @@ -146,49 +118,31 @@ def install(): content = get_content(_file) return render_template("/content.html", act="install", content=content) - @APP.route("/mss/plots") - def plots(): - if STATIC_LOCATION != "" and os.path.exists(os.path.join(STATIC_LOCATION, 'plots.html')): - _file = os.path.join(STATIC_LOCATION, 'plots.html') - content = get_content(_file) - else: - content = "Gallery was not generated for this server.
" \ - "For further info on how to generate it, run the " \ - "gallery --help command line parameter of mswms.
" \ - "An example of the gallery can be seen " \ - "here" - return render_template("/content.html", act="plots", content=content) - - @APP.route("/mss/code/") - def code(filename): - download = request.args.get("download", False) - _file = werkzeug.security.safe_join(STATIC_LOCATION, "code", filename) - if _file is None: - abort(404) - content = get_content(_file) - if not download: - return render_template("/content.html", act="code", content=content) - else: - if not os.path.isfile(_file): - abort(404) - with open(_file) as f: - text = f.read() - return Response("".join([s.replace("\t", "", 1) for s in text.split("```python")[-1] - .splitlines(keepends=True)][1:-2]), - mimetype="text/plain", - headers={"Content-disposition": f"attachment; filename={filename.split('-')[0]}.py"}) - @APP.route("/mss/help") - def help(): + def help(): # noqa: A001 _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'help.md') - content = get_content(_file) + html_overrides = ('Waypoint Tutorial', + 'Waypoint Tutorial') + content = get_content(_file, html_overrides=html_overrides) return render_template("/content.html", act="help", content=content) @APP.route("/mss/imprint") def imprint(): - _file = os.path.join(DOCS_SERVER_PATH, 'static', 'docs', 'imprint.md') - content = get_content(_file) - return render_template("/content.html", act="imprint", content=content) + if file_exists(imprint_file): + content = get_content(imprint_file) + return render_template("/content.html", act="imprint", content=content) + else: + return "" + + @APP.route("/mss/gdpr") + def gdpr(): + if file_exists(gdpr_file): + content = get_content(gdpr_file) + return render_template("/content.html", act="gdpr", content=content) + else: + return "" @APP.route('/mss/favicon.ico') def favicons(): diff --git a/mslib/mscolab/app/__init__.py b/mslib/mscolab/app/__init__.py index 5f9c6f06c..987bf42f7 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -25,10 +25,14 @@ """ import os + +from flask_migrate import Migrate + import mslib -from flask import Flask +from flask import Flask, url_for from mslib.mscolab.conf import mscolab_settings +from flask_sqlalchemy import SQLAlchemy from mslib.mswms.gallery_builder import STATIC_LOCATION from mslib.utils import prefix_route @@ -58,3 +62,17 @@ APP.config['MAIL_PASSWORD'] = getattr(mscolab_settings, "MAIL_PASSWORD", None) APP.config['MAIL_USE_TLS'] = getattr(mscolab_settings, "MAIL_USE_TLS", None) APP.config['MAIL_USE_SSL'] = getattr(mscolab_settings, "MAIL_USE_SSL", None) + +db = SQLAlchemy(APP) +migrate = Migrate(APP, db, render_as_batch=True) + + +def get_topmenu(): + menu = [ + (url_for('index'), 'Mission Support System', + ((url_for('about'), 'About'), + (url_for('install'), 'Install'), + (url_for('help'), 'Help'), + )), + ] + return menu diff --git a/mslib/mscolab/chat_manager.py b/mslib/mscolab/chat_manager.py index b94138fa9..da8555893 100644 --- a/mslib/mscolab/chat_manager.py +++ b/mslib/mscolab/chat_manager.py @@ -25,15 +25,18 @@ limitations under the License. """ import datetime +import os +import time import fs +from werkzeug.utils import secure_filename from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import db, Message, MessageType from mslib.mscolab.utils import get_message_dict -class ChatManager(object): +class ChatManager: """Class with handler functions for chat related functionalities""" def __init__(self): @@ -93,3 +96,24 @@ def delete_message(self, message_id): upload_dir.remove(fs.path.join(str(message.op_id), file_name)) db.session.delete(message) db.session.commit() + + def add_attachment(self, op_id, upload_folder, file, file_token): + with fs.open_fs('/') as home_fs: + file_dir = fs.path.join(upload_folder, str(op_id)) + if '\\' not in file_dir: + if not home_fs.exists(file_dir): + home_fs.makedirs(file_dir) + else: + file_dir = file_dir.replace('\\', '/') + if not os.path.exists(file_dir): + os.makedirs(file_dir) + file_name, file_ext = file.filename.rsplit('.', 1) + file_name = f'{file_name}-{time.strftime("%Y%m%dT%H%M%S")}-{file_token}.{file_ext}' + file_name = secure_filename(file_name) + file_path = fs.path.join(file_dir, file_name) + file.save(file_path) + static_dir = fs.path.basename(upload_folder) + static_dir = static_dir.replace('\\', '/') + static_file_path = os.path.join(static_dir, str(op_id), file_name) + if os.path.exists(file_path): + return static_file_path diff --git a/mslib/mscolab/conf.py b/mslib/mscolab/conf.py index 9c50074d5..30bf80495 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -24,78 +24,184 @@ See the License for the specific language governing permissions and limitations under the License. """ +import os import logging import secrets +import sys +import warnings +import yaml +from saml2 import SAMLError +from saml2.client import Saml2Client +from saml2.config import SPConfig +from urllib.parse import urlparse -try: - from mscolab_settings import mscolab_settings - logging.info("Using user defined settings") -except ImportError as ex: - logging.warning(u"Couldn't import mscolab_settings (ImportError:'%s'), using dummy config.", ex) - class mscolab_settings(object): - import os - import logging +class default_mscolab_settings: + # expire token in seconds + # EXPIRATION = 86400 + + # In the unit days when Operations get archived because not used + ARCHIVE_THRESHOLD = 30 + + # To enable logging set to True or pass a logger object to use. + SOCKETIO_LOGGER = False + + # To enable Engine.IO logging set to True or pass a logger object to use. + ENGINEIO_LOGGER = False + + # Which origins are allowed to communicate with your server + CORS_ORIGINS = ["*"] + + # dir where msui output files are stored + BASE_DIR = os.path.expanduser("~") + + DATA_DIR = os.path.join(BASE_DIR, "colabdata") + + # mscolab data directory + MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') - # expire token in seconds - # EXPIRATION = 86400 + # MYSQL CONNECTION STRING: "mysql+pymysql://:@:/?charset=utf8mb4" + SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') - # Which origins are allowed to communicate with your server - CORS_ORIGINS = ["*"] + # mscolab file upload settings + UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') + MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB - # dir where msui output files are stored - BASE_DIR = os.path.expanduser("~") + # used to generate and parse tokens + SECRET_KEY = secrets.token_urlsafe(16) - DATA_DIR = os.path.join(BASE_DIR, "colabdata") + # used to generate the password token + SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) - # mscolab data directory - MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') + STUB_CODE = """ + + + + + + + + + + + """ - # MYSQL CONNECTION STRING: "mysql+pymysql://:@:/?charset=utf8mb4" - SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') + # looks for a given category forn a operation ending with GROUP_POSTFIX + # e.g. category = Tex will look for TexGroup + # all users in that Group are set to the operations of that category + # having the roles in the TexGroup + GROUP_POSTFIX = "Group" - # mscolab file upload settings - UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') - MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB + enable_basic_http_authentication = False - # used to generate and parse tokens - SECRET_KEY = secrets.token_urlsafe(16) + # enable verification by Mail + MAIL_ENABLED = False - # looks for a given category forn a operation ending with GROUP_POSTFIX - # e.g. category = Tex will look for TexGroup - # all users in that Group are set to the operations of that category - # having the roles in the TexGroup - GROUP_POSTFIX = "Group" + # mail settings + # MAIL_SERVER = 'localhost' + # MAIL_PORT = 25 + # MAIL_USE_TLS = False + # MAIL_USE_SSL = True - # used to generate the password token - SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) + # mail authentication + # MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') + # MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') - STUB_CODE = """ - - - - - - - - - - - """ - enable_basic_http_authentication = False + # mail accounts + # MAIL_DEFAULT_SENDER = 'MSS@localhost' + # filepath to md file with imprint + IMPRINT = None + # filepath to md file with gdpr + GDPR = None - # enable verification by Mail - MAIL_ENABLED = False + # enable login by identity provider + USE_SAML2 = False - # mail settings - # MAIL_SERVER = 'localhost' - # MAIL_PORT = 25 - # MAIL_USE_TLS = False - # MAIL_USE_SSL = True + # accounts on a database on the server + DIRECT_LOGIN = True - # mail authentication - # MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') - # MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') + # Enable SSL certificate verification during SSO between MSColab and IdP + ENABLE_SSO_SSL_CERT_VERIFICATION = True - # mail accounts - # MAIL_DEFAULT_SENDER = 'MSS@localhost' + # dir where mscolab single sign process files are stored + MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') + + +mscolab_settings = default_mscolab_settings() + +try: + import mscolab_settings as user_settings + logging.info("Using user defined settings") + mscolab_settings.__dict__.update(user_settings.__dict__) +except ImportError as ex: + logging.warning(u"Couldn't import mscolab_settings (ImportError:'%s'), using dummy config.", ex) + +try: + from setup_saml2_backend import setup_saml2_backend + logging.info("Using user defined saml2 settings") +except ImportError as ex: + logging.warning(u"Couldn't import setup_saml2_backend (ImportError:'%s'), using dummy config.", ex) + + class setup_saml2_backend: + # idp settings + CONFIGURED_IDPS = [ + { + 'idp_identity_name': 'localhost_test_idp', + 'idp_data': { + 'idp_name': 'Testing Identity Provider', + } + + }, + # { + # 'idp_identity_name': 'idp2', + # 'idp_data': { + # 'idp_name': '2nd Identity Provider', + # } + # }, + ] + if os.path.exists(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml"): + with open(f"{mscolab_settings.MSCOLAB_SSO_DIR}/mss_saml2_backend.yaml", encoding="utf-8") as fobj: + yaml_data = yaml.safe_load(fobj) + # go through configured IDPs and set conf file paths for particular files + for configured_idp in CONFIGURED_IDPS: + # set CRTs and metadata paths for the localhost_test_idp + if 'localhost_test_idp' == configured_idp['idp_identity_name']: + yaml_data["config"]["localhost_test_idp"]["key_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/key_mscolab.key' + yaml_data["config"]["localhost_test_idp"]["cert_file"] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/crt_mscolab.crt' + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0] = \ + f'{mscolab_settings.MSCOLAB_SSO_DIR}/idp.xml' + + # configuration localhost_test_idp Saml2Client + try: + if not os.path.exists(yaml_data["config"]["localhost_test_idp"]["metadata"]["local"][0]): + yaml_data["config"]["localhost_test_idp"]["metadata"]["local"] = [] + warnings.warn("idp.xml file does not exists !\ + Ignore this warning when you initializeing metadata.") + + localhost_test_idp = SPConfig().load(yaml_data["config"]["localhost_test_idp"]) + localhost_test_idp.verify_ssl_cert = mscolab_settings.ENABLE_SSO_SSL_CERT_VERIFICATION + sp_localhost_test_idp = Saml2Client(localhost_test_idp) + + configured_idp['idp_data']['saml2client'] = sp_localhost_test_idp + for url_pair in (yaml_data["config"]["localhost_test_idp"] + ["service"]["sp"]["endpoints"]["assertion_consumer_service"]): + saml_url, binding = url_pair + path = urlparse(saml_url).path + configured_idp['idp_data']['assertion_consumer_endpoints'] = \ + configured_idp['idp_data'].get('assertion_consumer_endpoints', []) + [path] + + except SAMLError: + warnings.warn("Invalid Saml2Client Config with localhost_test_idp ! Please configure with\ + valid CRTs metadata and try again.") + sys.exit() + + # if multiple IdPs exists, development should need to implement accordingly below, + # make sure to set SSL certificates verification enablement. + """ + if 'idp_2'== configured_idp['idp_identity_name']: + # rest of code + # set CRTs and metadata paths for the idp_2 + # configuration idp_2 Saml2Client + """ diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 2387e25a6..1e1917bbe 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -29,18 +29,29 @@ import difflib import logging import git +import threading from sqlalchemy.exc import IntegrityError from mslib.mscolab.models import db, Operation, Permission, User, Change, Message from mslib.mscolab.conf import mscolab_settings -class FileManager(object): +class FileManager: """Class with handler functions for file related functionalities""" def __init__(self, data_dir): self.data_dir = data_dir + self.operation_dict_lock = threading.Lock() + self.operation_locks = {} - def create_operation(self, path, description, user, last_used=None, content=None, category="default"): + def _get_operation_lock(self, op_id): + with self.operation_dict_lock: + try: + return self.operation_locks[op_id] + except KeyError: + self.operation_locks[op_id] = threading.Lock() + return self.operation_locks[op_id] + + def create_operation(self, path, description, user, last_used=None, content=None, category="default", active=True): """ path: path to the operation description: description of the operation @@ -53,33 +64,36 @@ def create_operation(self, path, description, user, last_used=None, content=None if proj_available is not None: return False if last_used is None: - last_used = datetime.datetime.utcnow() - operation = Operation(path, description, last_used, category) + last_used = datetime.datetime.now(tz=datetime.timezone.utc) + operation = Operation(path, description, last_used, category, active=active) db.session.add(operation) db.session.flush() operation_id = operation.id - # this is the only insertion with "creator" access_level - perm = Permission(user.id, operation_id, "creator") - db.session.add(perm) - db.session.commit() - # here we can import the permissions from Group file - if not path.endswith(mscolab_settings.GROUP_POSTFIX): - import_op = Operation.query.filter_by(path=f"{category}{mscolab_settings.GROUP_POSTFIX}").first() - if import_op is not None: - self.import_permissions(import_op.id, operation_id, user.id) - data = fs.open_fs(self.data_dir) - data.makedir(operation.path) - operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'w') - if content is not None: - operation_file.write(content) - else: - operation_file.write(mscolab_settings.STUB_CODE) - operation_path = fs.path.combine(self.data_dir, operation.path) - r = git.Repo.init(operation_path) - r.git.clear_cache() - r.index.add(['main.ftml']) - r.index.commit("initial commit") - return True + + op_lock = self._get_operation_lock(operation_id) + with op_lock: + # this is the only insertion with "creator" access_level + perm = Permission(user.id, operation_id, "creator") + db.session.add(perm) + db.session.commit() + # here we can import the permissions from Group file + if not path.endswith(mscolab_settings.GROUP_POSTFIX): + import_op = Operation.query.filter_by(path=f"{category}{mscolab_settings.GROUP_POSTFIX}").first() + if import_op is not None: + self.import_permissions(import_op.id, operation_id, user.id) + data = fs.open_fs(self.data_dir) + data.makedir(operation.path) + operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'w') + if content is not None: + operation_file.write(content) + else: + operation_file.write(mscolab_settings.STUB_CODE) + operation_path = fs.path.combine(self.data_dir, operation.path) + r = git.Repo.init(operation_path) + r.git.clear_cache() + r.index.add(['main.ftml']) + r.index.commit("initial commit") + return True def get_operation_details(self, op_id, user): """ @@ -96,22 +110,35 @@ def get_operation_details(self, op_id, user): return op return False - def list_operations(self, user): + def list_operations(self, user, skip_archived=False): """ user: logged in user + skip_archived: filter by active operations """ operations = [] permissions = Permission.query.filter_by(u_id=user.id).all() for permission in permissions: operation = Operation.query.filter_by(id=permission.op_id).first() - operations.append({ - "op_id": permission.op_id, - "access_level": permission.access_level, - "path": operation.path, - "description": operation.description, - "category": operation.category, - "active": operation.active - }) + if 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 uptodate data + if skip_archived: + operation = Operation.query.filter_by(id=permission.op_id, active=skip_archived).first() + else: + operation = Operation.query.filter_by(id=permission.op_id).first() + + if operation is not None: + operations.append({ + "op_id": permission.op_id, + "access_level": permission.access_level, + "path": operation.path, + "description": operation.description, + "category": operation.category, + "active": operation.active + }) return operations def is_member(self, u_id, op_id): @@ -187,6 +214,42 @@ def auth_type(self, u_id, op_id): return False return perm.access_level + def modify_user(self, user, attribute=None, value=None, action=None): + if action == "create": + user_query = User.query.filter_by(emailid=str(user.emailid)).first() + if user_query is None: + db.session.add(user) + db.session.commit() + else: + return False + elif action == "delete": + user_query = User.query.filter_by(id=user.id).first() + if user_query is not None: + db.session.delete(user) + db.session.commit() + user_query = User.query.filter_by(id=user.id).first() + # on delete we return succesfull deleted + if user_query is None: + return True + elif action == "update_idp_user": + user_query = User.query.filter_by(emailid=str(user.emailid)).first() + if user_query is not None: + db.session.add(user) + db.session.commit() + else: + return False + user_query = User.query.filter_by(id=user.id).first() + if user_query is None: + return False + if None not in (attribute, value): + if attribute == "emailid": + user_query = User.query.filter_by(emailid=str(value)).first() + if user_query is not None: + return False + setattr(user, attribute, value) + db.session.commit() + return True + def update_operation(self, op_id, attribute, value, user): """ op_id: operation id @@ -221,12 +284,11 @@ def update_operation(self, op_id, attribute, value, user): db.session.commit() return True - def delete_file(self, op_id, user): + def delete_operation(self, op_id, user): """ op_id: operation id user: logged in user """ - # ToDo rename to delete_operation if self.auth_type(user.id, op_id) != "creator": return False Permission.query.filter_by(op_id=op_id).delete() @@ -261,31 +323,33 @@ def save_file(self, op_id, content, user, comment=""): if not operation: return False - with fs.open_fs(self.data_dir) as data: - """ - old file is read, the diff between old and new is calculated and stored - as 'Change' in changes table. comment for each change is optional - """ - old_data = data.readtext(fs.path.combine(operation.path, 'main.ftml')) - old_data_lines = old_data.splitlines() - content_lines = content.splitlines() - diff = difflib.unified_diff(old_data_lines, content_lines, lineterm='') - diff_content = '\n'.join(list(diff)) - data.writetext(fs.path.combine(operation.path, 'main.ftml'), content) - # commit changes if comment is not None - if diff_content != "": - # commit to git repository - operation_path = fs.path.combine(self.data_dir, operation.path) - repo = git.Repo(operation_path) - repo.git.clear_cache() - repo.index.add(['main.ftml']) - cm = repo.index.commit("committing changes") - # change db table - change = Change(op_id, user.id, cm.hexsha) - db.session.add(change) - db.session.commit() - return True - return False + op_lock = self._get_operation_lock(operation.id) + with op_lock: + with fs.open_fs(self.data_dir) as data: + """ + old file is read, the diff between old and new is calculated and stored + as 'Change' in changes table. comment for each change is optional + """ + old_data = data.readtext(fs.path.combine(operation.path, 'main.ftml')) + old_data_lines = old_data.splitlines() + content_lines = content.splitlines() + diff = difflib.unified_diff(old_data_lines, content_lines, lineterm='') + diff_content = '\n'.join(list(diff)) + data.writetext(fs.path.combine(operation.path, 'main.ftml'), content) + # commit changes if comment is not None + if diff_content != "": + # commit to git repository + operation_path = fs.path.combine(self.data_dir, operation.path) + repo = git.Repo(operation_path) + repo.git.clear_cache() + repo.index.add(['main.ftml']) + cm = repo.index.commit("committing changes") + # change db table + change = Change(op_id, user.id, cm.hexsha) + db.session.add(change) + db.session.commit() + return True + return False def get_file(self, op_id, user): """ @@ -298,12 +362,14 @@ def get_file(self, op_id, user): operation = Operation.query.filter_by(id=op_id).first() if operation is None: return False - with fs.open_fs(self.data_dir) as data: - operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'r') - operation_data = operation_file.read() - return operation_data + op_lock = self._get_operation_lock(op_id) + with op_lock: + with fs.open_fs(self.data_dir) as data: + operation_file = data.open(fs.path.combine(operation.path, 'main.ftml'), 'r') + operation_data = operation_file.read() + return operation_data - def get_all_changes(self, op_id, user, named_version=None): + def get_all_changes(self, op_id, user, named_version=False): """ op_id: operation-id user: user of this request @@ -314,19 +380,19 @@ def get_all_changes(self, op_id, user, named_version=None): perm = Permission.query.filter_by(u_id=user.id, op_id=op_id).first() if perm is None: return False - # Get all changes - if named_version is None: - changes = Change.query.\ - filter_by(op_id=op_id)\ - .order_by(Change.created_at.desc())\ - .all() # Get only named versions - else: + if named_version: changes = Change.query\ .filter(Change.op_id == op_id)\ .filter(~Change.version_name.is_(None))\ .order_by(Change.created_at.desc())\ .all() + # Get all changes + else: + changes = Change.query\ + .filter_by(op_id=op_id)\ + .order_by(Change.created_at.desc())\ + .all() return list(map(lambda change: { 'id': change.id, @@ -336,14 +402,18 @@ def get_all_changes(self, op_id, user, named_version=None): 'created_at': change.created_at.strftime("%Y-%m-%d, %H:%M:%S") }, changes)) - def get_change_content(self, ch_id): + def get_change_content(self, ch_id, user): """ ch_id: change id user: user of this request Get change related to id """ - # ToDo refactor check user in op + ch = Change.query.filter_by(id=ch_id).first() + perm = Permission.query.filter_by(u_id=user.id, op_id=ch.op_id).first() + if perm is None: + return False + change = Change.query.filter_by(id=ch_id).first() if not change: return False @@ -363,13 +433,12 @@ def set_version_name(self, ch_id, op_id, u_id, version_name): db.session.commit() return True - def undo(self, ch_id, user): + def undo_changes(self, ch_id, user): """ ch_id: change-id user: user of this request Undo a change - # ToDo rename to undo_changes # ToDo add a revert option, which removes only that commit's change """ ch = Change.query.filter_by(id=ch_id).first() @@ -382,22 +451,24 @@ def undo(self, ch_id, user): if not ch or not operation: return False - operation_path = fs.path.join(self.data_dir, operation.path) - repo = git.Repo(operation_path) - repo.git.clear_cache() - try: - file_content = repo.git.show(f'{ch.commit_hash}:main.ftml') - with fs.open_fs(operation_path) as proj_fs: - proj_fs.writetext('main.ftml', file_content) - repo.index.add(['main.ftml']) - cm = repo.index.commit(f"checkout to {ch.commit_hash}") - change = Change(ch.op_id, user.id, cm.hexsha) - db.session.add(change) - db.session.commit() - return True - except Exception as ex: - logging.debug(ex) - return False + op_lock = self._get_operation_lock(operation.id) + with op_lock: + operation_path = fs.path.join(self.data_dir, operation.path) + repo = git.Repo(operation_path) + repo.git.clear_cache() + try: + file_content = repo.git.show(f'{ch.commit_hash}:main.ftml') + with fs.open_fs(operation_path) as proj_fs: + proj_fs.writetext('main.ftml', file_content) + repo.index.add(['main.ftml']) + cm = repo.index.commit(f"checkout to {ch.commit_hash}") + change = Change(ch.op_id, user.id, cm.hexsha) + db.session.add(change) + db.session.commit() + return True + except Exception as ex: + logging.debug(ex) + return False def fetch_users_without_permission(self, op_id, u_id): if not self.is_admin(u_id, op_id) and not self.is_creator(u_id, op_id): @@ -425,7 +496,8 @@ def fetch_users_with_permission(self, op_id, u_id): return users def fetch_operation_creator(self, op_id, u_id): - if not self.is_admin(u_id, op_id) and not self.is_creator(u_id, op_id): + if not self.is_member(u_id, op_id): + # any participant of the OP is allowed to see who is the creator return False current_operation_creator = Permission.query.filter_by(op_id=op_id, access_level="creator").first() return current_operation_creator.user.username diff --git a/mslib/mscolab/message_type.py b/mslib/mscolab/message_type.py new file mode 100644 index 000000000..470cee761 --- /dev/null +++ b/mslib/mscolab/message_type.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" + + mslib.mscolab.message_type.py + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This file is part of MSS. + + :copyright: Copyright 2019 Shivashis Padhi + :copyright: Copyright 2019-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import enum + + +class MessageType(enum.IntEnum): + TEXT = 0 + SYSTEM_MESSAGE = 1 + IMAGE = 2 + DOCUMENT = 3 diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 3442fb681..bec78f606 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -26,36 +26,51 @@ """ import datetime -import enum import logging import jwt from passlib.apps import custom_app_context as pwd_context -from flask_sqlalchemy import SQLAlchemy -from mslib.mscolab.app import APP +import sqlalchemy.types -db = SQLAlchemy(APP) +from mslib.mscolab.app import db +from mslib.mscolab.message_type import MessageType + + +class AwareDateTime(sqlalchemy.types.TypeDecorator): + impl = sqlalchemy.types.DateTime + + def process_bind_param(self, value, dialect): + if value is not None: + return value.astimezone(datetime.timezone.utc) + return value + + def process_result_value(self, value, dialect): + if value is not None: + return value.replace(tzinfo=datetime.timezone.utc) + return value class User(db.Model): __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 username = db.Column(db.String(255)) emailid = db.Column(db.String(255), unique=True) password = db.Column(db.String(255), unique=True) - registered_on = db.Column(db.DateTime, nullable=False) + registered_on = db.Column(AwareDateTime, nullable=False) confirmed = db.Column(db.Boolean, nullable=False, default=False) - confirmed_on = db.Column(db.DateTime, nullable=True) + confirmed_on = db.Column(AwareDateTime, nullable=True) permissions = db.relationship('Permission', cascade='all,delete,delete-orphan', backref='user') + authentication_backend = db.Column(db.String(255), nullable=False, default='local') - def __init__(self, emailid, username, password, confirmed=False, confirmed_on=None): + def __init__(self, emailid, username, password, confirmed=False, confirmed_on=None, authentication_backend='local'): self.username = username self.emailid = emailid self.hash_password(password) - self.registered_on = datetime.datetime.now() + self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc) self.confirmed = confirmed self.confirmed_on = confirmed_on + self.authentication_backend = authentication_backend def __repr__(self): return f'' @@ -107,7 +122,7 @@ def verify_auth_token(token): class Permission(db.Model): __tablename__ = 'permissions' - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 op_id = db.Column(db.Integer, db.ForeignKey('operations.id')) u_id = db.Column(db.Integer, db.ForeignKey('users.id')) access_level = db.Column(db.Enum("admin", "collaborator", "viewer", "creator", name="access_level")) @@ -129,12 +144,12 @@ def __repr__(self): class Operation(db.Model): __tablename__ = "operations" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 path = db.Column(db.String(255), unique=True) category = db.Column(db.String(255)) description = db.Column(db.String(255)) active = db.Column(db.Boolean) - last_used = db.Column(db.DateTime) + last_used = db.Column(AwareDateTime) def __init__(self, path, description, last_used=None, category="default", active=True): """ @@ -147,7 +162,7 @@ def __init__(self, path, description, last_used=None, category="default", active self.category = category self.active = active if self.last_used is None: - self.last_used = datetime.datetime.utcnow() + self.last_used = datetime.datetime.now(tz=datetime.timezone.utc) else: self.last_used = last_used @@ -157,17 +172,10 @@ def __repr__(self): f'last_used: {self.last_used}> ' -class MessageType(enum.IntEnum): - TEXT = 0 - SYSTEM_MESSAGE = 1 - IMAGE = 2 - DOCUMENT = 3 - - class Message(db.Model): __tablename__ = "messages" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 op_id = db.Column(db.Integer, db.ForeignKey('operations.id')) u_id = db.Column(db.Integer, db.ForeignKey('users.id')) text = db.Column(db.Text) @@ -191,7 +199,7 @@ def __repr__(self): class Change(db.Model): __tablename__ = "changes" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 op_id = db.Column(db.Integer, db.ForeignKey('operations.id')) u_id = db.Column(db.Integer, db.ForeignKey('users.id')) commit_hash = db.Column(db.String(255), default=None) diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index d61a24ad0..cf1e03c2a 100644 --- a/mslib/mscolab/mscolab.py +++ b/mslib/mscolab/mscolab.py @@ -32,6 +32,8 @@ import shutil import sys import secrets +import subprocess +import git from mslib import __version__ from mslib.mscolab.conf import mscolab_settings @@ -93,6 +95,263 @@ def handle_db_seed(): print("Database seeded successfully!") +def handle_mscolab_certificate_init(): + print('generating CRTs for the mscolab server......') + + try: + cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", + os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "key_mscolab.key"), + "-nodes", "-x509", "-days", "365", "-batch", "-subj", + "/CN=localhost", "-out", os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, + "crt_mscolab.crt")] + subprocess.run(cmd, check=True) + logging.info("generated CRTs for the mscolab server.") + return True + except subprocess.CalledProcessError as error: + print(f"Error while generating CRTs for the mscolab server: {error}") + return False + + +def handle_local_idp_certificate_init(): + print('generating CRTs for the local identity provider......') + + try: + cmd = ["openssl", "req", "-newkey", "rsa:4096", "-keyout", + os.path.join(mscolab_settings.MSCOLAB_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")] + subprocess.run(cmd, check=True) + logging.info("generated CRTs for the local identity provider") + return True + except subprocess.CalledProcessError as error: + print(f"Error while generated CRTs for the local identity provider: {error}") + return False + + +def handle_mscolab_backend_yaml_init(): + saml_2_backend_yaml_content = """name: Saml2 +config: + entityid_endpoint: true + mirror_force_authn: no + memorize_idp: no + use_memorized_idp_when_force_authn: no + send_requester_id: no + enable_metadata_reload: no + + # SP Configuration for localhost_test_idp + localhost_test_idp: + name: "MSS Colab Server - Testing IDP(localhost)" + description: "MSS Collaboration Server with Testing IDP(localhost)" + key_file: path/to/key_sp.key # Will be set from the mscolab server + cert_file: path/to/crt_sp.crt # Will be set from the mscolab server + verify_ssl_cert: true # Specifies if the SSL certificates should be verified. + organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + contact_person: + - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + - {contact_type: support, email_address: support@example.com, given_name: Support} + + metadata: + local: [path/to/idp.xml] # Will be set from the mscolab server + + entityid: http://localhost:5000/proxy_saml2_backend.xml + accepted_time_diff: 60 + service: + sp: + ui_info: + display_name: + - lang: en + text: "Open MSS" + description: + - lang: en + text: "Mission Support System" + information_url: + - lang: en + text: "https://open-mss.github.io/about/" + privacy_statement_url: + - lang: en + text: "https://open-mss.github.io/about/" + keywords: + - lang: en + text: ["MSS"] + - lang: en + text: ["OpenMSS"] + logo: + text: "https://open-mss.github.io/assets/logo.png" + width: "100" + height: "100" + authn_requests_signed: true + want_response_signed: true + want_assertion_signed: true + allow_unknown_attributes: true + allow_unsolicited: true + endpoints: + assertion_consumer_service: + - [http://localhost:8083/localhost_test_idp/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + - [http://localhost:8083/localhost_test_idp/acs/redirect, + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + discovery_response: + - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + name_id_format_allow_create: true + + + # # SP Configuration for IDP 2 + # sp_config_idp_2: + # name: "MSS Colab Server - Testing IDP(localhost)" + # description: "MSS Collaboration Server with Testing IDP(localhost)" + # key_file: mslib/mscolab/app/key_sp.key + # cert_file: mslib/mscolab/app/crt_sp.crt + # organization: {display_name: Open-MSS, name: Mission Support System, url: 'https://open-mss.github.io/about/'} + # contact_person: + # - {contact_type: technical, email_address: technical@example.com, given_name: Technical} + # - {contact_type: support, email_address: support@example.com, given_name: Support} + + # metadata: + # local: [mslib/mscolab/app/idp.xml] + + # entityid: http://localhost:5000/proxy_saml2_backend.xml + # accepted_time_diff: 60 + # service: + # sp: + # ui_info: + # display_name: + # - lang: en + # text: "Open MSS" + # description: + # - lang: en + # text: "Mission Support System" + # information_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # privacy_statement_url: + # - lang: en + # text: "https://open-mss.github.io/about/" + # keywords: + # - lang: en + # text: ["MSS"] + # - lang: en + # text: ["OpenMSS"] + # logo: + # text: "https://open-mss.github.io/assets/logo.png" + # width: "100" + # height: "100" + # authn_requests_signed: true + # want_response_signed: true + # want_assertion_signed: true + # allow_unknown_attributes: true + # allow_unsolicited: true + # endpoints: + # assertion_consumer_service: + # - [http://localhost:8083/idp2/acs/post, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'] + # - [http://localhost:8083/idp2/acs/redirect, 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + # discovery_response: + # - [//disco, 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'] + # name_id_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' + # name_id_format_allow_create: true +""" + try: + file_path = os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "mss_saml2_backend.yaml") + with open(file_path, "w", encoding="utf-8") as file: + file.write(saml_2_backend_yaml_content) + return True + except (FileNotFoundError, PermissionError) as error: + print(f"Error while generated backend .yaml for the local mscolabserver: {error}") + return False + + +def handle_mscolab_metadata_init(repo_exists): + """ + This will generate necessary metada data file for sso in mscolab through localhost idp + + Before running this function: + - Ensure that USE_SAML2 is set to True. + - Generate the necessary keys and certificates and configure them in the .yaml + file for the local IDP. + """ + print('generating metadata file for the mscolab server') + + try: + command = ["python", os.path.join("mslib", "mscolab", "mscolab.py"), + "start"] if repo_exists else ["mscolab", "start"] + 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")] + subprocess.run(cmd_curl, check=True) + process.terminate() + logging.info('mscolab metadata file generated succesfully') + return True + + except subprocess.CalledProcessError as error: + print(f"Error while generating metadata file for the mscolab server: {error}") + return False + + +def handle_local_idp_metadata_init(repo_exists): + print('generating metadata for localhost identity provider') + + try: + if os.path.exists(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")): + os.remove(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")) + + idp_conf_path = os.path.join("mslib", "msidp", "idp_conf.py") + + if not repo_exists: + import site + site_packages_path = site.getsitepackages()[0] + idp_conf_path = os.path.join(site_packages_path, "mslib", "msidp", "idp_conf.py") + + cmd = ["make_metadata", idp_conf_path] + + with open(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml"), + "w", encoding="utf-8") as output_file: + subprocess.run(cmd, stdout=output_file, check=True) + logging.info("idp metadata file generated succesfully") + return True + except subprocess.CalledProcessError as error: + # Delete the idp.xml file when the subprocess fails + if os.path.exists(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")): + os.remove(os.path.join(mscolab_settings.MSCOLAB_SSO_DIR, "idp.xml")) + print(f"Error while generating metadata for localhost identity provider: {error}") + return False + + +def handle_sso_crts_init(): + """ + This will generate necessary CRTs files for sso in mscolab through localhost idp + """ + print("\n\nmscolab sso conf initiating......") + if os.path.exists(mscolab_settings.MSCOLAB_SSO_DIR): + shutil.rmtree(mscolab_settings.MSCOLAB_SSO_DIR) + create_files() + if not handle_mscolab_certificate_init(): + print('Error while handling mscolab certificate.') + return + + if not handle_local_idp_certificate_init(): + print('Error while handling local idp certificate.') + return + + if not handle_mscolab_backend_yaml_init(): + print('Error while handling mscolab backend YAML.') + return + + print('\n\nAll CRTs and mscolab backend saml files generated successfully !') + + +def handle_sso_metadata_init(repo_exists): + print('\n\ngenerating metadata files.......') + if not handle_mscolab_metadata_init(repo_exists): + print('Error while handling mscolab metadata.') + return + + if not handle_local_idp_metadata_init(repo_exists): + print('Error while handling idp metadata.') + return + + print("\n\nALl necessary metadata files generated successfully") + + def main(): parser = argparse.ArgumentParser() parser.add_argument("-v", "--version", help="show version", action="store_true", default=False) @@ -119,6 +378,15 @@ def main(): action="store_true") database_parser.add_argument("--add_all_to_all_operation", help="adds all users into all other operations", action="store_true") + sso_conf_parser = subparsers.add_parser("sso_conf", help="single sign on process configurations") + sso_conf_parser = sso_conf_parser.add_mutually_exclusive_group(required=True) + sso_conf_parser.add_argument("--init_sso_crts", + help="Generate all the essential CRTs required for the Single Sign-On process " + "using the local Identity Provider", + action="store_true") + sso_conf_parser.add_argument("--init_sso_metadata", help="Generate all the essential metadata files required " + "for the Single Sign-On process using the local Identity Provider", + action="store_true") args = parser.parse_args() @@ -130,6 +398,13 @@ def main(): print("Version:", __version__) sys.exit() + try: + _ = git.Repo(os.path.dirname(os.path.realpath(__file__)), search_parent_directories=True) + repo_exists = True + + except git.exc.InvalidGitRepositoryError: + repo_exists = False + updater = Updater() if args.update: updater.on_update_available.connect(lambda old, new: updater.update_mss()) @@ -186,6 +461,32 @@ def main(): for email in args.delete_users_by_file.readlines(): delete_user(email.strip()) + elif args.action == "sso_conf": + if args.init_sso_crts: + confirmation = confirm_action( + "This will reset and initiation all CRTs and SAML yaml file as default. " + "Are you sure to continue? (y/[n]):") + if confirmation is True: + handle_sso_crts_init() + if args.init_sso_metadata: + confirmation = confirm_action( + "Are you sure you executed --init_sso_crts before running this? (y/[n]):") + if confirmation is True: + confirmation = confirm_action( + """ + This will generate necessary metada data file for sso in mscolab through localhost idp + + Before running this function: + - Ensure that USE_SAML2 is set to True. + - Generate the necessary keys and certificates and configure them in the .yaml + file for the local IDP. + + Are you sure you set all correctly as per the documentation? (y/[n]): + """ + ) + if confirmation is True: + handle_sso_metadata_init(repo_exists) + if __name__ == '__main__': main() diff --git a/mslib/mscolab/seed.py b/mslib/mscolab/seed.py index 89b9b90b5..5c8165abc 100644 --- a/mslib/mscolab/seed.py +++ b/mslib/mscolab/seed.py @@ -46,7 +46,6 @@ def add_all_users_to_all_operations(access_level='collaborator'): all_path = [operation.path for operation in all_operations] db.session.close() for path in all_path: - access_level = 'collaborator' if path == "TEMPLATE": access_level = 'admin' add_all_users_default_operation(path=path, access_level=access_level) @@ -117,9 +116,6 @@ def add_user(email, username, password): app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - template = f""" - "MSCOLAB_mailid": "{email}", -""" with app.app_context(): user_email_exists = User.query.filter_by(emailid=str(email)).first() user_name_exists = User.query.filter_by(username=str(username)).first() @@ -129,7 +125,6 @@ def add_user(email, username, password): db.session.commit() db.session.close() logging.info("Userdata: %s %s %s", email, username, password) - logging.info(template) return True else: logging.info("%s already in db", user_name_exists) @@ -223,7 +218,10 @@ def archive_operation(path=None, emailid=None): elif perm.access_level != "creator": return False operation.active = False - operation.last_used = datetime.datetime.utcnow() - dateutil.relativedelta.relativedelta(months=2) + operation.last_used = ( + datetime.datetime.now(tz=datetime.timezone.utc) - + dateutil.relativedelta.relativedelta(months=2) + ) db.session.commit() diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index b335398e7..77b6b41ad 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -27,27 +27,25 @@ import functools import json import logging -import time import datetime import secrets -import fs -import os import socketio import sqlalchemy.exc import werkzeug from itsdangerous import URLSafeTimedSerializer, BadSignature from flask import g, jsonify, request, render_template, flash -from flask import send_from_directory, abort, url_for +from flask import send_from_directory, abort, url_for, redirect from flask_mail import Mail, Message from flask_cors import CORS -from flask_migrate import Migrate from flask_httpauth import HTTPBasicAuth from validate_email import validate_email -from werkzeug.utils import secure_filename +from saml2.metadata import create_metadata_string +from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST +from flask.wrappers import Response -from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Change, MessageType, User, Operation, db +from mslib.mscolab.conf import mscolab_settings, setup_saml2_backend +from mslib.mscolab.models import Change, MessageType, User from mslib.mscolab.sockets_manager import setup_managers from mslib.mscolab.utils import create_files, get_message_dict from mslib.utils import conditional_decorator @@ -55,18 +53,18 @@ from mslib.mscolab.forms import ResetRequestForm, ResetPasswordForm -APP = create_app(__name__) +APP = create_app(__name__, imprint=mscolab_settings.IMPRINT, gdpr=mscolab_settings.GDPR) mail = Mail(APP) CORS(APP, origins=mscolab_settings.CORS_ORIGINS if hasattr(mscolab_settings, "CORS_ORIGINS") else ["*"]) -migrate = Migrate(APP, db, render_as_batch=True) auth = HTTPBasicAuth() + try: from mscolab_auth import mscolab_auth except ImportError as ex: logging.warning("Couldn't import mscolab_auth (ImportError:'{%s), creating dummy config.", ex) - class mscolab_auth(object): + class mscolab_auth: allowed_users = [("mscolab", "add_md5_digest_of_PASSWORD_here"), ("add_new_user_here", "add_md5_digest_of_PASSWORD_here")] __file__ = None @@ -155,24 +153,22 @@ def check_login(emailid, password): return False -@conditional_decorator(auth.login_required, mscolab_settings.__dict__.get('enable_basic_http_authentication', False)) def register_user(email, password, username): user = User(email, username, password) is_valid_username = True if username.find("@") == -1 else False is_valid_email = validate_email(email) if not is_valid_email: - return {"success": False, "message": "Oh no, your email ID is not valid!"} + return {"success": False, "message": "Your email ID is not valid!"} if not is_valid_username: - return {"success": False, "message": "Oh no, your username cannot contain @ symbol!"} + return {"success": False, "message": "Your username cannot contain @ symbol!"} user_exists = User.query.filter_by(emailid=str(email)).first() if user_exists: - return {"success": False, "message": "Oh no, this email ID is already taken!"} + return {"success": False, "message": "This email ID is already taken!"} user_exists = User.query.filter_by(username=str(username)).first() if user_exists: - return {"success": False, "message": "Oh no, this username is already registered"} - db.session.add(user) - db.session.commit() - return {"success": True} + return {"success": False, "message": "This username is already registered"} + result = fm.modify_user(user, action="create") + return {"success": result} def verify_user(func): @@ -199,21 +195,70 @@ def wrapper(*args, **kwargs): return wrapper +def get_idp_entity_id(selected_idp): + """ + Finds the entity_id from the configured IDPs + :return: the entity_id of the idp or None + """ + for config in setup_saml2_backend.CONFIGURED_IDPS: + if selected_idp == config['idp_identity_name']: + idps = config['idp_data']['saml2client'].metadata.identity_providers() + only_idp = idps[0] + entity_id = only_idp + return entity_id + return None + + +def create_or_update_idp_user(email, username, token, authentication_backend): + """ + Creates or updates an idp user in the system based on the provided email, + username, token, and authentication backend. + :param email: idp users email + :param username: idp users username + :param token: authentication token + :param authentication_backend: authenticated identity providers name + :return: bool : query success or not + """ + user = User.query.filter_by(emailid=email).first() + if not user: + # using an IDP for a new account/profile, e-mail is already verified by the IDP + confirm_time = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(seconds=1) + user = User(email, username, password=token, confirmed=True, confirmed_on=confirm_time, + authentication_backend=authentication_backend) + result = fm.modify_user(user, action="create") + else: + user.authentication_backend = authentication_backend + user.hash_password(token) + result = fm.modify_user(user, action="update_idp_user") + return result + + @APP.route('/') def home(): return render_template("/index.html") -# ToDo setup codes in return statements @APP.route("/status") def hello(): if request.authorization is not None: if mscolab_settings.__dict__.get('enable_basic_http_authentication', False): auth.login_required() - return "Mscolab server" - return "Mscolab server" + return json.dumps({ + 'message': "Mscolab server", + 'use_saml2': mscolab_settings.USE_SAML2, + 'direct_login': mscolab_settings.DIRECT_LOGIN + }) + return json.dumps({ + 'message': "Mscolab server", + 'use_saml2': mscolab_settings.USE_SAML2, + 'direct_login': mscolab_settings.DIRECT_LOGIN + }) else: - return "Mscolab server" + return json.dumps({ + 'message': "Mscolab server", + 'use_saml2': mscolab_settings.USE_SAML2, + 'direct_login': mscolab_settings.DIRECT_LOGIN + }) @APP.route('/token', methods=["POST"]) @@ -222,7 +267,7 @@ def get_auth_token(): emailid = request.form['email'] password = request.form['password'] user = check_login(emailid, password) - if user: + if user is not False: if mscolab_settings.MAIL_ENABLED: if user.confirmed: token = user.generate_auth_token() @@ -258,6 +303,7 @@ def authorized(): @APP.route("/register", methods=["POST"]) +@conditional_decorator(auth.login_required, mscolab_settings.__dict__.get('enable_basic_http_authentication', False)) def user_register_handler(): email = request.form['email'] password = request.form['password'] @@ -292,10 +338,8 @@ def confirm_email(token): if user.confirmed: return render_template('user/confirmed.html', username=user.username) else: - user.confirmed = True - user.confirmed_on = datetime.datetime.now() - db.session.add(user) - db.session.commit() + fm.modify_user(user, attribute="confirmed_on", value=datetime.datetime.now(tz=datetime.timezone.utc)) + fm.modify_user(user, attribute="confirmed", value=True) return render_template('user/confirmed.html', username=user.username) @@ -305,24 +349,21 @@ def get_user(): return json.dumps({'user': {'id': g.user.id, 'username': g.user.username}}) -@APP.route("/delete_user", methods=["POST"]) +@APP.route("/delete_own_account", methods=["POST"]) @verify_user -def delete_user(): +def delete_own_account(): """ delete own account """ - # ToDo rename to delete_own_account user = g.user - db.session.delete(user) - db.session.commit() - return jsonify({"success": True}), 200 + result = fm.modify_user(user, action="delete") + return jsonify({"success": result}), 200 # Chat related routes @APP.route("/messages", methods=["GET"]) @verify_user def messages(): - # ToDo maybe move is_member part to file_manager user = g.user op_id = request.args.get("op_id", request.form.get("op_id", None)) if fm.is_member(user.id, op_id): @@ -342,32 +383,18 @@ def message_attachment(): file = request.files['file'] message_type = MessageType(int(request.form.get("message_type"))) user = g.user - # ToDo review users = fm.fetch_users_without_permission(int(op_id), user.id) if users is False: return jsonify({"success": False, "message": "Could not send message. No file uploaded."}) if file is not None: - with fs.open_fs('/') as home_fs: - file_dir = fs.path.join(APP.config['UPLOAD_FOLDER'], op_id) - if '\\' not in file_dir: - if not home_fs.exists(file_dir): - home_fs.makedirs(file_dir) - else: - file_dir = file_dir.replace('\\', '/') - if not os.path.exists(file_dir): - os.makedirs(file_dir) - file_name, file_ext = file.filename.rsplit('.', 1) - file_name = f'{file_name}-{time.strftime("%Y%m%dT%H%M%S")}-{file_token}.{file_ext}' - file_name = secure_filename(file_name) - file_path = fs.path.join(file_dir, file_name) - file.save(file_path) - static_dir = fs.path.basename(APP.config['UPLOAD_FOLDER']) - static_dir = static_dir.replace('\\', '/') - static_file_path = os.path.join(static_dir, op_id, file_name) - new_message = cm.add_message(user, static_file_path, op_id, message_type) - new_message_dict = get_message_dict(new_message) - sockio.emit('chat-message-client', json.dumps(new_message_dict)) - return jsonify({"success": True, "path": static_file_path}) + static_file_path = cm.add_attachment(op_id, APP.config['UPLOAD_FOLDER'], file, file_token) + if static_file_path is not None: + new_message = cm.add_message(user, static_file_path, op_id, message_type) + new_message_dict = get_message_dict(new_message) + sockio.emit('chat-message-client', json.dumps(new_message_dict)) + return jsonify({"success": True, "path": static_file_path}) + else: + return "False" return jsonify({"success": False, "message": "Could not send message. No file uploaded."}) # normal use case never gets to this return "False" @@ -398,9 +425,11 @@ def create_operation(): content = request.form.get('content', None) description = request.form.get('description', None) category = request.form.get('category', "default") - last_used = datetime.datetime.utcnow() + active = (request.form.get('active', "True") == "True") + last_used = datetime.datetime.now(tz=datetime.timezone.utc) user = g.user - r = str(fm.create_operation(path, description, user, last_used, content=content, category=category)) + r = str(fm.create_operation(path, description, user, last_used, + content=content, category=category, active=active)) if r == "True": token = request.args.get('token', request.form.get('token', False)) json_config = {"token": token} @@ -423,7 +452,7 @@ def get_operation_by_id(): @verify_user def get_all_changes(): op_id = request.args.get('op_id', request.form.get('op_id', None)) - named_version = request.args.get('named_version') + named_version = request.args.get('named_version') == "True" user = g.user result = fm.get_all_changes(int(op_id), user, named_version) if result is False: @@ -434,9 +463,9 @@ def get_all_changes(): @APP.route('/get_change_content', methods=['GET']) @verify_user def get_change_content(): - # ToDo refactor see fm.get_change_content( ch_id = int(request.args.get('ch_id', request.form.get('ch_id', 0))) - result = fm.get_change_content(ch_id) + user = g.user + result = fm.get_change_content(ch_id, user) if result is False: return "False" return jsonify({"content": result}) @@ -466,8 +495,9 @@ def authorized_users(): @APP.route('/operations', methods=['GET']) @verify_user def get_operations(): + skip_archived = (request.args.get('skip_archived', request.form.get('skip_archived', "False")) == "True") user = g.user - return json.dumps({"operations": fm.list_operations(user)}) + return json.dumps({"operations": fm.list_operations(user, skip_archived=skip_archived)}) @APP.route('/delete_operation', methods=["POST"]) @@ -475,7 +505,7 @@ def get_operations(): def delete_operation(): op_id = int(request.form.get('op_id', 0)) user = g.user - success = fm.delete_file(op_id, user) + success = fm.delete_operation(op_id, user) if success is False: return jsonify({"success": False, "message": "You don't have access for this operation!"}) @@ -512,44 +542,29 @@ def get_operation_details(): @APP.route('/set_last_used', methods=["POST"]) @verify_user def set_last_used(): - # ToDo refactor move to file_manager op_id = request.form.get('op_id', None) - operation = Operation.query.filter_by(id=int(op_id)).first() - operation.last_used = datetime.datetime.utcnow() - temp_operation_active = operation.active - operation.active = True - db.session.commit() - # Reload Operation List - if not temp_operation_active: + user = g.user + days_ago = int(request.form.get('days', 0)) + 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 -@APP.route('/update_last_used', methods=["POST"]) +@APP.route('/undo_changes', methods=["POST"]) @verify_user -def update_last_used(): - # ToDo refactor move to file_manager - operations = Operation.query.filter().all() - for operation in operations: - if operation.last_used is not None and \ - (datetime.datetime.utcnow() - operation.last_used).days > 30: - operation.active = False - else: - operation.active = True - db.session.commit() - return jsonify({"success": True}), 200 - - -@APP.route('/undo', methods=["POST"]) -@verify_user -def undo_ftml(): - # ToDo rename to undo_changes +def undo_changes(): ch_id = request.form.get('ch_id', -1) ch_id = int(ch_id) user = g.user - result = fm.undo(ch_id, user) + result = fm.undo_changes(ch_id, user) # get op_id from change ch = Change.query.filter_by(id=ch_id).first() if result is True: @@ -685,8 +700,7 @@ def reset_password(token): if form.validate_on_submit(): try: user.hash_password(form.confirm_password.data) - user.confirmed = True - db.session.commit() + fm.modify_user(user, "confirmed", True) flash('Password reset Success. Please login by the user interface.', 'category_success') return render_template('user/status.html') except IOError: @@ -724,6 +738,145 @@ def reset_request(): return render_template('errors/403.html'), 403 +if mscolab_settings.USE_SAML2: + # setup idp login config + setup_saml2_backend() + + # set routes for SSO + @APP.route('/available_idps/', methods=['GET']) + def available_idps(): + """ + This function checks if IDP (Identity Provider) is enabled in the mscolab_settings module. + If IDP is enabled, it retrieves the configured IDPs from setup_saml2_backend.CONFIGURED_IDPS + and renders the 'idp/available_idps.html' template with the list of configured IDPs. + """ + configured_idps = setup_saml2_backend.CONFIGURED_IDPS + return render_template('idp/available_idps.html', configured_idps=configured_idps), 200 + + @APP.route("/idp_login/", methods=['POST']) + def idp_login(): + """Handle the login process for the user by selected IDP""" + selected_idp = request.form.get('selectedIdentityProvider') + sp_config = None + for config in setup_saml2_backend.CONFIGURED_IDPS: + if selected_idp == config['idp_identity_name']: + sp_config = config['idp_data']['saml2client'] + break + + try: + _, response_binding = sp_config.config.getattr("endpoints", "sp")[ + "assertion_consumer_service" + ][0] + entity_id = get_idp_entity_id(selected_idp) + _, binding, http_args = sp_config.prepare_for_negotiated_authenticate( + entityid=entity_id, + response_binding=response_binding, + ) + if binding == BINDING_HTTP_REDIRECT: + headers = dict(http_args["headers"]) + return redirect(str(headers["Location"]), code=303) + return Response(http_args["data"], headers=http_args["headers"]) + except (NameError, AttributeError): + return render_template('errors/403.html'), 403 + + def create_acs_post_handler(config): + """ + Create acs_post_handler function for the given idp_config. + """ + def acs_post_handler(): + """ + Function to handle SAML authentication response. + """ + try: + outstanding_queries = {} + binding = BINDING_HTTP_POST + authn_response = config['idp_data']['saml2client'].parse_authn_request_response( + request.form["SAMLResponse"], binding, outstanding=outstanding_queries + ) + email = None + username = None + + try: + email = authn_response.ava["email"][0] + username = authn_response.ava["givenName"][0] + token = generate_confirmation_token(email) + except (NameError, AttributeError, KeyError): + try: + # Initialize an empty dictionary to store attribute values + attributes = {} + + # Loop through attribute statements + for attribute_statement in authn_response.assertion.attribute_statement: + for attribute in attribute_statement.attribute: + attribute_name = attribute.name + attribute_value = \ + attribute.attribute_value[0].text if attribute.attribute_value else None + attributes[attribute_name] = attribute_value + + # Extract the email and givenname attributes + email = attributes["email"] + username = attributes["givenName"] + token = generate_confirmation_token(email) + except (NameError, AttributeError, KeyError): + return render_template('errors/403.html'), 403 + + if email is not None and username is not None: + idp_user_db_state = create_or_update_idp_user(email, + username, token, idp_config['idp_identity_name']) + if idp_user_db_state: + return render_template('idp/idp_login_success.html', token=token), 200 + return render_template('errors/500.html'), 500 + return render_template('errors/500.html'), 500 + except (NameError, AttributeError, KeyError): + return render_template('errors/403.html'), 403 + return acs_post_handler + + # Implementation for handling configured SAML assertion consumer endpoints + for idp_config in setup_saml2_backend.CONFIGURED_IDPS: + try: + for assertion_consumer_endpoint in idp_config['idp_data']['assertion_consumer_endpoints']: + # Dynamically add the route for the current endpoint + APP.add_url_rule(f'/{assertion_consumer_endpoint}/', assertion_consumer_endpoint, + create_acs_post_handler(idp_config), methods=['POST']) + except (NameError, AttributeError, KeyError) as ex: + logging.warning("USE_SAML2 is %s, Failure is: %s", mscolab_settings.USE_SAML2, ex) + + @APP.route('/idp_login_auth/', methods=['POST']) + def idp_login_auth(): + """Handle the SAML authentication validation of client application.""" + try: + data = request.get_json() + token = data.get('token') + email = confirm_token(token, expiration=1200) + if email: + user = check_login(email, token) + if user: + random_token = secrets.token_hex(16) + user.hash_password(random_token) + fm.modify_user(user, action="update_idp_user") + return json.dumps({ + "success": True, + 'token': random_token, + 'user': {'username': user.username, 'id': user.id, 'emailid': user.emailid} + }) + return jsonify({"success": False}), 401 + return jsonify({"success": False}), 401 + except TypeError: + return jsonify({"success": False}), 401 + + @APP.route("/metadata/", methods=['GET']) + def metadata(idp_identity_name): + """Return the SAML metadata XML for the requested IDP""" + for config in setup_saml2_backend.CONFIGURED_IDPS: + if idp_identity_name == config['idp_identity_name']: + sp_config = config['idp_data']['saml2client'] + metadata_string = create_metadata_string( + None, sp_config.config, 4, None, None, None, None, None + ).decode("utf-8") + return Response(metadata_string, mimetype="text/xml") + return render_template('errors/404.html'), 404 + + def start_server(app, sockio, cm, fm, port=8083): create_files() sockio.run(app, port=port) diff --git a/mslib/mscolab/sockets_manager.py b/mslib/mscolab/sockets_manager.py index 5cff180d6..4bf5ecf9a 100644 --- a/mslib/mscolab/sockets_manager.py +++ b/mslib/mscolab/sockets_manager.py @@ -36,11 +36,12 @@ from mslib.mscolab.utils import get_session_id from mslib.mscolab.conf import mscolab_settings -socketio = SocketIO(cors_allowed_origins=("*" if not hasattr(mscolab_settings, "CORS_ORIGINS") or +socketio = SocketIO(logger=mscolab_settings.SOCKETIO_LOGGER, engineio_logger=mscolab_settings.ENGINEIO_LOGGER, + cors_allowed_origins=("*" if not hasattr(mscolab_settings, "CORS_ORIGINS") or "*" in mscolab_settings.CORS_ORIGINS else mscolab_settings.CORS_ORIGINS)) -class SocketsManager(object): +class SocketsManager: """Class with handler functions for socket related""" def __init__(self, chat_manager, file_manager): @@ -226,7 +227,7 @@ def handle_file_save(self, json_req): # emit file-changed event to trigger reload of flight track socketio.emit('file-changed', json.dumps({"op_id": op_id, "u_id": user.id})) else: - logging.debug("login expired for %s, state unauthorized!", user.username) + logging.debug("Auth Token expired!") def emit_file_change(self, op_id): socketio.emit('file-changed', json.dumps({"op_id": op_id})) diff --git a/mslib/mscolab/utils.py b/mslib/mscolab/utils.py index e9d41d48f..dd2e5b18f 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -59,16 +59,16 @@ def get_message_dict(message): } -def os_fs_create_dir(dir): - if '://' in dir: +def os_fs_create_dir(directory_path): + if '://' in directory_path: try: - _ = fs.open_fs(dir) + _ = fs.open_fs(directory_path) except fs.errors.CreateFailed: - logging.error('Make sure that the FS url "%s" exists', dir) + logging.error('Make sure that the FS url "%s" exists', directory_path) except fs.opener.errors.UnsupportedProtocol: - logging.error('FS url "%s" not supported', dir) + logging.error('FS url "%s" not supported', directory_path) else: - _dir = os.path.expanduser(dir) + _dir = os.path.expanduser(directory_path) if not os.path.exists(_dir): os.makedirs(_dir) @@ -76,3 +76,4 @@ def os_fs_create_dir(dir): def create_files(): os_fs_create_dir(mscolab_settings.MSCOLAB_DATA_DIR) os_fs_create_dir(mscolab_settings.UPLOAD_FOLDER) + os_fs_create_dir(mscolab_settings.MSCOLAB_SSO_DIR) diff --git a/mslib/msidp/README.md b/mslib/msidp/README.md new file mode 100644 index 000000000..360f587f5 --- /dev/null +++ b/mslib/msidp/README.md @@ -0,0 +1,3 @@ +# Identity Provider with PySAML2 Integration + +This repository contains an Identity Provider (IdP) implementation that enables single sign-on (SSO) authentication using PySAML2. diff --git a/mslib/msidp/__init__.py b/mslib/msidp/__init__.py new file mode 100644 index 000000000..4c998f670 --- /dev/null +++ b/mslib/msidp/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msidp + ~~~~~~~~~~~ + + init file of msidp + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" diff --git a/mslib/msidp/htdocs/login.mako b/mslib/msidp/htdocs/login.mako new file mode 100644 index 000000000..6d72acd80 --- /dev/null +++ b/mslib/msidp/htdocs/login.mako @@ -0,0 +1,29 @@ +<%inherit file="root.mako"/> + +

Please log in

+

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

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

Login

+
+ +<%def name="post()" filter="trim"> +
+ +
+ + ## + +IDP test login + ${self.css()} + + + + ${pre()} +## ${comps.dict_to_table(pageargs)} +##

+${next.body()} +${post()} + + diff --git a/mslib/msui/aircrafts.py b/mslib/msui/aircrafts.py index 1d797d835..e6f99a9c5 100644 --- a/mslib/msui/aircrafts.py +++ b/mslib/msui/aircrafts.py @@ -41,7 +41,7 @@ } -class SimpleAircraft(object): +class SimpleAircraft: """ Simple aircraft model that offers methods to estimate fuel and time consumption of aircraft for different flight maneuvers. diff --git a/mslib/msui/airdata_dockwidget.py b/mslib/msui/airdata_dockwidget.py index 52f2739b3..0531e3402 100644 --- a/mslib/msui/airdata_dockwidget.py +++ b/mslib/msui/airdata_dockwidget.py @@ -25,7 +25,7 @@ limitations under the License. """ import pycountry -from mslib.utils.qt import ui_airdata_dockwidget as ui +from mslib.msui.qt5 import ui_airdata_dockwidget as ui from PyQt5 import QtWidgets, QtCore from mslib.utils.config import save_settings_qsettings, load_settings_qsettings from mslib.utils.airdata import get_available_airspaces, update_airspace, get_airports @@ -33,7 +33,7 @@ class AirdataDockwidget(QtWidgets.QWidget, ui.Ui_AirdataDockwidget): def __init__(self, parent=None, view=None): - super(AirdataDockwidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view self.view.redrawn.connect(self.redraw_map) diff --git a/mslib/msui/editor.py b/mslib/msui/editor.py index 52c6a9ced..7b0e2a45f 100644 --- a/mslib/msui/editor.py +++ b/mslib/msui/editor.py @@ -31,7 +31,7 @@ import json from mslib.utils.qt import get_open_filename, get_save_filename, show_popup -from mslib.utils.qt import ui_configuration_editor_window as ui_conf +from mslib.msui.qt5 import ui_configuration_editor_window as ui_conf from PyQt5 import QtWidgets, QtCore, QtGui from mslib.msui.constants import MSUI_SETTINGS from mslib.msui.icons import icons @@ -93,23 +93,23 @@ def paint(self, painter, option, index): if model_data != default_data: option.font.setWeight(QtGui.QFont.Bold) - return super(JsonDelegate, self).paint(painter, option, index) + return super().paint(painter, option, index) type_ = index.data(TypeRole) if isinstance(type_, DataType): try: - super(JsonDelegate, self).paint(painter, option, index) + super().paint(painter, option, index) return type_.paint(painter, option, index) except NotImplementedError: pass - return super(JsonDelegate, self).paint(painter, option, index) + return super().paint(painter, option, index) class JsonSortFilterProxyModel(QtCore.QSortFilterProxyModel): def filterAcceptsRow(self, source_row, source_parent): # check if an item is currently accepted - accepted = super(JsonSortFilterProxyModel, self).filterAcceptsRow(source_row, source_parent) + accepted = super().filterAcceptsRow(source_row, source_parent) if accepted: return True @@ -119,7 +119,7 @@ def filterAcceptsRow(self, source_row, source_parent): has_parent = src_model.itemFromIndex(index).parent() if has_parent: parent_index = self.mapFromSource(has_parent.index()) - return super(JsonSortFilterProxyModel, self).filterAcceptsRow(has_parent.row(), parent_index) + return super().filterAcceptsRow(has_parent.row(), parent_index) return accepted @@ -131,7 +131,7 @@ class ConfigurationEditorWindow(QtWidgets.QMainWindow, ui_conf.Ui_ConfigurationE restartApplication = QtCore.pyqtSignal(name="restartApplication") def __init__(self, parent=None): - super(ConfigurationEditorWindow, self).__init__(parent) + super().__init__(parent) self.setupUi(self) options = config_loader() diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index 64080e717..2699d1741 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -128,7 +128,7 @@ class Waypoint: properties. Used internally by WaypointsTableModel. """ - def __init__(self, lat=0, lon=0, flightlevel=0, location="", comments=""): + def __init__(self, lat=0., lon=0., flightlevel=0., location="", comments=""): self.location = location locations = config_loader(dataset='locations') if location in locations: @@ -179,7 +179,7 @@ class WaypointsTableModel(QtCore.QAbstractTableModel): def __init__(self, name="", filename=None, waypoints=None, mscolab_mode=False, data_dir=mss_default.mss_dir, xml_content=None): - super(WaypointsTableModel, self).__init__() + super().__init__() self.name = name # a name for this flight track self.filename = filename # filename for store/load self.data_dir = data_dir @@ -669,7 +669,7 @@ class WaypointDelegate(QtWidgets.QItemDelegate): """ def __init__(self, parent=None): - super(WaypointDelegate, self).__init__(parent) + super().__init__(parent) def paint(self, painter, option, index): """ diff --git a/mslib/msui/hexagon_dockwidget.py b/mslib/msui/hexagon_dockwidget.py index 6435ab067..4ca103808 100644 --- a/mslib/msui/hexagon_dockwidget.py +++ b/mslib/msui/hexagon_dockwidget.py @@ -28,7 +28,7 @@ import logging from PyQt5 import QtWidgets -from mslib.utils.qt import ui_hexagon_dockwidget as ui +from mslib.msui.qt5 import ui_hexagon_dockwidget as ui from mslib.msui import flighttrack as ft from mslib.utils.coordinate import rotate_point from mslib.utils.config import config_loader @@ -62,7 +62,7 @@ def __init__(self, parent=None, view=None): parent -- Qt widget that is parent to this widget. view -- reference to mpl canvas class """ - super(HexagonControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view if self.view: @@ -151,7 +151,7 @@ def _remove_hexagon(self): f"points (min, max = {row_min:d}, {row_max:d})") else: sel = QtWidgets.QMessageBox.question( - None, "Remove hexagon", + table_view, "Remove hexagon", f"This will remove waypoints {row_min:d}-{row_max:d}. Continue?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) diff --git a/mslib/msui/kmloverlay_dockwidget.py b/mslib/msui/kmloverlay_dockwidget.py index 68ffa2e61..42815243d 100644 --- a/mslib/msui/kmloverlay_dockwidget.py +++ b/mslib/msui/kmloverlay_dockwidget.py @@ -33,27 +33,27 @@ from matplotlib import patheffects from mslib.utils.qt import get_open_filenames, get_save_filename -from mslib.utils.qt import ui_kmloverlay_dockwidget as ui +from mslib.msui.qt5 import ui_kmloverlay_dockwidget as ui from PyQt5 import QtGui, QtWidgets, QtCore from mslib.utils.config import save_settings_qsettings, load_settings_qsettings from mslib.utils.coordinate import normalize_longitude -class KMLPatch(object): +class KMLPatch: """ Represents a KML overlay. """ - def __init__(self, mapcanvas, kml, color="red", linewidth=1): + def __init__(self, mapcanvas, kml_data, color="red", linewidth=1): self.map = mapcanvas - self.kml = kml + self.kml = kml_data self.patches = [] self.color = color self.linewidth = linewidth self.draw() - def compute_xy(self, geometry): - unzipped = list(zip(*geometry.coords)) + def compute_xy(self, geometry_data): + unzipped = list(zip(*geometry_data.coords)) x, y = self.map.gcpoints_path(unzipped[0], unzipped[1]) if self.map.projection == "cyl": # hack for wraparound x = normalize_longitude(x, self.map.llcrnrlon, self.map.urcrnrlon) @@ -100,18 +100,18 @@ def add_polygon(self, polygon, style, _): x1, y1 = self.compute_xy(interior) self.patches.append(self.map.plot(x1, y1, "-", zorder=10, **kwargs)) - def add_multipoint(self, point, style, name): + def add_multipoint(self, geoms, style, name): """ Plot KML points in a MultiGeometry :param point: fastkml object specifying point :param name: name of placemark for annotation """ - x, y = self.map(point.x, point.y) - self.patches.append(self.map.plot(x, y, "o", zorder=10, color=self.color)) + xs, ys = self.map([point.x for point in geoms], [point.y for point in geoms]) + self.patches.append(self.map.plot(xs, ys, "o", zorder=10, color=self.color)) if name is not None: self.patches.append([self.map.ax.annotate( - name, xy=(x, y), xycoords="data", xytext=(5, 5), textcoords='offset points', zorder=10, + name, xy=(xs[0], ys[0]), xycoords="data", xytext=(5, 5), textcoords='offset points', zorder=10, path_effects=[patheffects.withStroke(linewidth=2, foreground='w')])]) def add_multiline(self, line, style, name): @@ -152,8 +152,7 @@ def parse_geometries(self, placemark): elif isinstance(placemark.geometry, geometry.Polygon): self.add_polygon(placemark, style, name) elif isinstance(placemark.geometry, geometry.MultiPoint): - for geom in placemark.geometry.geoms: - self.add_multipoint(geom, style, name) + self.add_multipoint(placemark.geometry.geoms, style, name) elif isinstance(placemark.geometry, geometry.MultiLineString): for geom in placemark.geometry.geoms: self.add_multiline(geom, style, name) @@ -163,7 +162,7 @@ def parse_geometries(self, placemark): elif isinstance(placemark.geometry, geometry.GeometryCollection): for geom in placemark.geometry.geoms: if geom.geom_type == "Point": - self.add_multipoint(geom, style, name) + self.add_multipoint([geom], style, name) elif geom.geom_type == "LineString": self.add_multiline(geom, style, name) elif geom.geom_type == "LinearRing": @@ -279,7 +278,7 @@ class KMLOverlayControlWidget(QtWidgets.QWidget, ui.Ui_KMLOverlayDockWidget): """ def __init__(self, parent=None, view=None): - super(KMLOverlayControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view # canvas self.kml = None diff --git a/mslib/msui/linearview.py b/mslib/msui/linearview.py index 76fe31dae..72b3fefd9 100644 --- a/mslib/msui/linearview.py +++ b/mslib/msui/linearview.py @@ -27,8 +27,8 @@ from mslib.utils.config import config_loader from PyQt5 import QtGui, QtWidgets -from mslib.utils.qt import ui_linearview_window as ui -from mslib.utils.qt import ui_linearview_options as ui_opt +from mslib.msui.qt5 import ui_linearview_window as ui +from mslib.msui.qt5 import ui_linearview_options as ui_opt from mslib.msui.viewwindows import MSUIMplViewWindow from mslib.msui import wms_control as wms from mslib.msui.icons import icons @@ -48,7 +48,7 @@ def __init__(self, parent=None, settings=None): parent -- Qt widget that is parent to this widget. settings_dict -- dictionary containing sideview options. """ - super(MSUI_LV_Options_Dialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) assert settings is not None @@ -83,7 +83,9 @@ def __init__(self, parent=None, model=None, _id=None): """ Set up user interface, connect signal/slots. """ - super(MSUILinearViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) + self.settings_tag = "linearview" + self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) @@ -133,7 +135,7 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the view displays. """ - super(MSUILinearViewWindow, self).setFlightTrackModel(model) + super().setFlightTrackModel(model) if self.docks[WMS] is not None: self.docks[WMS].widget().setFlightTrackModel(model) diff --git a/mslib/msui/mpl_map.py b/mslib/msui/mpl_map.py index 6c0496696..6c07477f7 100644 --- a/mslib/msui/mpl_map.py +++ b/mslib/msui/mpl_map.py @@ -198,7 +198,7 @@ def set_axes_limits(self, ax=None): """ intact = matplotlib.is_interactive() matplotlib.interactive(False) - super(MapCanvas, self).set_axes_limits(ax=ax) + super().set_axes_limits(ax=ax) matplotlib.interactive(intact) def _draw_auto_graticule(self, font_size=None): @@ -681,7 +681,7 @@ def imshow(self, X, **kwargs): """ if self.image is not None: self.image.remove() - self.image = super(MapCanvas, self).imshow(X, zorder=2, **kwargs) + self.image = super().imshow(X, zorder=2, **kwargs) self.ax.figure.canvas.draw() return self.image @@ -752,7 +752,7 @@ def drawgreatcircle_path(self, lons, lats, del_s=100., **kwargs): return self.plot(x, y, **kwargs) -class SatelliteOverpassPatch(object): +class SatelliteOverpassPatch: """ Represents a satellite overpass on the top view map (satellite track and, if available, swath). diff --git a/mslib/msui/mpl_pathinteractor.py b/mslib/msui/mpl_pathinteractor.py index d0d5062af..ff7a73c65 100644 --- a/mslib/msui/mpl_pathinteractor.py +++ b/mslib/msui/mpl_pathinteractor.py @@ -910,7 +910,7 @@ class VPathInteractor(PathInteractor): """Subclass of PathInteractor that implements an interactively editable vertical profile of the flight track. """ - signal_get_vsec = QtCore.Signal(name="get_vsec") + signal_get_vsec = QtCore.pyqtSignal(name="get_vsec") def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpoints=101): """Constructor passes a PathV instance its parent. @@ -1054,7 +1054,7 @@ class LPathInteractor(PathInteractor): """ Subclass of PathInteractor that implements a non interactive linear profile of the flight track. """ - signal_get_lsec = QtCore.Signal(name="get_lsec") + signal_get_lsec = QtCore.pyqtSignal(name="get_lsec") def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpoints=101): """Constructor passes a PathV instance its parent. @@ -1150,8 +1150,8 @@ def appropriate_epsilon_km(self, px=5): # (bounds = left, bottom, width, height) ax_bounds = self.plotter.ax.bbox.bounds diagonal = math.hypot(round(ax_bounds[2]), round(ax_bounds[3])) - map = self.plotter.map - map_delta = get_distance(map.llcrnrlat, map.llcrnrlon, map.urcrnrlat, map.urcrnrlon) + plot_map = self.plotter.map + map_delta = get_distance(plot_map.llcrnrlat, plot_map.llcrnrlon, plot_map.urcrnrlat, plot_map.urcrnrlon) km_per_px = map_delta / diagonal return km_per_px * px diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index a7470873a..9dfc7b050 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -475,6 +475,10 @@ def redraw_yaxis(self): for ax, typ in zip((self.ax, self.ax2), (vaxis, vaxis2)): ylabel, major_ticks, minor_ticks, labels = self._determine_ticks_labels(typ) + major_ticks_units = getattr(major_ticks, "units", None) + if ax.yaxis.units is None and major_ticks_units is not None: + ax.yaxis.set_units(major_ticks_units) + ax.set_ylabel(ylabel, fontsize=plot_title_size) ax.set_yticks(minor_ticks, minor=True) ax.set_yticks(major_ticks, minor=False) @@ -752,14 +756,14 @@ def __init__(self, plotter): self.default_filename = "_image" self.plotter = plotter # initialization of the canvas - super(MplCanvas, self).__init__(self.plotter.fig) + super().__init__(self.plotter.fig) # we define the widget as expandable - super(MplCanvas, self).setSizePolicy( + super().setSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) # notify the system of updated policy - super(MplCanvas, self).updateGeometry() + super().updateGeometry() def get_default_filename(self): """ @@ -830,12 +834,12 @@ def save_figure(self, *args): filters = [] for name, exts in sorted_filetypes: exts_list = " ".join(['*.%s' % ext for ext in exts]) - filter = '%s (%s)' % (name, exts_list) - filters.append(filter) + filter_value = '%s (%s)' % (name, exts_list) + filters.append(filter_value) - fname, filter = _getSaveFileName(self.parent, - title="Choose a filename to save to", - filename=start, filters=filters) + fname, filter_value = _getSaveFileName(self.parent, + title="Choose a filename to save to", + filename=start, filters=filters) if fname is not None: if not fname.endswith(filter[1:]): fname = filter.replace('*', fname) @@ -915,7 +919,7 @@ def __init__(self, canvas, parent, sideview=False, coordinates=True): ('Ins WP', 'Insert waypoints', "wp_insert", 'insert_wp'), ('Del WP', 'Delete waypoints', "wp_delete", 'delete_wp'), ]) - super(NavigationToolbar, self).__init__(canvas, parent, coordinates) + super().__init__(canvas, parent, coordinates) self._actions["move_wp"].setCheckable(True) self._actions["insert_wp"].setCheckable(True) self._actions["delete_wp"].setCheckable(True) @@ -933,13 +937,13 @@ def _icon(self, name, *args): if os.path.exists(myname): return QtGui.QIcon(myname) else: - return super(NavigationToolbar, self)._icon(name, *args) + return super()._icon(name, *args) def _zoom_pan_handler(self, event): """ extend zoom_pan_handler of base class with our own tools """ - super(NavigationToolbar, self)._zoom_pan_handler(event) + super()._zoom_pan_handler(event) if event.name == "button_press_event": if self.mode in (_Mode.INSERT_WP, _Mode.MOVE_WP, _Mode.DELETE_WP): self.canvas.waypoints_interactor.button_press_callback(event) @@ -959,7 +963,7 @@ def clear_history(self): def push_current(self): """Push the current view limits and position onto the stack.""" if self.sideview: - super(NavigationToolbar, self).push_current() + super().push_current() elif self.no_push_history: pass else: @@ -972,7 +976,7 @@ def _update_view(self): each axes. """ if self.sideview: - super(NavigationToolbar, self)._update_view() + super()._update_view() else: nav_info = self._nav_stack() if nav_info is None: @@ -1026,14 +1030,14 @@ def move_wp(self, *args): def release_zoom(self, event): self.no_push_history = True - super(NavigationToolbar, self).release_zoom(event) + super().release_zoom(event) self.no_push_history = False self.canvas.redraw_map() self.push_current() def release_pan(self, event): self.no_push_history = True - super(NavigationToolbar, self).release_pan(event) + super().release_pan(event) self.no_push_history = False self.canvas.redraw_map() self.push_current() @@ -1090,7 +1094,7 @@ def mouse_move(self, event): self.set_message(f"{self.mode} lat={lat:6.2f} lon={lon:7.2f} altitude={y_value:.2f}{units}") def _update_buttons_checked(self): - super(NavigationToolbar, self)._update_buttons_checked() + super()._update_buttons_checked() if "insert_wp" in self._actions: self._actions['insert_wp'].setChecked(self.mode.name == 'INSERT_WP') if "delete_wp" in self._actions: @@ -1104,7 +1108,7 @@ class MplNavBarWidget(QtWidgets.QWidget): def __init__(self, sideview=False, parent=None, canvas=None): # initialization of Qt MainWindow widget - super(MplNavBarWidget, self).__init__(parent) + super().__init__(parent) # set the canvas to the Matplotlib widget if canvas: @@ -1139,7 +1143,7 @@ def __init__(self, model=None, settings=None, numlabels=None): if numlabels is None: numlabels = config_loader(dataset='num_labels') self.plotter = SideViewPlotter() - super(MplSideViewCanvas, self).__init__(self.plotter) + super().__init__(self.plotter) if settings is not None: self.plotter.set_settings(settings) @@ -1354,7 +1358,7 @@ class MplSideViewWidget(MplNavBarWidget): """ def __init__(self, parent=None): - super(MplSideViewWidget, self).__init__( + super().__init__( sideview=True, parent=parent, canvas=MplSideViewCanvas()) # Disable some elements of the Matplotlib navigation toolbar. # Available actions: Home, Back, Forward, Pan, Zoom, Subplots, @@ -1379,7 +1383,7 @@ def __init__(self, model=None, numlabels=None): if numlabels is None: numlabels = config_loader(dataset='num_labels') self.plotter = LinearViewPlotter() - super(MplLinearViewCanvas, self).__init__(self.plotter) + super().__init__(self.plotter) # Setup the plot. self.numlabels = numlabels @@ -1460,7 +1464,7 @@ class MplLinearViewWidget(MplNavBarWidget): """ def __init__(self, parent=None): - super(MplLinearViewWidget, self).__init__( + super().__init__( sideview=False, parent=parent, canvas=MplLinearViewCanvas()) # Disable some elements of the Matplotlib navigation toolbar. # Available actions: Home, Back, Forward, Pan, Zoom, Subplots, @@ -1483,7 +1487,7 @@ def __init__(self, settings=None): """ """ self.plotter = TopViewPlotter() - super(MplTopViewCanvas, self).__init__(self.plotter) + super().__init__(self.plotter) self.waypoints_interactor = None self.satoverpasspatch = [] self.kmloverlay = None @@ -1499,7 +1503,7 @@ def __init__(self, settings=None): self.pdlg.close() @property - def map(self): + def map(self): # noqa: A003 return self.plotter.map def init_map(self, model=None, **kwargs): @@ -1673,7 +1677,7 @@ class MplTopViewWidget(MplNavBarWidget): """ def __init__(self, parent=None): - super(MplTopViewWidget, self).__init__( + super().__init__( sideview=False, parent=parent, canvas=MplTopViewCanvas()) # Disable some elements of the Matplotlib navigation toolbar. # Available actions: Home, Back, Forward, Pan, Zoom, Subplots, diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 46dc4e068..7ecb18782 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -38,11 +38,12 @@ import fs import requests import re +import webbrowser import urllib.request +from urllib.parse import urljoin from fs import open_fs from PIL import Image -from werkzeug.urls import url_join from keyring.errors import NoKeyringError, PasswordSetError, InitError from mslib.msui import flighttrack as ft @@ -51,18 +52,70 @@ from mslib.msui import mscolab_version_history as mvh from mslib.msui import socket_control as sc +import PyQt5 from PyQt5 import QtCore, QtGui, QtWidgets -from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring, get_auth_from_url_and_name +from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring from mslib.utils.verify_user_token import verify_user_token from mslib.utils.qt import get_open_filename, get_save_filename, dropEvent, dragEnterEvent, show_popup -from mslib.utils.qt import ui_mscolab_help_dialog as msc_help_dialog -from mslib.utils.qt import ui_add_operation_dialog as add_operation_ui -from mslib.utils.qt import ui_mscolab_merge_waypoints_dialog as merge_wp_ui -from mslib.utils.qt import ui_mscolab_connect_dialog as ui_conn -from mslib.utils.qt import ui_mscolab_profile_dialog as ui_profile +from mslib.msui.qt5 import ui_mscolab_help_dialog as msc_help_dialog +from mslib.msui.qt5 import ui_add_operation_dialog as add_operation_ui +from mslib.msui.qt5 import ui_mscolab_merge_waypoints_dialog as merge_wp_ui +from mslib.msui.qt5 import ui_mscolab_connect_dialog as ui_conn +from mslib.msui.qt5 import ui_mscolab_profile_dialog as ui_profile +from mslib.msui.qt5 import ui_operation_archive as ui_opar from mslib.msui import constants -from mslib.utils.config import config_loader, load_settings_qsettings, save_settings_qsettings, modify_config_file +from mslib.utils.config import config_loader, modify_config_file + + +class MSColab_OperationArchiveBrowser(QtWidgets.QDialog, ui_opar.Ui_OperationArchiveBrowser): + def __init__(self, parent=None, mscolab=None): + super().__init__(parent) + self.setupUi(self) + self.parent = parent + self.mscolab = mscolab + self.pbClose.clicked.connect(self.hide) + self.pbUnarchiveOperation.setEnabled(False) + self.pbUnarchiveOperation.clicked.connect(self.unarchive_operation) + self.listArchivedOperations.itemClicked.connect(self.select_archived_operation) + self.setModal(True) + + def select_archived_operation(self, item): + logging.debug('select_inactive_operation') + if item.access_level == "creator": + self.archived_op_id = item.op_id + self.pbUnarchiveOperation.setEnabled(True) + else: + self.archived_op_id = None + self.pbUnarchiveOperation.setEnabled(False) + + def unarchive_operation(self): + logging.debug('unarchive_operation') + if verify_user_token(self.mscolab.mscolab_server_url, self.mscolab.token): + # set last used date for operation + data = { + "token": self.mscolab.token, + "op_id": self.archived_op_id, + } + url = urljoin(self.mscolab.mscolab_server_url, 'set_last_used') + 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.") + else: + if res.text != "False": + res = res.json() + if res["success"]: + self.mscolab.reload_operations() + else: + show_popup(self.parent, "Error", "Some error occurred! Could not activate operation") + else: + show_popup(self.parent, "Error", "Session expired, new login required") + self.mscolab.logout() + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.mscolab.logout() class MSColab_ConnectDialog(QtWidgets.QDialog, ui_conn.Ui_MSColabConnectDialog): @@ -75,16 +128,17 @@ def __init__(self, parent=None, mscolab=None): Arguments: parent -- Qt widget that is parent to this widget. """ - super(MSColab_ConnectDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.parent = parent self.mscolab = mscolab # initialize server url as none self.mscolab_server_url = None + self.auth = None self.setFixedSize(self.size()) - self.stackedWidget.setCurrentWidget(self.loginPage) + self.stackedWidget.setCurrentWidget(self.httpAuthPage) # disable widgets in login frame self.loginEmailLe.setEnabled(False) @@ -94,16 +148,24 @@ def __init__(self, parent=None, mscolab=None): # add urls from settings to the combobox self.add_mscolab_urls() + self.mscolab_url_changed(self.urlCb.currentText()) - # connect login, adduser, connect buttons + # connect login, adduser, connect, login with idp, auth token submit buttons self.connectBtn.clicked.connect(self.connect_handler) + self.connectBtn.setFocus() + self.disconnectBtn.clicked.connect(self.disconnect_handler) + self.disconnectBtn.hide() self.loginBtn.clicked.connect(self.login_handler) + self.loginWithIDPBtn.clicked.connect(self.idp_login_handler) + self.idpAuthTokenSubmitBtn.clicked.connect(self.idp_auth_token_submit_handler) self.addUserBtn.clicked.connect(lambda: self.stackedWidget.setCurrentWidget(self.newuserPage)) # enable login button only if email and password are entered - self.loginEmailLe.textChanged[str].connect(self.enable_login_btn) + self.loginEmailLe.textChanged[str].connect(self.mscolab_login_changed) self.loginPasswordLe.textChanged[str].connect(self.enable_login_btn) + self.urlCb.editTextChanged.connect(self.mscolab_url_changed) + # connect new user dialogbutton self.newUserBb.accepted.connect(self.new_user_handler) self.newUserBb.rejected.connect(lambda: self.stackedWidget.setCurrentWidget(self.loginPage)) @@ -111,27 +173,21 @@ def __init__(self, parent=None, mscolab=None): # connecting slot to clear all input widgets while switching tabs self.stackedWidget.currentChanged.connect(self.page_switched) - # fill value of mscolab url if found in QSettings storage - self.settings = load_settings_qsettings('mscolab', default_settings={'auth': {}, 'server_settings': {}}) + def mscolab_url_changed(self, text): + self.httpPasswordLe.setText( + get_password_from_keyring("MSCOLAB_AUTH_" + text, config_loader(dataset="MSCOLAB_auth_user_name"))) - def page_switched(self, index): - # clear all text in all input - self.loginEmailLe.setText("") - self.loginPasswordLe.setText("") + def mscolab_login_changed(self, text): + self.loginPasswordLe.setText( + get_password_from_keyring(self.mscolab_server_url, text)) + def page_switched(self, index): + # clear all text in add user widget self.newUsernameLe.setText("") self.newEmailLe.setText("") self.newPasswordLe.setText("") self.newConfirmPasswordLe.setText("") - self.httpUsernameLe.setText("") - self.httpPasswordLe.setText("") - - if index == 2: - self.connectBtn.setEnabled(False) - else: - self.connectBtn.setEnabled(True) - def set_status(self, _type="Error", msg=""): if _type == "Error": msg = "⚠ " + msg @@ -144,6 +200,7 @@ def set_status(self, _type="Error", msg=""): self.statusLabel.setStyleSheet("") msg = "ⓘ " + msg self.statusLabel.setText(msg) + logging.debug("set_status: %s", msg) QtWidgets.QApplication.processEvents() def add_mscolab_urls(self): @@ -158,9 +215,16 @@ def enable_login_btn(self): def connect_handler(self): try: url = str(self.urlCb.currentText()) - r = requests.get(url_join(url, 'status')) - if r.text == "Mscolab server": - self.set_status("Success", "Successfully connected to MSColab Server") + auth = config_loader(dataset="MSCOLAB_auth_user_name"), self.httpPasswordLe.text() + s = requests.Session() + s.auth = auth + s.headers.update({'x-test': 'true'}) + r = s.get(urljoin(url, 'status'), timeout=tuple(tuple(config_loader(dataset="MSCOLAB_timeout")))) + if r.status_code == 401: + self.set_status("Error", 'Server authentication data were incorrect.') + elif r.status_code == 200: + self.stackedWidget.setCurrentWidget(self.loginPage) + self.set_status("Success", "Successfully connected to MSColab server.") # disable url input self.urlCb.setEnabled(False) @@ -170,41 +234,67 @@ def connect_handler(self): self.loginEmailLe.setEnabled(True) self.loginPasswordLe.setEnabled(True) - self.mscolab_server_url = url - # delete mscolab http_auth settings for the url - if self.mscolab_server_url in self.settings["auth"].keys(): - del self.settings["auth"][self.mscolab_server_url] + try: + idp_enabled = json.loads(r.text)["use_saml2 "] + except (json.decoder.JSONDecodeError, KeyError): + idp_enabled = False + + try: + direct_login = json.loads(r.text)["direct_login"] + except (json.decoder.JSONDecodeError, KeyError): + direct_login = True - if self.mscolab_server_url not in self.settings["server_settings"].keys(): - self.settings["server_settings"].update({self.mscolab_server_url: {}}) - save_settings_qsettings('mscolab', self.settings) + if not direct_login: + # Hide user creation when this is disabled on the server + self.addUserBtn.setHidden(True) + self.clickNewUserLabel.setHidden(True) + + if not idp_enabled: + # Hide login by identity provider if IDP login disabled + self.loginWithIDPBtn.setHidden(True) + + self.mscolab_server_url = url + self.auth = auth + save_password_to_keyring("MSCOLAB_AUTH_" + url, auth[0], auth[1]) + + url_list = config_loader(dataset="default_MSCOLAB") + if self.mscolab_server_url not in url_list: + ret = PyQt5.QtWidgets.QMessageBox.question( + self, self.tr("Update Server List"), + self.tr("You are using a new MSColab server. " + "Should your settings file be updated by adding the new server?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) + if ret == QtWidgets.QMessageBox.Yes: + url_list = [self.mscolab_server_url] + url_list + modify_config_file({"default_MSCOLAB": url_list}) # Fill Email and Password fields from config - self.loginEmailLe.setText(config_loader(dataset="MSCOLAB_mailid")) - self.loginPasswordLe.setText(get_password_from_keyring(service_name="MSCOLAB", - username=config_loader(dataset="MSCOLAB_mailid"))) + self.loginEmailLe.setText( + config_loader(dataset="MSS_auth").get(self.mscolab_server_url)) + self.mscolab_login_changed(self.loginEmailLe.text()) self.enable_login_btn() + self.loginBtn.setFocus() # Change connect button text and connect disconnect handler - self.connectBtn.setText('Disconnect') - self.connectBtn.clicked.disconnect(self.connect_handler) - self.connectBtn.clicked.connect(self.disconnect_handler) + self.connectBtn.hide() + self.disconnectBtn.show() else: + logging.error("Error %s", r) self.set_status("Error", "Some unexpected error occurred. Please try again.") except requests.exceptions.SSLError: logging.debug("Certificate Verification Failed") - self.set_status("Error", "Certificate Verification Failed") + self.set_status("Error", "Certificate Verification Failed.") except requests.exceptions.InvalidSchema: logging.debug("invalid schema of url") - self.set_status("Error", "Invalid Url Scheme!") + self.set_status("Error", "Invalid Url Scheme.") except requests.exceptions.InvalidURL: logging.debug("invalid url") - self.set_status("Error", "Invalid URL") + self.set_status("Error", "Invalid URL.") except requests.exceptions.ConnectionError: logging.debug("MSColab server isn't active") - self.set_status("Error", "MSColab server isn't active") + self.set_status("Error", "MSColab server isn't active.") except Exception as e: - logging.debug("Error %s", str(e)) + logging.error("Error %s %s", type(e), str(e)) self.set_status("Error", "Some unexpected error occurred. Please try again.") def disconnect_handler(self): @@ -217,141 +307,117 @@ def disconnect_handler(self): self.loginPasswordLe.setEnabled(False) # clear text - self.stackedWidget.setCurrentWidget(self.loginPage) + self.stackedWidget.setCurrentWidget(self.httpAuthPage) - # delete mscolab http_auth settings for the url - if self.mscolab_server_url in self.settings["auth"].keys(): - del self.settings["auth"][self.mscolab_server_url] - save_settings_qsettings('mscolab', self.settings) self.mscolab_server_url = None + self.auth = None - self.connectBtn.setText('Connect') - self.connectBtn.clicked.disconnect(self.disconnect_handler) - self.connectBtn.clicked.connect(self.connect_handler) - self.set_status("Info", 'Disconnected from server') - - def authenticate(self, data, r, url): - if r.status_code == 401: - auth_username, auth_password = self.httpUsernameLe.text(), self.httpPasswordLe.text() - self.settings["auth"][self.mscolab_server_url] = (auth_username, auth_password) - s = requests.Session() - s.auth = (auth_username, auth_password) - s.headers.update({'x-test': 'true'}) - r = s.post(url, data=data, timeout=(2, 10)) - return r + self.connectBtn.show() + self.connectBtn.setFocus() + self.disconnectBtn.hide() + self.set_status("Info", 'Disconnected from server.') def login_handler(self): - auth = get_auth_from_url_and_name(self.mscolab_server_url, config_loader(dataset="MSS_auth")) - emailid = self.loginEmailLe.text() - password = self.loginPasswordLe.text() data = { - "email": emailid, - "password": password + "email": self.loginEmailLe.text(), + "password": self.loginPasswordLe.text() } s = requests.Session() - s.auth = (auth[0], auth[1]) + s.auth = self.auth s.headers.update({'x-test': 'true'}) url = f'{self.mscolab_server_url}/token' url_recover_password = f'{self.mscolab_server_url}/reset_request' try: - r = s.post(url, data=data, timeout=(2, 10)) + r = s.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + if r.status_code == 401: + raise requests.exceptions.ConnectionError except requests.exceptions.RequestException as ex: logging.error("unexpected error: %s %s %s", type(ex), url, ex) self.set_status( "Error", - "Failed to establish a new connection" f' to "{self.mscolab_server_url}". Try in a moment again.', + f'Failed to establish a new connection to "{self.mscolab_server_url}". Try again in a moment.', ) self.disconnect_handler() return if r.text == "False": # show status indicating about wrong credentials - self.set_status("Error", 'Oh no, you need to add a user account or ' - f'Recover Your Password') - elif r.text == "Unauthorized Access": - # Server auth required for logging in - self.login_data = [data, r, url] - self.connectBtn.setEnabled(False) - self.stackedWidget.setCurrentWidget(self.httpAuthPage) - try: - self.httpBb.accepted.disconnect() - except TypeError: - pass - try: - self.httpBb.rejected.disconnect() - except TypeError: - pass - self.httpBb.accepted.connect(self.login_server_auth) - self.httpBb.rejected.connect(lambda: self.stackedWidget.setCurrentWidget(self.loginPage)) + self.set_status("Error", 'Invalid credentials. Fix them, create a new user, or ' + f'recover your password.') else: - try: - save_password_to_keyring(service_name="MSCOLAB", username=emailid, password=password) - except (NoKeyringError, PasswordSetError, InitError) as ex: - logging.warning("Can't use Keyring on your system: %s" % ex) - self.mscolab.after_login(emailid, self.mscolab_server_url, r) + self.save_user_credentials_to_config_file(data["email"], data["password"]) + self.mscolab.after_login(data["email"], self.mscolab_server_url, r) - def save_user_credentials_to_config_file(self, emailid, password): - data_to_save_in_config_file = { - "MSCOLAB_mailid": emailid - } + def idp_login_handler(self): + """Handle IDP login Button""" + url_idp_login = f'{self.mscolab_server_url}/available_idps' + webbrowser.open(url_idp_login, new=2) + self.stackedWidget.setCurrentWidget(self.idpAuthPage) + + def idp_auth_token_submit_handler(self): + """Handle IDP authentication token submission""" + url_idp_login_auth = f'{self.mscolab_server_url}/idp_login_auth' + user_token = self.idpAuthPasswordLe.text() + + try: + data = {'token': user_token} + response = requests.post(url_idp_login_auth, json=data, timeout=(2, 10)) + if response.status_code == 401: + self.set_status("Error", 'Invalid token or token expired. Please try again') + self.stackedWidget.setCurrentWidget(self.loginPage) + + elif response.status_code == 200: + _json = json.loads(response.text) + token = _json["token"] + user = _json["user"] + + data = { + "email": user["emailid"], + "password": token, + } + + s = requests.Session() + s.auth = self.auth + s.headers.update({'x-test': 'true'}) + url = f'{self.mscolab_server_url}/token' + + r = s.post(url, data=data, timeout=(2, 10)) + if r.status_code == 401: + raise requests.exceptions.ConnectionError + if r.text == "False": + # show status indicating about wrong credentials + self.set_status("Error", 'Invalid token. Please enter correct token') + else: + self.mscolab.after_login(data["email"], self.mscolab_server_url, r) + self.set_status("Success", 'Succesfully logged into mscolab server') + + except requests.exceptions.RequestException as error: + logging.error("unexpected error: %s %s %s", type(error), url, error) + def save_user_credentials_to_config_file(self, emailid, password): try: - save_password_to_keyring(service_name="MSCOLAB", username=emailid, password=password) + save_password_to_keyring(service_name=self.mscolab_server_url, username=emailid, password=password) except (NoKeyringError, PasswordSetError, InitError) as ex: logging.warning("Can't use Keyring on your system: %s" % ex) - exiting_mscolab_mailid = config_loader(dataset="MSCOLAB_mailid") - if exiting_mscolab_mailid != emailid: + mss_auth = config_loader(dataset="MSS_auth") + if mss_auth.get(self.mscolab_server_url) != emailid: ret = QtWidgets.QMessageBox.question( self, self.tr("Update Credentials"), self.tr("You are using new credentials. " "Should your settings file be updated with the new credentials?"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: - modify_config_file(data_to_save_in_config_file) - else: - modify_config_file(data_to_save_in_config_file) - - def login_server_auth(self): - data, r, url = self.login_data - emailid = data['email'] - password = data['password'] - if r.status_code == 401: - try: - r = self.authenticate(data, r, url) - except requests.exceptions.RequestException as ex: - logging.error("unexpected error: %s %s %s", type(ex), url, ex) - self.set_status( - "Error", - "Failed to establish a new connection" f' to "{self.mscolab_server_url}". Try in a moment again.', - ) - self.stackedWidget.setCurrentWidget(self.loginPage) - else: - if r.status_code == 200: - # http auth was successful - self.save_auth_credentials_to_config_file() - if r.text not in ["False", "Unauthorized Access"]: - # user does not exist or password is wrong - self.save_user_credentials_to_config_file(emailid, password) - self.mscolab.after_login(emailid, self.mscolab_server_url, r) - else: - self.stackedWidget.setCurrentWidget(self.loginPage) - url_recover_password = f'{self.mscolab_server_url}/reset_request' - self.set_status("Error", 'Oh no, you need to add a user account or ' - f'Recover Your Password') - else: - self.set_status("Error", 'Oh no, server authentication were incorrect.') - self.stackedWidget.setCurrentWidget(self.loginPage) + mss_auth[self.mscolab_server_url] = emailid + modify_config_file({"MSS_auth": mss_auth}) def new_user_handler(self): # get mscolab /token http auth credentials from cache - auth = get_auth_from_url_and_name(self.mscolab_server_url, config_loader(dataset="MSS_auth")) - emailid = self.newEmailLe.text() password = self.newPasswordLe.text() re_password = self.newConfirmPasswordLe.text() username = self.newUsernameLe.text() if password != re_password: - self.set_status("Error", 'Oh no, your passwords don\'t match') + self.set_status("Error", 'Your passwords don\'t match.') return data = { @@ -360,22 +426,22 @@ def new_user_handler(self): "username": username } s = requests.Session() - s.auth = (auth[0], auth[1]) + s.auth = self.auth s.headers.update({'x-test': 'true'}) url = f'{self.mscolab_server_url}/register' try: - r = s.post(url, data=data, timeout=(2, 10)) + r = s.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as ex: logging.error("unexpected error: %s %s %s", type(ex), url, ex) self.set_status( "Error", - "Failed to establish a new connection" f' to "{self.mscolab_server_url}". Try in a moment again.', + f'Failed to establish a new connection to "{self.mscolab_server_url}". Try again in a moment.', ) self.disconnect_handler() return if r.status_code == 204: - self.set_status("Success", 'You are registered, confirm your email to log in.') + self.set_status("Success", 'You are registered, confirm your email before logging in.') self.save_user_credentials_to_config_file(emailid, password) self.stackedWidget.setCurrentWidget(self.loginPage) self.loginEmailLe.setText(emailid) @@ -386,19 +452,6 @@ def new_user_handler(self): self.loginEmailLe.setText(emailid) self.loginPasswordLe.setText(password) self.login_handler() - elif r.status_code == 401: - self.newuser_data = [data, r, url] - self.stackedWidget.setCurrentWidget(self.httpAuthPage) - try: - self.httpBb.accepted.disconnect() - except TypeError: - pass - try: - self.httpBb.rejected.disconnect() - except TypeError: - pass - self.httpBb.accepted.connect(self.newuser_server_auth) - self.httpBb.rejected.connect(lambda: self.stackedWidget.setCurrentWidget(self.newuserPage)) else: try: error_msg = json.loads(r.text)["message"] @@ -407,51 +460,6 @@ def new_user_handler(self): error_msg = "Unexpected error occured. Please try again." self.set_status("Error", error_msg) - def save_auth_credentials_to_config_file(self): - http_auth_login_data = config_loader(dataset="MSS_auth") - auth_username = self.settings["auth"][self.mscolab_server_url][0] - auth_password = self.settings["auth"][self.mscolab_server_url][1] - http_auth_login_data[self.mscolab_server_url] = auth_username - - data_to_save_in_config_file = { - "default_MSCOLAB": [self.mscolab_server_url], - "MSS_auth": http_auth_login_data - } - - modify_config_file(data_to_save_in_config_file) - try: - save_password_to_keyring(self.mscolab_server_url, auth_username, auth_password) - except (NoKeyringError, PasswordSetError, InitError) as ex: - logging.warning("Can't use Keyring on your system: %s" % ex) - - def newuser_server_auth(self): - data, r, url = self.newuser_data - r = self.authenticate(data, r, url) - if r.status_code == 201: - self.save_auth_credentials_to_config_file() - self.set_status("Success", "You are registered.") - self.save_user_credentials_to_config_file(data['email'], data['password']) - self.loginEmailLe.setText(data['email']) - self.loginPasswordLe.setText(data['password']) - self.login_handler() - elif r.status_code == 200: - try: - error_msg = json.loads(r.text)["message"] - except Exception as e: - logging.debug("Unexpected error occured %s", e) - error_msg = "Unexpected error occured. Please try again." - self.set_status("Error", error_msg) - elif r.status_code == 204: - self.save_auth_credentials_to_config_file() - self.set_status("Success", 'You are registered, confirm your email to log in.') - self.save_user_credentials_to_config_file(data['email'], data['password']) - self.stackedWidget.setCurrentWidget(self.loginPage) - self.loginEmailLe.setText(data['email']) - self.loginPasswordLe.setText(data['password']) - else: - self.set_status("Error", "Oh no, server authentication were incorrect.") - self.stackedWidget.setCurrentWidget(self.newuserPage) - class MSUIMscolab(QtCore.QObject): """ @@ -459,29 +467,33 @@ class MSUIMscolab(QtCore.QObject): """ name = "Mscolab" - signal_activate_operation = QtCore.Signal(int, name="signal_activate_operation") - signal_operation_added = QtCore.Signal(int, str, name="signal_operation_added") - signal_operation_removed = QtCore.Signal(int, name="signal_operation_removed") - signal_login_mscolab = QtCore.Signal(str, str, name="signal_login_mscolab") - signal_logout_mscolab = QtCore.Signal(name="signal_logout_mscolab") - signal_listFlighttrack_doubleClicked = QtCore.Signal() - signal_permission_revoked = QtCore.Signal(int) - signal_render_new_permission = QtCore.Signal(int, str) + signal_unarchive_operation = QtCore.pyqtSignal(int, name="signal_unarchive_operation") + signal_operation_added = QtCore.pyqtSignal(int, str, name="signal_operation_added") + signal_operation_removed = QtCore.pyqtSignal(int, name="signal_operation_removed") + signal_login_mscolab = QtCore.pyqtSignal(str, str, name="signal_login_mscolab") + signal_logout_mscolab = QtCore.pyqtSignal(name="signal_logout_mscolab") + signal_listFlighttrack_doubleClicked = QtCore.pyqtSignal() + signal_permission_revoked = QtCore.pyqtSignal(int) + signal_render_new_permission = QtCore.pyqtSignal(int, str) def __init__(self, parent=None, data_dir=None): - super(MSUIMscolab, self).__init__(parent) + super().__init__(parent) self.ui = parent + self.operation_archive_browser = MSColab_OperationArchiveBrowser(self.ui, self) + self.operation_archive_browser.hide() + self.ui.listInactiveOperationsMSC = self.operation_archive_browser.listArchivedOperations + # connect mscolab help action from help menu self.ui.actionMSColabHelp.triggered.connect(self.open_help_dialog) + self.ui.pbOpenOperationArchive.clicked.connect(self.open_operation_archive) # hide mscolab related widgets self.ui.usernameLabel.hide() self.ui.userOptionsTb.hide() self.ui.actionAddOperation.setEnabled(False) - self.ui.actionUnarchiveOperation.setEnabled(False) - self.hide_operation_options() self.ui.activeOperationDesc.setHidden(True) + self.hide_operation_options() # reset operation description label for flight tracks and open views self.ui.listFlightTracks.itemDoubleClicked.connect(self.listFlighttrack_itemDoubleClicked) @@ -495,13 +507,11 @@ def __init__(self, parent=None, data_dir=None): self.ui.actionManageUsers.triggered.connect(self.operation_options_handler) self.ui.actionDeleteOperation.triggered.connect(self.operation_options_handler) self.ui.actionLeaveOperation.triggered.connect(self.operation_options_handler) - self.ui.actionUpdateOperationDesc.triggered.connect(self.update_description_handler) + self.ui.actionChangeCategory.triggered.connect(self.change_category_handler) + self.ui.actionChangeDescription.triggered.connect(self.change_description_handler) self.ui.actionRenameOperation.triggered.connect(self.rename_operation_handler) - self.ui.actionUnarchiveOperation.triggered.connect(self.activate_operation) - self.ui.actionDescription.triggered.connect( - lambda: QtWidgets.QMessageBox.information(None, - "Operation Description", - f"{self.active_operation_desc}")) + self.ui.actionArchiveOperation.triggered.connect(self.archive_operation) + self.ui.actionViewDescription.triggered.connect(self.view_description) self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) # connect slot for handling operation options combobox @@ -524,12 +534,12 @@ def __init__(self, parent=None, data_dir=None): self.new_op_id = None # int to store active pid self.active_op_id = None - # int to store selected inactive op_id - self.inactive_op_id = None # storing access_level to save network call self.access_level = None # storing operation_name to save network call self.active_operation_name = None + # storing operation category to save network call + self.active_operation_category = None # Storing operation list to pass to admin window self.operations = None # store active_flight_path here as object @@ -537,7 +547,7 @@ def __init__(self, parent=None, data_dir=None): # Store active operation's file path self.local_ftml_file = None # Store active_operation_description - self.active_operation_desc = None + self.active_operation_description = None # connection object to interact with sockets self.conn = None # operation window @@ -557,7 +567,7 @@ def __init__(self, parent=None, data_dir=None): # User email self.email = None # Display all categories by default - self.selected_category = "ANY" + self.selected_category = "*ANY*" # Gravatar image path self.gravatar = None @@ -568,6 +578,27 @@ def __init__(self, parent=None, data_dir=None): self.data_dir = data_dir self.create_dir() + def view_description(self): + data = { + "token": self.token, + "op_id": self.active_op_id + } + url = urljoin(self.mscolab_server_url, "/creator_of_operation") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + creator_name = "unknown" + if r.text != "False": + _json = json.loads(r.text) + creator_name = _json["username"] + QtWidgets.QMessageBox.information( + self.ui, "Operation Description", + f"Creator: {creator_name}

" + f"Category: {self.active_operation_category}

" + "

" + f"{self.active_operation_description}") + + def open_operation_archive(self): + self.operation_archive_browser.show() + def create_dir(self): # ToDo this needs to be done earlier if '://' in self.data_dir: @@ -605,13 +636,13 @@ def open_connect_window(self): self.connect_window.exec_() def after_login(self, emailid, url, r): + logging.debug("after login %s %s", emailid, url) # emailid by direct call self.email = emailid self.connect_window.close() self.connect_window = None QtWidgets.QApplication.processEvents() # fill value of mscolab url if found in QSettings storage - self.settings = load_settings_qsettings('mscolab', default_settings={'auth': {}, 'server_settings': {}}) _json = json.loads(r.text) self.token = _json["token"] @@ -621,14 +652,10 @@ def after_login(self, emailid, url, r): # create socket connection here try: self.conn = sc.ConnectionManager(self.token, user=self.user, mscolab_server_url=self.mscolab_server_url) - # Update Last Used - data = { - "token": self.token - } - r = requests.post(f"{self.mscolab_server_url}/update_last_used", data=data, timeout=(2, 10)) except Exception as ex: - logging.error("Couldn't create a socket connection: %s", ex) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") + logging.debug("Couldn't create a socket connection: %s", ex) + show_popup(self.ui, "Error", "Couldn't create a socket connection. Maybe the MSColab server is too old. " + "New Login required!") self.logout() else: self.conn.signal_operation_list_updated.connect(self.reload_operation_list) @@ -641,7 +668,9 @@ def after_login(self, emailid, url, r): self.ui.connectBtn.hide() self.ui.openOperationsGb.show() # display connection status - self.ui.mscStatusLabel.setText(self.ui.tr(f"Status: connected to '{self.mscolab_server_url}'")) + transport_layer = self.conn.sio.transport() + self.ui.mscStatusLabel.setText(self.ui.tr( + f"Status: connected to '{self.mscolab_server_url}' by transport layer '{transport_layer}'")) # display username beside useroptions toolbutton self.ui.usernameLabel.setText(f"{self.user['username']}") self.ui.usernameLabel.show() @@ -651,21 +680,16 @@ def after_login(self, emailid, url, r): self.ui.actionAddOperation.setEnabled(True) # Populate open operations list - self.add_operations_to_ui() - + ops = self.add_operations_to_ui() # Show category list - self.show_categories_to_ui() + self.show_categories_to_ui(ops) - # show operation_description self.ui.activeOperationDesc.setHidden(False) - # disable update operation description button - self.ui.actionUpdateOperationDesc.setEnabled(False) - # disable delete operation button + self.ui.actionChangeCategory.setEnabled(False) + self.ui.actionChangeDescription.setEnabled(False) self.ui.actionDeleteOperation.setEnabled(False) - # disable category change selector self.ui.filterCategoryCb.setEnabled(True) - # disable activate operation button - self.ui.actionUnarchiveOperation.setEnabled(False) + self.ui.actionViewDescription.setEnabled(False) self.signal_login_mscolab.emit(self.mscolab_server_url, self.token) @@ -800,7 +824,8 @@ def delete_account(self): } try: - r = requests.post(self.mscolab_server_url + '/delete_user', data=data, timeout=(2, 10)) + r = requests.post(self.mscolab_server_url + '/delete_own_account', 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.") @@ -823,10 +848,10 @@ def check_and_enable_operation_accept(): self.add_proj_dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False) def browse(): - type = self.add_proj_dialog.cb_ImportType.currentText() + import_type = self.add_proj_dialog.cb_ImportType.currentText() file_type = ["Flight track (*.ftml)"] - if type != 'FTML': - file_type = [f"Flight track (*.{self.ui.import_plugins[type][1]})"] + if import_type != 'FTML': + file_type = [f"Flight track (*.{self.ui.import_plugins[import_type][1]})"] file_path = get_open_filename( self.ui, "Open Flighttrack file", "", ';;'.join(file_type)) @@ -836,7 +861,7 @@ def browse(): with open_fs(fs.path.dirname(file_path)) as file_dir: file_content = file_dir.readtext(file_name) else: - function = self.ui.import_plugins[type][0] + function = self.ui.import_plugins[import_type][0] ft_name, waypoints = function(file_path) model = ft.WaypointsTableModel(waypoints=waypoints) xml_doc = model.get_xml_doc() @@ -898,15 +923,19 @@ def add_operation(self): if self.add_proj_dialog.f_content is not None: data["content"] = self.add_proj_dialog.f_content try: - r = requests.post(f'{self.mscolab_server_url}/create_operation', data=data, timeout=(2, 10)) + r = requests.post(f'{self.mscolab_server_url}/create_operation', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") self.logout() else: if r.text == "True": - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage('Your operation was created successfully') + QtWidgets.QMessageBox.information( + self.ui, + "Creation successful", + "Your operation was created successfully.", + ) op_id = self.get_recent_op_id() self.new_op_id = op_id self.conn.handle_new_operation(op_id) @@ -916,12 +945,15 @@ def add_operation(self): self.error_dialog.showMessage('The path already exists') def get_recent_op_id(self): + logging.debug('get_recent_op_id') if verify_user_token(self.mscolab_server_url, self.token): """ get most recent operation's op_id """ + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") data = { - "token": self.token + "token": self.token, + "skip_archived": skip_archived } r = requests.get(self.mscolab_server_url + '/operations', data=data) if r.text != "False": @@ -1077,9 +1109,9 @@ def handle_delete_operation(self): "token": self.token, "op_id": self.active_op_id } - url = url_join(self.mscolab_server_url, 'delete_operation') + url = urljoin(self.mscolab_server_url, 'delete_operation') try: - res = requests.post(url, data=data, timeout=(2, 10)) + 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.") @@ -1111,9 +1143,9 @@ def handle_leave_operation(self): "op_id": self.active_op_id, "selected_userids": json.dumps([self.user["id"]]) } - url = url_join(self.mscolab_server_url, "delete_bulk_permissions") + url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") try: - res = requests.post(url, data=data, timeout=(2, 10)) + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") @@ -1134,27 +1166,69 @@ def handle_leave_operation(self): self.logout() def set_operation_desc_label(self, op_desc): - self.active_operation_desc = op_desc - desc_count = len(str(self.active_operation_desc)) + self.active_operation_description = op_desc + desc_count = len(str(self.active_operation_description)) if desc_count < 95: self.ui.activeOperationDesc.setText( - self.ui.tr(f"{self.active_operation_name}: {self.active_operation_desc}")) + self.ui.tr(f"{self.active_operation_name}: {self.active_operation_description}")) else: self.ui.activeOperationDesc.setText( "Description is too long to show here, for long descriptions go " "to operations menu.") - def update_description_handler(self): + def change_category_handler(self): + # only after login + if verify_user_token(self.mscolab_server_url, self.token): + entered_operation_category, ok = QtWidgets.QInputDialog.getText( + self.ui, + self.ui.tr(f"{self.active_operation_name} - Change Category"), + self.ui.tr( + "You're about to change the operation category\n" + "Enter new operation category: " + ), + text=self.active_operation_category + ) + if ok: + data = { + "token": self.token, + "op_id": self.active_op_id, + "attribute": 'category', + "value": entered_operation_category + } + url = 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() + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() + + def change_description_handler(self): # only after login if verify_user_token(self.mscolab_server_url, self.token): entered_operation_desc, ok = QtWidgets.QInputDialog.getText( self.ui, - self.ui.tr(f"{self.active_operation_name} - Update Description"), + self.ui.tr(f"{self.active_operation_name} - Change Description"), self.ui.tr( - "You're about to update the operation description" - "\nEnter new operation description: " + "You're about to change the operation description\n" + "Enter new operation description: " ), - text=self.active_operation_desc + text=self.active_operation_description ) if ok: data = { @@ -1163,9 +1237,10 @@ def update_description_handler(self): "attribute": 'description', "value": entered_operation_desc } - url = url_join(self.mscolab_server_url, 'update_operation') + + url = urljoin(self.mscolab_server_url, 'update_operation') try: - r = requests.post(url, data=data, timeout=(2, 10)) + 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.") @@ -1176,8 +1251,14 @@ def update_description_handler(self): self.set_operation_desc_label(entered_operation_desc) self.reload_operation_list() - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage("Description is updated successfully.") + QtWidgets.QMessageBox.information( + self.ui, + "Update successful", + "Description is updated successfully.", + ) + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1192,6 +1273,7 @@ def rename_operation_handler(self): f"You're about to rename the operation - '{self.active_operation_name}' " f"Enter new operation name: " ), + text=f"{self.active_operation_name}", ) if ok: data = { @@ -1200,9 +1282,9 @@ def rename_operation_handler(self): "attribute": 'path', "value": entered_operation_name } - url = url_join(self.mscolab_server_url, 'update_operation') + url = urljoin(self.mscolab_server_url, 'update_operation') try: - r = requests.post(url, data=data, timeout=(2, 10)) + 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.") @@ -1213,14 +1295,20 @@ def rename_operation_handler(self): self.active_operation_name = entered_operation_name # Update active operation description - self.set_operation_desc_label(self.active_operation_desc) + self.set_operation_desc_label(self.active_operation_description) self.reload_operation_list() self.reload_windows_slot() # Update other user's operation list self.conn.signal_operation_list_updated.connect(self.reload_operation_list) - self.error_dialog = QtWidgets.QErrorMessage() - self.error_dialog.showMessage("Operation is renamed successfully.") + QtWidgets.QMessageBox.information( + self.ui, + "Rename successful", + "Operation is renamed successfully.", + ) + else: + show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") + self.logout() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1275,12 +1363,13 @@ def reload_local_wp(self): self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() - def operation_category_handler(self): + def operation_category_handler(self, update_operations=True): # only after_login if self.mscolab_server_url is not None: self.selected_category = self.ui.filterCategoryCb.currentText() - if self.selected_category != "ANY": + if update_operations: self.add_operations_to_ui() + if self.selected_category != "*ANY*": items = [self.ui.listOperationsMSC.item(i) for i in range(self.ui.listOperationsMSC.count())] row = 0 for item in items: @@ -1348,11 +1437,13 @@ 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 } - r = requests.get(self.mscolab_server_url + '/operations', data=data, timeout=(2, 10)) + r = requests.get(self.mscolab_server_url + '/operations', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1366,22 +1457,22 @@ def get_recent_operation(self): show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() - @QtCore.Slot() + @QtCore.pyqtSlot() def reload_operation_list(self): if self.mscolab_server_url is not None: self.reload_operations() - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def reload_window(self, value): if self.active_op_id != value or self.ui.workLocallyCheckbox.isChecked(): return self.reload_wps_from_server() - @QtCore.Slot() + @QtCore.pyqtSlot() def reload_windows_slot(self): self.reload_window(self.active_op_id) - @QtCore.Slot(int, int) + @QtCore.pyqtSlot(int, int) def render_new_permission(self, op_id, u_id): """ op_id: operation id @@ -1392,7 +1483,8 @@ def render_new_permission(self, op_id, u_id): data = { 'token': self.token } - r = requests.get(self.mscolab_server_url + '/user', data=data, timeout=(2, 10)) + r = requests.get(self.mscolab_server_url + '/user', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) if _json['user']['id'] == u_id: @@ -1403,7 +1495,7 @@ def render_new_permission(self, op_id, u_id): widgetItem.operation_category = operation["category"] widgetItem.operation_path = operation["path"] widgetItem.access_level = operation["access_level"] - widgetItem.active_operation_desc = operation["description"] + widgetItem.active_operation_description = operation["description"] self.ui.listOperationsMSC.addItem(widgetItem) self.signal_render_new_permission.emit(operation["op_id"], operation["path"]) if self.chat_window is not None: @@ -1412,7 +1504,7 @@ def render_new_permission(self, op_id, u_id): show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() - @QtCore.Slot(int, int, str) + @QtCore.pyqtSlot(int, int, str) def handle_update_permission(self, op_id, u_id, access_level): """ op_id: operation id @@ -1478,7 +1570,7 @@ def delete_operation_from_list(self, op_id): self.ui.listOperationsMSC.takeItem(self.ui.listOperationsMSC.row(remove_item)) return remove_item.operation_path - @QtCore.Slot(int, int) + @QtCore.pyqtSlot(int, int) def handle_revoke_permission(self, op_id, u_id): if u_id == self.user["id"]: operation_name = self.delete_operation_from_list(op_id) @@ -1494,8 +1586,9 @@ def handle_revoke_permission(self, op_id, u_id): self.ui.listFlightTracks.setCurrentRow(0) self.ui.activate_selected_flight_track() - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def handle_operation_deleted(self, op_id): + logging.debug('handle_operation_deleted') old_operation_name = self.active_operation_name old_active_id = self.active_op_id operation_name = self.delete_operation_from_list(op_id) @@ -1503,58 +1596,70 @@ def handle_operation_deleted(self, op_id): operation_name = old_operation_name show_popup(self.ui, "Success", f'Operation "{operation_name}" was deleted!', icon=1) - def show_categories_to_ui(self): + def show_categories_to_ui(self, ops=None): """ adds the list of operation categories to the UI """ - if verify_user_token(self.mscolab_server_url, self.token): - data = { - "token": self.token - } + logging.debug('show_categories_to_ui') + if verify_user_token(self.mscolab_server_url, self.token) or ops: r = None - try: - r = requests.get(f'{self.mscolab_server_url}/operations', data=data, timeout=(2, 10)) - except requests.exceptions.MissingSchema: - show_popup(self.ui, "Error", "Session expired, new login required") + if ops is not None: + r = ops + else: + data = { + "token": self.token + } + try: + r = requests.get(f'{self.mscolab_server_url}/operations', 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"]) + categories = set(["*ANY*"]) for operation in operations: categories.add(operation["category"]) - categories.remove("ANY") - categories = ["ANY"] + sorted(categories) + categories.remove("*ANY*") + categories = ["*ANY*"] + sorted(categories) category = config_loader(dataset="MSCOLAB_category") self.ui.filterCategoryCb.addItems(categories) if category in categories: index = categories.index(category) self.ui.filterCategoryCb.setCurrentIndex(index) + self.operation_category_handler(update_operations=False) + self.ui.filterCategoryCb.currentIndexChanged.connect(self.operation_category_handler) def add_operations_to_ui(self): logging.debug('add_operations_to_ui') + r = None if verify_user_token(self.mscolab_server_url, self.token): + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") data = { - "token": self.token + "token": self.token, + "skip_archived": skip_archived } - r = requests.get(f'{self.mscolab_server_url}/operations', data=data, timeout=(2, 10)) + r = requests.get(f'{self.mscolab_server_url}/operations', data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) self.operations = _json["operations"] logging.debug("adding operations to ui") operations = sorted(self.operations, key=lambda k: k["path"].lower()) self.ui.listOperationsMSC.clear() - self.ui.listInactiveOperationsMSC.clear() + self.operation_archive_browser.listArchivedOperations.clear() new_operation = None active_operation = None for operation in operations: operation_desc = f'{operation["path"]} - {operation["access_level"]}' widgetItem = QtWidgets.QListWidgetItem(operation_desc) - widgetItem.active_operation_desc = operation["description"] widgetItem.op_id = operation["op_id"] - widgetItem.access_level = operation["access_level"] - widgetItem.operation_path = operation["path"] widgetItem.operation_category = operation["category"] + widgetItem.operation_path = operation["path"] + widgetItem.access_level = operation["access_level"] + widgetItem.active_operation_description = operation["description"] try: # compatibility to 7.x # a newer server can distinguish older operations and move those into inactive state @@ -1568,7 +1673,7 @@ def add_operations_to_ui(self): if widgetItem.op_id == self.new_op_id: new_operation = widgetItem else: - self.ui.listInactiveOperationsMSC.addItem(widgetItem) + self.operation_archive_browser.listArchivedOperations.addItem(widgetItem) if new_operation is not None: logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) self.ui.listOperationsMSC.itemActivated.emit(new_operation) @@ -1576,7 +1681,6 @@ def add_operations_to_ui(self): logging.debug("%s %s %s", new_operation, self.new_op_id, self.active_op_id) self.ui.listOperationsMSC.itemActivated.emit(active_operation) self.ui.listOperationsMSC.itemActivated.connect(self.set_active_op_id) - self.ui.listInactiveOperationsMSC.itemClicked.connect(self.select_inactive_operation) self.new_op_id = None else: show_popup(self.ui, "Error", "Session expired, new login required") @@ -1584,41 +1688,40 @@ def add_operations_to_ui(self): else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() - - def select_inactive_operation(self, item): - logging.debug('select_inactive_operation') - self.inactive_op_id = item.op_id - self.show_operation_options_in_inactivated_state(item.access_level) + return r def show_operation_options_in_inactivated_state(self, access_level): self.ui.actionUnarchiveOperation.setEnabled(False) if access_level in ["creator", "admin"]: self.ui.actionUnarchiveOperation.setEnabled(True) - def activate_operation(self): - logging.debug('activate_operation') + def archive_operation(self): + logging.debug("handle_archive_operation") if verify_user_token(self.mscolab_server_url, self.token): - # set last used date for operation - data = { - "token": self.token, - "op_id": self.inactive_op_id, - } - try: - res = requests.post(f'{self.mscolab_server_url}/set_last_used', data=data, timeout=(2, 10)) - except requests.exceptions.RequestException as e: - logging.error(e) - show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") - self.logout() - else: - if res.text != "False": - res = res.json() - if res["success"]: - self.reload_operations() - else: - show_popup(self.ui, "Error", "Some error occurred! Could not activate operation") + ret = QtWidgets.QMessageBox.warning( + self.ui, self.tr("Mission Support System"), + self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + if ret == QtWidgets.QMessageBox.Yes: + data = { + "token": self.token, + "op_id": self.active_op_id, + "days": 31, + } + url = urljoin(self.mscolab_server_url, 'set_last_used') + 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: - show_popup(self.ui, "Error", "Session expired, new login required") - self.logout() + res.raise_for_status() + self.reload_operations() + self.signal_operation_removed.emit(self.active_op_id) + logging.debug("activate local") + self.ui.listFlightTracks.setCurrentRow(0) + self.ui.activate_selected_flight_track() else: show_popup(self.ui, "Error", "Your Connection is expired. New Login required!") self.logout() @@ -1642,24 +1745,15 @@ def set_active_op_id(self, item): self.ui.workLocallyCheckbox.setChecked(False) self.ui.workLocallyCheckbox.blockSignals(False) - # Disable Activate Operation Button - self.ui.actionUnarchiveOperation.setEnabled(False) - - # set last used date for operation - data = { - "token": self.token, - "op_id": item.op_id, - } - requests.post(f'{self.mscolab_server_url}/set_last_used', data=data, timeout=(2, 10)) - # 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_desc = item.active_operation_desc + self.active_operation_description = item.active_operation_description + self.active_operation_category = item.operation_category self.waypoints_model = None - self.signal_activate_operation.emit(self.active_op_id) + self.signal_unarchive_operation.emit(self.active_op_id) self.inactive_op_id = None font = QtGui.QFont() @@ -1668,7 +1762,7 @@ def set_active_op_id(self, item): font.setBold(False) # Set active operation description - self.set_operation_desc_label(self.active_operation_desc) + self.set_operation_desc_label(self.active_operation_description) # set active flightpath here self.load_wps_from_server() # display working status @@ -1689,7 +1783,6 @@ def set_active_op_id(self, item): item.setFont(font) # set new waypoints model to open views - logging.debug("mscolab set wpm") for window in self.ui.get_active_views(): window.setFlightTrackModel(self.waypoints_model) if self.access_level == "viewer": @@ -1726,11 +1819,14 @@ def show_operation_options(self): self.ui.actionChat.setEnabled(False) self.ui.actionVersionHistory.setEnabled(False) self.ui.actionManageUsers.setEnabled(False) - self.ui.menuProperties.setEnabled(True) self.ui.actionRenameOperation.setEnabled(False) self.ui.actionLeaveOperation.setEnabled(True) self.ui.actionDeleteOperation.setEnabled(False) - self.ui.actionUpdateOperationDesc.setEnabled(False) + self.ui.actionChangeCategory.setEnabled(False) + self.ui.actionChangeDescription.setEnabled(False) + self.ui.actionArchiveOperation.setEnabled(False) + self.ui.actionViewDescription.setEnabled(True) + self.ui.menuProperties.setEnabled(True) if self.access_level == "viewer": self.ui.menuImportFlightTrack.setEnabled(False) @@ -1753,7 +1849,8 @@ def show_operation_options(self): if self.access_level in ["creator", "admin"]: self.ui.actionManageUsers.setEnabled(True) - self.ui.actionUpdateOperationDesc.setEnabled(True) + self.ui.actionChangeCategory.setEnabled(True) + self.ui.actionChangeDescription.setEnabled(True) self.ui.filterCategoryCb.setEnabled(True) self.ui.actionRenameOperation.setEnabled(True) else: @@ -1763,6 +1860,7 @@ 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) @@ -1770,8 +1868,15 @@ def hide_operation_options(self): self.ui.actionChat.setEnabled(False) self.ui.actionVersionHistory.setEnabled(False) self.ui.actionManageUsers.setEnabled(False) - self.ui.menuProperties.setEnabled(False) + self.ui.actionViewDescription.setEnabled(False) + self.ui.actionLeaveOperation.setEnabled(False) + self.ui.actionRenameOperation.setEnabled(False) + self.ui.actionArchiveOperation.setEnabled(False) + self.ui.actionChangeCategory.setEnabled(False) + self.ui.actionChangeDescription.setEnabled(False) + self.ui.actionDeleteOperation.setEnabled(False) self.ui.workLocallyCheckbox.setEnabled(False) + self.ui.menuProperties.setEnabled(False) self.ui.serverOptionsCb.hide() # change working status label self.ui.workingStatusLabel.setText(self.ui.tr("\n\nNo Operation Selected")) @@ -1802,9 +1907,9 @@ def load_wps_from_server(self): self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) def reload_operations(self): - self.add_operations_to_ui() + ops = self.add_operations_to_ui() selected_category = self.ui.filterCategoryCb.currentText() - self.show_categories_to_ui() + self.show_categories_to_ui(ops) index = self.ui.filterCategoryCb.findText(selected_category, QtCore.Qt.MatchFixedString) if index >= 0: self.ui.filterCategoryCb.setCurrentIndex(index) @@ -1816,6 +1921,7 @@ def reload_wps_from_server(self): self.reload_view_windows() def handle_waypoints_changed(self): + logging.debug("handle_waypoints_changed") if verify_user_token(self.mscolab_server_url, self.token): if self.ui.workLocallyCheckbox.isChecked(): self.waypoints_model.save_to_ftml(self.local_ftml_file) @@ -1843,6 +1949,7 @@ def reload_view_windows(self): logging.error("%s" % err) def handle_import_msc(self, file_path, extension, function, pickertype): + logging.debug("handle_import_msc") if verify_user_token(self.mscolab_server_url, self.token): if self.active_op_id is None: return @@ -1865,14 +1972,10 @@ def handle_import_msc(self, file_path, extension, function, pickertype): model = ft.WaypointsTableModel(waypoints=new_waypoints) xml_doc = self.waypoints_model.get_xml_doc() xml_content = xml_doc.toprettyxml(indent=" ", newl="\n") - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.waypoints_model.dataChanged.disconnect(self.handle_waypoints_changed) self.waypoints_model = model - if self.ui.workLocallyCheckbox.isChecked(): - self.waypoints_model.save_to_ftml(self.local_ftml_file) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) - else: - self.conn.save_file(self.token, self.active_op_id, xml_content, comment=None) - self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) + self.handle_waypoints_changed() + self.waypoints_model.dataChanged.connect(self.handle_waypoints_changed) self.reload_view_windows() show_popup(self.ui, "Import Success", f"The file - {file_name}, was imported successfully!", 1) else: @@ -1880,6 +1983,7 @@ def handle_import_msc(self, file_path, extension, function, pickertype): self.logout() def handle_export_msc(self, extension, function, pickertype): + logging.debug("handle_export_msc") if verify_user_token(self.mscolab_server_url, self.token): if self.active_op_id is None: return @@ -1915,6 +2019,12 @@ def logout(self): return self.ui.local_active = True self.ui.menu_handler() + + # disconnect socket + if self.conn is not None: + self.conn.disconnect() + self.conn = None + # close all hanging window self.close_external_windows() self.hide_operation_options() @@ -1931,7 +2041,7 @@ def logout(self): # clear operation listing self.ui.listOperationsMSC.clear() # clear inactive operation listing - self.ui.listInactiveOperationsMSC.clear() + self.operation_archive_browser.listArchivedOperations.clear() # clear mscolab url self.mscolab_server_url = None # clear operations list here @@ -1939,6 +2049,7 @@ def logout(self): self.ui.usernameLabel.hide() self.ui.userOptionsTb.hide() self.ui.connectBtn.show() + self.ui.connectBtn.setFocus() self.ui.openOperationsGb.hide() self.ui.actionAddOperation.setEnabled(False) # hide operation description @@ -1947,10 +2058,6 @@ def logout(self): self.ui.activeOperationDesc.setText(self.ui.tr("Select Operation to View Description.")) # set usernameLabel back to default self.ui.usernameLabel.setText("User") - # disconnect socket - if self.conn is not None: - self.conn.disconnect() - self.conn = None # Turn off work locally toggle self.ui.workLocallyCheckbox.blockSignals(True) self.ui.workLocallyCheckbox.setChecked(False) @@ -1967,25 +2074,20 @@ def logout(self): # clear user email self.email = None - # delete mscolab http_auth settings for the url - if self.mscolab_server_url in self.settings["auth"].keys(): - del self.settings["auth"][self.mscolab_server_url] - save_settings_qsettings('mscolab', self.settings) - # disable category change selector self.ui.filterCategoryCb.setEnabled(False) self.signal_logout_mscolab.emit() - # Don't try to activate local flighttrack while testing - if "pytest" not in sys.modules: - # activate first local flighttrack after logging out - self.ui.listFlightTracks.setCurrentRow(0) - self.ui.activate_selected_flight_track() + self.operation_archive_browser.hide() + + # activate first local flighttrack after logging out + self.ui.listFlightTracks.setCurrentRow(0) + self.ui.activate_selected_flight_track() class MscolabMergeWaypointsDialog(QtWidgets.QDialog, merge_wp_ui.Ui_MergeWaypointsDialog): def __init__(self, local_waypoints_model, server_waypoints_model, fetch=False, parent=None): - super(MscolabMergeWaypointsDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.local_waypoints_model = local_waypoints_model @@ -2058,6 +2160,6 @@ def get_values(self): class MscolabHelpDialog(QtWidgets.QDialog, msc_help_dialog.Ui_mscolabHelpDialog): def __init__(self, parent=None): - super(MscolabHelpDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.okayBtn.clicked.connect(lambda: self.close()) diff --git a/mslib/msui/mscolab_admin_window.py b/mslib/msui/mscolab_admin_window.py index 1d23c5bba..71e50182d 100644 --- a/mslib/msui/mscolab_admin_window.py +++ b/mslib/msui/mscolab_admin_window.py @@ -27,7 +27,7 @@ import json import requests -from werkzeug.urls import url_join +from urllib.parse import urljoin from PyQt5 import QtCore, QtWidgets from mslib.utils.verify_user_token import verify_user_token @@ -47,7 +47,7 @@ def __init__(self, token, op_id, user, operation_name, operations, conn, parent= op_id: operation id conn: connection to send/receive socket messages """ - super(MSColabAdminWindow, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.mscolab_server_url = mscolab_server_url @@ -175,12 +175,13 @@ def set_label_text(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "/creator_of_operation") - r = requests.get(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "/creator_of_operation") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) creator_name = _json["username"] - self.operationNameLabel.setText(f"Operation: {self.operation_name} by User: {creator_name}") + self.operationNameLabel.setText(f"Operation: {self.operation_name}") + self.creatorNameLabel.setText(f"Creator: {creator_name}") self.usernameLabel.setText(f"Logged In: {self.user['username']}") def load_import_operations(self): @@ -188,8 +189,8 @@ def load_import_operations(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "operations") - r = requests.get(url, data=data, timeout=(2, 10)) + 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"] @@ -202,8 +203,8 @@ def load_users_without_permission(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "users_without_permission") - res = requests.get(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "users_without_permission") + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -227,8 +228,8 @@ def load_users_with_permission(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, "users_with_permission") - res = requests.get(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "users_with_permission") + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -259,8 +260,8 @@ def add_selected_users(self): "selected_userids": json.dumps(selected_userids), "selected_access_level": selected_access_level } - url = url_join(self.mscolab_server_url, "add_bulk_permissions") - res = requests.post(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "add_bulk_permissions") + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -290,8 +291,8 @@ def modify_selected_users(self): "selected_userids": json.dumps(selected_userids), "selected_access_level": selected_access_level } - url = url_join(self.mscolab_server_url, "modify_bulk_permissions") - res = requests.post(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "modify_bulk_permissions") + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -318,8 +319,8 @@ def delete_selected_users(self): "op_id": self.op_id, "selected_userids": json.dumps(selected_userids) } - url = url_join(self.mscolab_server_url, "delete_bulk_permissions") - res = requests.post(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "delete_bulk_permissions") + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: @@ -343,8 +344,8 @@ def import_permissions(self): "current_op_id": self.op_id, "import_op_id": import_op_id } - url = url_join(self.mscolab_server_url, 'import_permissions') - res = requests.post(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, 'import_permissions') + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() if res["success"]: diff --git a/mslib/msui/mscolab_chat.py b/mslib/msui/mscolab_chat.py index b85c19f45..aa9f34191 100644 --- a/mslib/msui/mscolab_chat.py +++ b/mslib/msui/mscolab_chat.py @@ -31,9 +31,9 @@ import requests from markdown import Markdown from markdown.extensions import Extension -from werkzeug.urls import url_join +from urllib.parse import urljoin -from mslib.mscolab.models import MessageType +from mslib.mscolab.message_type import MessageType from PyQt5 import QtCore, QtGui, QtWidgets from mslib.utils.qt import get_open_filename, get_save_filename, show_popup from mslib.msui.qt5 import ui_mscolab_operation_window as ui @@ -83,7 +83,7 @@ def __init__(self, token, op_id, user, operation_name, access_level, conn, paren parent: widget parent mscolab_server_url: server url for mscolab """ - super(MSColabChatWindow, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.mscolab_server_url = mscolab_server_url @@ -275,9 +275,9 @@ def send_message(self): "op_id": self.op_id, "message_type": int(self.attachment_type) } - url = url_join(self.mscolab_server_url, 'message_attachment') + url = urljoin(self.mscolab_server_url, 'message_attachment') try: - requests.post(url, data=data, files=files, timeout=(2, 10)) + requests.post(url, data=data, files=files, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.ConnectionError: show_popup(self, "Error", "File size too large") self.send_message_state() @@ -332,8 +332,8 @@ def load_users(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, 'authorized_users') - r = requests.get(url, data=data, timeout=(2, 10)) + 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": self.collaboratorsList.clear() users = r.json()["users"] @@ -352,9 +352,9 @@ def load_all_messages(self): "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") } # returns an array of messages - url = url_join(self.mscolab_server_url, "messages") + url = urljoin(self.mscolab_server_url, "messages") - res = requests.get(url, data=data, timeout=(2, 10)) + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() messages = res["messages"] @@ -375,16 +375,16 @@ def render_new_message(self, message, scroll=True): self.messageList.scrollToBottom() # SOCKET HANDLERS - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def handle_permissions_updated(self, _): self.load_users() - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_incoming_message(self, message): message = json.loads(message) self.render_new_message(message) - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_incoming_message_reply(self, reply): reply = json.loads(reply) for i in range(self.messageList.count() - 1, -1, -1): @@ -410,7 +410,7 @@ def handle_incoming_message_reply(self, reply): self.messageList.setItemWidget(item, new_message_item) break - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_message_edited(self, message): message = json.loads(message) message_id = message["message_id"] @@ -424,7 +424,7 @@ def handle_message_edited(self, message): item.setSizeHint(message_widget.sizeHint()) break - @QtCore.Slot(str) + @QtCore.pyqtSlot(str) def handle_deleted_message(self, message): message = json.loads(message) message_id = message["message_id"] @@ -442,7 +442,7 @@ def closeEvent(self, event): class MessageItem(QtWidgets.QWidget): def __init__(self, message, chat_window): - super(MessageItem, self).__init__() + super().__init__() self.id = message["id"] self.u_id = message["u_id"] self.username = message["username"] @@ -470,11 +470,11 @@ def setup_image_message_box(self): MAX_WIDTH = MAX_HEIGHT = 300 self.messageBox = QtWidgets.QLabel() if '\\' in self.attachment_path: - img_url = url_join(self.chat_window.mscolab_server_url, - self.attachment_path.replace('\\', '/').split('colabdata')[1]) + img_url = urljoin(self.chat_window.mscolab_server_url, + self.attachment_path.replace('\\', '/').split('colabdata')[1]) else: - img_url = url_join(self.chat_window.mscolab_server_url, self.attachment_path) - data = requests.get(img_url, timeout=(2, 10)).content + img_url = urljoin(self.chat_window.mscolab_server_url, self.attachment_path) + data = requests.get(img_url, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))).content image = QtGui.QImage() image.loadFromData(data) self.message_image = image @@ -505,7 +505,7 @@ def get_text_browser(self, text): def setup_text_message_box(self): if self.message_type == MessageType.DOCUMENT: - doc_url = url_join(self.chat_window.mscolab_server_url, self.attachment_path) + doc_url = urljoin(self.chat_window.mscolab_server_url, self.attachment_path) file_name = fs.path.basename(self.attachment_path) self.message_text = f"Document: [{file_name}]({doc_url})" self.messageBox = self.get_text_browser(self.message_text) @@ -653,8 +653,8 @@ def handle_download_action(self): if self.message_type == MessageType.DOCUMENT: file_path = get_save_filename(self, "Save Document", default_filename, f"Document (*{file_ext})") if file_path is not None: - file_content = requests.get(url_join(self.chat_window.mscolab_server_url, self.attachment_path), - timeout=(2, 10)).content + file_content = requests.get(urljoin(self.chat_window.mscolab_server_url, self.attachment_path), + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))).content with open(file_path, "wb") as f: f.write(file_content) else: diff --git a/mslib/msui/mscolab_version_history.py b/mslib/msui/mscolab_version_history.py index 2b8a6a190..869ef2527 100644 --- a/mslib/msui/mscolab_version_history.py +++ b/mslib/msui/mscolab_version_history.py @@ -29,7 +29,7 @@ import json import requests -from werkzeug.urls import url_encode, url_join +from urllib.parse import urljoin, urlencode from mslib.utils.verify_user_token import verify_user_token from mslib.msui.flighttrack import WaypointsTableModel @@ -60,7 +60,7 @@ def __init__(self, token, op_id, user, operation_name, conn, parent=None, parent: parent of widget mscolab_server_url: server url of mscolab """ - super(MSColabVersionHistory, self).__init__(parent) + super().__init__(parent) self.setupUi(self) # Initialise Variables self.token = token @@ -111,8 +111,8 @@ def load_current_waypoints(self): "token": self.token, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, 'get_operation_by_id') - res = requests.get(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, 'get_operation_by_id') + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": xml_content = json.loads(res.text)["content"] waypoint_model = WaypointsTableModel(name="Current Waypoints", xml_content=xml_content) @@ -133,13 +133,13 @@ def load_all_changes(self): "token": self.token, "op_id": self.op_id } - named_version_only = None + named_version_only = False if self.versionFilterCB.currentIndex() == 0: named_version_only = True - query_string = url_encode({"named_version": named_version_only}) + query_string = urlencode({"named_version": named_version_only}) url_path = f'get_all_changes?{query_string}' - url = url_join(self.mscolab_server_url, url_path) - r = requests.get(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, url_path) + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": changes = json.loads(r.text)["changes"] self.changes.clear() @@ -180,8 +180,8 @@ def preview_change(self, current_item, previous_item): "token": self.token, "ch_id": current_item.id } - url = url_join(self.mscolab_server_url, 'get_change_content') - res = requests.get(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, 'get_change_content') + res = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if res.text != "False": res = res.json() waypoint_model = WaypointsTableModel(xml_content=res["content"]) @@ -206,8 +206,8 @@ def request_set_version_name(self, version_name, ch_id): "ch_id": ch_id, "op_id": self.op_id } - url = url_join(self.mscolab_server_url, 'set_version_name') - res = requests.post(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, 'set_version_name') + res = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) return res else: # this triggers disconnect @@ -273,8 +273,8 @@ def handle_undo(self): "token": self.token, "ch_id": self.changes.currentItem().id } - url = url_join(self.mscolab_server_url, 'undo') - r = requests.post(url, data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, 'undo_changes') + r = requests.post(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": # reload windows self.reloadWindows.emit() diff --git a/mslib/msui/mss.py b/mslib/msui/mss.py index 606f63daf..a67fd1873 100644 --- a/mslib/msui/mss.py +++ b/mslib/msui/mss.py @@ -30,7 +30,7 @@ import sys -from mslib.utils.qt import ui_mss_rename_message as ui +from mslib.msui.qt5 import ui_mss_rename_message as ui from PyQt5 import QtWidgets diff --git a/mslib/msui/msui.py b/mslib/msui/msui.py index 33d5c23b4..c9559c965 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -30,1056 +30,29 @@ """ import argparse -import copy -import functools import hashlib -import importlib import logging import os import platform -import re -import requests import shutil import sys import fs from packaging import version from mslib import __version__ -from mslib.utils.qt import ui_mainwindow as ui -from mslib.utils.qt import ui_about_dialog as ui_ab -from mslib.utils.qt import ui_shortcuts as ui_sh -from mslib.msui import flighttrack as ft -from mslib.msui import tableview, topview, sideview, linearview -from mslib.msui import editor +from mslib.msui.msui_mainwindow import MSUIMainWindow from mslib.msui import constants -from mslib.msui import wms_control -from mslib.msui import mscolab -from mslib.msui.updater import UpdaterUI from mslib.utils import setup_logging -from mslib.plugins.io.csv import load_from_csv, save_to_csv -from mslib.msui.icons import icons, python_powered -from mslib.utils.qt import get_open_filenames, get_save_filename, show_popup, Worker, Updater -from mslib.utils.config import read_config_file, config_loader -from mslib.utils.auth import get_auth_from_url_and_name -from PyQt5 import QtGui, QtCore, QtWidgets, QtTest +from mslib.msui.icons import icons +from mslib.utils.qt import Worker, Updater +from mslib.utils.config import read_config_file +from PyQt5 import QtGui, QtCore, QtWidgets # Add config path to PYTHONPATH so plugins located there may be found sys.path.append(constants.MSUI_CONFIG_PATH) -def clean_string(string): - return re.sub(r'\W|^(?=\d)', '_', string) - - -class QActiveViewsListWidgetItem(QtWidgets.QListWidgetItem): - """Subclass of QListWidgetItem, represents an open view in the list of - open views. Keeps a reference to the view instance (i.e. the window) it - represents in the list of open views. - """ - - # Class variable to assign a unique ID to each view. - opened_views = 0 - open_views = [] - - def __init__(self, view_window, parent=None, viewsChanged=None, mscolab=False, - _type=QtWidgets.QListWidgetItem.UserType): - """Add ID number to the title of the corresponding view window. - """ - QActiveViewsListWidgetItem.opened_views += 1 - view_name = f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.name}" - super(QActiveViewsListWidgetItem, self).__init__(view_name, parent, _type) - - view_window.setWindowTitle(f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.windowTitle()} - " - f"{view_window.waypoints_model.name}") - view_window.setIdentifier(view_name) - self.window = view_window - self.parent = parent - self.viewsChanged = viewsChanged - QActiveViewsListWidgetItem.open_views.append(view_window) - - def view_destroyed(self): - """Slot that removes this QListWidgetItem from the parent (the - QListWidget) if the corresponding view has been deleted. - """ - if self.parent is not None: - self.parent.takeItem(self.parent.row(self)) - for index, window in enumerate(QActiveViewsListWidgetItem.open_views): - if window.identifier == self.window.identifier: - del QActiveViewsListWidgetItem.open_views[index] - break - if self.viewsChanged is not None: - self.viewsChanged.emit() - - -class QFlightTrackListWidgetItem(QtWidgets.QListWidgetItem): - """Subclass of QListWidgetItem, represents a flight track in the list of - open flight tracks. Keeps a reference to the flight track instance - (i.e. the instance of WaypointsTableModel). - """ - - def __init__(self, flighttrack_model, parent=None, - type=QtWidgets.QListWidgetItem.UserType): - """Item class for the list widget that accommodates the open flight - tracks. - - Arguments: - flighttrack_model -- instance of a flight track model that is - associated with the item - parent -- pointer to the QListWidgetItem that accommodates this item. - If not None, the itemChanged() signal of the parent is - connected to the nameChanged() slot of this class, reacting - to name changes of the item. - """ - view_name = flighttrack_model.name - super(QFlightTrackListWidgetItem, self).__init__( - view_name, parent, type) - - self.parent = parent - self.flighttrack_model = flighttrack_model - - -class MSUI_ShortcutsDialog(QtWidgets.QDialog, ui_sh.Ui_ShortcutsDialog): - """ - Dialog showing shortcuts for all currently open windows - """ - - def __init__(self): - super(MSUI_ShortcutsDialog, self).__init__(QtWidgets.QApplication.activeWindow()) - self.setupUi(self) - self.current_shortcuts = None - self.treeWidget.itemDoubleClicked.connect(self.double_clicked) - self.treeWidget.itemClicked.connect(self.clicked) - self.leShortcutFilter.textChanged.connect(self.filter_shortcuts) - self.filterRemoveAction = self.leShortcutFilter.addAction(QtGui.QIcon(icons("64x64", "remove.png")), - QtWidgets.QLineEdit.TrailingPosition) - self.filterRemoveAction.setVisible(False) - self.filterRemoveAction.setToolTip("Click to remove the filter") - self.filterRemoveAction.triggered.connect(lambda: self.leShortcutFilter.setText("")) - self.cbNoShortcut.stateChanged.connect(self.fill_list) - self.cbAdvanced.stateChanged.connect(lambda i: (self.cbNoShortcut.setVisible(i), - self.leShortcutFilter.setVisible(i), - self.cbDisplayType.setVisible(i), - self.label.setVisible(i), - self.label_2.setVisible(i), - self.line.setVisible(i))) - self.cbHighlight.stateChanged.connect(self.filter_shortcuts) - self.cbDisplayType.currentTextChanged.connect(self.fill_list) - self.cbAdvanced.stateChanged.emit(self.cbAdvanced.checkState()) - self.oldReject = self.reject - self.reject = self.custom_reject - - def custom_reject(self): - """ - Reset highlighted objects when closing the shortcuts dialog - """ - self.reset_highlight() - self.oldReject() - - def reset_highlight(self): - """ - Iterates through all shortcuts and resets the stylesheet - """ - if self.current_shortcuts: - for shortcuts in self.current_shortcuts.values(): - for shortcut in shortcuts.values(): - try: - if shortcut[-1] and hasattr(shortcut[-1], "setStyleSheet"): - shortcut[-1].setStyleSheet("") - except RuntimeError: - # when we have deleted a QAction we have to update the list - # Because we cannot test if the underlying object exist we have to catch that - self.fill_list() - - def clicked(self, item): - """ - Highlights the selected item in the GUI as yellow - """ - self.reset_highlight() - if hasattr(item, "source_object") and item.source_object and hasattr(item.source_object, "setStyleSheet"): - item.source_object.setStyleSheet("background-color:yellow;") - - def double_clicked(self, item): - """ - Executes the shortcut for the doubleclicked item - """ - if hasattr(item, "source_object") and item.source_object: - self.reset_highlight() - self.hide() - obj = item.source_object - if isinstance(obj, QtWidgets.QShortcut): - obj.activated.emit() - elif isinstance(obj, QtWidgets.QAction): - obj.trigger() - elif isinstance(obj, QtWidgets.QAbstractButton): - obj.click() - elif isinstance(obj, QtWidgets.QComboBox): - QtCore.QTimer.singleShot(200, obj.showPopup) - elif isinstance(obj, QtWidgets.QLineEdit) or isinstance(obj, QtWidgets.QAbstractSpinBox): - obj.setFocus() - - def fill_list(self): - """ - Fills the treeWidget with all relevant windows as top level items and their shortcuts as children - """ - self.treeWidget.clear() - self.current_shortcuts = self.get_shortcuts() - for widget in self.current_shortcuts: - if hasattr(widget, "window"): - name = widget.window().windowTitle() - else: - name = widget.objectName() - if len(name) == 0 or (hasattr(widget, "window") and widget.window().isHidden()): - continue - header = QtWidgets.QTreeWidgetItem(self.treeWidget) - header.setText(0, name) - if hasattr(widget, "window") and widget.window() == self.parent(): - header.setExpanded(True) - header.setSelected(True) - self.treeWidget.setCurrentItem(header) - for objectName in self.current_shortcuts[widget].keys(): - description, text, _, shortcut, obj = self.current_shortcuts[widget][objectName] - item = QtWidgets.QTreeWidgetItem(header) - item.source_object = obj - itemText = description if self.cbDisplayType.currentText() == 'Tooltip' \ - else text if self.cbDisplayType.currentText() == 'Text' else objectName - item.setText(0, f"{itemText}: {shortcut}") - item.setToolTip(0, f"ToolTip: {description}\nText: {text}\nObjectName: {objectName}") - header.addChild(item) - self.filter_shortcuts(self.leShortcutFilter.text()) - - def get_shortcuts(self): - """ - Iterates through all top level widgets and puts their shortcuts in a dictionary - """ - shortcuts = {} - for qobject in QtWidgets.QApplication.topLevelWidgets(): - actions = [] - actions.extend([ - (action.parent().window() if hasattr(action.parent(), "window") else action.parent(), - action.toolTip(), action.text().replace("&&", "%%").replace("&", "").replace("%%", "&"), - action.objectName(), - ",".join([shortcut.toString() for shortcut in action.shortcuts()]), action) - for action in qobject.findChildren( - QtWidgets.QAction) if len(action.shortcuts()) > 0 or self.cbNoShortcut.checkState()]) - actions.extend([(shortcut.parentWidget().window(), shortcut.whatsThis(), "", - shortcut.objectName(), shortcut.key().toString(), shortcut) - for shortcut in qobject.findChildren(QtWidgets.QShortcut)]) - actions.extend([(button.window(), button.toolTip(), button.text().replace("&&", "%%").replace("&", "") - .replace("%%", "&"), button.objectName(), - button.shortcut().toString() if button.shortcut() else "", button) - for button in qobject.findChildren(QtWidgets.QAbstractButton) if button.shortcut() or - self.cbNoShortcut.checkState()]) - - # Additional objects which have no shortcuts, if requested - actions.extend([(obj.window(), obj.toolTip(), obj.currentText(), obj.objectName(), "", obj) - for obj in qobject.findChildren(QtWidgets.QComboBox) if self.cbNoShortcut.checkState()]) - actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) - for obj in qobject.findChildren(QtWidgets.QAbstractSpinBox) + - qobject.findChildren(QtWidgets.QLineEdit) - if self.cbNoShortcut.checkState()]) - actions.extend([(obj.window(), obj.toolTip(), obj.toPlainText(), obj.objectName(), "", obj) - for obj in qobject.findChildren(QtWidgets.QPlainTextEdit) + - qobject.findChildren(QtWidgets.QTextEdit) - if self.cbNoShortcut.checkState()]) - - if not any(action for action in actions if action[3] == "actionShortcuts"): - actions.append((qobject.window(), "Show Current Shortcuts", "Show Current Shortcuts", - "Show Current Shortcuts", "Alt+S", None)) - if not any(action for action in actions if action[3] == "actionSearch"): - actions.append((qobject.window(), "Search for interactive text in the UI", - "Search for interactive text in the UI", "Search for interactive text in the UI", - "Ctrl+F", None)) - - for item in actions: - if item[0] not in shortcuts: - shortcuts[item[0]] = {} - shortcuts[item[0]][item[3].strip()] = item[1:] - - return shortcuts - - def filter_shortcuts(self, text="Nothing", rerun=True): - """ - Hides all shortcuts not containing the text - """ - text = self.leShortcutFilter.text() - self.reset_highlight() - - window_count = 0 - for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): - if not window.isHidden(): - window_count += 1 - - for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): - wms_hits = 0 - - for child_index in range(window.childCount()): - widget = window.child(child_index) - if text.lower() in widget.text(0).lower() or text.lower() in window.text(0).lower(): - widget.setHidden(False) - wms_hits += 1 - else: - widget.setHidden(True) - - if wms_hits == 1 and (self.cbHighlight.isChecked() or window_count == 1): - for child_index in range(window.childCount()): - widget = window.child(child_index) - if (not widget.isHidden()) and hasattr(widget.source_object, "setStyleSheet"): - widget.source_object.setStyleSheet("background-color: yellow;") - break - - if wms_hits == 0 and len(text) > 0: - window.setHidden(True) - else: - window.setHidden(False) - - self.filterRemoveAction.setVisible(len(text) > 0) - if rerun: - self.filter_shortcuts(text, False) - - -class MSUI_AboutDialog(QtWidgets.QDialog, ui_ab.Ui_AboutMSUIDialog): - """Dialog showing information about MSUI. Most of the displayed text is - defined in the QtDesigner file. - """ - - def __init__(self, parent=None): - """ - Arguments: - parent -- Qt widget that is parent to this widget. - """ - super(MSUI_AboutDialog, self).__init__(parent) - self.setupUi(self) - self.lblVersion.setText(f"Version: {__version__}") - self.milestone_url = f'https://github.com/Open-MSS/MSS/issues?q=is%3Aclosed+milestone%3A{__version__[:-1]}' - self.lblChanges.setText(f'New Features and Changes') - blub = QtGui.QPixmap(python_powered()) - self.lblPython.setPixmap(blub) - - -class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): - """MSUI new main window class. Provides user interface elements for managing - flight tracks, views and MSColab functionalities. - """ - - viewsChanged = QtCore.pyqtSignal(name="viewsChanged") - signal_activate_flighttrack = QtCore.Signal(ft.WaypointsTableModel, name="signal_activate_flighttrack") - signal_activate_operation = QtCore.Signal(int, name="signal_activate_operation") - signal_operation_added = QtCore.Signal(int, str, name="signal_operation_added") - signal_operation_removed = QtCore.Signal(int, name="signal_operation_removed") - signal_login_mscolab = QtCore.Signal(str, str, name="signal_login_mscolab") - signal_logout_mscolab = QtCore.Signal(name="signal_logout_mscolab") - signal_listFlighttrack_doubleClicked = QtCore.Signal() - signal_permission_revoked = QtCore.Signal(int) - signal_render_new_permission = QtCore.Signal(int, str) - - def __init__(self, mscolab_data_dir=None, *args): - super(MSUIMainWindow, self).__init__(*args) - self.setupUi(self) - self.setWindowIcon(QtGui.QIcon(icons('32x32'))) - # This code is required in Windows 7 to use the icon set by setWindowIcon in taskbar - # instead of the default Icon of python/pythonw - try: - import ctypes - myappid = f"msui.msui.{__version__}" # arbitrary string - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) - except (ImportError, AttributeError) as error: - logging.debug("AttributeError, ImportError Exception %s", error) - - self.config_editor = None - self.local_active = True - self.new_flight_track_counter = 0 - - # Reference to the flight track that is currently displayed in the views. - self.active_flight_track = None - self.last_save_directory = config_loader(dataset="data_dir") - - # bind meta (ctrl in macOS) to override automatic translation of ctrl to command by qt - if sys.platform == 'darwin': - self.actionTopView.setShortcut(QtGui.QKeySequence("Meta+h")) - self.actionSideView.setShortcut(QtGui.QKeySequence("Meta+v")) - self.actionTableView.setShortcut(QtGui.QKeySequence("Meta+t")) - self.actionLinearView.setShortcut(QtGui.QKeySequence("Meta+l")) - self.actionConfiguration.setShortcut(QtGui.QKeySequence("Ctrl+,")) - - # File menu. - self.actionNewFlightTrack.triggered.connect(functools.partial(self.create_new_flight_track, None, None)) - self.actionSaveActiveFlightTrack.triggered.connect(self.save_handler) - self.actionSaveActiveFlightTrackAs.triggered.connect(self.save_as_handler) - self.actionCloseSelectedFlightTrack.triggered.connect(self.close_selected_flight_track) - - # Views menu. - self.actionTopView.triggered.connect(functools.partial(self.create_view_handler, "topview")) - self.actionSideView.triggered.connect(functools.partial(self.create_view_handler, "sideview")) - self.actionTableView.triggered.connect(functools.partial(self.create_view_handler, "tableview")) - self.actionLinearView.triggered.connect(functools.partial(self.create_view_handler, "linearview")) - - # Help menu. - self.actionOnlineHelp.triggered.connect(self.show_online_help) - self.actionAboutMSUI.triggered.connect(self.show_about_dialog) - self.actionShortcuts.triggered.connect(self.show_shortcuts) - self.actionShortcuts.setShortcutContext(QtCore.Qt.ApplicationShortcut) - self.actionSearch.triggered.connect(lambda: self.show_shortcuts(True)) - self.actionSearch.setShortcutContext(QtCore.Qt.ApplicationShortcut) - - # # Config - self.actionConfiguration.triggered.connect(self.open_config_editor) - - # Raise Main Window to front with Ctrl/Cmnd + up keyboard shortcut - self.addAction(self.actionBringMainWindowToFront) - self.actionBringMainWindowToFront.triggered.connect(self.bring_main_window_to_front) - self.actionBringMainWindowToFront.setShortcutContext(QtCore.Qt.ApplicationShortcut) - - # Flight Tracks. - self.listFlightTracks.itemActivated.connect(self.activate_flight_track) - - # Views. - self.listViews.itemActivated.connect(self.activate_sub_window) - - # Add default and plugins from settings - picker_default = config_loader(dataset="filepicker_default") - self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Import") - self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Export") - self.add_plugin_submenu("CSV", "csv", load_from_csv, picker_default, plugin_type="Import") - self.add_plugin_submenu("CSV", "csv", save_to_csv, picker_default, plugin_type="Export") - self.add_plugins() - - preload_urls = config_loader(dataset="WMS_preload") - self.preload_wms(preload_urls) - - # Status Bar - self.statusBar.showMessage(self.status()) - - # Create MSColab instance to handle all MSColab functionalities - self.mscolab = mscolab.MSUIMscolab(parent=self, data_dir=mscolab_data_dir) - - # Setting up MSColab Tab - self.connectBtn.clicked.connect(self.mscolab.open_connect_window) - - self.shortcuts_dlg = None - - # deactivate vice versa selection of Operation, inactive operation or Flight Track - - def deselecter(list_a, list_b, disable): - list_a.setCurrentItem(None) - list_b.setCurrentItem(None) - if disable: - self.mscolab.ui.actionUnarchiveOperation.setEnabled(False) - - self.listFlightTracks.itemClicked.connect( - lambda: deselecter(self.listOperationsMSC, self.listInactiveOperationsMSC, True)) - self.listOperationsMSC.itemClicked.connect( - lambda: deselecter(self.listFlightTracks, self.listInactiveOperationsMSC, True)) - self.listInactiveOperationsMSC.itemClicked.connect( - lambda: deselecter(self.listFlightTracks, self.listOperationsMSC, False)) - - # disable category until connected/login into mscolab - self.filterCategoryCb.setEnabled(False) - self.mscolab.signal_activate_operation.connect(self.activate_operation_slot) - self.mscolab.signal_operation_added.connect(self.add_operation_slot) - self.mscolab.signal_operation_removed.connect(self.remove_operation_slot) - self.mscolab.signal_login_mscolab.connect(lambda d, t: self.signal_login_mscolab.emit(d, t)) - self.mscolab.signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) - self.mscolab.signal_listFlighttrack_doubleClicked.connect( - lambda: self.signal_listFlighttrack_doubleClicked.emit()) - self.mscolab.signal_permission_revoked.connect(lambda op_id: self.signal_permission_revoked.emit(op_id)) - self.mscolab.signal_render_new_permission.connect( - lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) - - # Don't start the updater during a test run of msui - if "pytest" not in sys.modules: - self.updater = UpdaterUI(self) - self.actionUpdater.triggered.connect(self.updater.show) - self.openOperationsGb.hide() - - @staticmethod - def preload_wms(urls): - """ - This method accesses a list of WMS servers and load their capability documents. - :param urls: List of URLs - """ - pdlg = QtWidgets.QProgressDialog("Preloading WMS servers...", "Cancel", 0, len(urls)) - pdlg.reset() - pdlg.setValue(0) - pdlg.setModal(True) - pdlg.show() - QtWidgets.QApplication.processEvents() - for i, base_url in enumerate(urls): - pdlg.setValue(i) - QtWidgets.QApplication.processEvents() - # initialize login cache from config file, but do not overwrite existing keys - http_auth = config_loader(dataset="MSS_auth") - auth_username, auth_password = get_auth_from_url_and_name(base_url, http_auth, overwrite_login_cache=False) - try: - request = requests.get(base_url, timeout=(2, 10)) - if pdlg.wasCanceled(): - break - - wms = wms_control.MSUIWebMapService(request.url, version=None, - username=auth_username, password=auth_password) - wms_control.WMS_SERVICE_CACHE[wms.url] = wms - logging.info("Stored WMS info for '%s'", wms.url) - except Exception as ex: - logging.error("Error in preloading '%s': '%s'", type(ex), ex) - if pdlg.wasCanceled(): - break - logging.debug("Contents of WMS_SERVICE_CACHE: %s", wms_control.WMS_SERVICE_CACHE.keys()) - pdlg.close() - - def bring_main_window_to_front(self): - self.show() - self.raise_() - self.activateWindow() - - def menu_handler(self): - self.menuImportFlightTrack.setEnabled(True) - if not self.local_active and self.mscolab.access_level == "viewer": - # viewer has no import access to server - self.menuImportFlightTrack.setEnabled(False) - - # enable/disable flight track menus - self.actionSaveActiveFlightTrack.setEnabled(self.local_active) - self.actionSaveActiveFlightTrackAs.setEnabled(self.local_active) - - def add_plugins(self): - picker_default = config_loader(dataset="filepicker_default") - self.import_plugins = {} - self.export_plugins = {} - self.add_import_plugins(picker_default) - self.add_export_plugins(picker_default) - - @QtCore.Slot(int) - def activate_operation_slot(self, active_op_id): - self.signal_activate_operation.emit(active_op_id) - - @QtCore.Slot(int, str) - def add_operation_slot(self, op_id, path): - self.signal_operation_added.emit(op_id, path) - - @QtCore.Slot(int) - def remove_operation_slot(self, op_id): - self.signal_operation_removed.emit(op_id) - - def add_plugin_submenu(self, name, extension, function, pickertype, plugin_type="Import"): - if plugin_type == "Import": - menu = self.menuImportFlightTrack - action_name = "actionImportFlightTrack" + clean_string(name) - handler = self.handle_import_local - elif plugin_type == "Export": - menu = self.menuExportActiveFlightTrack - action_name = "actionExportFlightTrack" + clean_string(name) - handler = self.handle_export_local - - if hasattr(self, action_name): - raise ValueError(f"'{action_name}' has already been set!") - action = QtWidgets.QAction(self) - action.setObjectName(action_name) - action.setText(QtCore.QCoreApplication.translate("MSUIMainWindow", name, None)) - action.triggered.connect(functools.partial(handler, extension, function, pickertype)) - menu.addAction(action) - setattr(self, action_name, action) - - def add_import_plugins(self, picker_default): - plugins = config_loader(dataset="import_plugins") - for name in plugins: - extension, module, function = plugins[name][:3] - picker_type = picker_default - if len(plugins[name]) == 4: - picker_type = plugins[name][3] - try: - imported_module = importlib.import_module(module) - imported_function = getattr(imported_module, function) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on import: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) - continue - try: - self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Import") - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on installing plugin: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error import plugins"), - self.tr(f"ERROR: Configuration\n\n{self.import_plugins}\n\nthrows {type(ex)} error:\n{ex}")) - continue - self.import_plugins[name] = (imported_function, extension) - - def add_export_plugins(self, picker_default): - plugins = config_loader(dataset="export_plugins") - for name in plugins: - extension, module, function = plugins[name][:3] - picker_type = picker_default - if len(plugins[name]) == 4: - picker_type = plugins[name][3] - try: - imported_module = importlib.import_module(module) - imported_function = getattr(imported_module, function) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on import: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error export plugins"), - self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) - continue - try: - self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Export") - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("Error on installing plugin: %s: %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error import plugins"), - self.tr(f"ERROR: Configuration\n\n{self.export_plugins}\n\nthrows {type(ex)} error:\n{ex}")) - continue - self.export_plugins[name] = (imported_function, extension) - - def remove_plugins(self): - for name, _ in self.import_plugins.items(): - full_name = "actionImportFlightTrack" + clean_string(name) - actions = [_x for _x in self.menuImportFlightTrack.actions() - if _x.objectName() == full_name] - assert len(actions) == 1 - self.menuImportFlightTrack.removeAction(actions[0]) - delattr(self, full_name) - self.import_plugins = {} - - for name, _ in self.export_plugins.items(): - full_name = "actionExportFlightTrack" + clean_string(name) - actions = [_x for _x in self.menuExportActiveFlightTrack.actions() - if _x.objectName() == full_name] - assert len(actions) == 1 - self.menuExportActiveFlightTrack.removeAction(actions[0]) - delattr(self, full_name) - self.export_plugins = {} - - def handle_import_local(self, extension, function, pickertype): - filenames = get_open_filenames( - self, "Import Flight Track", - self.last_save_directory, - f"Flight Track (*.{extension});;All files (*.*)", - pickertype=pickertype) - if self.local_active: - if filenames is not None: - activate = True - if len(filenames) > 1: - activate = False - for name in filenames: - self.create_new_flight_track(filename=name, function=function, activate=activate) - self.last_save_directory = fs.path.dirname(name) - else: - for name in filenames: - self.mscolab.handle_import_msc(name, extension, function, pickertype) - - def handle_export_local(self, extension, function, pickertype): - if self.local_active: - default_filename = f'{os.path.join(self.last_save_directory, self.active_flight_track.name)}.{extension}' - filename = get_save_filename( - self, "Export Flight Track", - default_filename, f"Flight Track (*.{extension})", - pickertype=pickertype) - if filename is not None: - self.last_save_directory = fs.path.dirname(filename) - try: - if function is None: - doc = self.active_flight_track.get_xml_doc() - dirname, name = fs.path.split(filename) - file_dir = fs.open_fs(dirname) - with file_dir.open(name, 'w') as file_object: - doc.writexml(file_object, indent=" ", addindent=" ", newl="\n", encoding="utf-8") - file_dir.close() - else: - function(filename, self.active_flight_track.name, self.active_flight_track.waypoints) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("file io plugin error: %s %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error"), - self.tr(f"ERROR: {type(ex)} {ex}")) - else: - self.mscolab.handle_export_msc(extension, function, pickertype) - - def create_new_flight_track(self, template=None, filename=None, function=None, activate=True): - """Creates a new flight track model from a template. Adds a new entry to - the list of flight tracks. Called when the user selects the 'new/open - flight track' menu entries. - - Arguments: - template -- copy the specified template to the new flight track (so that - it is not empty). - filename -- if not None, load the flight track in the specified file. - """ - if template is None: - template = [] - waypoints = config_loader(dataset="new_flighttrack_template") - default_flightlevel = config_loader(dataset="new_flighttrack_flightlevel") - for wp in waypoints: - template.append(ft.Waypoint(flightlevel=default_flightlevel, location=wp)) - if len(template) < 2: - QtWidgets.QMessageBox.critical( - self, self.tr("flighttrack template"), - self.tr("ERROR:Flighttrack template in configuration is too short. " - "Please add at least two valid locations.")) - - waypoints_model = None - if filename is not None: - # function is none if ftml file is selected - if function is None: - try: - waypoints_model = ft.WaypointsTableModel(filename=filename) - except (SyntaxError, OSError, IOError) as ex: - QtWidgets.QMessageBox.critical( - self, self.tr("Problem while opening flight track FTML:"), - self.tr(f"ERROR: {type(ex)} {ex}")) - else: - try: - ft_name, new_waypoints = function(filename) - waypoints_model = ft.WaypointsTableModel(name=ft_name, waypoints=new_waypoints) - # wildcard exception to be resilient against error introduced by user code - except Exception as ex: - logging.error("file io plugin error: %s %s", type(ex), ex) - QtWidgets.QMessageBox.critical( - self, self.tr("file io plugin error"), - self.tr(f"ERROR: {type(ex)} {ex}")) - if waypoints_model is not None: - for i in range(self.listFlightTracks.count()): - fltr = self.listFlightTracks.item(i) - if fltr.flighttrack_model.name == waypoints_model.name: - waypoints_model.name += " - imported from file" - break - else: - # Create a new flight track from the waypoints' template. - self.new_flight_track_counter += 1 - waypoints_model = ft.WaypointsTableModel( - name=f"new flight track ({self.new_flight_track_counter:d})") - # Make a copy of the template. Otherwise, all new flight tracks would - # use the same data structure in memory. - template_copy = copy.deepcopy(template) - waypoints_model.insertRows(0, rows=len(template_copy), waypoints=template_copy) - - if waypoints_model is not None: - # Create a new list entry for the flight track. Make the item name editable. - listitem = QFlightTrackListWidgetItem(waypoints_model, self.listFlightTracks) - listitem.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) - - # Activate new item - if activate: - self.activate_flight_track(listitem) - - def activate_flight_track(self, item): - """Set the currently selected flight track to be the active one, i.e. - the one that is displayed in the views (only one flight track can be - displayed at a time). - """ - self.mscolab.switch_to_local() - # self.setWindowModality(QtCore.Qt.NonModal) - self.active_flight_track = item.flighttrack_model - self.update_active_flight_track() - font = QtGui.QFont() - for i in range(self.listFlightTracks.count()): - self.listFlightTracks.item(i).setFont(font) - font.setBold(True) - item.setFont(font) - self.menu_handler() - self.signal_activate_flighttrack.emit(self.active_flight_track) - - def update_active_flight_track(self, old_flight_track_name=None): - logging.debug("update_active_flight_track") - for i in range(self.listViews.count()): - view_item = self.listViews.item(i) - view_item.window.setFlightTrackModel(self.active_flight_track) - # local we have always all options enabled - view_item.window.enable_navbar_action_buttons() - if old_flight_track_name is not None: - view_item.window.setWindowTitle(view_item.window.windowTitle().replace(old_flight_track_name, - self.active_flight_track.name)) - - def activate_selected_flight_track(self): - item = self.listFlightTracks.currentItem() - self.activate_flight_track(item) - - def switch_to_mscolab(self): - self.local_active = False - font = QtGui.QFont() - for i in range(self.listFlightTracks.count()): - self.listFlightTracks.item(i).setFont(font) - - # disable appropriate menu options - self.menu_handler() - - def save_handler(self): - """Slot for the 'Save Active Flight Track' menu entry. - """ - filename = self.active_flight_track.get_filename() - if filename: - self.save_flight_track(filename) - else: - self.save_as() - - def save_as_handler(self): - self.save_as() - - def save_as(self): - """ - Slot for the 'Save Active Flight Track As' menu entry. - """ - default_filename = os.path.join(self.last_save_directory, self.active_flight_track.name + ".ftml") - file_type = ["Flight track (*.ftml)"] - filepicker_default = config_loader(dataset="filepicker_default") - filename = get_save_filename( - self, "Save Flight Track", default_filename, ";;".join(file_type), pickertype=filepicker_default - ) - logging.debug("filename : '%s'", filename) - if filename: - ext = "ftml" - self.save_flight_track(filename) - self.last_save_directory = fs.path.dirname(filename) - self.active_flight_track.filename = filename - self.active_flight_track.name = fs.path.basename(filename.replace(f"{ext}", "").strip()) - - def save_flight_track(self, file_name): - ext = ".ftml" - if file_name: - if file_name.endswith(ext): - try: - self.active_flight_track.save_to_ftml(file_name) - except (OSError, IOError) as ex: - QtWidgets.QMessageBox.critical( - self, self.tr("Problem while saving flight track to FTML:"), - self.tr(f"ERROR: {type(ex)} {ex}")) - - for idx in range(self.listFlightTracks.count()): - if self.listFlightTracks.item(idx).flighttrack_model == self.active_flight_track: - old_filght_track_name = self.listFlightTracks.item(idx).text() - self.listFlightTracks.item(idx).setText(self.active_flight_track.name) - - self.update_active_flight_track(old_filght_track_name) - - def close_selected_flight_track(self): - """Slot to close the currently selected flight track. Flight tracks can - only be closed if at least one other flight track remains open. The - currently active flight track cannot be closed. - """ - if self.listFlightTracks.count() < 2: - QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), - self.tr("At least one flight track has to be open.")) - return - item = self.listFlightTracks.currentItem() - if item.flighttrack_model == self.active_flight_track and self.local_active: - QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), - self.tr("Cannot close currently active flight track.")) - return - if item.flighttrack_model.modified: - ret = QtWidgets.QMessageBox.warning(self, self.tr("Mission Support System"), - self.tr("The flight track you are about to close has " - "been modified. Close anyway?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) - if ret == QtWidgets.QMessageBox.Yes: - self.listFlightTracks.takeItem(self.listFlightTracks.currentRow()) - - def create_view_handler(self, _type): - if self.local_active: - self.create_view(_type, self.active_flight_track) - else: - try: - self.mscolab.waypoints_model.name = self.mscolab.active_operation_name - self.create_view(_type, self.mscolab.waypoints_model) - except AttributeError: - # can happen, when the servers secret was changed - show_popup(self.mscolab.ui, "Error", "Session expired, new login required") - - def create_view(self, _type, model): - """Method called when the user selects a new view to be opened. Creates - a new instance of the view and adds a QActiveViewsListWidgetItem to - the list of open views (self.listViews). - """ - layout = config_loader(dataset="layout") - view_window = None - if _type == "topview": - # Top view. - view_window = topview.MSUITopViewWindow(parent=self, model=model, - active_flighttrack=self.active_flight_track, - mscolab_server_url=self.mscolab.mscolab_server_url, - token=self.mscolab.token) - view_window.mpl.resize(layout['topview'][0], layout['topview'][1]) - if layout["immutable"]: - view_window.mpl.setFixedSize(layout['topview'][0], layout['topview'][1]) - elif _type == "sideview": - # Side view. - view_window = sideview.MSUISideViewWindow(model=model) - view_window.mpl.resize(layout['sideview'][0], layout['sideview'][1]) - if layout["immutable"]: - view_window.mpl.setFixedSize(layout['sideview'][0], layout['sideview'][1]) - elif _type == "tableview": - # Table view. - view_window = tableview.MSUITableViewWindow(model=model) - view_window.centralwidget.resize(layout['tableview'][0], layout['tableview'][1]) - elif _type == "linearview": - # Linear view. - view_window = linearview.MSUILinearViewWindow(model=model) - view_window.mpl.resize(layout['linearview'][0], layout['linearview'][1]) - if layout["immutable"]: - view_window.mpl.setFixedSize(layout['linearview'][0], layout['linearview'][1]) - - if view_window is not None: - # Set view type to window - view_window.view_type = view_window.name - # Make sure view window will be deleted after being closed, not - # just hidden (cf. Chapter 5 in PyQt4). - view_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) - # Open as a non-modal window. - view_window.show() - # Add an entry referencing the new view to the list of views. - # listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged, mscolab) - listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged) - view_window.viewCloses.connect(listitem.view_destroyed) - self.listViews.setCurrentItem(listitem) - # self.active_view_windows.append(view_window) - # disable navbar actions in the view for viewer - try: - if self.mscolab.access_level == "viewer": - view_window.disable_navbar_action_buttons() - except AttributeError: - view_window.enable_navbar_action_buttons() - self.viewsChanged.emit() - - def get_active_views(self): - active_view_windows = [] - for i in range(self.listViews.count()): - active_view_windows.append(self.listViews.item(i).window) - return active_view_windows - - def activate_sub_window(self, item): - """When the user clicks on one of the open view or tool windows, this - window is brought to the front. This function implements the slot to - activate a window if the user selects it in the list of views or - tools. - """ - # Restore the activated view and bring it to the front. - item.window.showNormal() - item.window.raise_() - item.window.activateWindow() - - def restart_application(self): - while self.listViews.count() > 0: - self.listViews.item(0).window.handle_force_close() - self.listViews.clear() - self.remove_plugins() - if self.mscolab.token is not None: - self.mscolab.logout() - read_config_file() - self.add_plugins() - - def open_config_editor(self): - """ - Opens up a JSON config editor - """ - if self.config_editor is None: - self.config_editor = editor.ConfigurationEditorWindow(parent=self) - self.config_editor.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.config_editor.destroyed.connect(self.close_config_editor) - self.config_editor.restartApplication.connect(self.restart_application) - self.config_editor.show() - else: - self.config_editor.showNormal() - self.config_editor.activateWindow() - - def close_config_editor(self): - self.config_editor = None - - def show_online_help(self): - """Open Documentation in a browser""" - QtGui.QDesktopServices.openUrl( - QtCore.QUrl("http://mss.readthedocs.io/en/stable")) - - def show_about_dialog(self): - """Show the 'About MSUI' dialog to the user. - """ - dlg = MSUI_AboutDialog(parent=self) - dlg.setModal(True) - dlg.exec_() - - def show_shortcuts(self, search_mode=False): - """Show the shortcuts dialog to the user. - """ - if QtWidgets.QApplication.activeWindow() == self.shortcuts_dlg: - return - - self.shortcuts_dlg = MSUI_ShortcutsDialog() if not self.shortcuts_dlg else self.shortcuts_dlg - - # In case the dialog gets deleted by QT, recreate it - try: - self.shortcuts_dlg.setModal(True) - except RuntimeError: - self.shortcuts_dlg = MSUI_ShortcutsDialog() - - self.shortcuts_dlg.setParent(QtWidgets.QApplication.activeWindow(), QtCore.Qt.Dialog) - self.shortcuts_dlg.reset_highlight() - self.shortcuts_dlg.fill_list() - self.shortcuts_dlg.show() - - self.shortcuts_dlg.cbAdvanced.setHidden(True) - self.shortcuts_dlg.cbHighlight.setHidden(True) - self.shortcuts_dlg.cbAdvanced.setCheckState(0) - self.shortcuts_dlg.cbHighlight.setCheckState(0) - self.shortcuts_dlg.leShortcutFilter.setText("") - self.shortcuts_dlg.setWindowTitle("Shortcuts") - - if search_mode: - self.shortcuts_dlg.setWindowTitle("Search") - self.shortcuts_dlg.cbAdvanced.setHidden(False) - self.shortcuts_dlg.cbHighlight.setHidden(False) - self.shortcuts_dlg.cbDisplayType.setCurrentIndex(1) - self.shortcuts_dlg.leShortcutFilter.setText("") - self.shortcuts_dlg.cbAdvanced.setCheckState(2) - self.shortcuts_dlg.cbNoShortcut.setCheckState(2) - self.shortcuts_dlg.leShortcutFilter.setFocus() - - def status(self): - if config_loader() != config_loader(default=True): - return ("Status : System Configuration") - else: - return (f"Status : User Configuration '{constants.MSUI_SETTINGS}' loaded") - - def closeEvent(self, event): - """Ask user if he/she wants to close the application. If yes, also - close all views that are open. - - Overloads QtGui.QMainWindow.closeEvent(). This method is called if - Qt receives a window close request for our application window. - """ - ret = QtWidgets.QMessageBox.warning( - self, self.tr("Mission Support System"), - self.tr("Do you want to close the Mission Support System application?"), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) - - if ret == QtWidgets.QMessageBox.Yes: - if self.mscolab.help_dialog is not None: - self.mscolab.help_dialog.close() - # cleanup mscolab widgets - if self.mscolab.token is not None: - self.mscolab.logout() - # Table View stick around after MainWindow closes - maybe some dangling reference? - # This removes them for sure! - while self.listViews.count() > 0: - self.listViews.item(0).window.handle_force_close() - self.listViews.clear() - self.listFlightTracks.clear() - # close configuration editor - if self.config_editor is not None: - self.config_editor.restart_on_save = False - self.config_editor.close() - QtTest.QTest.qWait(5) - if self.config_editor is not None: - self.statusBar.showMessage("Save your config changes and try closing again") - event.ignore() - return - event.accept() - else: - event.ignore() - - -def main(): +def main(tutorial_mode=False): try: prefix = os.environ["CONDA_DEFAULT_ENV"] except KeyError: @@ -1185,19 +158,17 @@ def notify(QObject, QEvent): application.setWindowIcon(QtGui.QIcon(icons('128x128'))) application.setApplicationDisplayName("MSUI") application.setAttribute(QtCore.Qt.AA_DisableWindowContextHelpButton) - mainwindow = MSUIMainWindow() - if version.parse(__version__) >= version.parse('8.0.0') and version.parse(__version__) < version.parse('9.0.0'): - from mslib.utils.migration.config_before_eight import read_config_file as read_config_file_before_eight - from mslib.utils.migration.config_before_eight import config_loader as config_loader_before_eight - read_config_file_before_eight() - if config_loader_before_eight(dataset="WMS_login") or config_loader_before_eight( - dataset="MSC_login") or config_loader_before_eight(dataset="MSCOLAB_password"): + mainwindow = MSUIMainWindow(tutorial_mode=tutorial_mode) + if version.parse(__version__) >= version.parse('9.0.0') and version.parse(__version__) < version.parse('10.0.0'): + from mslib.utils.migration.config_before_nine import read_config_file as read_config_file_before_nine + from mslib.utils.migration.config_before_nine import config_loader as config_loader_before_nine + read_config_file_before_nine() + if config_loader_before_nine(dataset="MSCOLAB_mailid"): text = """We can update your msui_settings.json file \n -We add the new attributes for the webserver authentication, see \n +We add the new attributes for the MSColab login, see \n https://mss.readthedocs.io/en/stable/usage.html#mscolab-login-and-www-authentication \n -The old attributes get removed: \n -WMS_login, MSC_login, MSCOLAB_password \n\n + A backup of the old file is stored. """ @@ -1206,8 +177,8 @@ def notify(QObject, QEvent): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: - from mslib.utils.migration.update_json_file_to_version_eight import JsonConversion - if version.parse(__version__) >= version.parse('8.0.0'): + from mslib.utils.migration.update_json_file_to_version_nine import JsonConversion + if version.parse(__version__) >= version.parse('9.0.0'): new_version = JsonConversion() new_version.change_parameters() read_config_file() diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py new file mode 100644 index 000000000..aed56354e --- /dev/null +++ b/mslib/msui/msui_mainwindow.py @@ -0,0 +1,1101 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + mslib.msui.msui_mainwindow + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Mission Support System Python/Qt User Interface + Main window of the user interface application. Manages view and tool windows + (the user can open multiple windows) and provides functionality to open, save, + and switch between flight tracks. + + This file is part of MSS. + + :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. + :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import copy +import functools +import importlib +import logging +import os +import re +import sys +import fs + +from slugify import slugify +from mslib import __version__ +from mslib.msui.qt5 import ui_mainwindow as ui +from mslib.msui.qt5 import ui_about_dialog as ui_ab +from mslib.msui.qt5 import ui_shortcuts as ui_sh +from mslib.msui import flighttrack as ft +from mslib.msui import tableview, topview, sideview, linearview +from mslib.msui import constants, editor, mscolab +from mslib.msui.updater import UpdaterUI +from mslib.plugins.io.csv import load_from_csv, save_to_csv +from mslib.msui.icons import icons, python_powered +from mslib.utils.qt import get_open_filenames, get_save_filename, show_popup +from mslib.utils.config import read_config_file, config_loader +from PyQt5 import QtGui, QtCore, QtWidgets +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas + +# Add config path to PYTHONPATH so plugins located there may be found +sys.path.append(constants.MSUI_CONFIG_PATH) + + +def clean_string(string): + return re.sub(r'\W|^(?=\d)', '_', string) + + +class QActiveViewsListWidgetItem(QtWidgets.QListWidgetItem): + """Subclass of QListWidgetItem, represents an open view in the list of + open views. Keeps a reference to the view instance (i.e. the window) it + represents in the list of open views. + """ + + # Class variable to assign a unique ID to each view. + opened_views = 0 + open_views = [] + + def __init__(self, view_window, parent=None, viewsChanged=None, mscolab=False, + _type=QtWidgets.QListWidgetItem.UserType): + """Add ID number to the title of the corresponding view window. + """ + QActiveViewsListWidgetItem.opened_views += 1 + view_name = f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.name}" + super().__init__(view_name, parent, _type) + + view_window.setWindowTitle(f"({QActiveViewsListWidgetItem.opened_views:d}) {view_window.windowTitle()} - " + f"{view_window.waypoints_model.name}") + view_window.setIdentifier(view_name) + self.window = view_window + self.parent = parent + self.viewsChanged = viewsChanged + QActiveViewsListWidgetItem.open_views.append(view_window) + + def view_destroyed(self): + """Slot that removes this QListWidgetItem from the parent (the + QListWidget) if the corresponding view has been deleted. + """ + if self.parent is not None: + self.parent.takeItem(self.parent.row(self)) + for index, window in enumerate(QActiveViewsListWidgetItem.open_views): + if window.identifier == self.window.identifier: + del QActiveViewsListWidgetItem.open_views[index] + break + if self.viewsChanged is not None: + self.viewsChanged.emit() + + +class QFlightTrackListWidgetItem(QtWidgets.QListWidgetItem): + """Subclass of QListWidgetItem, represents a flight track in the list of + open flight tracks. Keeps a reference to the flight track instance + (i.e. the instance of WaypointsTableModel). + """ + + def __init__(self, flighttrack_model, parent=None, + user_type=QtWidgets.QListWidgetItem.UserType): + """Item class for the list widget that accommodates the open flight + tracks. + + Arguments: + flighttrack_model -- instance of a flight track model that is + associated with the item + parent -- pointer to the QListWidgetItem that accommodates this item. + If not None, the itemChanged() signal of the parent is + connected to the nameChanged() slot of this class, reacting + to name changes of the item. + """ + view_name = flighttrack_model.name + super().__init__( + view_name, parent, user_type) + + self.parent = parent + self.flighttrack_model = flighttrack_model + + +class MSUI_ShortcutsDialog(QtWidgets.QDialog, ui_sh.Ui_ShortcutsDialog): + """ + Dialog showing shortcuts for all currently open windows + """ + + def __init__(self, tutorial_mode=False): + super().__init__(QtWidgets.QApplication.activeWindow()) + self.tutorial_mode = tutorial_mode + self.setupUi(self) + self.current_shortcuts = None + self.treeWidget.itemDoubleClicked.connect(self.double_clicked) + self.treeWidget.itemClicked.connect(self.clicked) + self.leShortcutFilter.textChanged.connect(self.filter_shortcuts) + self.filterRemoveAction = self.leShortcutFilter.addAction(QtGui.QIcon(icons("64x64", "remove.png")), + QtWidgets.QLineEdit.TrailingPosition) + self.filterRemoveAction.setVisible(False) + self.filterRemoveAction.setToolTip("Click to remove the filter") + self.filterRemoveAction.triggered.connect(lambda: self.leShortcutFilter.setText("")) + self.cbNoShortcut.stateChanged.connect(self.fill_list) + self.cbAdvanced.stateChanged.connect(lambda i: (self.cbNoShortcut.setVisible(i), + self.leShortcutFilter.setVisible(i), + self.cbDisplayType.setVisible(i), + self.label.setVisible(i), + self.label_2.setVisible(i), + self.line.setVisible(i))) + self.cbHighlight.stateChanged.connect(self.filter_shortcuts) + self.cbDisplayType.currentTextChanged.connect(self.fill_list) + self.cbAdvanced.stateChanged.emit(self.cbAdvanced.checkState()) + self.oldReject = self.reject + self.reject = self.custom_reject + + def custom_reject(self): + """ + Reset highlighted objects when closing the shortcuts dialog + """ + self.reset_highlight() + self.oldReject() + + def reset_highlight(self): + """ + Iterates through all shortcuts and resets the stylesheet + """ + if self.current_shortcuts: + for shortcuts in self.current_shortcuts.values(): + for shortcut in shortcuts.values(): + try: + if shortcut[-1] and hasattr(shortcut[-1], "setStyleSheet"): + shortcut[-1].setStyleSheet("") + except RuntimeError: + # when we have deleted a QAction we have to update the list + # Because we cannot test if the underlying object exist we have to catch that + self.fill_list() + + def clicked(self, item): + """ + Highlights the selected item in the GUI as yellow + """ + self.reset_highlight() + if hasattr(item, "source_object") and item.source_object and hasattr(item.source_object, "setStyleSheet"): + item.source_object.setStyleSheet("background-color:yellow;") + + def double_clicked(self, item): + """ + Executes the shortcut for the doubleclicked item + """ + if hasattr(item, "source_object") and item.source_object: + self.reset_highlight() + self.hide() + obj = item.source_object + if isinstance(obj, QtWidgets.QShortcut): + obj.activated.emit() + elif isinstance(obj, QtWidgets.QAction): + obj.trigger() + elif isinstance(obj, QtWidgets.QAbstractButton): + obj.click() + elif isinstance(obj, QtWidgets.QComboBox): + QtCore.QTimer.singleShot(200, obj.showPopup) + elif isinstance(obj, QtWidgets.QLineEdit) or isinstance(obj, QtWidgets.QAbstractSpinBox): + obj.setFocus() + + def fill_list(self): + """ + Fills the treeWidget with all relevant windows as top level items and their shortcuts as children + """ + self.treeWidget.clear() + self.current_shortcuts = self.get_shortcuts() + for widget in self.current_shortcuts: + if hasattr(widget, "window"): + name = widget.window().windowTitle() + else: + name = widget.objectName() + if len(name) == 0 or (hasattr(widget, "window") and widget.window().isHidden()): + continue + header = QtWidgets.QTreeWidgetItem(self.treeWidget) + header.setText(0, name) + if hasattr(widget, "window") and widget.window() == self.parent(): + header.setExpanded(True) + header.setSelected(True) + self.treeWidget.setCurrentItem(header) + for objectName in self.current_shortcuts[widget].keys(): + description, text, _, shortcut, obj = self.current_shortcuts[widget][objectName] + if obj is None: + continue + item = QtWidgets.QTreeWidgetItem(header) + item.source_object = obj + itemText = description if self.cbDisplayType.currentText() == 'Tooltip' \ + else text if self.cbDisplayType.currentText() == 'Text' else obj.objectName() + item.setText(0, f"{itemText}: {shortcut}") + item.setToolTip(0, f"ToolTip: {description}\nText: {text}\nObjectName: {objectName}") + header.addChild(item) + self.filter_shortcuts(self.leShortcutFilter.text()) + + def get_shortcuts(self): + """ + Iterates through all top level widgets and puts their shortcuts in a dictionary + """ + shortcuts = {} + for qobject in QtWidgets.QApplication.allWidgets(): + actions = [] + # QAction + actions.extend([ + (action.parent().window() if hasattr(action.parent(), "window") else action.parent(), + action.toolTip(), action.text().replace("&&", "%%").replace("&", "").replace("%%", "&"), + action.objectName(), + ",".join([shortcut.toString() for shortcut in action.shortcuts()]), action) + for action in qobject.findChildren( + QtWidgets.QAction) if len(action.shortcuts()) > 0 or self.cbNoShortcut.checkState()]) + + # QShortcut + actions.extend([(shortcut.parentWidget().window(), shortcut.whatsThis(), "", + shortcut.objectName(), shortcut.key().toString(), shortcut) + for shortcut in qobject.findChildren(QtWidgets.QShortcut)]) + + # QAbstractButton + actions.extend([(button.window(), button.toolTip(), button.text().replace("&&", "%%").replace("&", "") + .replace("%%", "&"), button.objectName(), + button.shortcut().toString() if button.shortcut() else "", button) + for button in qobject.findChildren(QtWidgets.QAbstractButton) if button.shortcut() or + self.cbNoShortcut.checkState()]) + + # Additional objects which have no shortcuts, if requested + # QComboBox + actions.extend([(obj.window(), obj.toolTip(), obj.currentText(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QComboBox) if self.cbNoShortcut.checkState()]) + + # QAbstractSpinBox, QLineEdit, QDoubleSpinBox + actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QAbstractSpinBox) + + qobject.findChildren(QtWidgets.QLineEdit) + qobject.findChildren(QtWidgets.QDoubleSpinBox) + if self.cbNoShortcut.checkState()]) + # QPlainTextEdit, QTextEdit + actions.extend([(obj.window(), obj.toolTip(), obj.toPlainText(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QPlainTextEdit) + + qobject.findChildren(QtWidgets.QTextEdit) + if self.cbNoShortcut.checkState()]) + + # QLabel + actions.extend([(obj.window(), obj.toolTip(), obj.text(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QLabel) + if self.cbNoShortcut.checkState()]) + + # FigureCanvas + actions.extend([(obj.window(), "", obj.figure.axes[0].get_title(), obj.objectName(), "", obj) + for obj in qobject.findChildren(FigureCanvas) + if self.cbNoShortcut.checkState()]) + + # QMenu + actions.extend([(obj.window(), obj.toolTip(), obj.title(), obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QMenu) + if self.cbNoShortcut.checkState()]) + + # QMenuBar + actions.extend([(obj.window(), "menubar", "menubar", obj.objectName(), "", obj) + for obj in qobject.findChildren(QtWidgets.QMenuBar) + if self.cbNoShortcut.checkState()]) + + if not any(action for action in actions if action[3] == "actionShortcuts"): + actions.append((qobject.window(), "Show Current Shortcuts", "Show Current Shortcuts", + "Show Current Shortcuts", "Alt+S", None)) + if not any(action for action in actions if action[3] == "actionSearch"): + actions.append((qobject.window(), "Search for interactive text in the UI", + "Search for interactive text in the UI", "Search for interactive text in the UI", + "Ctrl+F", None)) + + if "://" in constants.MSUI_CONFIG_PATH: + # Todo remove all os.path dependencies, when needed use getsyspath + pix_dir = fs.path.combine(constants.MSUI_CONFIG_PATH, 'tutorial_images') + try: + _fs = fs.open_fs(pix_dir) + except fs.errors.CreateFailed: + dir_path, name = fs.path.split(pix_dir) + _fs = fs.open_fs(dir_path) + _fs.makedir(name) + else: + pix_dir = os.path.join(constants.MSUI_CONFIG_PATH, 'tutorial_images') + if not os.path.exists(pix_dir): + os.makedirs(pix_dir) + for item in actions: + if len(item[2]) > 0: + # These are twice defined, but only one can be used for highlighting + if (item[2] in ['Pan', 'Home', 'Forward', + 'Back', 'Zoom', 'Save', 'Mv WP', + 'Ins WP', 'Del WP'] and isinstance(item[5], QtWidgets.QAction) or + len(item[0].objectName()) == 0): + continue + + if item[0] not in shortcuts: + shortcuts[item[0]] = {} + shortcuts[item[0]][item[5]] = item[1:] + if self.tutorial_mode: + try: + prefix = item[0].objectName() + attr = item[2] + if item[5] is None: + continue + pixmap = item[5].grab() + pix_name = slugify(f"{prefix}-{attr}") + if pix_name.startswith("Search") is False: + pix_file = f"{pix_name}.png" + _fs = fs.open_fs(pix_dir) + pix_file = os.path.join(_fs.getsyspath("."), pix_file) + pixmap.save(pix_file, 'png') + except AttributeError: + pass + return shortcuts + + def filter_shortcuts(self, text="Nothing", rerun=True): + """ + Hides all shortcuts not containing the text + """ + text = self.leShortcutFilter.text() + self.reset_highlight() + + window_count = 0 + for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): + if not window.isHidden(): + window_count += 1 + + for window in self.treeWidget.findItems("", QtCore.Qt.MatchContains): + wms_hits = 0 + + for child_index in range(window.childCount()): + widget = window.child(child_index) + if text.lower() in widget.text(0).lower() or text.lower() in window.text(0).lower(): + widget.setHidden(False) + wms_hits += 1 + else: + widget.setHidden(True) + + if wms_hits == 1 and (self.cbHighlight.isChecked() or window_count == 1): + for child_index in range(window.childCount()): + widget = window.child(child_index) + if (not widget.isHidden()) and hasattr(widget.source_object, "setStyleSheet"): + widget.source_object.setStyleSheet("background-color: yellow;") + break + + if wms_hits == 0 and len(text) > 0: + window.setHidden(True) + else: + window.setHidden(False) + + self.filterRemoveAction.setVisible(len(text) > 0) + if rerun: + self.filter_shortcuts(text, False) + + +class MSUI_AboutDialog(QtWidgets.QDialog, ui_ab.Ui_AboutMSUIDialog): + """Dialog showing information about MSUI. Most of the displayed text is + defined in the QtDesigner file. + """ + + def __init__(self, parent=None): + """ + Arguments: + parent -- Qt widget that is parent to this widget. + """ + super().__init__(parent) + self.setupUi(self) + self.lblVersion.setText(f"Version: {__version__}") + self.milestone_url = f'https://github.com/Open-MSS/MSS/issues?q=is%3Aclosed+milestone%3A{__version__[:-1]}' + self.lblChanges.setText(f'New Features and Changes') + blub = QtGui.QPixmap(python_powered()) + self.lblPython.setPixmap(blub) + + +class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): + """MSUI new main window class. Provides user interface elements for managing + flight tracks, views and MSColab functionalities. + """ + + viewsChanged = QtCore.pyqtSignal(name="viewsChanged") + signal_activate_flighttrack = QtCore.pyqtSignal(ft.WaypointsTableModel, name="signal_activate_flighttrack") + signal_activate_operation = QtCore.pyqtSignal(int, name="signal_activate_operation") + signal_operation_added = QtCore.pyqtSignal(int, str, name="signal_operation_added") + signal_operation_removed = QtCore.pyqtSignal(int, name="signal_operation_removed") + signal_login_mscolab = QtCore.pyqtSignal(str, str, name="signal_login_mscolab") + signal_logout_mscolab = QtCore.pyqtSignal(name="signal_logout_mscolab") + signal_listFlighttrack_doubleClicked = QtCore.pyqtSignal() + signal_permission_revoked = QtCore.pyqtSignal(int) + signal_render_new_permission = QtCore.pyqtSignal(int, str) + + def __init__(self, mscolab_data_dir=None, tutorial_mode=False, *args): + super().__init__(*args) + self.tutorial_mode = tutorial_mode + self.setupUi(self) + self.setWindowIcon(QtGui.QIcon(icons('32x32'))) + # This code is required in Windows 7 to use the icon set by setWindowIcon in taskbar + # instead of the default Icon of python/pythonw + try: + import ctypes + myappid = f"msui.msui.{__version__}" # arbitrary string + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except (ImportError, AttributeError) as error: + logging.debug("AttributeError, ImportError Exception %s", error) + + self.config_editor = None + self.local_active = True + self.new_flight_track_counter = 0 + + # Reference to the flight track that is currently displayed in the views. + self.active_flight_track = None + self.last_save_directory = config_loader(dataset="data_dir") + + # bind meta (ctrl in macOS) to override automatic translation of ctrl to command by qt + if sys.platform == 'darwin': + self.actionTopView.setShortcut(QtGui.QKeySequence("Meta+h")) + self.actionSideView.setShortcut(QtGui.QKeySequence("Meta+v")) + self.actionTableView.setShortcut(QtGui.QKeySequence("Meta+t")) + self.actionLinearView.setShortcut(QtGui.QKeySequence("Meta+l")) + self.actionConfiguration.setShortcut(QtGui.QKeySequence("Ctrl+,")) + + # File menu. + self.actionNewFlightTrack.triggered.connect(functools.partial(self.create_new_flight_track, None, None)) + self.actionSaveActiveFlightTrack.triggered.connect(self.save_handler) + self.actionSaveActiveFlightTrackAs.triggered.connect(self.save_as_handler) + self.actionCloseSelectedFlightTrack.triggered.connect(self.close_selected_flight_track) + + # Views menu. + self.actionTopView.triggered.connect(functools.partial(self.create_view_handler, "topview")) + self.actionSideView.triggered.connect(functools.partial(self.create_view_handler, "sideview")) + self.actionTableView.triggered.connect(functools.partial(self.create_view_handler, "tableview")) + self.actionLinearView.triggered.connect(functools.partial(self.create_view_handler, "linearview")) + + # Help menu. + self.actionOnlineHelp.triggered.connect(self.show_online_help) + self.actionAboutMSUI.triggered.connect(self.show_about_dialog) + self.actionShortcuts.triggered.connect(self.show_shortcuts) + self.actionShortcuts.setShortcutContext(QtCore.Qt.ApplicationShortcut) + self.actionSearch.triggered.connect(lambda: self.show_shortcuts(True)) + self.actionSearch.setShortcutContext(QtCore.Qt.ApplicationShortcut) + + # # Config + self.actionConfiguration.triggered.connect(self.open_config_editor) + + # Raise Main Window to front with Ctrl/Cmnd + up keyboard shortcut + self.addAction(self.actionBringMainWindowToFront) + self.actionBringMainWindowToFront.triggered.connect(self.bring_main_window_to_front) + self.actionBringMainWindowToFront.setShortcutContext(QtCore.Qt.ApplicationShortcut) + + # Flight Tracks. + self.listFlightTracks.itemActivated.connect(self.activate_flight_track) + + # Views. + self.listViews.itemActivated.connect(self.activate_sub_window) + + # Add default and plugins from settings + picker_default = config_loader(dataset="filepicker_default") + self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Import") + self.add_plugin_submenu("FTML", "ftml", None, picker_default, plugin_type="Export") + self.add_plugin_submenu("CSV", "csv", load_from_csv, picker_default, plugin_type="Import") + self.add_plugin_submenu("CSV", "csv", save_to_csv, picker_default, plugin_type="Export") + self.add_plugins() + + # Status Bar + self.statusBar.showMessage(self.status()) + + # Create MSColab instance to handle all MSColab functionalities + self.mscolab = mscolab.MSUIMscolab(parent=self, data_dir=mscolab_data_dir) + + # Setting up MSColab Tab + self.connectBtn.clicked.connect(self.mscolab.open_connect_window) + + self.shortcuts_dlg = None + + # deactivate vice versa selection of Operation, inactive operation or Flight Track + + self.listFlightTracks.itemClicked.connect( + lambda: self.listOperationsMSC.setCurrentItem(None)) + self.listOperationsMSC.itemClicked.connect( + lambda: self.listFlightTracks.setCurrentItem(None)) + # disable category until connected/login into mscolab + self.filterCategoryCb.setEnabled(False) + self.mscolab.signal_unarchive_operation.connect(self.activate_operation_slot) + self.mscolab.signal_operation_added.connect(self.add_operation_slot) + self.mscolab.signal_operation_removed.connect(self.remove_operation_slot) + self.mscolab.signal_login_mscolab.connect(lambda d, t: self.signal_login_mscolab.emit(d, t)) + self.mscolab.signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) + self.mscolab.signal_listFlighttrack_doubleClicked.connect( + lambda: self.signal_listFlighttrack_doubleClicked.emit()) + self.mscolab.signal_permission_revoked.connect(lambda op_id: self.signal_permission_revoked.emit(op_id)) + self.mscolab.signal_render_new_permission.connect( + lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) + + # Don't start the updater during a test run of msui + if "pytest" not in sys.modules: + self.updater = UpdaterUI(self) + self.actionUpdater.triggered.connect(self.updater.show) + self.openOperationsGb.hide() + + def bring_main_window_to_front(self): + self.show() + self.raise_() + self.activateWindow() + + def menu_handler(self): + self.menuImportFlightTrack.setEnabled(True) + if not self.local_active and self.mscolab.access_level == "viewer": + # viewer has no import access to server + self.menuImportFlightTrack.setEnabled(False) + + # enable/disable flight track menus + self.actionSaveActiveFlightTrack.setEnabled(self.local_active) + self.actionSaveActiveFlightTrackAs.setEnabled(self.local_active) + + def add_plugins(self): + picker_default = config_loader(dataset="filepicker_default") + self.import_plugins = {} + self.export_plugins = {} + self.add_import_plugins(picker_default) + self.add_export_plugins(picker_default) + + @QtCore.pyqtSlot(int) + def activate_operation_slot(self, active_op_id): + self.signal_activate_operation.emit(active_op_id) + + @QtCore.pyqtSlot(int, str) + def add_operation_slot(self, op_id, path): + self.signal_operation_added.emit(op_id, path) + + @QtCore.pyqtSlot(int) + def remove_operation_slot(self, op_id): + self.signal_operation_removed.emit(op_id) + + def add_plugin_submenu(self, name, extension, function, pickertype, plugin_type="Import"): + if plugin_type == "Import": + menu = self.menuImportFlightTrack + action_name = "actionImportFlightTrack" + clean_string(name) + handler = self.handle_import_local + elif plugin_type == "Export": + menu = self.menuExportActiveFlightTrack + action_name = "actionExportFlightTrack" + clean_string(name) + handler = self.handle_export_local + + if hasattr(self, action_name): + raise ValueError(f"'{action_name}' has already been set!") + action = QtWidgets.QAction(self) + action.setObjectName(action_name) + action.setText(QtCore.QCoreApplication.translate("MSUIMainWindow", name, None)) + action.triggered.connect(functools.partial(handler, extension, function, pickertype)) + menu.addAction(action) + setattr(self, action_name, action) + + def add_import_plugins(self, picker_default): + plugins = config_loader(dataset="import_plugins") + for name in plugins: + extension, module, function = plugins[name][:3] + picker_type = picker_default + if len(plugins[name]) == 4: + picker_type = plugins[name][3] + try: + imported_module = importlib.import_module(module) + imported_function = getattr(imported_module, function) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on import: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) + continue + try: + self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Import") + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on installing plugin: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error import plugins"), + self.tr(f"ERROR: Configuration\n\n{self.import_plugins}\n\nthrows {type(ex)} error:\n{ex}")) + continue + self.import_plugins[name] = (imported_function, extension) + + def add_export_plugins(self, picker_default): + plugins = config_loader(dataset="export_plugins") + for name in plugins: + extension, module, function = plugins[name][:3] + picker_type = picker_default + if len(plugins[name]) == 4: + picker_type = plugins[name][3] + try: + imported_module = importlib.import_module(module) + imported_function = getattr(imported_module, function) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on import: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error export plugins"), + self.tr(f"ERROR: Configuration\n\n{plugins,}\n\nthrows {type(ex)} error:\n{ex}")) + continue + try: + self.add_plugin_submenu(name, extension, imported_function, picker_type, plugin_type="Export") + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("Error on installing plugin: %s: %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error import plugins"), + self.tr(f"ERROR: Configuration\n\n{self.export_plugins}\n\nthrows {type(ex)} error:\n{ex}")) + continue + self.export_plugins[name] = (imported_function, extension) + + def remove_plugins(self): + for name, _ in self.import_plugins.items(): + full_name = "actionImportFlightTrack" + clean_string(name) + actions = [_x for _x in self.menuImportFlightTrack.actions() + if _x.objectName() == full_name] + assert len(actions) == 1 + self.menuImportFlightTrack.removeAction(actions[0]) + delattr(self, full_name) + self.import_plugins = {} + + for name, _ in self.export_plugins.items(): + full_name = "actionExportFlightTrack" + clean_string(name) + actions = [_x for _x in self.menuExportActiveFlightTrack.actions() + if _x.objectName() == full_name] + assert len(actions) == 1 + self.menuExportActiveFlightTrack.removeAction(actions[0]) + delattr(self, full_name) + self.export_plugins = {} + + def handle_import_local(self, extension, function, pickertype): + filenames = get_open_filenames( + self, "Import Flight Track", + self.last_save_directory, + f"Flight Track (*.{extension});;All files (*.*)", + pickertype=pickertype) + if self.local_active: + if filenames is not None: + activate = True + if len(filenames) > 1: + activate = False + for name in filenames: + self.create_new_flight_track(filename=name, function=function, activate=activate) + self.last_save_directory = fs.path.dirname(name) + else: + for name in filenames: + self.mscolab.handle_import_msc(name, extension, function, pickertype) + + def handle_export_local(self, extension, function, pickertype): + if self.local_active: + default_filename = f'{os.path.join(self.last_save_directory, self.active_flight_track.name)}.{extension}' + filename = get_save_filename( + self, "Export Flight Track", + default_filename, f"Flight Track (*.{extension})", + pickertype=pickertype) + if filename is not None: + self.last_save_directory = fs.path.dirname(filename) + try: + if function is None: + doc = self.active_flight_track.get_xml_doc() + dirname, name = fs.path.split(filename) + file_dir = fs.open_fs(dirname) + with file_dir.open(name, 'w') as file_object: + doc.writexml(file_object, indent=" ", addindent=" ", newl="\n", encoding="utf-8") + file_dir.close() + else: + function(filename, self.active_flight_track.name, self.active_flight_track.waypoints) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("file io plugin error: %s %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error"), + self.tr(f"ERROR: {type(ex)} {ex}")) + else: + self.mscolab.handle_export_msc(extension, function, pickertype) + + def create_new_flight_track(self, template=None, filename=None, function=None, activate=True): + """Creates a new flight track model from a template. Adds a new entry to + the list of flight tracks. Called when the user selects the 'new/open + flight track' menu entries. + + Arguments: + template -- copy the specified template to the new flight track (so that + it is not empty). + filename -- if not None, load the flight track in the specified file. + """ + if template is None: + template = [] + waypoints = config_loader(dataset="new_flighttrack_template") + default_flightlevel = config_loader(dataset="new_flighttrack_flightlevel") + for wp in waypoints: + template.append(ft.Waypoint(flightlevel=default_flightlevel, location=wp)) + if len(template) < 2: + QtWidgets.QMessageBox.critical( + self, self.tr("flighttrack template"), + self.tr("ERROR:Flighttrack template in configuration is too short. " + "Please add at least two valid locations.")) + + waypoints_model = None + if filename is not None: + # function is none if ftml file is selected + if function is None: + try: + waypoints_model = ft.WaypointsTableModel(filename=filename) + except (SyntaxError, OSError, IOError) as ex: + QtWidgets.QMessageBox.critical( + self, self.tr("Problem while opening flight track FTML:"), + self.tr(f"ERROR: {type(ex)} {ex}")) + else: + try: + ft_name, new_waypoints = function(filename) + waypoints_model = ft.WaypointsTableModel(name=ft_name, waypoints=new_waypoints) + # wildcard exception to be resilient against error introduced by user code + except Exception as ex: + logging.error("file io plugin error: %s %s", type(ex), ex) + QtWidgets.QMessageBox.critical( + self, self.tr("file io plugin error"), + self.tr(f"ERROR: {type(ex)} {ex}")) + if waypoints_model is not None: + for i in range(self.listFlightTracks.count()): + fltr = self.listFlightTracks.item(i) + if fltr.flighttrack_model.name == waypoints_model.name: + waypoints_model.name += " - imported from file" + break + else: + # Create a new flight track from the waypoints' template. + self.new_flight_track_counter += 1 + waypoints_model = ft.WaypointsTableModel( + name=f"new flight track ({self.new_flight_track_counter:d})") + # Make a copy of the template. Otherwise, all new flight tracks would + # use the same data structure in memory. + template_copy = copy.deepcopy(template) + waypoints_model.insertRows(0, rows=len(template_copy), waypoints=template_copy) + + if waypoints_model is not None: + # Create a new list entry for the flight track. Make the item name editable. + listitem = QFlightTrackListWidgetItem(waypoints_model, self.listFlightTracks) + listitem.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + + # Activate new item + if activate: + self.activate_flight_track(listitem) + + def activate_flight_track(self, item): + """Set the currently selected flight track to be the active one, i.e. + the one that is displayed in the views (only one flight track can be + displayed at a time). + """ + self.mscolab.switch_to_local() + # self.setWindowModality(QtCore.Qt.NonModal) + self.active_flight_track = item.flighttrack_model + self.update_active_flight_track() + font = QtGui.QFont() + for i in range(self.listFlightTracks.count()): + self.listFlightTracks.item(i).setFont(font) + font.setBold(True) + item.setFont(font) + self.menu_handler() + self.signal_activate_flighttrack.emit(self.active_flight_track) + + def update_active_flight_track(self, old_flight_track_name=None): + logging.debug("update_active_flight_track") + for i in range(self.listViews.count()): + view_item = self.listViews.item(i) + view_item.window.setFlightTrackModel(self.active_flight_track) + # local we have always all options enabled + view_item.window.enable_navbar_action_buttons() + if old_flight_track_name is not None: + view_item.window.setWindowTitle(view_item.window.windowTitle().replace(old_flight_track_name, + self.active_flight_track.name)) + + def activate_selected_flight_track(self): + item = self.listFlightTracks.currentItem() + self.activate_flight_track(item) + + def switch_to_mscolab(self): + self.local_active = False + font = QtGui.QFont() + for i in range(self.listFlightTracks.count()): + self.listFlightTracks.item(i).setFont(font) + + # disable appropriate menu options + self.menu_handler() + + def save_handler(self): + """Slot for the 'Save Active Flight Track' menu entry. + """ + filename = self.active_flight_track.get_filename() + if filename: + self.save_flight_track(filename) + else: + self.save_as() + + def save_as_handler(self): + self.save_as() + + def save_as(self): + """ + Slot for the 'Save Active Flight Track As' menu entry. + """ + default_filename = os.path.join(self.last_save_directory, self.active_flight_track.name + ".ftml") + file_type = ["Flight track (*.ftml)"] + filepicker_default = config_loader(dataset="filepicker_default") + filename = get_save_filename( + self, "Save Flight Track", default_filename, ";;".join(file_type), pickertype=filepicker_default + ) + logging.debug("filename : '%s'", filename) + if filename: + ext = "ftml" + self.save_flight_track(filename) + self.last_save_directory = fs.path.dirname(filename) + self.active_flight_track.filename = filename + self.active_flight_track.name = fs.path.basename(filename.replace(f"{ext}", "").strip()) + + def save_flight_track(self, file_name): + ext = ".ftml" + if file_name: + if file_name.endswith(ext): + try: + self.active_flight_track.save_to_ftml(file_name) + except (OSError, IOError) as ex: + QtWidgets.QMessageBox.critical( + self, self.tr("Problem while saving flight track to FTML:"), + self.tr(f"ERROR: {type(ex)} {ex}")) + + for idx in range(self.listFlightTracks.count()): + if self.listFlightTracks.item(idx).flighttrack_model == self.active_flight_track: + old_filght_track_name = self.listFlightTracks.item(idx).text() + self.listFlightTracks.item(idx).setText(self.active_flight_track.name) + + self.update_active_flight_track(old_filght_track_name) + + def close_selected_flight_track(self): + """Slot to close the currently selected flight track. Flight tracks can + only be closed if at least one other flight track remains open. The + currently active flight track cannot be closed. + """ + if self.listFlightTracks.count() < 2: + QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), + self.tr("At least one flight track has to be open.")) + return + item = self.listFlightTracks.currentItem() + if item.flighttrack_model == self.active_flight_track and self.local_active: + QtWidgets.QMessageBox.information(self, self.tr("Flight Track Management"), + self.tr("Cannot close currently active flight track.")) + return + if item.flighttrack_model.modified: + ret = QtWidgets.QMessageBox.warning(self, self.tr("Mission Support System"), + self.tr("The flight track you are about to close has " + "been modified. Close anyway?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + if ret == QtWidgets.QMessageBox.Yes: + self.listFlightTracks.takeItem(self.listFlightTracks.currentRow()) + + def create_view_handler(self, _type): + if self.local_active: + self.create_view(_type, self.active_flight_track) + else: + try: + self.mscolab.waypoints_model.name = self.mscolab.active_operation_name + self.create_view(_type, self.mscolab.waypoints_model) + except AttributeError: + # can happen, when the servers secret was changed + show_popup(self.mscolab.ui, "Error", "Session expired, new login required") + + def create_view(self, _type, model): + """Method called when the user selects a new view to be opened. Creates + a new instance of the view and adds a QActiveViewsListWidgetItem to + the list of open views (self.listViews). + """ + layout = config_loader(dataset="layout") + view_window = None + if _type == "topview": + # Top view. + view_window = topview.MSUITopViewWindow(mainwindow=self, model=model, + active_flighttrack=self.active_flight_track, + mscolab_server_url=self.mscolab.mscolab_server_url, + token=self.mscolab.token) + view_window.mpl.resize(layout['topview'][0], layout['topview'][1]) + if layout["immutable"]: + view_window.mpl.setFixedSize(layout['topview'][0], layout['topview'][1]) + elif _type == "sideview": + # Side view. + view_window = sideview.MSUISideViewWindow(model=model) + view_window.mpl.resize(layout['sideview'][0], layout['sideview'][1]) + if layout["immutable"]: + view_window.mpl.setFixedSize(layout['sideview'][0], layout['sideview'][1]) + elif _type == "tableview": + # Table view. + view_window = tableview.MSUITableViewWindow(model=model) + view_window.centralwidget.resize(layout['tableview'][0], layout['tableview'][1]) + elif _type == "linearview": + # Linear view. + view_window = linearview.MSUILinearViewWindow(model=model) + view_window.mpl.resize(layout['linearview'][0], layout['linearview'][1]) + if layout["immutable"]: + view_window.mpl.setFixedSize(layout['linearview'][0], layout['linearview'][1]) + + if view_window is not None: + # Set view type to window + view_window.view_type = view_window.name + # Make sure view window will be deleted after being closed, not + # just hidden (cf. Chapter 5 in PyQt4). + view_window.setAttribute(QtCore.Qt.WA_DeleteOnClose) + # Open as a non-modal window. + view_window.show() + # Add an entry referencing the new view to the list of views. + # listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged, mscolab) + listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged) + view_window.viewCloses.connect(listitem.view_destroyed) + self.listViews.setCurrentItem(listitem) + # self.active_view_windows.append(view_window) + # disable navbar actions in the view for viewer + try: + if self.mscolab.access_level == "viewer": + view_window.disable_navbar_action_buttons() + except AttributeError: + view_window.enable_navbar_action_buttons() + self.viewsChanged.emit() + # this triggers the changeEvent to get the screen position. + # On X11, a window does not have a frame until the window manager decorates it. + view_window.showMaximized() + view_window.showNormal() + + def get_active_views(self): + active_view_windows = [] + for i in range(self.listViews.count()): + active_view_windows.append(self.listViews.item(i).window) + return active_view_windows + + def activate_sub_window(self, item): + """When the user clicks on one of the open view or tool windows, this + window is brought to the front. This function implements the slot to + activate a window if the user selects it in the list of views or + tools. + """ + # Restore the activated view and bring it to the front. + item.window.showNormal() + item.window.raise_() + item.window.activateWindow() + + def restart_application(self): + while self.listViews.count() > 0: + self.listViews.item(0).window.handle_force_close() + self.listViews.clear() + self.remove_plugins() + if self.mscolab.token is not None: + self.mscolab.logout() + read_config_file() + self.add_plugins() + + def open_config_editor(self): + """ + Opens up a JSON config editor + """ + if self.config_editor is None: + self.config_editor = editor.ConfigurationEditorWindow(parent=self) + self.config_editor.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.config_editor.destroyed.connect(self.close_config_editor) + self.config_editor.restartApplication.connect(self.restart_application) + self.config_editor.show() + else: + self.config_editor.showNormal() + self.config_editor.activateWindow() + + def close_config_editor(self): + self.config_editor = None + + def show_online_help(self): + """Open Documentation in a browser""" + QtGui.QDesktopServices.openUrl( + QtCore.QUrl("http://mss.readthedocs.io/en/stable")) + + def show_about_dialog(self): + """Show the 'About MSUI' dialog to the user. + """ + dlg = MSUI_AboutDialog(parent=self) + dlg.setModal(True) + dlg.show() + + def show_shortcuts(self, search_mode=False): + """Show the shortcuts dialog to the user. + """ + if QtWidgets.QApplication.activeWindow() == self.shortcuts_dlg: + return + + self.shortcuts_dlg = MSUI_ShortcutsDialog( + tutorial_mode=self.tutorial_mode) if not self.shortcuts_dlg else self.shortcuts_dlg + + # In case the dialog gets deleted by QT, recreate it + try: + self.shortcuts_dlg.setModal(True) + except RuntimeError: + self.shortcuts_dlg = MSUI_ShortcutsDialog(tutorial_mode=self.tutorial_mode) + + self.shortcuts_dlg.setParent(QtWidgets.QApplication.activeWindow(), QtCore.Qt.Dialog) + self.shortcuts_dlg.reset_highlight() + self.shortcuts_dlg.fill_list() + if self.tutorial_mode: + self.shortcuts_dlg.hide() + else: + self.shortcuts_dlg.show() + + self.shortcuts_dlg.cbAdvanced.setHidden(True) + self.shortcuts_dlg.cbHighlight.setHidden(True) + self.shortcuts_dlg.cbAdvanced.setCheckState(0) + self.shortcuts_dlg.cbHighlight.setCheckState(0) + self.shortcuts_dlg.leShortcutFilter.setText("") + self.shortcuts_dlg.setWindowTitle("Shortcuts") + + if search_mode: + self.shortcuts_dlg.setWindowTitle("Search") + self.shortcuts_dlg.cbAdvanced.setHidden(False) + self.shortcuts_dlg.cbHighlight.setHidden(False) + self.shortcuts_dlg.cbDisplayType.setCurrentIndex(1) + self.shortcuts_dlg.leShortcutFilter.setText("") + self.shortcuts_dlg.cbAdvanced.setCheckState(2) + self.shortcuts_dlg.cbNoShortcut.setCheckState(2) + self.shortcuts_dlg.leShortcutFilter.setFocus() + + def status(self): + if config_loader() != config_loader(default=True): + return ("Status : System Configuration") + else: + return (f"Status : User Configuration '{constants.MSUI_SETTINGS}' loaded") + + def closeEvent(self, event): + """Ask user if he/she wants to close the application. If yes, also + close all views that are open. + + Overloads QtGui.QMainWindow.closeEvent(). This method is called if + Qt receives a window close request for our application window. + """ + ret = QtWidgets.QMessageBox.warning( + self, self.tr("Mission Support System"), + self.tr("Do you want to close the Mission Support System application?"), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) + + if ret == QtWidgets.QMessageBox.Yes: + if self.mscolab.help_dialog is not None: + self.mscolab.help_dialog.close() + # cleanup mscolab widgets + if self.mscolab.token is not None: + self.mscolab.logout() + # Table View stick around after MainWindow closes - maybe some dangling reference? + # This removes them for sure! + while self.listViews.count() > 0: + self.listViews.item(0).window.handle_force_close() + self.listViews.clear() + self.listFlightTracks.clear() + # close configuration editor + if self.config_editor is not None: + self.config_editor.restart_on_save = False + self.config_editor.close() + from PyQt5 import QtTest + QtTest.QTest.qWait(5) + if self.config_editor is not None: + self.statusBar.showMessage("Save your config changes and try closing again") + event.ignore() + return + event.accept() + else: + event.ignore() diff --git a/mslib/msui/msui_web_browser.py b/mslib/msui/msui_web_browser.py new file mode 100644 index 000000000..6347ad155 --- /dev/null +++ b/mslib/msui/msui_web_browser.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" + + mslib.msui.msui_web_browser.py + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + MSUIWebBrowser can be used for localhost usage and testing purposes. + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import os +import sys + +from PyQt5.QtCore import QUrl, QTimer +from PyQt5.QtWidgets import QMainWindow, QPushButton, QToolBar, QApplication +from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile + +from mslib.msui.constants import MSUI_CONFIG_PATH + + +class MSUIWebBrowser(QMainWindow): + def __init__(self, url: str): + super().__init__() + + self.web_view = QWebEngineView(self) + self.setCentralWidget(self.web_view) + + self._url = url + self.profile = QWebEngineProfile().defaultProfile() + self.profile.setPersistentCookiesPolicy(QWebEngineProfile.ForcePersistentCookies) + self.browser_storage_folder = os.path.join(MSUI_CONFIG_PATH, 'webbrowser', '.cookies') + self.profile.setPersistentStoragePath(self.browser_storage_folder) + + self.back_button = QPushButton("← Back", self) + self.forward_button = QPushButton("→ Forward", self) + self.refresh_button = QPushButton("🔄 Refresh", self) + + self.back_button.clicked.connect(self.web_view.back) + self.forward_button.clicked.connect(self.web_view.forward) + self.refresh_button.clicked.connect(self.web_view.reload) + + toolbar = QToolBar() + toolbar.addWidget(self.back_button) + toolbar.addWidget(self.forward_button) + toolbar.addWidget(self.refresh_button) + self.addToolBar(toolbar) + + self.web_view.load(QUrl(self._url)) + self.setWindowTitle("MSS Web Browser") + self.resize(800, 600) + self.show() + + def closeEvent(self, event): + """ + Delete all cookies when closing the web browser + """ + self.profile.cookieStore().deleteAllCookies() + + +if __name__ == "__main__": + ''' + This function will be moved to handle accordingly the test cases. + The 'connection' variable determines when the web browser should be + closed, typically after the user logged in and establishes a connection + ''' + + CONNECTION = False + + def close_qtwebengine(): + """ + Close the main window + """ + main.close() + + def check_connection(): + """ + Schedule the close_qtwebengine function to be called asynchronously + """ + if CONNECTION: + QTimer.singleShot(0, close_qtwebengine) + + # app = QApplication(sys.argv) + app = QApplication(['', '--no-sandbox']) + WEB_URL = "https://www.google.com/" + main = MSUIWebBrowser(WEB_URL) + + QTimer.singleShot(0, check_connection) + + sys.exit(app.exec_()) diff --git a/mslib/msui/multilayers.py b/mslib/msui/multilayers.py index 1cd38edb6..8c13cabfd 100644 --- a/mslib/msui/multilayers.py +++ b/mslib/msui/multilayers.py @@ -28,7 +28,7 @@ import logging import mslib.msui.wms_control from mslib.msui.icons import icons -from mslib.utils.qt import ui_wms_multilayers as ui +from mslib.msui.qt5 import ui_wms_multilayers as ui from mslib.utils.config import save_settings_qsettings, load_settings_qsettings diff --git a/mslib/msui/multiple_flightpath_dockwidget.py b/mslib/msui/multiple_flightpath_dockwidget.py index 03bd8af30..c34c85e60 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -30,26 +30,27 @@ from PyQt5 import QtWidgets, QtGui, QtCore from mslib.msui.qt5 import ui_multiple_flightpath_dockwidget as ui from mslib.msui import flighttrack as ft -from mslib.msui import msui +import mslib.msui.msui_mainwindow as msui_mainwindow from mslib.utils.verify_user_token import verify_user_token from mslib.utils.qt import Worker +from mslib.utils.config import config_loader class QMscolabOperationsListWidgetItem(QtWidgets.QListWidgetItem): """ """ - def __init__(self, flighttrack_model, op_id: int, parent=None, type=QtWidgets.QListWidgetItem.UserType): + def __init__(self, flighttrack_model, op_id: int, parent=None, user_type=QtWidgets.QListWidgetItem.UserType): view_name = flighttrack_model.name - super(QMscolabOperationsListWidgetItem, self).__init__( - view_name, parent, type + super().__init__( + view_name, parent, user_type ) self.parent = parent self.flighttrack_model = flighttrack_model self.op_id = op_id -class MultipleFlightpath(object): +class MultipleFlightpath: """ Represent a Multiple FLightpath """ @@ -110,11 +111,11 @@ class MultipleFlightpathControlWidget(QtWidgets.QWidget, ui.Ui_MultipleViewWidge # ToDO: Make a new parent class with all the functions in this class and inherit them # in MultipleFlightpathControlWidget and MultipleFlightpathOperations classes. - signal_parent_closes = QtCore.Signal() + signal_parent_closes = QtCore.pyqtSignal() def __init__(self, parent=None, view=None, listFlightTracks=None, - listOperationsMSC=None, activeFlightTrack=None, mscolab_server_url=None, token=None): - super(MultipleFlightpathControlWidget, self).__init__(parent) + listOperationsMSC=None, category=None, activeFlightTrack=None, mscolab_server_url=None, token=None): + super().__init__(parent) # ToDO: Remove all patches, on closing dockwidget. self.ui = parent self.setupUi(self) @@ -122,6 +123,7 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.flight_path = None # flightpath object self.dict_flighttrack = {} # Dictionary of flighttrack data: patch,color,wp_model self.active_flight_track = activeFlightTrack + self.msc_category = category # object of active category self.listOperationsMSC = listOperationsMSC self.listFlightTracks = listFlightTracks self.mscolab_server_url = mscolab_server_url @@ -173,9 +175,10 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.activate_flighttrack() self.multipleflightrack_worker = Worker(None) - @QtCore.Slot() + @QtCore.pyqtSlot() def logout(self): - self.operations.logout_mscolab() + if self.operations is not None: + self.operations.logout_mscolab() self.ui.signal_listFlighttrack_doubleClicked.disconnect() self.ui.signal_permission_revoked.disconnect() self.ui.signal_render_new_permission.disconnect() @@ -185,7 +188,7 @@ def logout(self): for idx in range(len(self.obb)): del self.obb[idx] - @QtCore.Slot(str, str) + @QtCore.pyqtSlot(str, str) def login(self, url, token): self.mscolab_server_url = url self.token = token @@ -250,7 +253,7 @@ def flighttrackAdded(self, parent, start, end): self.operations.deactivate_all_operations() self.activate_flighttrack() - @QtCore.Slot(tuple) + @QtCore.pyqtSlot(tuple) def ft_vertices_color(self, color): self.color = color self.colorPixmap.setPixmap(self.show_color_pixmap(color)) @@ -265,19 +268,19 @@ def ft_vertices_color(self, color): elif self.operation_list: self.operations.ft_color_update(color) - @QtCore.Slot(int, str) + @QtCore.pyqtSlot(int, str) def add_operation_slot(self, op_id, path): self.operations.operationsAdded(op_id, path) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def remove_operation_slot(self, op_id): self.operations.operationRemoved(op_id) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def update_op_id(self, op_id): self.operations.get_op_id(op_id) - @QtCore.Slot(ft.WaypointsTableModel) + @QtCore.pyqtSlot(ft.WaypointsTableModel) def get_active(self, active_flighttrack): self.update_last_flighttrack() self.active_flight_track = active_flighttrack @@ -303,7 +306,7 @@ def create_list_item(self, wp_model): self.save_waypoint_model_data(wp_model, self.list_flighttrack) - listItem = msui.QFlightTrackListWidgetItem(wp_model, self.list_flighttrack) + listItem = msui_mainwindow.QFlightTrackListWidgetItem(wp_model, self.list_flighttrack) listItem.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsUserCheckable) if not self.flighttrack_added: self.flighttrack_added = True @@ -522,9 +525,6 @@ def __init__(self, parent, mscolab_server_url, token, list_operation_track, list self.operation_activated = False self.color_change = False - # Connect signals and slots - self.list_operation_track.itemChanged.connect(self.set_flag) - # Load operations from wps server server_operations = self.get_wps_from_server() sorted_server_operations = sorted(server_operations, key=lambda d: d["path"]) @@ -536,6 +536,10 @@ def __init__(self, parent, mscolab_server_url, token, list_operation_track, list wp_model.name = operations["path"] self.create_operation(op_id, wp_model) + # This needs to be done after operations are loaded + # Connect signals and slots + self.list_operation_track.itemChanged.connect(self.set_flag) + def set_flag(self): if self.operation_added: self.operation_added = False @@ -548,13 +552,18 @@ def set_flag(self): def get_wps_from_server(self): operations = {} + skip_archived = config_loader(dataset="MSCOLAB_skip_archived_operations") data = { - "token": self.token + "token": self.token, + "skip_archived": skip_archived } r = requests.get(self.mscolab_server_url + "/operations", data=data, timeout=(2, 10)) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] + selected_category = self.parent.msc_category.currentText() + if selected_category != "*ANY*": + operations = [op for op in operations if op['category'] == selected_category] return operations def request_wps_from_server(self, op_id): @@ -609,6 +618,8 @@ def activate_operation(self): """ Activate Mscolab Operation """ + # disconnect itemChanged during activation loop + self.list_operation_track.itemChanged.disconnect(self.set_flag) font = QtGui.QFont() for i in range(self.list_operation_track.count()): listItem = self.list_operation_track.item(i) @@ -627,6 +638,8 @@ def activate_operation(self): listItem.setFlags(listItem.flags() | QtCore.Qt.ItemIsUserCheckable) self.set_activate_flag() listItem.setFont(font) + # connect itemChanged after everything setup, otherwise it will be triggered on each entry + self.list_operation_track.itemChanged.connect(self.set_flag) def save_last_used_operation(self, op_id): if self.active_op_id is not None: @@ -673,6 +686,7 @@ def operationRemoved(self, op_id): """ Slot to remove operation. """ + self.list_operation_track.itemChanged.disconnect(self.set_flag) self.operation_removed = True for index in range(self.list_operation_track.count()): if self.list_operation_track.item(index).op_id == op_id: @@ -683,6 +697,7 @@ def operationRemoved(self, op_id): self.list_operation_track.takeItem(index) self.active_op_id = None break + self.list_operation_track.itemChanged.connect(self.set_flag) def set_activate_flag(self): if not self.operation_activated: @@ -768,11 +783,11 @@ def logout_mscolab(self): self.token = None self.dict_operations = {} - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def permission_revoked(self, op_id): self.operationRemoved(op_id) - @QtCore.Slot(int, str) + @QtCore.pyqtSlot(int, str) def render_permission(self, op_id, path): self.operationsAdded(op_id, path) diff --git a/mslib/msui/performance_settings.py b/mslib/msui/performance_settings.py index 325af9ecd..dfb4f1e4c 100644 --- a/mslib/msui/performance_settings.py +++ b/mslib/msui/performance_settings.py @@ -32,7 +32,7 @@ from mslib.utils import FatalUserError from mslib.msui import aircrafts, constants from mslib.utils.qt import get_open_filename -from mslib.utils.qt import ui_performance_dockwidget as ui_dw +from mslib.msui.qt5 import ui_performance_dockwidget as ui_dw DEFAULT_PERFORMANCE = { @@ -57,7 +57,7 @@ def __init__(self, parent=None, view=None, settings_dict=None): view -- reference to mpl canvas class settings_dict -- dictionary containing topview options """ - super(MSUI_PerformanceSettingsWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view self.parent = parent diff --git a/mslib/msui/qt5/ui_mainwindow.py b/mslib/msui/qt5/ui_mainwindow.py index 9ecc88b89..2b8056f50 100644 --- a/mslib/msui/qt5/ui_mainwindow.py +++ b/mslib/msui/qt5/ui_mainwindow.py @@ -55,6 +55,7 @@ def setupUi(self, MSUIMainWindow): self.userOptionsTb.setObjectName("userOptionsTb") self.userOptionsHL.addWidget(self.userOptionsTb, 0, QtCore.Qt.AlignRight) self.connectBtn = QtWidgets.QPushButton(self.MSColabConnectGb) + self.connectBtn.setAutoDefault(True) self.connectBtn.setObjectName("connectBtn") self.userOptionsHL.addWidget(self.connectBtn) self.userOptionsHL.setStretch(0, 1) @@ -111,50 +112,52 @@ def setupUi(self, MSUIMainWindow): self.gridLayout_3 = QtWidgets.QGridLayout(self.openOperationsGb) self.gridLayout_3.setContentsMargins(8, 8, 8, 8) self.gridLayout_3.setObjectName("gridLayout_3") + self.workingStatusLabel = QtWidgets.QLabel(self.openOperationsGb) + self.workingStatusLabel.setWordWrap(True) + self.workingStatusLabel.setObjectName("workingStatusLabel") + self.gridLayout_3.addWidget(self.workingStatusLabel, 6, 0, 1, 2) + self.activeOperationDesc = QtWidgets.QLabel(self.openOperationsGb) + self.activeOperationDesc.setObjectName("activeOperationDesc") + self.gridLayout_3.addWidget(self.activeOperationDesc, 1, 0, 1, 2) + self.activeOperationsLabel = QtWidgets.QLabel(self.openOperationsGb) + self.activeOperationsLabel.setObjectName("activeOperationsLabel") + self.gridLayout_3.addWidget(self.activeOperationsLabel, 2, 0, 1, 1) + self.workLocallyCheckbox = QtWidgets.QCheckBox(self.openOperationsGb) + self.workLocallyCheckbox.setObjectName("workLocallyCheckbox") + self.gridLayout_3.addWidget(self.workLocallyCheckbox, 10, 0, 1, 1) self.filterCategoryCb = QtWidgets.QComboBox(self.openOperationsGb) self.filterCategoryCb.setAutoFillBackground(False) self.filterCategoryCb.setEditable(False) self.filterCategoryCb.setObjectName("filterCategoryCb") self.filterCategoryCb.addItem("") - self.gridLayout_3.addWidget(self.filterCategoryCb, 10, 1, 1, 1) - self.categoryLabel = QtWidgets.QLabel(self.openOperationsGb) - self.categoryLabel.setObjectName("categoryLabel") - self.gridLayout_3.addWidget(self.categoryLabel, 10, 0, 1, 1) - self.inactiveOperationsLabel = QtWidgets.QLabel(self.openOperationsGb) - self.inactiveOperationsLabel.setObjectName("inactiveOperationsLabel") - self.gridLayout_3.addWidget(self.inactiveOperationsLabel, 5, 0, 1, 1) - self.listInactiveOperationsMSC = QtWidgets.QListWidget(self.openOperationsGb) - self.listInactiveOperationsMSC.setObjectName("listInactiveOperationsMSC") - self.gridLayout_3.addWidget(self.listInactiveOperationsMSC, 6, 0, 1, 2) - self.workingStatusLabel = QtWidgets.QLabel(self.openOperationsGb) - self.workingStatusLabel.setWordWrap(True) - self.workingStatusLabel.setObjectName("workingStatusLabel") - self.gridLayout_3.addWidget(self.workingStatusLabel, 7, 0, 1, 2) + self.gridLayout_3.addWidget(self.filterCategoryCb, 9, 1, 1, 1) self.serverOptionsCb = QtWidgets.QComboBox(self.openOperationsGb) self.serverOptionsCb.setObjectName("serverOptionsCb") self.serverOptionsCb.addItem("") self.serverOptionsCb.addItem("") self.serverOptionsCb.addItem("") - self.gridLayout_3.addWidget(self.serverOptionsCb, 11, 1, 1, 1) - self.workLocallyCheckbox = QtWidgets.QCheckBox(self.openOperationsGb) - self.workLocallyCheckbox.setObjectName("workLocallyCheckbox") - self.gridLayout_3.addWidget(self.workLocallyCheckbox, 11, 0, 1, 1) + self.gridLayout_3.addWidget(self.serverOptionsCb, 10, 1, 1, 1) + self.categoryLabel = QtWidgets.QLabel(self.openOperationsGb) + self.categoryLabel.setObjectName("categoryLabel") + self.gridLayout_3.addWidget(self.categoryLabel, 9, 0, 1, 1) self.listOperationsMSC = QtWidgets.QListWidget(self.openOperationsGb) self.listOperationsMSC.setObjectName("listOperationsMSC") self.gridLayout_3.addWidget(self.listOperationsMSC, 4, 0, 1, 2) - self.activeOperationDesc = QtWidgets.QLabel(self.openOperationsGb) - self.activeOperationDesc.setObjectName("activeOperationDesc") - self.gridLayout_3.addWidget(self.activeOperationDesc, 1, 0, 1, 2) - self.activeOperationsLabel = QtWidgets.QLabel(self.openOperationsGb) - self.activeOperationsLabel.setObjectName("activeOperationsLabel") - self.gridLayout_3.addWidget(self.activeOperationsLabel, 2, 0, 1, 1) + self.pbOpenOperationArchive = QtWidgets.QPushButton(self.openOperationsGb) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.pbOpenOperationArchive.sizePolicy().hasHeightForWidth()) + self.pbOpenOperationArchive.setSizePolicy(sizePolicy) + self.pbOpenOperationArchive.setObjectName("pbOpenOperationArchive") + self.gridLayout_3.addWidget(self.pbOpenOperationArchive, 11, 0, 1, 2) self.horizontalLayout.addWidget(self.openOperationsGb) self.verticalLayout_2.addLayout(self.horizontalLayout) self.gridLayout.addLayout(self.verticalLayout_2, 0, 0, 1, 2) self.gridLayout.setColumnStretch(0, 1) MSUIMainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MSUIMainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 20)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 22)) self.menubar.setNativeMenuBar(False) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) @@ -221,16 +224,18 @@ def setupUi(self, MSUIMainWindow): self.actionAddOperation.setObjectName("actionAddOperation") self.actionSearch = QtWidgets.QAction(MSUIMainWindow) self.actionSearch.setObjectName("actionSearch") - self.actionDescription = QtWidgets.QAction(MSUIMainWindow) - self.actionDescription.setObjectName("actionDescription") - self.actionUpdateOperationDesc = QtWidgets.QAction(MSUIMainWindow) - self.actionUpdateOperationDesc.setObjectName("actionUpdateOperationDesc") + self.actionViewDescription = QtWidgets.QAction(MSUIMainWindow) + self.actionViewDescription.setObjectName("actionViewDescription") + self.actionChangeDescription = QtWidgets.QAction(MSUIMainWindow) + self.actionChangeDescription.setObjectName("actionChangeDescription") self.actionRenameOperation = QtWidgets.QAction(MSUIMainWindow) self.actionRenameOperation.setObjectName("actionRenameOperation") self.actionLeaveOperation = QtWidgets.QAction(MSUIMainWindow) self.actionLeaveOperation.setObjectName("actionLeaveOperation") - self.actionUnarchiveOperation = QtWidgets.QAction(MSUIMainWindow) - self.actionUnarchiveOperation.setObjectName("actionUnarchiveOperation") + self.actionArchiveOperation = QtWidgets.QAction(MSUIMainWindow) + self.actionArchiveOperation.setObjectName("actionArchiveOperation") + self.actionChangeCategory = QtWidgets.QAction(MSUIMainWindow) + self.actionChangeCategory.setObjectName("actionChangeCategory") self.menuNew.addAction(self.actionNewFlightTrack) self.menuNew.addAction(self.actionAddOperation) self.menuFile.addAction(self.menuNew.menuAction()) @@ -256,16 +261,17 @@ def setupUi(self, MSUIMainWindow): self.menuViews.addAction(self.actionSideView) self.menuViews.addAction(self.actionTableView) self.menuViews.addAction(self.actionLinearView) - self.menuProperties.addAction(self.actionDeleteOperation) - self.menuProperties.addAction(self.actionDescription) - self.menuProperties.addAction(self.actionUpdateOperationDesc) + self.menuProperties.addAction(self.actionChangeCategory) + self.menuProperties.addAction(self.actionChangeDescription) + self.menuProperties.addAction(self.actionManageUsers) self.menuProperties.addAction(self.actionRenameOperation) - self.menuProperties.addAction(self.actionLeaveOperation) + self.menuProperties.addAction(self.actionDeleteOperation) + self.menuProperties.addAction(self.actionArchiveOperation) self.menuOperation.addAction(self.actionChat) self.menuOperation.addAction(self.actionVersionHistory) - self.menuOperation.addAction(self.actionManageUsers) - self.menuOperation.addAction(self.actionUnarchiveOperation) + self.menuOperation.addAction(self.actionViewDescription) self.menuOperation.addSeparator() + self.menuOperation.addAction(self.actionLeaveOperation) self.menuOperation.addAction(self.menuProperties.menuAction()) self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuViews.menuAction()) @@ -298,22 +304,22 @@ def retranslateUi(self, MSUIMainWindow): "Save a flight track to name it.")) self.openViewsLabel.setText(_translate("MSUIMainWindow", "Open Views:")) self.listViews.setToolTip(_translate("MSUIMainWindow", "Double-click a view to bring it to the front.")) + self.workingStatusLabel.setText(_translate("MSUIMainWindow", "No operations selected")) + self.activeOperationDesc.setText(_translate("MSUIMainWindow", "Select Operation to View Description")) + self.activeOperationsLabel.setText(_translate("MSUIMainWindow", "Operations")) + self.workLocallyCheckbox.setToolTip(_translate("MSUIMainWindow", "Check to work asynchronously from the server")) + self.workLocallyCheckbox.setText(_translate("MSUIMainWindow", "Work Asynchronously")) self.filterCategoryCb.setWhatsThis(_translate("MSUIMainWindow", "filter by operation category")) self.filterCategoryCb.setCurrentText(_translate("MSUIMainWindow", "ANY")) self.filterCategoryCb.setItemText(0, _translate("MSUIMainWindow", "ANY")) - self.categoryLabel.setText(_translate("MSUIMainWindow", "Category:")) - self.inactiveOperationsLabel.setText(_translate("MSUIMainWindow", "Archived Operations")) - self.workingStatusLabel.setText(_translate("MSUIMainWindow", "No operations selected")) self.serverOptionsCb.setToolTip(_translate("MSUIMainWindow", "Fetch/Save Server options")) self.serverOptionsCb.setItemText(0, _translate("MSUIMainWindow", "Server Options")) self.serverOptionsCb.setItemText(1, _translate("MSUIMainWindow", "Fetch From Server")) self.serverOptionsCb.setItemText(2, _translate("MSUIMainWindow", "Save To Server")) - self.workLocallyCheckbox.setToolTip(_translate("MSUIMainWindow", "Check to work asynchronously from the server")) - self.workLocallyCheckbox.setText(_translate("MSUIMainWindow", "Work Asynchronously")) + self.categoryLabel.setText(_translate("MSUIMainWindow", "Category:")) self.listOperationsMSC.setToolTip(_translate("MSUIMainWindow", "List of mscolab operations.\n" "Double click a operation to activate and view its description.")) - self.activeOperationDesc.setText(_translate("MSUIMainWindow", "Select Operation to View Description")) - self.activeOperationsLabel.setText(_translate("MSUIMainWindow", "Operations")) + self.pbOpenOperationArchive.setText(_translate("MSUIMainWindow", "Operation Archive")) self.menuFile.setTitle(_translate("MSUIMainWindow", "&File")) self.menuImportFlightTrack.setTitle(_translate("MSUIMainWindow", "Import Flight Track")) self.menuExportActiveFlightTrack.setTitle(_translate("MSUIMainWindow", "Export Flight Track")) @@ -321,7 +327,7 @@ def retranslateUi(self, MSUIMainWindow): self.menuHelp.setTitle(_translate("MSUIMainWindow", "&Help")) self.menuViews.setTitle(_translate("MSUIMainWindow", "Views")) self.menuOperation.setTitle(_translate("MSUIMainWindow", "Operation")) - self.menuProperties.setTitle(_translate("MSUIMainWindow", "Properties")) + self.menuProperties.setTitle(_translate("MSUIMainWindow", "Maintenance")) self.actionSaveActiveFlightTrack.setText(_translate("MSUIMainWindow", "&Save Active Flight Track")) self.actionSaveActiveFlightTrack.setShortcut(_translate("MSUIMainWindow", "Ctrl+S")) self.actionSaveActiveFlightTrackAs.setText(_translate("MSUIMainWindow", "Save Active Flight Track As")) @@ -356,8 +362,9 @@ def retranslateUi(self, MSUIMainWindow): self.actionSearch.setText(_translate("MSUIMainWindow", "Search")) self.actionSearch.setToolTip(_translate("MSUIMainWindow", "Search for interactive text in the UI")) self.actionSearch.setShortcut(_translate("MSUIMainWindow", "Ctrl+F")) - self.actionDescription.setText(_translate("MSUIMainWindow", "View Description")) - self.actionUpdateOperationDesc.setText(_translate("MSUIMainWindow", "Update Description")) + self.actionViewDescription.setText(_translate("MSUIMainWindow", "View Description")) + self.actionChangeDescription.setText(_translate("MSUIMainWindow", "Change Description")) self.actionRenameOperation.setText(_translate("MSUIMainWindow", "Rename Operation")) self.actionLeaveOperation.setText(_translate("MSUIMainWindow", "&Leave Operation")) - self.actionUnarchiveOperation.setText(_translate("MSUIMainWindow", "Unarchive Operation")) + self.actionArchiveOperation.setText(_translate("MSUIMainWindow", "Archive Operation")) + self.actionChangeCategory.setText(_translate("MSUIMainWindow", "Change Category")) diff --git a/mslib/msui/qt5/ui_mscolab_admin_window.py b/mslib/msui/qt5/ui_mscolab_admin_window.py index 8c952d3ff..e357b8c3a 100644 --- a/mslib/msui/qt5/ui_mscolab_admin_window.py +++ b/mslib/msui/qt5/ui_mscolab_admin_window.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'ui_mscolab_admin_window.ui' +# Form implementation generated from reading ui file 'mslib/msui/ui/ui_mscolab_admin_window.ui' # -# Created by: PyQt5 UI code generator 5.12.3 +# Created by: PyQt5 UI code generator 5.15.7 # -# WARNING! All changes made in this file will be lost! +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. from PyQt5 import QtCore, QtGui, QtWidgets @@ -25,18 +26,15 @@ def setupUi(self, MscolabAdminWindow): self.horizontalLayout_6.setObjectName("horizontalLayout_6") self.verticalLayout = QtWidgets.QVBoxLayout() self.verticalLayout.setObjectName("verticalLayout") - self.horizontalLayout_7 = QtWidgets.QHBoxLayout() - self.horizontalLayout_7.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) - self.horizontalLayout_7.setContentsMargins(0, 8, -1, 8) - self.horizontalLayout_7.setSpacing(6) - self.horizontalLayout_7.setObjectName("horizontalLayout_7") self.usernameLabel = QtWidgets.QLabel(self.centralwidget) self.usernameLabel.setObjectName("usernameLabel") - self.horizontalLayout_7.addWidget(self.usernameLabel) + self.verticalLayout.addWidget(self.usernameLabel) self.operationNameLabel = QtWidgets.QLabel(self.centralwidget) self.operationNameLabel.setObjectName("operationNameLabel") - self.horizontalLayout_7.addWidget(self.operationNameLabel) - self.verticalLayout.addLayout(self.horizontalLayout_7) + self.verticalLayout.addWidget(self.operationNameLabel) + self.creatorNameLabel = QtWidgets.QLabel(self.centralwidget) + self.creatorNameLabel.setObjectName("creatorNameLabel") + self.verticalLayout.addWidget(self.creatorNameLabel) self.label = QtWidgets.QLabel(self.centralwidget) self.label.setObjectName("label") self.verticalLayout.addWidget(self.label) @@ -180,7 +178,7 @@ def setupUi(self, MscolabAdminWindow): MscolabAdminWindow.addAction(self.actionCloseWindow) self.retranslateUi(MscolabAdminWindow) - self.actionCloseWindow.triggered.connect(MscolabAdminWindow.close) + self.actionCloseWindow.triggered.connect(MscolabAdminWindow.close) # type: ignore QtCore.QMetaObject.connectSlotsByName(MscolabAdminWindow) def retranslateUi(self, MscolabAdminWindow): @@ -188,6 +186,7 @@ def retranslateUi(self, MscolabAdminWindow): MscolabAdminWindow.setWindowTitle(_translate("MscolabAdminWindow", "Manage Users")) self.usernameLabel.setText(_translate("MscolabAdminWindow", "Logged In: ")) self.operationNameLabel.setText(_translate("MscolabAdminWindow", "Operation: ")) + self.creatorNameLabel.setText(_translate("MscolabAdminWindow", "Creator:")) self.label.setText(_translate("MscolabAdminWindow", "All Users Without Permission:")) self.addUsersSearch.setPlaceholderText(_translate("MscolabAdminWindow", "Search User")) self.selectAllAddBtn.setText(_translate("MscolabAdminWindow", "Select All")) diff --git a/mslib/msui/qt5/ui_mscolab_connect_dialog.py b/mslib/msui/qt5/ui_mscolab_connect_dialog.py index 328c70761..93f8ee09b 100644 --- a/mslib/msui/qt5/ui_mscolab_connect_dialog.py +++ b/mslib/msui/qt5/ui_mscolab_connect_dialog.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'mslib/msui/ui/ui_mscolab_connect_dialog.ui' +# Form implementation generated from reading ui file 'ui_mscolab_connect_dialog.ui' # # Created by: PyQt5 UI code generator 5.12.3 # @@ -13,11 +13,9 @@ class Ui_MSColabConnectDialog(object): def setupUi(self, MSColabConnectDialog): MSColabConnectDialog.setObjectName("MSColabConnectDialog") - MSColabConnectDialog.resize(478, 255) - self.verticalLayout = QtWidgets.QVBoxLayout(MSColabConnectDialog) - self.verticalLayout.setContentsMargins(12, 10, 10, 10) - self.verticalLayout.setSpacing(5) - self.verticalLayout.setObjectName("verticalLayout") + MSColabConnectDialog.resize(478, 271) + self.gridLayout_4 = QtWidgets.QGridLayout(MSColabConnectDialog) + self.gridLayout_4.setObjectName("gridLayout_4") self.horizontalLayout_2 = QtWidgets.QHBoxLayout() self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.urlLabel = QtWidgets.QLabel(MSColabConnectDialog) @@ -28,16 +26,19 @@ def setupUi(self, MSColabConnectDialog): self.urlCb.setObjectName("urlCb") self.horizontalLayout_2.addWidget(self.urlCb) self.connectBtn = QtWidgets.QPushButton(MSColabConnectDialog) - self.connectBtn.setAutoDefault(False) + self.connectBtn.setAutoDefault(True) self.connectBtn.setObjectName("connectBtn") self.horizontalLayout_2.addWidget(self.connectBtn) + self.disconnectBtn = QtWidgets.QPushButton(MSColabConnectDialog) + self.disconnectBtn.setObjectName("disconnectBtn") + self.horizontalLayout_2.addWidget(self.disconnectBtn) self.horizontalLayout_2.setStretch(1, 1) - self.verticalLayout.addLayout(self.horizontalLayout_2) + self.gridLayout_4.addLayout(self.horizontalLayout_2, 0, 0, 1, 1) self.line = QtWidgets.QFrame(MSColabConnectDialog) self.line.setFrameShape(QtWidgets.QFrame.HLine) self.line.setFrameShadow(QtWidgets.QFrame.Sunken) self.line.setObjectName("line") - self.verticalLayout.addWidget(self.line) + self.gridLayout_4.addWidget(self.line, 1, 0, 1, 1) self.stackedWidget = QtWidgets.QStackedWidget(MSColabConnectDialog) self.stackedWidget.setObjectName("stackedWidget") self.loginPage = QtWidgets.QWidget() @@ -45,30 +46,33 @@ def setupUi(self, MSColabConnectDialog): self.gridLayout_3 = QtWidgets.QGridLayout(self.loginPage) self.gridLayout_3.setContentsMargins(100, 0, 100, 0) self.gridLayout_3.setObjectName("gridLayout_3") + self.loginBtn = QtWidgets.QPushButton(self.loginPage) + self.loginBtn.setAutoDefault(True) + self.loginBtn.setObjectName("loginBtn") + self.gridLayout_3.addWidget(self.loginBtn, 3, 0, 1, 2) self.addUserBtn = QtWidgets.QPushButton(self.loginPage) self.addUserBtn.setAutoDefault(False) self.addUserBtn.setObjectName("addUserBtn") self.gridLayout_3.addWidget(self.addUserBtn, 4, 1, 1, 1) - self.loginBtn = QtWidgets.QPushButton(self.loginPage) - self.loginBtn.setAutoDefault(False) - self.loginBtn.setObjectName("loginBtn") - self.gridLayout_3.addWidget(self.loginBtn, 3, 0, 1, 2) - self.loginPasswordLe = QtWidgets.QLineEdit(self.loginPage) - self.loginPasswordLe.setEchoMode(QtWidgets.QLineEdit.Password) - self.loginPasswordLe.setObjectName("loginPasswordLe") - self.gridLayout_3.addWidget(self.loginPasswordLe, 2, 0, 1, 2) - self.loginEmailLe = QtWidgets.QLineEdit(self.loginPage) - self.loginEmailLe.setObjectName("loginEmailLe") - self.gridLayout_3.addWidget(self.loginEmailLe, 1, 0, 1, 2) - self.clickNewUserLabel = QtWidgets.QLabel(self.loginPage) - self.clickNewUserLabel.setObjectName("clickNewUserLabel") - self.gridLayout_3.addWidget(self.clickNewUserLabel, 4, 0, 1, 1) self.loginTopicLabel = QtWidgets.QLabel(self.loginPage) font = QtGui.QFont() font.setPointSize(16) self.loginTopicLabel.setFont(font) self.loginTopicLabel.setObjectName("loginTopicLabel") self.gridLayout_3.addWidget(self.loginTopicLabel, 0, 0, 1, 2, QtCore.Qt.AlignHCenter) + self.clickNewUserLabel = QtWidgets.QLabel(self.loginPage) + self.clickNewUserLabel.setObjectName("clickNewUserLabel") + self.gridLayout_3.addWidget(self.clickNewUserLabel, 4, 0, 1, 1) + self.loginWithIDPBtn = QtWidgets.QPushButton(self.loginPage) + self.loginWithIDPBtn.setObjectName("loginWithIDPBtn") + self.gridLayout_3.addWidget(self.loginWithIDPBtn, 5, 0, 1, 2) + self.loginEmailLe = QtWidgets.QLineEdit(self.loginPage) + self.loginEmailLe.setObjectName("loginEmailLe") + self.gridLayout_3.addWidget(self.loginEmailLe, 1, 0, 1, 2) + self.loginPasswordLe = QtWidgets.QLineEdit(self.loginPage) + self.loginPasswordLe.setEchoMode(QtWidgets.QLineEdit.Password) + self.loginPasswordLe.setObjectName("loginPasswordLe") + self.gridLayout_3.addWidget(self.loginPasswordLe, 2, 0, 1, 2) self.stackedWidget.addWidget(self.loginPage) self.newuserPage = QtWidgets.QWidget() self.newuserPage.setObjectName("newuserPage") @@ -120,38 +124,69 @@ def setupUi(self, MSColabConnectDialog): self.verticalLayout_4.setContentsMargins(0, 0, 0, 0) self.verticalLayout_4.setObjectName("verticalLayout_4") self.httpTopicLabel = QtWidgets.QLabel(self.httpAuthPage) + self.httpTopicLabel.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.httpTopicLabel.sizePolicy().hasHeightForWidth()) + self.httpTopicLabel.setSizePolicy(sizePolicy) + self.httpTopicLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.httpTopicLabel.setObjectName("httpTopicLabel") self.verticalLayout_4.addWidget(self.httpTopicLabel) - self.httpInfoLabel = QtWidgets.QLabel(self.httpAuthPage) - self.httpInfoLabel.setObjectName("httpInfoLabel") - self.verticalLayout_4.addWidget(self.httpInfoLabel) self.gridLayout = QtWidgets.QGridLayout() + self.gridLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) self.gridLayout.setObjectName("gridLayout") - self.httpUsernameLabel = QtWidgets.QLabel(self.httpAuthPage) - self.httpUsernameLabel.setObjectName("httpUsernameLabel") - self.gridLayout.addWidget(self.httpUsernameLabel, 0, 0, 1, 1) self.httpPasswordLe = QtWidgets.QLineEdit(self.httpAuthPage) self.httpPasswordLe.setEchoMode(QtWidgets.QLineEdit.Password) self.httpPasswordLe.setObjectName("httpPasswordLe") - self.gridLayout.addWidget(self.httpPasswordLe, 1, 1, 1, 1) + self.gridLayout.addWidget(self.httpPasswordLe, 0, 1, 1, 1) self.httpPasswordLabel = QtWidgets.QLabel(self.httpAuthPage) self.httpPasswordLabel.setObjectName("httpPasswordLabel") - self.gridLayout.addWidget(self.httpPasswordLabel, 1, 0, 1, 1) - self.httpUsernameLe = QtWidgets.QLineEdit(self.httpAuthPage) - self.httpUsernameLe.setObjectName("httpUsernameLe") - self.gridLayout.addWidget(self.httpUsernameLe, 0, 1, 1, 1) + self.gridLayout.addWidget(self.httpPasswordLabel, 0, 0, 1, 1) self.verticalLayout_4.addLayout(self.gridLayout) - self.httpBb = QtWidgets.QDialogButtonBox(self.httpAuthPage) - self.httpBb.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) - self.httpBb.setObjectName("httpBb") - self.verticalLayout_4.addWidget(self.httpBb) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_4.addItem(spacerItem) self.stackedWidget.addWidget(self.httpAuthPage) - self.verticalLayout.addWidget(self.stackedWidget) + self.idpAuthPage = QtWidgets.QWidget() + self.idpAuthPage.setEnabled(True) + self.idpAuthPage.setObjectName("idpAuthPage") + self.layoutWidget = QtWidgets.QWidget(self.idpAuthPage) + self.layoutWidget.setGeometry(QtCore.QRect(0, 20, 451, 141)) + self.layoutWidget.setObjectName("layoutWidget") + self.idpAuthGridLayout = QtWidgets.QGridLayout(self.layoutWidget) + self.idpAuthGridLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) + self.idpAuthGridLayout.setContentsMargins(0, 0, 0, 0) + self.idpAuthGridLayout.setObjectName("idpAuthGridLayout") + self.idpAuthTokenLabel = QtWidgets.QLabel(self.layoutWidget) + self.idpAuthTokenLabel.setObjectName("idpAuthTokenLabel") + self.idpAuthGridLayout.addWidget(self.idpAuthTokenLabel, 0, 0, 1, 1) + self.idpAuthPasswordLe = QtWidgets.QLineEdit(self.layoutWidget) + self.idpAuthPasswordLe.setText("") + self.idpAuthPasswordLe.setEchoMode(QtWidgets.QLineEdit.Normal) + self.idpAuthPasswordLe.setObjectName("idpAuthPasswordLe") + self.idpAuthGridLayout.addWidget(self.idpAuthPasswordLe, 0, 1, 1, 1) + self.idpAuthTokenSubmitBtn = QtWidgets.QPushButton(self.layoutWidget) + self.idpAuthTokenSubmitBtn.setObjectName("idpAuthTokenSubmitBtn") + self.idpAuthGridLayout.addWidget(self.idpAuthTokenSubmitBtn, 1, 1, 1, 1) + spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.idpAuthGridLayout.addItem(spacerItem1, 3, 0, 1, 2) + self.idpAuthTopicLabel = QtWidgets.QLabel(self.idpAuthPage) + self.idpAuthTopicLabel.setEnabled(True) + self.idpAuthTopicLabel.setGeometry(QtCore.QRect(0, 0, 456, 15)) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.idpAuthTopicLabel.sizePolicy().hasHeightForWidth()) + self.idpAuthTopicLabel.setSizePolicy(sizePolicy) + self.idpAuthTopicLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.idpAuthTopicLabel.setObjectName("idpAuthTopicLabel") + self.stackedWidget.addWidget(self.idpAuthPage) + self.gridLayout_4.addWidget(self.stackedWidget, 2, 0, 1, 1) self.line_2 = QtWidgets.QFrame(MSColabConnectDialog) self.line_2.setFrameShape(QtWidgets.QFrame.HLine) self.line_2.setFrameShadow(QtWidgets.QFrame.Sunken) self.line_2.setObjectName("line_2") - self.verticalLayout.addWidget(self.line_2) + self.gridLayout_4.addWidget(self.line_2, 3, 0, 1, 1) self.statusHL = QtWidgets.QHBoxLayout() self.statusHL.setContentsMargins(-1, 0, -1, -1) self.statusHL.setObjectName("statusHL") @@ -160,10 +195,10 @@ def setupUi(self, MSColabConnectDialog): self.statusLabel.setObjectName("statusLabel") self.statusHL.addWidget(self.statusLabel) self.statusHL.setStretch(0, 1) - self.verticalLayout.addLayout(self.statusHL) + self.gridLayout_4.addLayout(self.statusHL, 4, 0, 1, 1) self.retranslateUi(MSColabConnectDialog) - self.stackedWidget.setCurrentIndex(0) + self.stackedWidget.setCurrentIndex(3) QtCore.QMetaObject.connectSlotsByName(MSColabConnectDialog) MSColabConnectDialog.setTabOrder(self.urlCb, self.connectBtn) MSColabConnectDialog.setTabOrder(self.connectBtn, self.loginEmailLe) @@ -174,8 +209,7 @@ def setupUi(self, MSColabConnectDialog): MSColabConnectDialog.setTabOrder(self.newUsernameLe, self.newEmailLe) MSColabConnectDialog.setTabOrder(self.newEmailLe, self.newPasswordLe) MSColabConnectDialog.setTabOrder(self.newPasswordLe, self.newConfirmPasswordLe) - MSColabConnectDialog.setTabOrder(self.newConfirmPasswordLe, self.httpUsernameLe) - MSColabConnectDialog.setTabOrder(self.httpUsernameLe, self.httpPasswordLe) + MSColabConnectDialog.setTabOrder(self.newConfirmPasswordLe, self.httpPasswordLe) def retranslateUi(self, MSColabConnectDialog): _translate = QtCore.QCoreApplication.translate @@ -184,14 +218,18 @@ def retranslateUi(self, MSColabConnectDialog): self.urlCb.setToolTip(_translate("MSColabConnectDialog", "Enter Mscolab Server URL")) self.connectBtn.setToolTip(_translate("MSColabConnectDialog", "Connect to entered URL")) self.connectBtn.setText(_translate("MSColabConnectDialog", "Connect")) - self.addUserBtn.setToolTip(_translate("MSColabConnectDialog", "Add new user to the server")) - self.addUserBtn.setText(_translate("MSColabConnectDialog", "Add user")) + self.connectBtn.setShortcut(_translate("MSColabConnectDialog", "Return")) + self.disconnectBtn.setText(_translate("MSColabConnectDialog", "Disconnect")) self.loginBtn.setToolTip(_translate("MSColabConnectDialog", "Login using entered credentials")) self.loginBtn.setText(_translate("MSColabConnectDialog", "Login")) - self.loginPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Password")) - self.loginEmailLe.setPlaceholderText(_translate("MSColabConnectDialog", "Email ID")) - self.clickNewUserLabel.setText(_translate("MSColabConnectDialog", "Click here if new user")) + self.loginBtn.setShortcut(_translate("MSColabConnectDialog", "Return")) + self.addUserBtn.setToolTip(_translate("MSColabConnectDialog", "Add new user to the server")) + self.addUserBtn.setText(_translate("MSColabConnectDialog", "Add user")) self.loginTopicLabel.setText(_translate("MSColabConnectDialog", "Login Details:")) + self.clickNewUserLabel.setText(_translate("MSColabConnectDialog", "Click here if new user")) + self.loginWithIDPBtn.setText(_translate("MSColabConnectDialog", "Login by Identity Provider")) + self.loginEmailLe.setPlaceholderText(_translate("MSColabConnectDialog", "Email ID")) + self.loginPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Password")) self.newUsernameLe.setPlaceholderText(_translate("MSColabConnectDialog", "John Doe")) self.newPasswordLabel.setText(_translate("MSColabConnectDialog", "Password:")) self.newConfirmPasswordLabel.setText(_translate("MSColabConnectDialog", "Confirm Password:")) @@ -202,9 +240,10 @@ def retranslateUi(self, MSColabConnectDialog): self.newUsernameLabel.setText(_translate("MSColabConnectDialog", "Username:")) self.newConfirmPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Confirm New Password")) self.httpTopicLabel.setText(_translate("MSColabConnectDialog", "HTTP Server Authentication")) - self.httpInfoLabel.setText(_translate("MSColabConnectDialog", "The server you are trying to connect requires a username and a password:")) - self.httpUsernameLabel.setText(_translate("MSColabConnectDialog", "Username:")) self.httpPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Server Auth Password")) self.httpPasswordLabel.setText(_translate("MSColabConnectDialog", "Password:")) - self.httpUsernameLe.setPlaceholderText(_translate("MSColabConnectDialog", "Server Auth Username")) + self.idpAuthTokenLabel.setText(_translate("MSColabConnectDialog", "Token")) + self.idpAuthPasswordLe.setPlaceholderText(_translate("MSColabConnectDialog", "Identity Provider Auth Token")) + self.idpAuthTokenSubmitBtn.setText(_translate("MSColabConnectDialog", "Submit")) + self.idpAuthTopicLabel.setText(_translate("MSColabConnectDialog", "Identity Provider Authentication")) self.statusLabel.setText(_translate("MSColabConnectDialog", "Status:")) diff --git a/mslib/msui/qt5/ui_operation_archive.py b/mslib/msui/qt5/ui_operation_archive.py new file mode 100644 index 000000000..7b19f3474 --- /dev/null +++ b/mslib/msui/qt5/ui_operation_archive.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'mslib/msui/ui/ui_operation_archive.ui' +# +# Created by: PyQt5 UI code generator 5.15.7 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_OperationArchiveBrowser(object): + def setupUi(self, OperationArchiveBrowser): + OperationArchiveBrowser.setObjectName("OperationArchiveBrowser") + OperationArchiveBrowser.resize(754, 393) + self.verticalLayout = QtWidgets.QVBoxLayout(OperationArchiveBrowser) + self.verticalLayout.setObjectName("verticalLayout") + self.label = QtWidgets.QLabel(OperationArchiveBrowser) + self.label.setObjectName("label") + self.verticalLayout.addWidget(self.label) + self.listArchivedOperations = QtWidgets.QListWidget(OperationArchiveBrowser) + self.listArchivedOperations.setObjectName("listArchivedOperations") + self.verticalLayout.addWidget(self.listArchivedOperations) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.pbUnarchiveOperation = QtWidgets.QPushButton(OperationArchiveBrowser) + self.pbUnarchiveOperation.setObjectName("pbUnarchiveOperation") + self.horizontalLayout.addWidget(self.pbUnarchiveOperation) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout.addItem(spacerItem) + self.pbClose = QtWidgets.QPushButton(OperationArchiveBrowser) + self.pbClose.setObjectName("pbClose") + self.horizontalLayout.addWidget(self.pbClose) + self.verticalLayout.addLayout(self.horizontalLayout) + + self.retranslateUi(OperationArchiveBrowser) + QtCore.QMetaObject.connectSlotsByName(OperationArchiveBrowser) + + def retranslateUi(self, OperationArchiveBrowser): + _translate = QtCore.QCoreApplication.translate + OperationArchiveBrowser.setWindowTitle(_translate("OperationArchiveBrowser", "Browse archived operations")) + self.label.setText(_translate("OperationArchiveBrowser", "Operation Archive")) + self.pbUnarchiveOperation.setToolTip(_translate("OperationArchiveBrowser", "

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

")) + self.pbUnarchiveOperation.setText(_translate("OperationArchiveBrowser", "Unarchive")) + self.pbClose.setText(_translate("OperationArchiveBrowser", "close")) diff --git a/mslib/msui/remotesensing_dockwidget.py b/mslib/msui/remotesensing_dockwidget.py index 64369980f..7d83ddd0b 100644 --- a/mslib/msui/remotesensing_dockwidget.py +++ b/mslib/msui/remotesensing_dockwidget.py @@ -33,7 +33,7 @@ import skyfield_data from PyQt5 import QtGui, QtWidgets -from mslib.utils.qt import ui_remotesensing_dockwidget as ui +from mslib.msui.qt5 import ui_remotesensing_dockwidget as ui from mslib.utils.time import jsec_to_datetime, datetime_to_jsec from mslib.utils.coordinate import get_distance, rotate_point, fix_angle, normalize_longitude @@ -51,7 +51,7 @@ def __init__(self, parent=None, view=None): parent -- Qt widget that is parent to this widget. view -- reference to mpl canvas class """ - super(RemoteSensingControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view diff --git a/mslib/msui/satellite_dockwidget.py b/mslib/msui/satellite_dockwidget.py index 3f37267e8..a1e761780 100644 --- a/mslib/msui/satellite_dockwidget.py +++ b/mslib/msui/satellite_dockwidget.py @@ -33,7 +33,7 @@ import numpy as np from mslib.utils.qt import get_open_filename -from mslib.utils.qt import ui_satellite_dockwidget as ui +from mslib.msui.qt5 import ui_satellite_dockwidget as ui from PyQt5 import QtWidgets from mslib.utils.config import save_settings_qsettings, load_settings_qsettings from fs import open_fs @@ -114,7 +114,7 @@ def read_nasa_satellite_prediction(fname): class SatelliteControlWidget(QtWidgets.QWidget, ui.Ui_SatelliteDockWidget): def __init__(self, parent=None, view=None): - super(SatelliteControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view diff --git a/mslib/msui/sideview.py b/mslib/msui/sideview.py index d4edcca4c..75c9ab7d2 100644 --- a/mslib/msui/sideview.py +++ b/mslib/msui/sideview.py @@ -31,8 +31,8 @@ from PyQt5 import QtGui, QtWidgets -from mslib.utils.qt import ui_sideview_window as ui -from mslib.utils.qt import ui_sideview_options as ui_opt +from mslib.msui.qt5 import ui_sideview_window as ui +from mslib.msui.qt5 import ui_sideview_options as ui_opt from mslib.msui.viewwindows import MSUIMplViewWindow from mslib.msui import wms_control as wms from mslib.msui.icons import icons @@ -56,7 +56,7 @@ def __init__(self, parent=None, settings=None): parent -- Qt widget that is parent to this widget. settings -- dictionary containing sideview options. """ - super(MSUI_SV_OptionsDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self._suffixes = ['hPa', 'km', 'hft'] @@ -256,10 +256,10 @@ def __init__(self, parent=None, model=None, _id=None): """ Set up user interface, connect signal/slots. """ - super(MSUISideViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) - + self.settings_tag = "sideview" # Dock windows [WMS]: self.cbTools.clear() self.cbTools.addItems(["(select to open control)", "Vertical Section WMS"]) @@ -307,7 +307,7 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the view displays. """ - super(MSUISideViewWindow, self).setFlightTrackModel(model) + super().setFlightTrackModel(model) if self.docks[WMS] is not None: self.docks[WMS].widget().setFlightTrackModel(model) diff --git a/mslib/msui/socket_control.py b/mslib/msui/socket_control.py index 7c99b32ce..840081905 100644 --- a/mslib/msui/socket_control.py +++ b/mslib/msui/socket_control.py @@ -35,17 +35,17 @@ class ConnectionManager(QtCore.QObject): - signal_reload = QtCore.Signal(int, name="reload_wps") - signal_message_receive = QtCore.Signal(str, name="message rcv") - signal_message_reply_receive = QtCore.Signal(str, name="message reply") - signal_message_edited = QtCore.Signal(str, name="message editted") - signal_message_deleted = QtCore.Signal(str, name="message deleted") - signal_new_permission = QtCore.Signal(int, int, name="new permission") - signal_update_permission = QtCore.Signal(int, int, str, name="update permission") - signal_revoke_permission = QtCore.Signal(int, int, name="revoke permission") - signal_operation_permissions_updated = QtCore.Signal(int, name="operation permissions updated") - signal_operation_list_updated = QtCore.Signal(name="operation list updated") - signal_operation_deleted = QtCore.Signal(int, name="operation deleted") + signal_reload = QtCore.pyqtSignal(int, name="reload_wps") + signal_message_receive = QtCore.pyqtSignal(str, name="message rcv") + signal_message_reply_receive = QtCore.pyqtSignal(str, name="message reply") + signal_message_edited = QtCore.pyqtSignal(str, name="message editted") + signal_message_deleted = QtCore.pyqtSignal(str, name="message deleted") + signal_new_permission = QtCore.pyqtSignal(int, int, name="new permission") + signal_update_permission = QtCore.pyqtSignal(int, int, str, name="update permission") + signal_revoke_permission = QtCore.pyqtSignal(int, int, name="revoke permission") + signal_operation_permissions_updated = QtCore.pyqtSignal(int, name="operation permissions updated") + signal_operation_list_updated = QtCore.pyqtSignal(name="operation list updated") + signal_operation_deleted = QtCore.pyqtSignal(int, name="operation deleted") def __init__(self, token, user, mscolab_server_url=mss_default.mscolab_server_url): super(ConnectionManager, self).__init__() @@ -195,4 +195,24 @@ def save_file(self, token, op_id, content, comment=None): self.signal_reload.emit(op_id) def disconnect(self): + # Get all pyqtSignals defined in this class and disconnect them from all slots + allSignals = { + attr + for attr in dir(self.__class__) + if isinstance(getattr(self.__class__, attr), QtCore.pyqtSignal) + } + inheritedSignals = { + attr + for base_class in self.__class__.__bases__ + for attr in dir(base_class) + if isinstance(getattr(base_class, attr), QtCore.pyqtSignal) + } + signals = {getattr(self, signal) for signal in allSignals - inheritedSignals} + for signal in signals: + try: + signal.disconnect() + except TypeError: + # The disconnect call can fail if there are no connected slots, so catch that error here + pass + self.sio.disconnect() diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index df793c90f..8fe687d3b 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -34,10 +34,10 @@ import types -from mslib.msui import hexagon_dockwidget as hex +from mslib.msui import hexagon_dockwidget as hex_dock from mslib.msui import performance_settings as perfset from PyQt5 import QtWidgets, QtGui -from mslib.utils.qt import ui_tableview_window as ui +from mslib.msui.qt5 import ui_tableview_window as ui from mslib.utils.qt import dropEvent, dragEnterEvent from mslib.msui import flighttrack as ft from mslib.msui.viewwindows import MSUIViewWindow @@ -60,9 +60,11 @@ class MSUITableViewWindow(MSUIViewWindow, ui.Ui_TableViewWindow): def __init__(self, parent=None, model=None, _id=None): """ """ - super(MSUITableViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) + self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) + self.settings_tag = "tableview" self.setFlightTrackModel(model) self.tableWayPoints.setItemDelegate(ft.WaypointDelegate(self)) @@ -115,7 +117,7 @@ def openTool(self, index): if index >= 0: if index == 0: title = "Hexagon Control" - widget = hex.HexagonControlWidget(view=self) + widget = hex_dock.HexagonControlWidget(view=self) elif index == 1: title = "Performance Settings" widget = perfset.MSUI_PerformanceSettingsWidget( @@ -202,7 +204,7 @@ def confirm_delete_waypoint(self, rows): wps = self.waypoints_model.all_waypoint_data() if len(wps) - len(rows) < 2: QtWidgets.QMessageBox.warning( - None, "Remove waypoint", + self.tableWayPoints, "Remove waypoint", "Cannot remove waypoint, the flight track needs to consist of at least two points.") return False else: @@ -212,7 +214,7 @@ def confirm_delete_waypoint(self, rows): for waypoint in waypoints]) return QtWidgets.QMessageBox.question( - None, "Remove waypoint", text, + self.tableWayPoints, "Remove waypoint", text, QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes @@ -271,7 +273,7 @@ def setFlightTrackModel(self, model): """ Set the QAbstractItemModel instance that the table displays. """ - super(MSUITableViewWindow, self).setFlightTrackModel(model) + super().setFlightTrackModel(model) self.tableWayPoints.setModel(self.waypoints_model) # Automatically enable or disable roundtrip when data changes diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index 70792e29c..24a19a5f5 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -30,11 +30,12 @@ import functools import logging + from mslib.utils.config import config_loader from mslib.utils.coordinate import get_projection_params from PyQt5 import QtGui, QtWidgets, QtCore -from mslib.utils.qt import ui_topview_window as ui -from mslib.utils.qt import ui_topview_mapappearance as ui_ma +from mslib.msui.qt5 import ui_topview_window as ui +from mslib.msui.qt5 import ui_topview_mapappearance as ui_ma from mslib.msui.viewwindows import MSUIMplViewWindow from mslib.msui import wms_control as wc from mslib.msui import satellite_dockwidget as sat @@ -60,7 +61,7 @@ class MSUI_TV_MapAppearanceDialog(QtWidgets.QDialog, ui_ma.Ui_MapAppearanceDialo Dialog to set map appearance parameters. User interface is defined in "ui_topview_mapappearance.py". """ - signal_ft_vertices_color_change = QtCore.Signal(str, tuple) + signal_ft_vertices_color_change = QtCore.pyqtSignal(str, tuple) def __init__(self, parent=None, settings=None, wms_connected=False): """ @@ -68,7 +69,7 @@ def __init__(self, parent=None, settings=None, wms_connected=False): parent -- Qt widget that is parent to this widget. settings -- dictionary containing topview options. """ - super(MSUI_TV_MapAppearanceDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) assert settings is not None @@ -174,24 +175,39 @@ class MSUITopViewWindow(MSUIMplViewWindow, ui.Ui_TopViewWindow): """ name = "Top View" - signal_activate_flighttrack1 = QtCore.Signal(ft.WaypointsTableModel) - signal_activate_operation = QtCore.Signal(int) - signal_ft_vertices_color_change = QtCore.Signal(tuple) - signal_operation_added = QtCore.Signal(int, str) - signal_operation_removed = QtCore.Signal(int) - signal_login_mscolab = QtCore.Signal(str, str) - signal_logout_mscolab = QtCore.Signal() - signal_listFlighttrack_doubleClicked = QtCore.Signal() - signal_permission_revoked = QtCore.Signal(int) - signal_render_new_permission = QtCore.Signal(int, str) - - def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, mscolab_server_url=None, token=None): + signal_activate_flighttrack1 = QtCore.pyqtSignal(ft.WaypointsTableModel) + signal_activate_operation = QtCore.pyqtSignal(int) + signal_ft_vertices_color_change = QtCore.pyqtSignal(tuple) + signal_operation_added = QtCore.pyqtSignal(int, str) + signal_operation_removed = QtCore.pyqtSignal(int) + signal_login_mscolab = QtCore.pyqtSignal(str, str) + signal_logout_mscolab = QtCore.pyqtSignal() + signal_listFlighttrack_doubleClicked = QtCore.pyqtSignal() + signal_permission_revoked = QtCore.pyqtSignal(int) + signal_render_new_permission = QtCore.pyqtSignal(int, str) + + def __init__(self, parent=None, mainwindow=None, model=None, _id=None, + active_flighttrack=None, mscolab_server_url=None, token=None): """ Set up user interface, connect signal/slots. """ - super(MSUITopViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) logging.debug(_id) - self.ui = parent + self.settings_tag = "topview" + self.mainwindow_signal_login_mscolab = mainwindow.signal_login_mscolab + self.mainwindow_signal_logout_mscolab = mainwindow.signal_logout_mscolab + self.mainwindow_signal_listFlighttrack_doubleClicked = mainwindow.signal_listFlighttrack_doubleClicked + self.mainwindow_signal_activate_operation = mainwindow.signal_activate_operation + self.mainwindow_signal_permission_revoked = mainwindow.signal_permission_revoked + self.mainwindow_signal_render_new_permission = mainwindow.signal_render_new_permission + self.mainwindow_signal_activate_flighttrack = mainwindow.signal_activate_flighttrack + self.mainwindow_signal_activate_operation = mainwindow.signal_activate_operation + self.mainwindow_signal_login_mscolab = mainwindow.signal_login_mscolab + self.mainwindow_signal_logout_mscolab = mainwindow.signal_logout_mscolab + self.mainwindow_listFlightTracks = mainwindow.listFlightTracks + self.mainwindow_filterCategoryCb = mainwindow.filterCategoryCb + self.mainwindow_listOperationsMSC = mainwindow.listOperationsMSC + self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) @@ -230,20 +246,20 @@ def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, m # Tool opener. self.cbTools.currentIndexChanged.connect(self.openTool) - if parent is not None: + if mainwindow is not None: # Update flighttrack - self.ui.signal_activate_flighttrack.connect(self.update_active_flighttrack) - self.ui.signal_activate_operation.connect(self.update_active_operation) + self.mainwindow_signal_activate_flighttrack.connect(self.update_active_flighttrack) + self.mainwindow_signal_activate_operation.connect(self.update_active_operation) - self.ui.signal_operation_added.connect(self.add_operation_slot) - self.ui.signal_operation_removed.connect(self.remove_operation_slot) + self.signal_operation_added.connect(self.add_operation_slot) + self.signal_operation_removed.connect(self.remove_operation_slot) - self.ui.signal_login_mscolab.connect(self.login) + self.mainwindow_signal_login_mscolab.connect(self.login) def __del__(self): del self.mpl.canvas.waypoints_interactor - @QtCore.Slot(ft.WaypointsTableModel) + @QtCore.pyqtSlot(ft.WaypointsTableModel) def update_active_flighttrack(self, active_flighttrack): """ Slot that handles update of active flighttrack variable. @@ -251,20 +267,20 @@ def update_active_flighttrack(self, active_flighttrack): self.active_flighttrack = active_flighttrack self.signal_activate_flighttrack1.emit(active_flighttrack) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def update_active_operation(self, active_op_id): self.active_op_id = active_op_id self.signal_activate_operation.emit(self.active_op_id) - @QtCore.Slot(int, str) + @QtCore.pyqtSlot(int, str) def add_operation_slot(self, op_id, path): self.signal_operation_added.emit(op_id, path) - @QtCore.Slot(int) + @QtCore.pyqtSlot(int) def remove_operation_slot(self, op_id): self.signal_operation_removed.emit(op_id) - @QtCore.Slot(str, str) + @QtCore.pyqtSlot(str, str) def login(self, mscolab_server_url, token): self.mscolab_server_url = mscolab_server_url self.token = token @@ -339,17 +355,19 @@ def openTool(self, index): elif index == MULTIPLEFLIGHTPATH: title = "Multiple Flightpath" widget = mf.MultipleFlightpathControlWidget(parent=self, view=self.mpl.canvas, - listFlightTracks=self.ui.listFlightTracks, - listOperationsMSC=self.ui.listOperationsMSC, + listFlightTracks=self.mainwindow_listFlightTracks, + listOperationsMSC=self.mainwindow_listOperationsMSC, + category=self.mainwindow_filterCategoryCb, activeFlightTrack=self.active_flighttrack, mscolab_server_url=self.mscolab_server_url, token=self.token) - self.ui.signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) - self.ui.signal_listFlighttrack_doubleClicked.connect( + self.mainwindow_signal_logout_mscolab.connect(lambda: self.signal_logout_mscolab.emit()) + self.mainwindow_signal_listFlighttrack_doubleClicked.connect( lambda: self.signal_listFlighttrack_doubleClicked.emit()) - self.ui.signal_permission_revoked.connect(lambda op_id: self.signal_permission_revoked.emit(op_id)) - self.ui.signal_render_new_permission.connect( + self.mainwindow_signal_permission_revoked.connect( + lambda op_id: self.signal_permission_revoked.emit(op_id)) + self.mainwindow_signal_render_new_permission.connect( lambda op_id, path: self.signal_render_new_permission.emit(op_id, path)) if self.active_op_id is not None: self.signal_activate_operation.emit(self.active_op_id) @@ -361,18 +379,18 @@ def openTool(self, index): self.createDockWidget(index, title, widget) def closed(self): - self.ui.signal_login_mscolab.disconnect() - self.ui.signal_logout_mscolab.disconnect() - self.ui.signal_listFlighttrack_doubleClicked.disconnect() - self.ui.signal_activate_operation.disconnect() - self.ui.signal_permission_revoked.disconnect() - self.ui.signal_render_new_permission.disconnect() - - @QtCore.Slot() + self.mainwindow_signal_login_mscolab.disconnect() + self.mainwindow_signal_logout_mscolab.disconnect() + self.mainwindow_signal_listFlighttrack_doubleClicked.disconnect() + self.mainwindow_signal_activate_operation.disconnect() + self.mainwindow_signal_permission_revoked.disconnect() + self.mainwindow_signal_render_new_permission.disconnect() + + @QtCore.pyqtSlot() def disable_cbs(self): self.wms_connected = True - @QtCore.Slot() + @QtCore.pyqtSlot() def enable_cbs(self): self.wms_connected = False @@ -404,7 +422,7 @@ def changeMapSection(self, index=0, only_kwargs=False): self.mpl.navbar.clear_history() def setIdentifier(self, identifier): - super(MSUITopViewWindow, self).setIdentifier(identifier) + super().setIdentifier(identifier) self.mpl.canvas.map.set_identifier(identifier) def open_settings_dialog(self): @@ -420,7 +438,7 @@ def open_settings_dialog(self): self.mpl.canvas.waypoints_interactor.redraw_path() dlg.destroy() - @QtCore.Slot(str, tuple) + @QtCore.pyqtSlot(str, tuple) def set_ft_vertices_color(self, which, color): if which == "ft_vertices": self.signal_ft_vertices_color_change.emit(color) diff --git a/mslib/msui/ui/ui_mainwindow.ui b/mslib/msui/ui/ui_mainwindow.ui index efb8e8fc3..9f6209063 100644 --- a/mslib/msui/ui/ui_mainwindow.ui +++ b/mslib/msui/ui/ui_mainwindow.ui @@ -112,6 +112,9 @@ Connect + + true + @@ -230,7 +233,41 @@ Save a flight track to name it. 8 - + + + + No operations selected + + + true + + + + + + + Select Operation to View Description + + + + + + + Operations + + + + + + + Check to work asynchronously from the server + + + Work Asynchronously + + + + filter by operation category @@ -254,34 +291,7 @@ Save a flight track to name it. - - - - Category: - - - - - - - Archived Operations - - - - - - - - - - No operations selected - - - true - - - - + Fetch/Save Server options @@ -303,13 +313,10 @@ Save a flight track to name it. - - - - Check to work asynchronously from the server - + + - Work Asynchronously + Category: @@ -321,17 +328,16 @@ Double click a operation to activate and view its description. - - - - Select Operation to View Description + + + + + 0 + 0 + - - - - - Operations + Operation Archive @@ -350,7 +356,7 @@ Double click a operation to activate and view its description. 0 0 738 - 20 + 22 @@ -417,19 +423,20 @@ Double click a operation to activate and view its description. - Properties + Maintenance - - - + + + - + + - - + + @@ -584,14 +591,14 @@ Double click a operation to activate and view its description. Ctrl+F - + View Description - + - Update Description + Change Description @@ -604,9 +611,14 @@ Double click a operation to activate and view its description. &Leave Operation - + + + Archive Operation + + + - Unarchive Operation + Change Category diff --git a/mslib/msui/ui/ui_mscolab_admin_window.ui b/mslib/msui/ui/ui_mscolab_admin_window.ui index 38364b941..749fb215e 100644 --- a/mslib/msui/ui/ui_mscolab_admin_window.ui +++ b/mslib/msui/ui/ui_mscolab_admin_window.ui @@ -38,37 +38,25 @@ - - - 6 - - - QLayout::SetDefaultConstraint - - - 0 + + + Logged In: - - 8 + + + + + + Operation: - - 8 + + + + + + Creator: - - - - Logged In: - - - - - - - Operation: - - - - + diff --git a/mslib/msui/ui/ui_mscolab_connect_dialog.ui b/mslib/msui/ui/ui_mscolab_connect_dialog.ui index d740d2bc0..595eee1dd 100644 --- a/mslib/msui/ui/ui_mscolab_connect_dialog.ui +++ b/mslib/msui/ui/ui_mscolab_connect_dialog.ui @@ -7,29 +7,14 @@ 0 0 478 - 255 + 271 Connect to MSColab - - - 5 - - - 12 - - - 10 - - - 10 - - - 10 - - + + @@ -56,24 +41,34 @@ Connect + + Return + - false + true + + + + + + + Disconnect - + Qt::Horizontal - + - 0 + 3 @@ -89,6 +84,22 @@ 0 + + + + Login using entered credentials + + + Login + + + Return + + + true + + + @@ -102,26 +113,29 @@ - - - - Login using entered credentials + + + + + 16 + - Login - - - false + Login Details: - - - - QLineEdit::Password + + + + Click here if new user - - Password + + + + + + Login by Identity Provider @@ -132,22 +146,13 @@ - - - - Click here if new user - - - - - - - - 16 - + + + + QLineEdit::Password - - Login Details: + + Password @@ -272,28 +277,29 @@ + + true + + + + 0 + 0 + + HTTP Server Authentication - - - - - - The server you are trying to connect requires a username and a password: + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - Username: - - - - + + QLayout::SetDefaultConstraint + + QLineEdit::Password @@ -303,41 +309,125 @@ - + Password: - - - - Server Auth Username - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + Qt::Vertical - + + + 20 + 40 + + + + + + true + + + + + 0 + 20 + 451 + 141 + + + + + QLayout::SetDefaultConstraint + + + + + Token + + + + + + + + + + QLineEdit::Normal + + + Identity Provider Auth Token + + + + + + + Submit + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + true + + + + 0 + 0 + 456 + 15 + + + + + 0 + 0 + + + + Identity Provider Authentication + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + - + Qt::Horizontal - + 0 @@ -367,7 +457,6 @@ newEmailLe newPasswordLe newConfirmPasswordLe - httpUsernameLe httpPasswordLe diff --git a/mslib/msui/ui/ui_operation_archive.ui b/mslib/msui/ui/ui_operation_archive.ui new file mode 100644 index 000000000..62cf5e185 --- /dev/null +++ b/mslib/msui/ui/ui_operation_archive.ui @@ -0,0 +1,65 @@ + + + OperationArchiveBrowser + + + + 0 + 0 + 754 + 393 + + + + Browse archived operations + + + + + + Operation Archive + + + + + + + + + + + + <html><head/><body><p>Becomes available when you have the right to unarchive an operation. Moves this operation to the list of current operations.</p></body></html> + + + Unarchive + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + close + + + + + + + + + + diff --git a/mslib/msui/updater.py b/mslib/msui/updater.py index f81003f94..19be9e59e 100644 --- a/mslib/msui/updater.py +++ b/mslib/msui/updater.py @@ -26,7 +26,8 @@ from PyQt5 import QtCore, QtWidgets, QtGui -from mslib.utils.qt import ui_updater_dialog, Updater +from mslib.utils.qt import Updater +from mslib.msui.qt5 import ui_updater_dialog from mslib import __version__ @@ -38,7 +39,7 @@ class UpdaterUI(QtWidgets.QDialog, ui_updater_dialog.Ui_Updater): on_update_available = QtCore.pyqtSignal([str, str]) def __init__(self, parent=None): - super(UpdaterUI, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.hide() self.labelVersion.setText(f"Newest Version: {__version__}") diff --git a/mslib/msui/viewwindows.py b/mslib/msui/viewwindows.py index 052c33a37..a72c62c6d 100644 --- a/mslib/msui/viewwindows.py +++ b/mslib/msui/viewwindows.py @@ -26,11 +26,12 @@ See the License for the specific language governing permissions and limitations under the License. """ +import logging from abc import abstractmethod from PyQt5 import QtCore, QtWidgets -import logging +from mslib.utils.config import save_settings_qsettings class MSUIViewWindow(QtWidgets.QMainWindow): @@ -43,10 +44,10 @@ class MSUIViewWindow(QtWidgets.QMainWindow): viewCloses = QtCore.pyqtSignal(name="viewCloses") # views for mscolab - # viewClosesId = QtCore.Signal(int, name="viewClosesId") + # viewClosesId = QtCore.pyqtSignal(int, name="viewClosesId") def __init__(self, parent=None, model=None, _id=None): - super(MSUIViewWindow, self).__init__(parent) + super().__init__(parent) # Object variables: self.waypoints_model = model # pointer to the current flight track. @@ -212,6 +213,24 @@ def disable_navbar_action_buttons(self): self.cbTools.setEnabled(False) self.tableWayPoints.setEnabled(False) + def changeEvent(self, event): + top_left = self.mapToGlobal(QtCore.QPoint(0, 0)) + if top_left.x() != 0: + os_screen_region = (top_left.x(), top_left.y(), self.width(), self.height()) + settings = {'os_screen_region': os_screen_region} + # we have to save this to reuse it by the tutorials + save_settings_qsettings(self.settings_tag, settings) + QtWidgets.QWidget.changeEvent(self, event) + + def moveEvent(self, event): + top_left = self.mapToGlobal(QtCore.QPoint(0, 0)) + if top_left.x() != 0: + os_screen_region = (top_left.x(), top_left.y(), self.width(), self.height()) + settings = {'os_screen_region': os_screen_region} + # we have to save this to reuse it by the tutorials + save_settings_qsettings(self.settings_tag, settings) + QtWidgets.QWidget.moveEvent(self, event) + class MSUIMplViewWindow(MSUIViewWindow): """ @@ -219,7 +238,7 @@ class MSUIMplViewWindow(MSUIViewWindow): """ def __init__(self, parent=None, model=None, _id=None): - super(MSUIMplViewWindow, self).__init__(parent, model, _id) + super().__init__(parent, model, _id) logging.debug(_id) self.mpl = None diff --git a/mslib/msui/wms_capabilities.py b/mslib/msui/wms_capabilities.py index a446649aa..ede744fb9 100644 --- a/mslib/msui/wms_capabilities.py +++ b/mslib/msui/wms_capabilities.py @@ -29,7 +29,7 @@ import collections from PyQt5 import QtWidgets -from mslib.utils.qt import ui_wms_capabilities as ui +from mslib.msui.qt5 import ui_wms_capabilities as ui class WMSCapabilitiesBrowser(QtWidgets.QDialog, ui.Ui_WMSCapabilitiesBrowser): @@ -42,7 +42,7 @@ def __init__(self, parent=None, url=None, capabilities=None): parent -- Qt widget that is parent to this widget. capabilities_xml -- . """ - super(WMSCapabilitiesBrowser, self).__init__(parent) + super().__init__(parent) self.setupUi(self) if url is None: diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 32697b39b..a1b16a8b5 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -47,8 +47,8 @@ from keyring.errors import NoKeyringError, PasswordSetError, InitError from mslib.msui import constants, wms_capabilities -from mslib.utils.qt import ui_wms_dockwidget as ui -from mslib.utils.qt import ui_wms_password_dialog as ui_pw +from mslib.msui.qt5 import ui_wms_dockwidget as ui +from mslib.msui.qt5 import ui_wms_password_dialog as ui_pw from mslib.utils.qt import Worker from mslib.msui.multilayers import Multilayers, Layer import mslib.utils.ogcwms as ogcwms @@ -79,7 +79,8 @@ class MSUIWebMapService(ogcwms.WebMapService): """ def getmap(self, layers=None, styles=None, srs=None, bbox=None, - format=None, size=None, time=None, init_time=None, + format=None, # noqa: A002 + size=None, time=None, init_time=None, path_str=None, level=None, transparent=False, bgcolor='#FFFFFF', time_name="time", init_time_name="init_time", exceptions='XML', method='Get', @@ -238,7 +239,7 @@ def __init__(self, parent=None): Arguments: parent -- Qt widget that is parent to this widget. """ - super(MSS_WMS_AuthenticationDialog, self).__init__(parent) + super().__init__(parent) self.setupUi(self) def getAuthInfo(self): @@ -266,7 +267,7 @@ class WMSMapFetcher(QtCore.QObject): started_request = QtCore.pyqtSignal() def __init__(self, wms_cache, parent=None): - super(WMSMapFetcher, self).__init__(parent) + super().__init__(parent) self.wms_cache = wms_cache self.maps = [] self.map_imgs = [] @@ -400,8 +401,8 @@ class WMSControlWidget(QtWidgets.QWidget, ui.Ui_WMSDockWidget): prefetch = QtCore.pyqtSignal([list], name="prefetch") fetch = QtCore.pyqtSignal([list], name="fetch") - signal_disable_cbs = QtCore.Signal(name="disable_cbs") - signal_enable_cbs = QtCore.Signal(name="enable_cbs") + signal_disable_cbs = QtCore.pyqtSignal(name="disable_cbs") + signal_enable_cbs = QtCore.pyqtSignal(name="enable_cbs") image_displayed = QtCore.pyqtSignal() def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): @@ -411,7 +412,7 @@ def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): default_WMS -- list of strings that specify WMS URLs that will be displayed in the URL combobox as default values. """ - super(WMSControlWidget, self).__init__(parent) + super().__init__(parent) self.setupUi(self) self.view = view @@ -1241,7 +1242,9 @@ def get_md5_filename(self, layer, kwargs): return os.path.join(self.wms_cache, hashlib.md5(urlstr.encode('utf-8')).hexdigest() + ending) def retrieve_image(self, layers=None, crs="EPSG:4326", bbox=None, path_string=None, - width=800, height=400, transparent=False, format="image/png"): + width=800, height=400, transparent=False, + format="image/png", # noqa: A002 + ): """Retrieve an image of the layer currently selected in the GUI elements from the current WMS provider. If caching is enabled, first check the cache for the requested image. If @@ -1494,12 +1497,6 @@ def append_multiple_images(self, imgs): result.thumbnail((result.width, max_height), Image.ANTIALIAS) return result - ################################################################################ - -# -# CLASS VSecWMSControlWidget -# - class VSecWMSControlWidget(WMSControlWidget): """Subclass of WMSControlWidget that extends the WMS client to @@ -1508,7 +1505,7 @@ class VSecWMSControlWidget(WMSControlWidget): def __init__(self, parent=None, default_WMS=None, waypoints_model=None, view=None, wms_cache=None): - super(VSecWMSControlWidget, self).__init__( + super().__init__( parent=parent, default_WMS=default_WMS, wms_cache=wms_cache, view=view) self.waypoints_model = waypoints_model self.btGetMap.clicked.connect(self.get_all_maps) @@ -1525,7 +1522,7 @@ def setFlightTrackModel(self, model): """ self.waypoints_model = model - @QtCore.Slot() + @QtCore.pyqtSlot() def call_get_vsec(self): if self.btGetMap.isEnabled() and self.cbAutoUpdate.isChecked() and not self.layerChangeInProgress: self.get_all_maps() @@ -1579,10 +1576,6 @@ def is_layer_aligned(self, layer): crss = getattr(layer, "crsOptions", None) return crss is not None and any(crs.startswith("VERT") for crs in crss) -# -# CLASS HSecWMSControlWidget -# - class HSecWMSControlWidget(WMSControlWidget): """Subclass of WMSControlWidget that extends the WMS client to @@ -1590,7 +1583,7 @@ class HSecWMSControlWidget(WMSControlWidget): """ def __init__(self, parent=None, default_WMS=None, view=None, wms_cache=None): - super(HSecWMSControlWidget, self).__init__( + super().__init__( parent=parent, default_WMS=default_WMS, wms_cache=wms_cache, view=view) self.btGetMap.clicked.connect(self.get_all_maps) @@ -1652,7 +1645,7 @@ class LSecWMSControlWidget(WMSControlWidget): def __init__(self, parent=None, default_WMS=None, waypoints_model=None, view=None, wms_cache=None): - super(LSecWMSControlWidget, self).__init__( + super().__init__( parent=parent, default_WMS=default_WMS, wms_cache=wms_cache, view=view) self.waypoints_model = waypoints_model self.btGetMap.clicked.connect(self.get_all_maps) @@ -1669,7 +1662,7 @@ def setFlightTrackModel(self, model): """ self.waypoints_model = model - @QtCore.Slot() + @QtCore.pyqtSlot() def call_get_lsec(self): if self.btGetMap.isEnabled() and self.cbAutoUpdate.isChecked() and not self.layerChangeInProgress: self.get_all_maps() diff --git a/mslib/mswms/app/__init__.py b/mslib/mswms/app/__init__.py index e53968228..1f047ff40 100644 --- a/mslib/mswms/app/__init__.py +++ b/mslib/mswms/app/__init__.py @@ -27,7 +27,7 @@ import os import mslib -from flask import Flask +from flask import Flask, url_for from mslib.mswms.gallery_builder import STATIC_LOCATION from mslib.utils import prefix_route @@ -42,3 +42,16 @@ static_folder=STATIC_LOCATION) APP.config.from_object(__name__) APP.route = prefix_route(APP.route, SCRIPT_NAME) + + +def get_topmenu(): + menu = [ + (url_for('index'), 'Mission Support System', + ((url_for('about'), 'About'), + (url_for('install'), 'Install'), + (url_for("plots"), 'Gallery'), + (url_for('help'), 'Help'), + )), + ] + + return menu diff --git a/mslib/mswms/dataaccess.py b/mslib/mswms/dataaccess.py index c848987c7..3450f77c7 100644 --- a/mslib/mswms/dataaccess.py +++ b/mslib/mswms/dataaccess.py @@ -202,11 +202,13 @@ class DefaultDataAccess(NWPDataAccess): # Workaround for the numerical issue concering the lon dimension in # NetCDF files produced by netcdf-java 4.3.. - def __init__(self, rootpath, domain_id, skip_dim_check=[], **kwargs): + def __init__(self, rootpath, domain_id, skip_dim_check=None, **kwargs): """ Constructor takes the path of the data directory and determines whether this class employs different init_times or valid_times. """ + if skip_dim_check is None: + skip_dim_check = [] NWPDataAccess.__init__(self, rootpath, **kwargs) self._domain_id = domain_id self._available_files = None diff --git a/mslib/mswms/demodata.py b/mslib/mswms/demodata.py index 04b54b1ce..ccb80d06c 100644 --- a/mslib/mswms/demodata.py +++ b/mslib/mswms/demodata.py @@ -831,7 +831,7 @@ def generate_field(coordinate, levels, standard_name, ntimes, nlats, nlons): return data, _PROFILES[standard_name]["unit"] -class DataFiles(object): +class DataFiles: """ Routine to write test data files for MSS using extracted variable ranges from ECMWF data @@ -969,6 +969,8 @@ def create_server_config(self, detailed_information=False): #service_country = "Germany" #service_fees = "none" #service_access_constraints = "This service is intended for research purposes only." +#imprint = "" +#gdpr = "" # diff --git a/mslib/mswms/gallery_builder.py b/mslib/mswms/gallery_builder.py index 22d3ef5e3..ccebba6fa 100644 --- a/mslib/mswms/gallery_builder.py +++ b/mslib/mswms/gallery_builder.py @@ -723,8 +723,8 @@ def add_image(path, plot, plot_object, generate_code=False, sphinx=False, url_pr markdown = add_times(itime, [vtime], markdown) plot_htmls[f"{l_type}_{dataset}{plot_object.name}"] = markdown - id = img_path.split("-" + f"{level}".replace(" ", "_").replace(":", "_").replace("-", "_"))[0] - if not any([id in html for html in plots[l_type]]): + img_id = img_path.split("-" + f"{level}".replace(" ", "_").replace(":", "_").replace("-", "_"))[0] + if not any([img_id in html for html in plots[l_type]]): plots[l_type].append(image_md( img_path, plot_object.name, code_path if generate_code else None, f"{plot_object.title}" + (f"
{plot_object.abstract}" diff --git a/mslib/mswms/generics.py b/mslib/mswms/generics.py index 93c91bae4..23ea01e90 100644 --- a/mslib/mswms/generics.py +++ b/mslib/mswms/generics.py @@ -35,6 +35,7 @@ """ N_LEVELS = 16 +DEFAULT_CMAP = matplotlib.pyplot.cm.turbo """ List of supported targets using the CF standard_name as unique identifier. @@ -43,11 +44,13 @@ """ _TARGETS = [ "air_temperature", + "air_potential_temperature", "eastward_wind", "equivalent_latitude", "ertel_potential_vorticity", "mean_age_of_air", "mole_fraction_of_active_chlorine_in_air", + "mole_fraction_of_ammonia_in_air", "mole_fraction_of_bromine_nitrate_in_air", "mole_fraction_of_bromo_methane_in_air", "mole_fraction_of_bromochlorodifluoromethane_in_air", @@ -61,11 +64,15 @@ "mole_fraction_of_cfc113_in_air", "mole_fraction_of_cfc12_in_air", "mole_fraction_of_ethane_in_air", + "mole_fraction_of_ethene_in_air", "mole_fraction_of_formaldehyde_in_air", + "mole_fraction_of_formic_acid_in_air", "mole_fraction_of_hcfc22_in_air", "mole_fraction_of_hydrogen_chloride_in_air", + "mole_fraction_of_hydrogen_peroxide_in_air", "mole_fraction_of_hypobromite_in_air", "mole_fraction_of_methane_in_air", + "mole_fraction_of_methanol_in_air", "mole_fraction_of_nitric_acid_in_air", "mole_fraction_of_nitrous_oxide_in_air", "mole_fraction_of_nitrogen_dioxide_in_air", @@ -96,6 +103,7 @@ """ _UNITS = { "air_temperature": "K", + "air_potential_temperature": "K", "eastward_wind": "m/s", "equivalent_latitude": "degree N", "ertel_potential_vorticity": "PVU", @@ -161,6 +169,7 @@ _UNITS[standard_name] = "nmol/mol" for standard_name in [ + "mole_fraction_of_ammonia_in_air", "mole_fraction_of_bromine_nitrate_in_air", "mole_fraction_of_bromo_methane_in_air", "mole_fraction_of_bromochlorodifluoromethane_in_air", @@ -171,9 +180,13 @@ "mole_fraction_of_cfc12_in_air", "mole_fraction_of_cfc113_in_air", "mole_fraction_of_hcfc22_in_air", + "mole_fraction_of_hydrogen_peroxide_in_air", "mole_fraction_of_ethane_in_air", + "mole_fraction_of_ethene_in_air", "mole_fraction_of_formaldehyde_in_air", + "mole_fraction_of_formic_acid_in_air", "mole_fraction_of_hypobromite_in_air", + "mole_fraction_of_methanol_in_air", "mole_fraction_of_nitrogen_dioxide_in_air", "mole_fraction_of_nitrogen_monoxide_in_air", "mole_fraction_of_peroxyacetyl_nitrate_in_air", @@ -302,6 +315,30 @@ def get_log_levels(cmin, cmax, levels=None): return clev +CBAR_LABEL_FORMATS = { + "log": "%.3g", + "log_ice_cloud": "%.0E", +} + + +def get_cbar_label_format(style, maxvalue): + if style in CBAR_LABEL_FORMATS: + return CBAR_LABEL_FORMATS[style] + if 100 <= maxvalue < 10000.: + label_format = "%4i" + elif 10 <= maxvalue < 100.: + label_format = "%.1f" + elif 1 <= maxvalue < 10.: + label_format = "%.2f" + elif 0.1 <= maxvalue < 1.: + label_format = "%.3f" + elif 0.01 <= maxvalue < 0.1: + label_format = "%.4f" + else: + label_format = "%.3g" + return label_format + + def _style_default(_dataname, _style, cmin, cmax, cmap, _data): clev = np.linspace(cmin, cmax, 16) norm = matplotlib.colors.BoundaryNorm(clev, cmap.N) @@ -558,7 +595,7 @@ def get_style_parameters(dataname, style, cmin, cmax, data): cmin, cmax = 0., 1. if 0 < cmin < 0.05 * cmax: cmin = 0. - cmap = matplotlib.pyplot.cm.rainbow + cmap = DEFAULT_CMAP ticks = None if any(isinstance(_x, np.ma.core.MaskedConstant) for _x in (cmin, cmax)): diff --git a/mslib/mswms/mpl_hsec_styles.py b/mslib/mswms/mpl_hsec_styles.py index 33d475f3d..bbec22d65 100644 --- a/mslib/mswms/mpl_hsec_styles.py +++ b/mslib/mswms/mpl_hsec_styles.py @@ -74,7 +74,7 @@ from matplotlib import patheffects from mslib.mswms.mpl_hsec import MPLBasemapHorizontalSectionStyle -from mslib.mswms.utils import get_cbar_label_format, make_cbar_labels_readable +from mslib.mswms.utils import make_cbar_labels_readable import mslib.mswms.generics as generics from mslib.utils import thermolib from mslib.utils.units import convert_to @@ -88,6 +88,7 @@ class HS_GenericStyle(MPLBasemapHorizontalSectionStyle): styles = [ ("auto", "auto colour scale"), ("autolog", "auto logcolour scale"), ] + cbar_format = None def _plot_style(self): bm = self.bm @@ -99,7 +100,10 @@ def _plot_style(self): cmin, cmax, clevs, cmap, norm, ticks = generics.get_style_parameters( self.dataname, self.style, cmin, cmax, show_data) - tc = bm.contourf(self.lonmesh, self.latmesh, show_data, levels=clevs, cmap=cmap, extend="both", norm=norm) + if self.use_pcolormesh: + tc = bm.pcolormesh(self.lonmesh, self.latmesh, show_data, cmap=cmap, norm=norm) + else: + tc = bm.contourf(self.lonmesh, self.latmesh, show_data, levels=clevs, cmap=cmap, extend="both", norm=norm) for cont_data, cont_levels, cont_colour, cont_label_colour, cont_style, cont_lw, pe in self.contours: cs_pv = ax.contour(self.lonmesh, self.latmesh, self.data[cont_data], cont_levels, @@ -120,51 +124,86 @@ def _plot_style(self): # Format for colorbar labels cbar_label = self.title - cbar_format = get_cbar_label_format(self.style, np.median(np.abs(clevs))) + if self.cbar_format is None: + cbar_format = generics.get_cbar_label_format(self.style, np.median(np.abs(clevs))) + else: + cbar_format = self.cbar_format if not self.noframe: - cbar = self.fig.colorbar(tc, fraction=0.05, pad=0.08, shrink=0.7, - label=cbar_label, format=cbar_format, ticks=ticks) - cbar.set_ticks(clevs) - cbar.set_ticklabels(clevs) + self.fig.colorbar(tc, fraction=0.05, pad=0.08, shrink=0.7, + label=cbar_label, format=cbar_format, ticks=ticks, extend="both") else: axins1 = mpl_toolkits.axes_grid1.inset_locator.inset_axes( ax, width="3%", height="40%", loc=cbar_location) - self.fig.colorbar(tc, cax=axins1, orientation="vertical", format=cbar_format, ticks=ticks) + self.fig.colorbar(tc, cax=axins1, orientation="vertical", format=cbar_format, ticks=ticks, extend="both") axins1.yaxis.set_ticks_position(tick_pos) make_cbar_labels_readable(self.fig, axins1) def make_generic_class(name, standard_name, vert, add_data=None, add_contours=None, - fix_styles=None, add_styles=None, add_prepare=None): + fix_styles=None, add_styles=None, add_prepare=None, use_pcolormesh=False): """ - This function instantiates a plotting class and adds it to the global name space - of this module. + This function instantiates a plotting class and adds it to the global name + space of this module. Args: name (str): name of the class, under which it will be added to the module name space + standard_name (str): CF standard_name of the main plotting target. - This must be registered within the mslib.mswms.generics module. + This standard_name must be registered (by default or manually) + within the mslib.mswms.generics module. + vert (str): vertical level type, e.g. "pl" + add_data (list, optional): List of tuples adding data to be read in and - provide to the plotting class. E.g. [("pl", "ertel_potential_vorticity", "PVU")] - for ertel_potential_vorticity on pressure levels in PVU units. The vertical - level type must be the one specified by the vert variable or "sfc". - By default ertel_potential_vorticity in PVU is provide. - add_contours (list, optional): List of tuples specifying contour lines to be - plotted. E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] - cause PV to be plotted for 2, 4, 8, and 16 PVU with dashed green lines, - red labels, and line width 2. The last value defines wether a stroke effect - shall be applied. - fix_styles (list, optional): A list of plotting styles, which must be defined in the - mslib.mswms.generics.STYLES dictionary. Defaults to a list of standard styles - ("auto", "logauto", "default", "nonlinear") depending on which ranges and thresholds - are defined for the main variable in the generics module. - add_styles (list, optional): Similar to fix_styles, but *adds* the supplied styles to - the list of support styles instead of overwriting them. Defaults to None. - add_prepare (function, optional): a function to overwrite the _prepare_datafield method. + provide to the plotting class. + E.g. [("pl", "ertel_potential_vorticity", "PVU")] + for ertel_potential_vorticity on pressure levels in PVU units. + The vertical level type must be the one specified by the vert + variable or "sfc". + + By default ertel_potential_vorticity in PVU is selected. + + add_contours (list, optional): List of tuples specifying contour lines + to be plotted. + E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] + causes PV to be plotted at 2, 4, 8, and 16 PVU with dashed green + lines, red labels, and line width of 2. The last value defines + whether a stroke effect shall be applied. + + fix_styles (list, optional): A list of plotting styles, which must + be defined in the mslib.mswms.generics.STYLES dictionary. + Defaults to a list of standard styles + ("auto", "logauto", "default", "nonlinear") depending on which + ranges and thresholds are defined for the main variable in the + generics module. Further styles can be registered to that dict + if desired. + + add_styles (list, optional): Similar to fix_styles, but *adds* the + supplied styles to the list of support styles instead of + overwriting them. If both add_styles and fix_styles are supplied, + fix_styles takes precedence. Don't do this. + Defaults to None. + + add_prepare (function, optional): a function to overwrite the + _prepare_datafield method. Use this to add derived quantities based + on those provided by the modes. For example 'horizontal_wind' could + be computed from U and V in here. + + Defaults to None. + + use_pcolormesh (bool, optional): determines whether to use pcolormesh + or plotting instead of the default "contourf" method. Use + pcolormesh for data that contains a lot of fill values or NaNs, + or to show the actual location of data. + + Defaults to False. + + Returns: + The generated class. (The class is also placed in this module under the + given name). """ if add_data is None: add_data = [(vert, "ertel_potential_vorticity", "PVU")] @@ -186,6 +225,7 @@ class fnord(HS_GenericStyle): required_datafields = [(vert, standard_name, units)] + add_data contours = add_contours + fnord.use_pcolormesh = use_pcolormesh fnord.__name__ = name fnord.styles = list(fnord.styles) if generics.get_thresholds(standard_name) is not None: @@ -203,6 +243,8 @@ class fnord(HS_GenericStyle): fnord._prepare_datafields = add_prepare globals()[name] = fnord + return fnord + # Generation of HS plotting layers for registered CF standard_names for vert in ["al", "ml", "pl", "tl"]: diff --git a/mslib/mswms/mpl_lsec.py b/mslib/mswms/mpl_lsec.py index e9ac2f2c9..8a44844ad 100644 --- a/mslib/mswms/mpl_lsec.py +++ b/mslib/mswms/mpl_lsec.py @@ -48,7 +48,7 @@ def __init__(self, driver=None): """ Constructor. """ - super(AbstractLinearSectionStyle, self).__init__(driver=driver) + super().__init__(driver=driver) self.variable = "" self.unit = "" self.y_values = [] diff --git a/mslib/mswms/mpl_lsec_styles.py b/mslib/mswms/mpl_lsec_styles.py index 396e9a464..7bafc4728 100644 --- a/mslib/mswms/mpl_lsec_styles.py +++ b/mslib/mswms/mpl_lsec_styles.py @@ -38,7 +38,7 @@ class LS_DefaultStyle(AbstractLinearSectionStyle): """ def __init__(self, driver, variable="air_temperature", filetype="ml"): - super(AbstractLinearSectionStyle, self).__init__(driver=driver) + super().__init__(driver=driver) self.variable = variable self.required_datafields = [(filetype, self.variable, None)] if filetype != "pl": diff --git a/mslib/mswms/mpl_vsec.py b/mslib/mswms/mpl_vsec.py index e9aae92bc..4d6131dda 100644 --- a/mslib/mswms/mpl_vsec.py +++ b/mslib/mswms/mpl_vsec.py @@ -60,7 +60,7 @@ def __init__(self, driver=None): """ Constructor. """ - super(AbstractVerticalSectionStyle, self).__init__(driver=driver) + super().__init__(driver=driver) def add_colorbar(self, contour, label=None, tick_levels=None, width="3%", height="30%", cb_format=None, left=0.08, right=0.95, top=0.9, bottom=0.14, fraction=0.05, pad=0.01, loc=1, tick_position="left"): @@ -132,7 +132,7 @@ def _latlon_logp_setup(self, orography=105000.): # Set axis limits and draw grid for major ticks. ax.set_xlim(self.lat_inds[0], self.lat_inds[-1]) ax.set_ylim(self.p_bot, self.p_top) - ax.grid(b=True) + ax.grid(visible=True) @abstractmethod def _plot_style(self): diff --git a/mslib/mswms/mpl_vsec_styles.py b/mslib/mswms/mpl_vsec_styles.py index 65a59ff85..a1cf21148 100644 --- a/mslib/mswms/mpl_vsec_styles.py +++ b/mslib/mswms/mpl_vsec_styles.py @@ -38,7 +38,7 @@ import numpy as np from mslib.mswms.mpl_vsec import AbstractVerticalSectionStyle -from mslib.mswms.utils import get_cbar_label_format, make_cbar_labels_readable +from mslib.mswms.utils import make_cbar_labels_readable import mslib.mswms.generics as generics from mslib.utils import thermolib from mslib.utils.units import convert_to @@ -52,6 +52,7 @@ class VS_GenericStyle(AbstractVerticalSectionStyle): styles = [ ("auto", "auto colour scale"), ("autolog", "auto log colour scale"), ] + cbar_format = None def _plot_style(self): ax = self.ax @@ -97,7 +98,11 @@ def _plot_style(self): self._latlon_logp_setup() # Format for colorbar labels - cbar_format = get_cbar_label_format(self.style, np.abs(clevs).max()) + if self.cbar_format is None: + cbar_format = generics.get_cbar_label_format(self.style, np.median(np.abs(clevs))) + else: + cbar_format = self.cbar_format + cbar_label = self.title # Add colorbar. @@ -115,33 +120,57 @@ def _plot_style(self): def make_generic_class(name, standard_name, vert, add_data=None, add_contours=None, fix_styles=None, add_styles=None, add_prepare=None): """ - This function instantiates a plotting class and adds it to the global name space - of this module. + This function instantiates a plotting class and adds it to the global name + space of this module. Args: - name (str): name of the class, under which it will be added to the module - name space + name (str): name of the class, under which it will be added to the + module name space + standard_name (str): CF standard_name of the main plotting target. This must be registered within the mslib.mswms.generics module. + vert (str): vertical level type, e.g. "pl" + add_data (list, optional): List of tuples adding data to be read in and - provide to the plotting class. E.g. [("pl", "ertel_potential_vorticity", "PVU")] - for ertel_potential_vorticity on pressure levels in PVU units. The vertical - level type must be the one specified by the vert variable or "sfc". + provide to the plotting class. + E.g. [("pl", "ertel_potential_vorticity", "PVU")] + for ertel_potential_vorticity on pressure levels in PVU units. + The vertical level type must be the one specified by the vert + variable or "sfc". + By default ertel_potential_vorticity in PVU is provide. - add_contours (list, optional): List of tuples specifying contour lines to be - plotted. E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] - cause PV to be plotted for 2, 4, 8, and 16 PVU with dashed green lines, - red labels, and line width 2. The last value defines wether a stroke effect - shall be applied. - fix_styles (list, optional): A list of plotting styles, which must be defined in the - mslib.mswms.generics.STYLES dictionary. Defaults to a list of standard styles - ("auto", "logauto", "default", "nonlinear") depending on which ranges and thresholds - are defined for the main variable in the generics module. - add_styles (list, optional): Similar to fix_styles, but *adds* the supplied styles to - the list of support styles instead of overwriting them. Defaults to None. - add_prepare (function, optional): a function to overwrite the _prepare_datafield method. + + add_contours (list, optional): List of tuples specifying contour + lines to be plotted. + E.g. [("ertel_potential_vorticity", [2, 4, 8, 16], "green", "red", "dashed", 2, True)] + cause PV to be plotted for 2, 4, 8, and 16 PVU with dashed green + lines, red labels, and line width of 2. The last value defines + wether a stroke effect shall be applied. + + fix_styles (list, optional): A list of plotting styles, which must be + defined in the mslib.mswms.generics.STYLES dictionary. Defaults + to a list of standard styles + ("auto", "logauto", "default", "nonlinear") depending on which + ranges and thresholds are defined for the main variable in the + generics module. + Defaults to None. + + add_styles (list, optional): Similar to fix_styles, but *adds* the + supplied styles to the list of support styles instead of + overwriting them. + + Defaults to None. + + add_prepare (function, optional): a function to overwrite the + _prepare_datafield method. + + Defaults to None. + + Returns: + The generated class. (The class is also placed in this module under the + given name). """ if add_data is None: add_data = [(vert, "ertel_potential_vorticity", "PVU")] @@ -181,6 +210,8 @@ class fnord(VS_GenericStyle): globals()[name] = fnord + return fnord + _ADD_DATA = { "al": [("al", "ertel_potential_vorticity", "PVU"), diff --git a/mslib/mswms/utils.py b/mslib/mswms/utils.py index 4811dab5c..03196527d 100644 --- a/mslib/mswms/utils.py +++ b/mslib/mswms/utils.py @@ -28,24 +28,6 @@ import matplotlib -def get_cbar_label_format(style, maxvalue): - format = "%.3g" - if style != "log": - if 100 <= maxvalue < 10000.: - format = "%4i" - elif 10 <= maxvalue < 100.: - format = "%.1f" - elif 1 <= maxvalue < 10.: - format = "%.2f" - elif 0.1 <= maxvalue < 1.: - format = "%.3f" - elif 0.01 <= maxvalue < 0.1: - format = "%.4f" - if style == 'log_ice_cloud': - format = "%.0E" - return format - - def make_cbar_labels_readable(fig, axs): """ Adjust font size of the colorbar labels and put a white background behind them diff --git a/mslib/mswms/wms.py b/mslib/mswms/wms.py index aecbd2f12..4f3c7e8f0 100644 --- a/mslib/mswms/wms.py +++ b/mslib/mswms/wms.py @@ -41,10 +41,6 @@ limitations under the License. """ -from future import standard_library - -standard_library.install_aliases() - import glob import os import io @@ -53,6 +49,7 @@ import shutil import tempfile import traceback +import werkzeug import urllib.parse from xml.etree import ElementTree @@ -60,11 +57,11 @@ from owslib.crs import axisorder_yx from PIL import Image import numpy as np -from flask import request, make_response, render_template +from flask import request, make_response, render_template, Response, abort from flask_httpauth import HTTPBasicAuth - from multidict import CIMultiDict from mslib.utils import conditional_decorator +from mslib.utils.get_content import get_content from mslib.utils.time import parse_iso_datetime from mslib.index import create_app from mslib.mswms.gallery_builder import add_image, write_html, add_levels, add_times, \ @@ -73,51 +70,60 @@ # Flask basic auth's documentation # https://flask-basicauth.readthedocs.io/en/latest/#flask.ext.basicauth.BasicAuth.check_credentials -app = create_app(__name__) -auth = HTTPBasicAuth() -realm = 'Mission Support Web Map Service' -app.config['realm'] = realm +class default_mswms_settings: + base_dir = os.path.abspath(os.path.dirname(__file__)) + xml_template_location = os.path.join(base_dir, "xml_templates") + service_name = "OGC:WMS" + service_title = "Mission Support System Web Map Service" + service_abstract = "" + service_contact_person = "" + service_contact_organisation = "" + service_contact_position = "" + service_address_type = "" + service_address = "" + service_city = "" + service_state_or_province = "" + service_post_code = "" + service_country = "" + service_fees = "" + service_email = "" + service_access_constraints = "This service is intended for research purposes only." + register_horizontal_layers = [] + register_vertical_layers = [] + register_linear_layers = [] + imprint = "" + gdpr = "" + data = {} + enable_basic_http_authentication = False + __file__ = None + + +mswms_settings = default_mswms_settings() try: - import mswms_settings + import mswms_settings as user_settings + mswms_settings.__dict__.update(user_settings.__dict__) except ImportError as ex: - logging.warning("Couldn't import mswms_settings (ImportError:'%s'), creating dummy config.", ex) - - class mswms_settings(object): - base_dir = os.path.abspath(os.path.dirname(__file__)) - xml_template_location = os.path.join(base_dir, "xml_templates") - service_name = "OGC:WMS" - service_title = "Mission Support System Web Map Service" - service_abstract = "" - service_contact_person = "" - service_contact_organisation = "" - service_address_type = "" - service_address = "" - service_city = "" - service_state_or_province = "" - service_post_code = "" - service_country = "" - service_fees = "" - service_access_constraints = "This service is intended for research purposes only." - register_horizontal_layers = [] - register_vertical_layers = [] - register_linear_layers = [] - data = {} - enable_basic_http_authentication = False - __file__ = None + logging.warning("Couldn't import mswms_settings (ImportError:'%s'), Using dummy config.", ex) + +app = create_app(__name__, imprint=mswms_settings.imprint, gdpr=mswms_settings.gdpr) +auth = HTTPBasicAuth() + +realm = 'Mission Support Web Map Service' +app.config['realm'] = realm try: import mswms_auth except ImportError as ex: logging.warning("Couldn't import mswms_auth (ImportError:'{%s), creating dummy config.", ex) - class mswms_auth(object): + class mswms_auth: allowed_users = [("mswms", "add_md5_digest_of_PASSWORD_here"), ("add_new_user_here", "add_md5_digest_of_PASSWORD_here")] __file__ = None -if mswms_settings.__dict__.get('enable_basic_http_authentication', False): +if mswms_settings.enable_basic_http_authentication: logging.debug("Enabling basic HTTP authentication. Username and " "password required to access the service.") import hashlib @@ -145,9 +151,7 @@ def verify_pw(username, password): datefmt="%Y-%m-%d %H:%M:%S") # Chameleon XMl template -base_dir = os.path.abspath(os.path.dirname(__file__)) -xml_template_location = os.path.join(base_dir, "xml_templates") -templates = PageTemplateLoader(mswms_settings.__dict__.get("xml_template_location", xml_template_location)) +templates = PageTemplateLoader(mswms_settings.xml_template_location) def squash_multiple_images(imgs): @@ -171,7 +175,7 @@ def squash_multiple_xml(xml_strings): return ElementTree.tostring(base) -class WMSServer(object): +class WMSServer: def __init__(self): """ @@ -617,26 +621,23 @@ def get_capabilities(self, query, server_url=None): continue lsec_layers.append((dataset, layer)) - settings = mswms_settings.__dict__ return_data = template(hsec_layers=hsec_layers, vsec_layers=vsec_layers, lsec_layers=lsec_layers, server_url=server_url, - service_name=settings.get("service_name", "OGC:WMS"), - service_title=settings.get("service_title", "Mission Support System Web Map Service"), - service_abstract=settings.get("service_abstract", ""), - service_contact_person=settings.get("service_contact_person", ""), - service_contact_organisation=settings.get("service_contact_organisation", ""), - service_contact_position=settings.get("service_contact_position", ""), - service_email=settings.get("service_email", ""), - service_address_type=settings.get("service_address_type", ""), - service_address=settings.get("service_address", ""), - service_city=settings.get("service_city", ""), - service_state_or_province=settings.get("service_state_or_province", ""), - service_post_code=settings.get("service_post_code", ""), - service_country=settings.get("service_country", ""), - service_fees=settings.get("service_fees", ""), - service_access_constraints=settings.get( - "service_access_constraints", - "This service is intended for research purposes only.")) + service_name=mswms_settings.service_name, + service_title=mswms_settings.service_title, + service_abstract=mswms_settings.service_abstract, + service_contact_person=mswms_settings.service_contact_person, + service_contact_organisation=mswms_settings.service_contact_organisation, + service_contact_position=mswms_settings.service_contact_position, + service_email=mswms_settings.service_email, + service_address_type=mswms_settings.service_address_type, + service_address=mswms_settings.service_address, + service_city=mswms_settings.service_city, + service_state_or_province=mswms_settings.service_state_or_province, + service_post_code=mswms_settings.service_post_code, + service_country=mswms_settings.service_country, + service_fees=mswms_settings.service_fees, + service_access_constraints=mswms_settings.service_access_constraints) return return_data.encode("utf-8"), "text/xml" def produce_plot(self, query, mode): @@ -949,7 +950,7 @@ def produce_plot(self, query, mode): @app.route('/') -@conditional_decorator(auth.login_required, mswms_settings.__dict__.get('enable_basic_http_authentication', False)) +@conditional_decorator(auth.login_required, mswms_settings.enable_basic_http_authentication) def application(): try: # Request info @@ -998,3 +999,37 @@ def application(): for response_header in response_headers: res.headers[response_header[0]] = response_header[1] return res + + +@app.route("/mss/plots") +def plots(): + if STATIC_LOCATION != "" and os.path.exists(os.path.join(STATIC_LOCATION, 'plots.html')): + _file = os.path.join(STATIC_LOCATION, 'plots.html') + content = get_content(_file) + else: + content = "Gallery was not generated for this server.
" \ + "For further info on how to generate it, run the " \ + "gallery --help command line parameter of mswms.
" \ + "An example of the gallery can be seen " \ + "here" + return render_template("/content.html", act="plots", content=content) + + +@app.route("/mss/code/") +def code(filename): + download = request.args.get("download", False) + _file = werkzeug.security.safe_join(STATIC_LOCATION, "code", filename) + if _file is None: + abort(404) + content = get_content(_file) + if not download: + return render_template("/content.html", act="code", content=content) + else: + if not os.path.isfile(_file): + abort(404) + with open(_file) as f: + text = f.read() + return Response("".join([s.replace("\t", "", 1) for s in text.split("```python")[-1] + .splitlines(keepends=True)][1:-2]), + mimetype="text/plain", + headers={"Content-disposition": f"attachment; filename={filename.split('-')[0]}.py"}) diff --git a/mslib/plugins/io/kml.py b/mslib/plugins/io/kml.py index 6a014022f..0628862fc 100644 --- a/mslib/plugins/io/kml.py +++ b/mslib/plugins/io/kml.py @@ -31,8 +31,7 @@ def save_to_kml(filename, name, waypoints): if not filename: raise ValueError("filename to save flight track cannot be None") - with codecs.open(filename, "w", "utf_8") as out_file: - header = f""" + header = f""" {name} @@ -42,20 +41,37 @@ def save_to_kml(filename, name, waypoints): ff0000002 {name} #flighttrack - -1absolute - """ - line = "{lon:.3f},{lat:.3f},{alt:.3f}\n" - footer = """ - - + path = """ +1absolute +{coordinates} +""" + line = "{lon:.3f},{lat:.3f},{alt:.3f}\n" + waypoint = """ +{name} + + {lon:.3f},{lat:.3f},{alt:.3f} + +""" + footer = """ """ + with codecs.open(filename, "w", "utf_8") as out_file: + line_coords = "" + for i, wp in enumerate(waypoints): + lat = wp.lat + lon = wp.lon + lvl = wp.flightlevel + alt = lvl * 100 * 0.3048 + line_coords += line.format(lon=lon, lat=lat, alt=alt) out_file.write(header) + out_file.write(path.format(coordinates=line_coords)) for i, wp in enumerate(waypoints): + name = str(wp.location) + if not name: + name = str(i) lat = wp.lat lon = wp.lon lvl = wp.flightlevel alt = lvl * 100 * 0.3048 - out_file.write(line.format(lon=lon, lat=lat, alt=alt)) + out_file.write(waypoint.format(name=str(name), lon=lon, lat=lat, alt=alt)) out_file.write(footer) diff --git a/mslib/static/templates/errors/404.html b/mslib/static/templates/errors/404.html new file mode 100644 index 000000000..1169620e8 --- /dev/null +++ b/mslib/static/templates/errors/404.html @@ -0,0 +1,5 @@ +
+

404 - Page Not Found

+
+

The resource requested could not be found in this server.

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

500 - Sorry Unexpected Error

+
+

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

+
diff --git a/mslib/static/templates/footer.html b/mslib/static/templates/footer.html index 713e1c37d..7bd2cae4d 100644 --- a/mslib/static/templates/footer.html +++ b/mslib/static/templates/footer.html @@ -16,9 +16,17 @@
- + {% if file_exists(imprint) %} + + {% endif %} + {% if file_exists(gdpr) %} + + + {% endif %}
diff --git a/mslib/static/templates/idp/available_idps.html b/mslib/static/templates/idp/available_idps.html new file mode 100644 index 000000000..ce361c22e --- /dev/null +++ b/mslib/static/templates/idp/available_idps.html @@ -0,0 +1,74 @@ +{% extends "theme.html" %} {% block body %} +
+ +
+

Choose Identity Provider

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

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

+

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

+

Token : {{token}} +
+ +

+ +
+ + +
+ {% endblock %} diff --git a/mslib/support/qt_json_view/datatypes.py b/mslib/support/qt_json_view/datatypes.py index 2c381a8cf..fbc216281 100644 --- a/mslib/support/qt_json_view/datatypes.py +++ b/mslib/support/qt_json_view/datatypes.py @@ -9,7 +9,7 @@ TypeRole = QtCore.Qt.UserRole + 1 -class DataType(object): +class DataType: """Base class for data types.""" # (mss) @@ -19,7 +19,7 @@ def matches(self, data): """Logic to define whether the given data matches this type.""" raise NotImplementedError - def next(self, model, data, parent): + def next(self, model, data, parent): # noqa: A003 """Implement if this data type has to add child items to itself.""" pass @@ -162,7 +162,7 @@ class ListType(DataType): def matches(self, data): return isinstance(data, list) - def next(self, model, data, parent): + def next(self, model, data, parent): # noqa: A003 for i, value in enumerate(data): type_ = match_type(value) key_item = self.key_item( @@ -200,7 +200,7 @@ class DictType(DataType): def matches(self, data): return isinstance(data, dict) - def next(self, model, data, parent): + def next(self, model, data, parent): # noqa: A003 for key, value in data.items(): type_ = match_type(value) key_item = self.key_item(key, datatype=type_, model=model) @@ -259,7 +259,7 @@ def paint(self, painter, option, index): metrics = painter.fontMetrics() spinbox_option = QtWidgets.QStyleOptionSpinBox() start_rect = QtCore.QRect(option.rect) - start_rect.setWidth(start_rect.width() / 3.0) + start_rect.setWidth(int(start_rect.width() / 3.0)) spinbox_option.rect = start_rect spinbox_option.frame = True spinbox_option.state = option.state diff --git a/mslib/utils/__init__.py b/mslib/utils/__init__.py index f73ec58af..294c527e4 100644 --- a/mslib/utils/__init__.py +++ b/mslib/utils/__init__.py @@ -128,13 +128,13 @@ def decorator(func): def prefix_route(route_function, prefix='', mask='{0}{1}'): - ''' + """ https://stackoverflow.com/questions/18967441/add-a-prefix-to-all-flask-routes/18969161#18969161 Defines a new route function with a prefix. The mask argument is a `format string` formatted with, in that order: prefix, route - ''' + """ def newroute(route, *args, **kwargs): - ''' prefix route ''' + """ prefix route """ return route_function(mask.format(prefix, route), *args, **kwargs) return newroute diff --git a/mslib/utils/airdata.py b/mslib/utils/airdata.py index dc7dfdead..8e0a40157 100644 --- a/mslib/utils/airdata.py +++ b/mslib/utils/airdata.py @@ -252,7 +252,7 @@ def get_airspaces(countries=None): for data in airspace_data["polygon"].split(",")] _airspaces.append(airspace_data) _airspaces_mtime[file] = os.path.getmtime(os.path.join(OSDIR, "downloads", "aip", file)) - else: - QtWidgets.QMessageBox.information(None, "No Airspaces data in file:", f"{file}") + else: + QtWidgets.QMessageBox.information(None, "No Airspaces data in file:", f"{file}") return _airspaces diff --git a/mslib/utils/auth.py b/mslib/utils/auth.py index f2a0dd739..a466ca624 100644 --- a/mslib/utils/auth.py +++ b/mslib/utils/auth.py @@ -33,6 +33,17 @@ import logging import keyring + +try: + from jeepney.wrappers import DBusErrorResponse +except (ImportError, ModuleNotFoundError): + class DBusErrorResponse(Exception): + """ + Fallback definition on not DBus systems + """ + def __init__(self, message): + super().__init__(message) + from mslib.msui import constants @@ -64,8 +75,8 @@ def get_password_from_keyring(service_name=NAME, username=""): return None else: return cred.password - except keyring.errors.KeyringLocked as ex: - logging.warn(ex) + except (keyring.errors.KeyringLocked, keyring.errors.InitError, DBusErrorResponse) as ex: + logging.warning(ex) return None diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 7a27d13ef..a7849a7d3 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -41,7 +41,7 @@ from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType -class MSUIDefaultConfig(object): +class MSUIDefaultConfig: """Central configuration for the Mission Support System User Interface Application (msui). @@ -129,12 +129,18 @@ class MSUIDefaultConfig(object): "http://localhost:8083", ] - # mail address to sign in - MSCOLAB_mailid = "" + # Username used for http auth + MSCOLAB_auth_user_name = "mscolab" # category for MSC operations MSCOLAB_category = "default" + # timeout for MSColab in seconds. First value is for connection, second for reply + MSCOLAB_timeout = [2, 10] + + # don't query for archived operations + MSCOLAB_skip_archived_operations = False + # list of MSC servers {"http://www.your-mscolab-server.de": "authuser", # "http://www.your-wms-server.de": "authuser"} MSS_auth = {} @@ -239,9 +245,10 @@ class MSUIDefaultConfig(object): 'num_labels', 'num_interpolation_points', 'new_flighttrack_flightlevel', - 'MSCOLAB_mailid', 'MSCOLAB_category', + 'MSCOLAB_skip_archived_operations', 'mscolab_server_url', + 'MSCOLAB_auth_user_name', 'wms_cache', 'wms_cache_max_size_bytes', 'wms_cache_max_age_seconds', @@ -285,6 +292,7 @@ class MSUIDefaultConfig(object): "new_flighttrack_template": ["new-location"], "gravatar_ids": ["example@email.com"], "WMS_preload": ["https://wms-preload-url.com"], + "MSCOLAB_timeout": [[2, 10]], "automated_plotting_flights": [["", "", "", "", "", ""]], "automated_plotting_hsecs": [["http://www.your-wms-server.de", "", "", ""]], "automated_plotting_vsecs": [["http://www.your-wms-server.de", "", "", ""]], @@ -302,7 +310,8 @@ class MSUIDefaultConfig(object): "default_LSEC_WMS": "Documentation Required", "default_MSCOLAB": "Documentation Required", "MSS_auth": "Documentation Required", - "MSCOLAB_mailid": "Documentation Required", + "MSCOLAB_auth_user_name": "Documentation Required", + "MSCOLAB_timeout": "Documentation Required", "WMS_request_timeout": "Documentation Required", "WMS_preload": "Documentation Required", "wms_cache": "Documentation Required", @@ -458,10 +467,12 @@ def save_settings_qsettings(tag, settings, ignore_test=False): """ assert isinstance(tag, str) assert isinstance(settings, dict) - if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + if not ignore_test and ("pytest" in sys.modules): return settings + # ToDo we have to verify if we can all switch to this definition, not having 3 different + q_settings = QtCore.QSettings(os.path.join(constants.MSUI_CONFIG_PATH, "msui-core.conf"), + QtCore.QSettings.IniFormat) - q_settings = QtCore.QSettings("msui", "msui-core") file_path = q_settings.fileName() logging.debug("storing settings for %s to %s", tag, file_path) try: @@ -485,11 +496,13 @@ def load_settings_qsettings(tag, default_settings=None, ignore_test=False): if default_settings is None: default_settings = {} assert isinstance(default_settings, dict) - if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + if not ignore_test and "pytest" in sys.modules: return default_settings settings = {} - q_settings = QtCore.QSettings("msui", "msui-core") + + q_settings = QtCore.QSettings(os.path.join(constants.MSUI_CONFIG_PATH, "msui-core.conf"), + QtCore.QSettings.IniFormat) file_path = q_settings.fileName() logging.debug("loading settings for %s from %s", tag, file_path) try: diff --git a/mslib/utils/coordinate.py b/mslib/utils/coordinate.py index 2fc0bc315..54ecb2ff0 100644 --- a/mslib/utils/coordinate.py +++ b/mslib/utils/coordinate.py @@ -29,18 +29,14 @@ import logging import netCDF4 as nc import numpy as np +from pyproj import Geod from scipy.interpolate import interp1d from scipy.ndimage import map_coordinates -try: - import mpl_toolkits.basemap.pyproj as pyproj -except ImportError: - import pyproj - from mslib.utils.config import config_loader -__PR = pyproj.Geod(ellps='WGS84') +__PR = Geod(ellps='WGS84') def get_distance(lat0, lon0, lat1, lon1): diff --git a/mslib/utils/get_content.py b/mslib/utils/get_content.py new file mode 100644 index 000000000..9bac2ae13 --- /dev/null +++ b/mslib/utils/get_content.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.get_content + ~~~~~~~~~~~~~~~~~~~~~~~ + + Returns the content of a markdown file as html + + This file is part of MSS. + + :copyright: Copyright 2020-2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import codecs +import os + +from markdown import Markdown + + +def get_content(filename, md_overrides=None, html_overrides=None): + markdown = Markdown(extensions=["fenced_code"]) + content = "" + if os.path.isfile(filename): + with codecs.open(filename, 'r', 'utf-8') as f: + md_data = f.read() + md_data = md_data.replace(':ref:', '') + if md_overrides is not None: + v1, v2 = md_overrides + md_data = md_data.replace(v1, v2) + content = markdown.convert(md_data) + if html_overrides is not None: + v1, v2 = html_overrides + content = content.replace(v1, v2) + return content diff --git a/mslib/utils/migration/config_before_eight.py b/mslib/utils/migration/config_before_eight.py index d7ecd0c36..579d0354b 100644 --- a/mslib/utils/migration/config_before_eight.py +++ b/mslib/utils/migration/config_before_eight.py @@ -41,7 +41,7 @@ from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType -class MSUIDefaultConfig(object): +class MSUIDefaultConfig: """Central configuration for the Mission Support System User Interface Application (msui). diff --git a/mslib/utils/migration/config_before_nine.py b/mslib/utils/migration/config_before_nine.py new file mode 100644 index 000000000..d82dbb211 --- /dev/null +++ b/mslib/utils/migration/config_before_nine.py @@ -0,0 +1,627 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.migration.config_before_nine + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Collection of functions all around config handling before version 9.0.0 + + This file is part of MSS. + + :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. + :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import sys +from PyQt5 import QtCore + +import copy +import json +import logging +import fs +import os +import tempfile + +from mslib.utils import FatalUserError +from mslib.msui import constants +from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType + + +class MSUIDefaultConfig: + """Central configuration for the Mission Support System User Interface + Application (msui). + + DESCRIPTION: + ============ + + This file includes configuration settings central to the entire + Mission Support User Interface (msui). Among others, define + -- available map projections + -- vertical section interpolation options + -- the lists of predefined web service URLs + -- predefined waypoints for the table view + in this file. + + Do not change any value for good reasons. + Your values can be set in your personal msui_settings.json file + """ + # this skips the verification of the user token on each mscolab request + mscolab_skip_verify_user_token = True + + # Default for general filepicker. Pick "default", "qt", or "fs" + filepicker_default = "default" + + # dir where msui output files are stored + data_dir = "~/mssdata" + + # layout of different views, with immutable they can't resized + layout = {"topview": [963, 702], + "sideview": [913, 557], + "linearview": [913, 557], + "tableview": [1236, 424], + "immutable": False} + + # Predefined map regions to be listed in the corresponding topview combobox + predefined_map_sections = { + "01 Europe (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": -15.0, "llcrnrlat": 35.0, + "urcrnrlon": 30.0, "urcrnrlat": 65.0}}, + "02 Germany (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": 5.0, "llcrnrlat": 45.0, + "urcrnrlon": 15.0, "urcrnrlat": 57.0}}, + "03 Global (cyl)": {"CRS": "EPSG:4326", + "map": {"llcrnrlon": -180.0, "llcrnrlat": -90.0, + "urcrnrlon": 180.0, "urcrnrlat": 90.0}}, + "04 Northern Hemisphere (stereo)": {"CRS": "MSS:stere,0,90,90", + "map": {"llcrnrlon": -45.0, "llcrnrlat": 0.0, + "urcrnrlon": 135.0, "urcrnrlat": 0.0}} + } + + # Side View. + # The following two parameters are passed to the WMS in the BBOX + # argument when a vertical cross section is requested. + + # Number of interpolation points used to interpolate the flight track + # to a great circle. + num_interpolation_points = 201 + + # Number of x-axis labels in the side view. + num_labels = 10 + + # Web Map Service Client. + # Settings for the WMS client. Set the URLs of WMS servers that appear + # by default in the WMS control (for examples, see + # http://external.opengis.org/twiki_public/bin/view/MetOceanDWG/MetocWMS_Servers). + # Also set the location of the image file cache and its size. + + # URLs of default WMS servers. + default_WMS = [ + "http://localhost:8081/", + "http://eumetview.eumetsat.int/geoserver/wms", + "https://apps.ecmwf.int/wms/?token=public" + ] + + default_VSEC_WMS = [ + "http://localhost:8081/" + ] + + default_LSEC_WMS = [ + "http://localhost:8081/" + ] + + # URLs of default mscolab servers + default_MSCOLAB = [ + "http://localhost:8083", + ] + + # mail address to sign in + MSCOLAB_mailid = "" + + # category for MSC operations + MSCOLAB_category = "default" + + # list of MSC servers {"http://www.your-mscolab-server.de": "authuser", + # "http://www.your-wms-server.de": "authuser"} + MSS_auth = {} + + # timeout of Url request + WMS_request_timeout = 30 + + WMS_preload = [] + + # WMS image cache settings: + # this changes on any start of msui, use ths msui_settings.json when you want a persistent path + wms_cache = os.path.join(tempfile.TemporaryDirectory().name, "msui_wms_cache") + + # Maximum size of the cache in bytes. + wms_cache_max_size_bytes = 20 * 1024 * 1024 + + # Maximum age of a cached file in seconds. + wms_cache_max_age_seconds = 5 * 86400 + + wms_prefetch = { + "validtime_fwd": 0, + "validtime_bck": 0, + "level_up": 0, + "level_down": 0 + } + + locations = { + "EDMO": [48.08, 11.28], + "Hannover": [52.37, 9.74], + "Hamburg": [53.55, 9.99], + "Juelich": [50.92, 6.36], + "Leipzig": [51.34, 12.37], + "Muenchen": [48.14, 11.57], + "Stuttgart": [48.78, 9.18], + "Wien": [48.20833, 16.373064], + "Zugspitze": [47.42, 10.98], + "Kiruna": [67.821, 20.336], + "Ny-Alesund": [78.928, 11.986], + "Zhukovsky": [55.6, 38.116], + "Paphos": [34.775, 32.425], + "Sharjah": [25.35, 55.65], + "Brindisi": [40.658, 17.947], + "Nagpur": [21.15, 79.083], + "Mumbai": [19.089, 72.868], + "Delhi": [28.566, 77.103], + } + + # Main application: Template for new flight tracks + # Flight track template that is used when a new flight track is + # created. Specify a list of place names that can be found in the + # "locations" dictionary defined above. + new_flighttrack_template = ["Nagpur", "Delhi"] + + # This configures the flight level for waypoints inserted by the + # flighttrack template + new_flighttrack_flightlevel = 0 + + # None is not wanted here + proxies = {} + + # ToDo configurable later + # mscolab server + mscolab_server_url = "http://localhost:8083" + # ToDo refactor to rename this to data_dir/mss_data_dir + # mss dir + mss_dir = "~/mss" + + # list of gravatar email ids to automatically fetch + gravatar_ids = [] + + # dictionary for export plugins, e.g. {"Text": ["txt", "mslib.plugins.io.text", "save_to_txt"] } + export_plugins = {} + + # dictionary for import plugins, e.g. { "FliteStar": ["txt", "mslib.plugins.io.flitestar", "load_from_flitestar"] } + import_plugins = {} + + # dictionary to make title, label and ticklabel sizes for topview and sideview configurable. + # You can put your default value here, whatever you want to give,it should be a number. + topview = {"plot_title_size": 10, + "axes_label_size": 10} + + sideview = {"plot_title_size": 10, + "axes_label_size": 10} + + linearview = {"plot_title_size": 10, + "axes_label_size": 10} + + automated_plotting_flights = [[]] + automated_plotting_hsecs = [[]] + automated_plotting_vsecs = [[]] + automated_plotting_lsecs = [[]] + + # Dictionary options with fixed key/value pairs + fixed_dict_options = ["layout", "wms_prefetch", "topview", "sideview", "linearview"] + + # Fixed key/value pair options + key_value_options = [ + 'mscolab_skip_verify_user_token', + 'filepicker_default', + 'mss_dir', + 'data_dir', + 'num_labels', + 'num_interpolation_points', + 'new_flighttrack_flightlevel', + 'MSCOLAB_mailid', + 'MSCOLAB_category', + 'mscolab_server_url', + 'wms_cache', + 'wms_cache_max_size_bytes', + 'wms_cache_max_age_seconds', + 'WMS_request_timeout', + ] + + # Dictionary options with predefined structure + dict_option_structure = { + "MSS_auth": {"http://www.your-wms-server.de": "authusername"}, + "predefined_map_sections": { + "new_map_section": { + "CRS": "crs_value", + "map": { + "llcrnrlon": 0.0, + "llcrnrlat": 0.0, + "urcrnrlon": 0.0, + "urcrnrlat": 0.0, + }, + } + }, + "locations": { + "new-location": [0.0, 0.0], + }, + "export_plugins": { + "plugin-name": ["extension", "module", "function", "default"], + }, + "import_plugins": { + "plugin-name": ["extension", "module", "function", "default"], + }, + "proxies": { + "https": "https://proxy.com", + }, + } + + # List options with predefined structure + list_option_structure = { + "default_WMS": ["https://wms-server-url.com"], + "default_VSEC_WMS": ["https://vsec-wms-server-url.com"], + "default_LSEC_WMS": ["https://lsec-wms-server-url.com"], + "default_MSCOLAB": ["https://mscolab-server-url.com"], + "new_flighttrack_template": ["new-location"], + "gravatar_ids": ["example@email.com"], + "WMS_preload": ["https://wms-preload-url.com"], + "automated_plotting_flights": [["", "", "", "", "", ""]], + "automated_plotting_hsecs": [["http://www.your-wms-server.de", "", "", ""]], + "automated_plotting_vsecs": [["http://www.your-wms-server.de", "", "", ""]], + "automated_plotting_lsecs": [["http://www.your-wms-server.de", "", ""]] + } + + config_descriptions = { + "filepicker_default": "Documentation Required", + "data_dir": "Documentation Required", + "predefined_map_sections": "Documentation Required", + "num_interpolation_points": "Documentation Required", + "num_labels": "Documentation Required", + "default_WMS": "Documentation Required", + "default_VSEC_WMS": "Documentation Required", + "default_LSEC_WMS": "Documentation Required", + "default_MSCOLAB": "Documentation Required", + "MSS_auth": "Documentation Required", + "MSCOLAB_mailid": "Documentation Required", + "WMS_request_timeout": "Documentation Required", + "WMS_preload": "Documentation Required", + "wms_cache": "Documentation Required", + "wms_cache_max_size_bytes": "Documentation Required", + "wms_cache_max_age_seconds": "Documentation Required", + "wms_prefetch": "Documentation Required", + "locations": "Documentation Required", + "new_flighttrack_template": "Documentation Required", + "new_flighttrack_flightlevel": "Documentation Required", + "proxies": "Documentation Required", + "mscolab_server_url": "Documentation Required", + "mss_dir": "Documentation Required", + "gravatar_ids": "Documentation Required", + "export_plugins": "Documentation Required", + "import_plugins": "Documentation Required", + "layout": "Documentation Required", + "topview": "Documentation Required", + "sideview": "Documentation Required", + "linearview": "Documentation Required", + } + + +# default options as dictionary +default_options = dict(MSUIDefaultConfig.__dict__) +for key in [ + "__module__", + "__doc__", + "__dict__", + "__weakref__", + "fixed_dict_options", + "dict_option_structure", + "list_option_structure", + "key_value_options", + "config_descriptions", +]: + del default_options[key] + + +# user options as dictionary +user_options = copy.deepcopy(default_options) + + +def read_config_file(path=constants.MSUI_SETTINGS): + """ + reads a config file and updates global user_options + + Args: + path: path of config file + + Note: + sole purpose of the path argument is to be able to test with example config files + """ + path = path.replace("\\", "/") + dir_name, file_name = fs.path.split(path) + json_file_data = {} + with fs.open_fs(dir_name) as _fs: + if _fs.exists(file_name): + file_content = _fs.readtext(file_name) + try: + json_file_data = json.loads(file_content, object_pairs_hook=dict_raise_on_duplicates_empty) + except json.JSONDecodeError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Unexpected error while loading config\n{e}" + raise FatalUserError(error_message) + except ValueError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Invalid keys detected in config\n{e}" + raise FatalUserError(error_message) + else: + error_message = f"MSS config File '{path}' not found" + raise FileNotFoundError(error_message) + + global user_options + if json_file_data: + user_options = merge_dict(copy.deepcopy(default_options), json_file_data) + logging.debug("Merged default and user settings") + else: + user_options = copy.deepcopy(default_options) + logging.debug("No user settings found, using default settings") + + +def modify_config_file(data, path=constants.MSUI_SETTINGS): + """ + modifies a config file + + Args: + data: data to be modified/written + path: path of config file + + Note: + sole purpose of the path argument is to be able to test with example config files + """ + path = path.replace("\\", "/") + dir_name, file_name = fs.path.split(path) + json_file_data = {} + with fs.open_fs(dir_name) as _fs: + if _fs.exists(file_name): + try: + file_content = _fs.readtext(file_name) + json_file_data = json.loads(file_content, object_pairs_hook=dict_raise_on_duplicates_empty) + json_file_data_copy = copy.deepcopy(json_file_data) + for key in data: + if key not in json_file_data: + json_file_data_copy[key] = config_loader(dataset=key, default=True) + modified_data = merge_dict(json_file_data_copy, data) + logging.debug("Merged default and user settings") + _fs.writetext(file_name, json.dumps(modified_data, indent=4)) + read_config_file() + except json.JSONDecodeError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Unexpected error while loading config\n{e}" + raise FatalUserError(error_message) + except ValueError as e: + logging.error("Error while loading json file %s", e) + error_message = f"Invalid keys detected in config\n{e}" + raise FatalUserError(error_message) + else: + error_message = f"MSS config File '{path}' not found" + raise FileNotFoundError(error_message) + + +def config_loader(dataset=None, default=False): + """ + Function for returning config value + + Args: + dataset: section to pull from json file + default: option to return default config for the dataset + + Returns: a the dataset value or the config as dictionary + + """ + if dataset is not None and dataset not in user_options: + raise KeyError(f"requested dataset '{dataset}' not in defaults!") + + if dataset is not None: + if default: + return default_options[dataset] + return user_options[dataset] + else: + if default: + return default_options + return user_options + + +def save_settings_qsettings(tag, settings, ignore_test=False): + """ + Saves a dictionary settings to disk. + + :param tag: string specifying the settings + :param settings: dictionary of settings + :return: None + """ + assert isinstance(tag, str) + assert isinstance(settings, dict) + if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + return settings + + q_settings = QtCore.QSettings("msui", "msui-core") + file_path = q_settings.fileName() + logging.debug("storing settings for %s to %s", tag, file_path) + try: + q_settings.setValue(tag, QtCore.QVariant(settings)) + except (OSError, IOError) as ex: + logging.warning("Problems storing %s settings (%s: %s).", tag, type(ex), ex) + return settings + + +def load_settings_qsettings(tag, default_settings=None, ignore_test=False): + """ + Loads a dictionary of settings from disk. May supply a dictionary of default settings + to return in case the settings file is not present or damaged. The default_settings one will + be updated by the restored one so one may rely on all keys of the default_settings dictionary + being present in the returned dictionary. + + :param tag: string specifying the settings + :param default_settings: dictionary of settings or None + :return: dictionary of settings + """ + if default_settings is None: + default_settings = {} + assert isinstance(default_settings, dict) + if not ignore_test and ("pytest" in sys.modules or "pyautogui" in sys.modules): + return default_settings + + settings = {} + q_settings = QtCore.QSettings("msui", "msui-core") + file_path = q_settings.fileName() + logging.debug("loading settings for %s from %s", tag, file_path) + try: + settings = q_settings.value(tag) + except Exception as ex: + logging.error("Problems reloading stored %s settings (%s: %s). Switching to default", + tag, type(ex), ex) + if isinstance(settings, dict): + default_settings.update(settings) + return default_settings + + +def merge_dict(existing_dict, new_dict): + """ + Merge two dictionaries by comparing all the options from + the MSUIDefaultConfig class + + Arguments: + existing_dict -- Dict to merge new_dict into + new_dict -- Dict with new values + """ + # Check if dictionary options with fixed key/value pairs match data types from default + for key in MSUIDefaultConfig.fixed_dict_options: + if key in new_dict: + existing_dict[key] = compare_data( + existing_dict[key], new_dict[key] + )[0] + + # Check if dictionary options with predefined structure match data types from default + dos = copy.deepcopy(MSUIDefaultConfig.dict_option_structure) + # adding plugin structure : ["extension", "module", "function"] to + # recognize user plugin options that don't have the optional filepicker option set + dos["import_plugins"]["plugin-name-a"] = dos["import_plugins"]["plugin-name"][:3] + dos["export_plugins"]["plugin-name-a"] = dos["export_plugins"]["plugin-name"][:3] + for key in dos: + if key in new_dict: + temp_data = {} + for option_key in new_dict[key]: + for dos_key_key in dos[key]: + data, match = compare_data(dos[key][dos_key_key], new_dict[key][option_key]) + if match: + temp_data[option_key] = new_dict[key][option_key] + break + if temp_data != {}: + existing_dict[key] = temp_data + + # Check if list options with predefined structure match data types from default + los = copy.deepcopy(MSUIDefaultConfig.list_option_structure) + for key in los: + if key in new_dict: + temp_data = [] + for i in range(len(new_dict[key])): + for los_key_item in los[key]: + data, match = compare_data(los_key_item, new_dict[key][i]) + if match: + temp_data.append(data) + break + if temp_data != []: + existing_dict[key] = temp_data + + # Check if options with fixed key/value pair structure match data types from default + for key in MSUIDefaultConfig.key_value_options: + if key in new_dict: + data, match = compare_data(existing_dict[key], new_dict[key]) + if match: + existing_dict[key] = data + + # add filepicker default to import and export plugins if missing + for plugin_type in ["import_plugins", "export_plugins"]: + if plugin_type in existing_dict: + for plugin in existing_dict[plugin_type]: + if len(existing_dict[plugin_type][plugin]) == 3: + existing_dict[plugin_type][plugin].append( + existing_dict.get("filepicker_default", "default") + ) + + return existing_dict + + +def compare_data(default, user_data): + """ + Recursively compares two dictionaries based on qt_json_view datatypes + and returns default or user_data appropriately. + + Arguments: + default -- Dict to return if datatype not matching + user_data -- Dict to return if datatype is matching + """ + # If data is neither list not dict type, compare individual type + if not isinstance(default, dict) and not isinstance(default, list): + if isinstance(default, float) and isinstance(user_data, int): + user_data = float(default) + if isinstance(match_type(default), UrlType) and isinstance(match_type(user_data), StrType): + return user_data, True + if isinstance(match_type(default), type(match_type(user_data))): + return user_data, True + else: + return default, False + + data = copy.deepcopy(default) + matches = [] + # If data is list type, compare all values in list + if isinstance(default, list) and isinstance(user_data, list): + if len(default) == len(user_data): + for i in range(len(default)): + data[i], match = compare_data(default[i], user_data[i]) + matches.append(match) + else: + return default, False + + # If data is dict type, goes through the dict and update + elif isinstance(default, dict) and isinstance(user_data, dict): + if default.keys() == user_data.keys(): + for key in default: + if key in user_data: + data[key], match = compare_data(default[key], user_data[key]) + matches.append(match) + else: + matches.append(False) + else: + return default, False + + return data, all(matches) + + +def dict_raise_on_duplicates_empty(ordered_pairs): + """Reject duplicate and empty keys.""" + accepted = {} + for key, value in ordered_pairs: + if key in accepted: + raise ValueError(f"duplicate key found: {key}") + elif key == "": + raise ValueError("empty key found") + else: + accepted[key] = value + return accepted diff --git a/mslib/utils/migration/update_json_file_to_version_nine.py b/mslib/utils/migration/update_json_file_to_version_nine.py new file mode 100644 index 000000000..d7febf47b --- /dev/null +++ b/mslib/utils/migration/update_json_file_to_version_nine.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.update_json_file_to_version_nine + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + updates the old attributes to the new attributes and creates credentials in keyring + + This file is part of MSS. + + :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. + :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import fs +import json +import copy + +from packaging import version +from mslib import __version__ +from mslib.utils.migration.config_before_nine import read_config_file as read_config_file_before_nine +from mslib.utils.migration.config_before_nine import config_loader as config_loader_before_nine +from mslib.utils.config import modify_config_file +from mslib.utils.config import read_config_file, config_loader +from mslib.msui.constants import MSUI_SETTINGS + + +class JsonConversion: + def __init__(self): + read_config_file_before_nine() + self.MSCOLAB_mailid = config_loader_before_nine(dataset="MSCOLAB_mailid") + self.MSS_auth = config_loader_before_nine(dataset="MSS_auth") + self.default_MSCOLAB = config_loader_before_nine(dataset="default_MSCOLAB") + + def change_parameters(self): + """ + adds new parameters and store passwords in the keyring + """ + if version.parse(__version__) > version.parse('8.0.0') and version.parse(__version__) < version.parse('10.0.0'): + + mss_auth = self.MSS_auth + + for url, username in self.MSS_auth.items(): + if url in self.default_MSCOLAB and mss_auth[url] != self.MSCOLAB_mailid: + mss_auth[url] = self.MSCOLAB_mailid + + data_to_save_in_config_file = { + "MSS_auth": mss_auth + } + + filename = MSUI_SETTINGS.replace('\\', '/') + dir_name, file_name = fs.path.split(filename) + # create the backup file + with fs.open_fs(dir_name) as _fs: + fs.copy.copy_file(_fs, file_name, _fs, f"{file_name}.bak") + # add the modification + modify_config_file(data_to_save_in_config_file) + # read new file + read_config_file() + # Todo move this to a seperate function to utils + # get all defaults + default_options = config_loader(default=True) + # get the data from the local file + json_data = config_loader() + save_data = copy.deepcopy(json_data) + + # remove everything we have as defaults + for key in json_data: + if json_data[key] == default_options[key] or json_data[key] == {} or json_data[key] == []: + del save_data[key] + + # write new data + with fs.open_fs(dir_name) as _fs: + _fs.writetext(file_name, json.dumps(save_data, indent=4)) + + +if __name__ == "__main__": + if version.parse(__version__) >= version.parse('9.0.0'): + new_version = JsonConversion() + new_version.change_parameters() diff --git a/mslib/utils/ogcwms.py b/mslib/utils/ogcwms.py index 531237f43..daf9f7d23 100644 --- a/mslib/utils/ogcwms.py +++ b/mslib/utils/ogcwms.py @@ -57,9 +57,6 @@ Currently supports only versions 1.1.1/1.3.0 of the WMS protocol. """ -from future import standard_library -standard_library.install_aliases() - import defusedxml.ElementTree as etree import requests import logging diff --git a/mslib/utils/qt.py b/mslib/utils/qt.py index a32dab700..cbb47d265 100644 --- a/mslib/utils/qt.py +++ b/mslib/utils/qt.py @@ -25,7 +25,6 @@ limitations under the License. """ -import importlib import logging import os import re @@ -281,8 +280,8 @@ def resizeEvent(self, event): self.updateText() super().resizeEvent(event) - def eventFilter(self, object, event): - if object == self.lineEdit(): + def eventFilter(self, obj, event): + if obj == self.lineEdit(): if event.type() == QtCore.QEvent.MouseButtonRelease: if self.closeOnLineEditClick: self.hidePopup() @@ -291,7 +290,7 @@ def eventFilter(self, object, event): return True return False - if object == self.view().viewport(): + if obj == self.view().viewport(): if event.type() == QtCore.QEvent.MouseButtonRelease: index = self.view().indexAt(event.pos()) item = self.model().item(index.row()) @@ -379,7 +378,7 @@ class Worker(QtCore.QThread): def __init__(self, function): Worker.workers.add(self) - super(Worker, self).__init__() + super().__init__() self.function = function # pyqtSignals don't work without an application eventloop running if QtCore.QCoreApplication.startingUp(): @@ -443,7 +442,7 @@ class Updater(QtCore.QObject): on_status_update = QtCore.pyqtSignal([str]) def __init__(self, parent=None): - super(Updater, self).__init__(parent) + super().__init__(parent) self.is_git_env = False self.new_version = None self.old_version = None @@ -606,38 +605,4 @@ def emit(self, *args): pass -# Import all Dialogues from the proper module directory. -for mod in [ - "ui_about_dialog", - "ui_shortcuts", - "ui_updater_dialog", - "ui_hexagon_dockwidget", - "ui_kmloverlay_dockwidget", - "ui_customize_kml", - "ui_mainwindow", - "ui_configuration_editor_window", - "ui_mscolab_connect_dialog", - "ui_mscolab_help_dialog", - "ui_add_operation_dialog", - "ui_mscolab_merge_waypoints_dialog", - "ui_mscolab_profile_dialog", - "ui_mss_rename_message", - "ui_performance_dockwidget", - "ui_remotesensing_dockwidget", - "ui_satellite_dockwidget", - "ui_airdata_dockwidget", - "ui_sideview_options", - "ui_sideview_window", - "ui_tableview_window", - "ui_topview_mapappearance", - "ui_topview_window", - "ui_linearview_options", - "ui_linearview_window", - "ui_wms_password_dialog", - "ui_wms_capabilities", - "ui_wms_dockwidget", - "ui_wms_multilayers"]: - globals()[mod] = importlib.import_module("mslib.msui.qt5." + mod) - - sys.excepthook = excepthook diff --git a/mslib/utils/thermolib.py b/mslib/utils/thermolib.py index 4c8d3cb54..93cba6447 100644 --- a/mslib/utils/thermolib.py +++ b/mslib/utils/thermolib.py @@ -27,15 +27,10 @@ """ import numpy as np - -from mslib.utils.units import units, check_units - -from metpy.package_tools import Exporter from metpy.constants import Rd, g from metpy.xarray import preprocess_and_wrap -import metpy.calc as mpcalc -exporter = Exporter(globals()) +from mslib.utils.units import units, check_units def rel_hum(p, t, q): @@ -85,6 +80,7 @@ def pot_temp(p, t): Returns: potential temperature in [K]. """ + import metpy.calc as mpcalc return mpcalc.potential_temperature( units.Pa * p, units.K * t).to("K").m @@ -104,6 +100,7 @@ def eqpt_approx(p, t, q): Returns: equivalent potential temperature in [K]. """ + import metpy.calc as mpcalc return mpcalc.equivalent_potential_temperature( units.Pa * p, units.K * t, mpcalc.dewpoint_from_specific_humidity(units.Pa * p, units.K * t, q)).to("K").m @@ -122,6 +119,7 @@ def omega_to_w(omega, p, t): Returns the vertical velocity in geometric coordinates, [m/s]. """ + import metpy.calc as mpcalc return mpcalc.vertical_velocity( units("Pa/s") * omega, units.Pa * p, units.K * t).to("m/s").m @@ -140,7 +138,6 @@ def omega_to_w(omega, p, t): _HEIGHT, _TEMPERATURE, _PRESSURE, _TEMPERATURE_GRADIENT = 0, 1, 2, 3 -@exporter.export @preprocess_and_wrap(wrap_like='height') @check_units('[length]') def flightlevel2pressure(height): @@ -192,7 +189,6 @@ def flightlevel2pressure(height): return p if is_array else p[0] -@exporter.export @preprocess_and_wrap(wrap_like='pressure') @check_units('[pressure]') def pressure2flightlevel(pressure): @@ -245,7 +241,6 @@ def pressure2flightlevel(pressure): return z if is_array else z[0] -@exporter.export @preprocess_and_wrap(wrap_like='height') @check_units('[length]') def isa_temperature(height): diff --git a/mslib/utils/units.py b/mslib/utils/units.py index 2a6105111..91bc39434 100644 --- a/mslib/utils/units.py +++ b/mslib/utils/units.py @@ -99,16 +99,16 @@ def convert_to(value, from_unit, to_unit, default=1.): value_unit = units.Quantity(value, from_unit) result = value_unit.to(to_unit).magnitude except pint.UndefinedUnitError: - logging.error("Error in unit conversion (undefined) '%s'/'%s'", from_unit, to_unit) + logging.warning("Error in unit conversion (undefined) '%s'/'%s'", from_unit, to_unit) result = value * default except pint.DimensionalityError: if units(to_unit).to_base_units().units == units.m: try: result = (value_unit / units.Quantity(9.81, "m s^-2")).to(to_unit).magnitude except pint.DimensionalityError: - logging.error("Error in unit conversion (dimensionality) %s/%s", from_unit, to_unit) + logging.warning("Error in unit conversion (dimensionality) '%s'/'%s'", from_unit, to_unit) result = value * default else: - logging.error("Error in unit conversion (dimensionality) %s/%s", from_unit, to_unit) + logging.warning("Error in unit conversion (dimensionality) '%s'/'%s'", from_unit, to_unit) result = value * default return result diff --git a/pytest.ini b/pytest.ini index 994165b57..33d560e1e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,14 @@ [pytest] -log_format = %(asctime)s %(levelname)s %(message)s -log_date_format = %Y-%m-%d %H:%M:%S -timeout = 60 +log_cli = 1 +log_cli_level = ERROR +log_cli_format = %(message)s +log_file = pytest.log +log_file_level = DEBUG +log_file_format = %(asctime)s %(levelname)s %(message)s +log_file_date_format = %Y-%m-%d %H:%M:%S +timeout = 30 +filterwarnings = + # These namespaces are declared in a way not conformant with PEP420. Not much we can do about that here, we should keep an eye on when this is fixed in our dependencies though. + ignore:Deprecated call to `pkg_resources.declare_namespace\('(xstatic|xstatic\.pkg|mpl_toolkits|mpl_toolkits\.basemap_data|sphinxcontrib|zope|fs|fs\.opener)'\)`\.:DeprecationWarning + # pkg_resources is explicitly used in fs (PyFilesystem2). Ignore the deprecation warning here. + ignore:pkg_resources is deprecated as an API.:DeprecationWarning diff --git a/requirements.d/development.txt b/requirements.d/development.txt index 62956c407..c34b4fdb9 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -1,27 +1,23 @@ # This file may be used to create an environment using: # $ conda create --name --file # platform: linux-64 -pep8 +flake8 +flake8-builtins py mock -pycodestyle pytest -pytest-cache -pytest-pep8 -pytest-flake8 +pytest-qt pytest-xdist pytest-cov pytest-timeout sphinx sphinx_rtd_theme gitpython -gevent pynco conda-verify -Flask-Testing pytest-reverse eventlet>0.30.2 dnspython>=2.0.0, <2.3.0 gsl==2.7.0 +boa xmlschema<2.5.0 - diff --git a/requirements.d/tutorials.txt b/requirements.d/tutorials.txt index 5e45ae893..e264bdcb6 100644 --- a/requirements.d/tutorials.txt +++ b/requirements.d/tutorials.txt @@ -1,9 +1,9 @@ # This file may be used to create an environment using: # $ conda create --name --file # platform: linux-64 -mouseinfo=0.1.3=pypi_0 -opencv=4.5.2=py39hf3d152e_0 -playsound=1.3.0=pypi_0 -pyautogui=0.9.48=py39hde42818_1 -pyscreeze=0.1.27=pyhd8ed1ab_0 -python-mss=6.1.0=pyhd3deb0d_0 +mouseinfo=0.1.3 +opencv=4.5.5 +# playsound=1.3.0=pypi_0 # not yet on conda-forge +pyautogui=0.9.54 +pyscreeze=0.1.29 +python-mss=9.0.1 diff --git a/setup.cfg b/setup.cfg index 0ae5bec02..20ed0d901 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,30 +34,7 @@ omit = tests/* [tool:pytest] -addopts = --flake8 -flake8-max-line-length = 120 -flake8-ignore = - *.py E402 W504 N801 N802 N803 N805 N806 N813 - conftest.py F821 - setup.py F821 - docs/conf.py ALL - mslib/__init__.py F401 - mslib/msui/mss_qt.py F401 # lots of imports for importing code - mslib/msui/mss_pyui.py F401 # nappy imported for testing - mslib/msui/qt5/*.py ALL # ignore all pyuic5 created files -pep8maxlinelength = 120 norecursedirs = .git .idea .cache -pep8ignore = - *.py E402 # futurize requires some code between imports - *.py E124 # closing bracket does not match visual indentation (behaves strange!?) - *.py E125 # continuation line does not distinguish itself from next logical line (difficult to avoid!) - mslib/msui/qt5/*.py ALL # ignore all pyuic5 created files - docs/conf.py ALL - -[pycodestyle] -ignore = E124,E125,E402,W504 -max-line-length = 120 -exclude = mslib/msui/qt5/*.py [flake8] ignore = E124,E125,E402,W504 diff --git a/tests/_test_mscolab/test_chat_manager.py b/tests/_test_mscolab/test_chat_manager.py index 30ceef682..f5999869d 100644 --- a/tests/_test_mscolab/test_chat_manager.py +++ b/tests/_test_mscolab/test_chat_manager.py @@ -24,44 +24,31 @@ See the License for the specific language governing permissions and limitations under the License. """ +import os +import secrets +import pytest -from flask_testing import TestCase +from werkzeug.datastructures import FileStorage from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Message, MessageType -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import APP +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.sockets_manager import setup_managers -class Test_Chat_Manager(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_Chat_Manager: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, self.cm, _ = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' self.operation_name = "europe" - socketio, self.cm, self.fm = setup_managers(self.app) assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_add_message(self): with self.app.test_client(): @@ -89,3 +76,17 @@ def test_delete_messages(self): self.cm.delete_message(message.id) message = Message.query.filter(Message.id == message.id).first() assert message is None + + def test_add_attachment(self): + sample_path = os.path.join(os.path.dirname(__file__), "..", "data") + filename = "example.csv" + name, ext = filename.split('.') + open_csv = os.path.join(sample_path, "example.csv") + operation = Operation.query.filter_by(path=self.operation_name).first() + token = secrets.token_urlsafe(16) + with open(open_csv, 'rb') as fp: + file = FileStorage(fp, filename=filename, content_type="text/csv") + static_path = self.cm.add_attachment(operation.id, mscolab_settings.UPLOAD_FOLDER, file, token) + assert name in static_path + assert static_path.endswith(ext) + assert token in static_path diff --git a/tests/_test_mscolab/test_file_manager.py b/tests/_test_mscolab/test_file_manager.py index 37c14cf08..424aea351 100644 --- a/tests/_test_mscolab/test_file_manager.py +++ b/tests/_test_mscolab/test_file_manager.py @@ -24,39 +24,20 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase -import os +import datetime import pytest -from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Operation -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager +from mslib.mscolab.models import Operation, User from mslib.mscolab.seed import add_user, get_user -from mslib.mscolab.mscolab import handle_db_reset - - -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_FileManager(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() + + +class Test_FileManager: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) self.user = get_user(self.userdata[0]) @@ -76,15 +57,58 @@ def setUp(self): assert add_user('UV80@uv80', 'UV80', 'uv80') self.adminuser = get_user('UV80@uv80') self._example_data() + with self.app.app_context(): + yield - def tearDown(self): - pass + def test_modify_user(self): + with self.app.test_client(): + user = User("user@example.com", "user", "password") + assert user.id is None + assert User.query.filter_by(emailid=user.emailid).first() is None + # create the user + self.fm.modify_user(user, action="create") + user_query = User.query.filter_by(emailid=user.emailid).first() + assert user_query.id is not None + assert user_query is not None + assert user_query.confirmed is False + # cannot create a user a second time + assert self.fm.modify_user(user, action="create") is False + # confirming the user + confirm_time = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + self.fm.modify_user(user_query, attribute="confirmed_on", value=confirm_time) + self.fm.modify_user(user_query, attribute="confirmed", value=True) + user_query = User.query.filter_by(id=user.id).first() + assert user_query.confirmed is True + assert user_query.confirmed_on == confirm_time + assert user_query.confirmed_on > user_query.registered_on + # deleting the user + self.fm.modify_user(user_query, action="delete") + user_query = User.query.filter_by(id=user_query.id).first() + assert user_query is None + + def test_modify_user_special_cases(self): + user1 = User("user1@example.com", "user1", "password") + user2 = User("user2@example.com", "user2", "password") + self.fm.modify_user(user1, action="create") + self.fm.modify_user(user2, action="create") + user_query1 = User.query.filter_by(emailid=user1.emailid).first() + assert self.fm.modify_user(user_query1, "emailid", user2.emailid) is False def test_fetch_operation_creator(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="more_than_one") + self.fm.add_bulk_permission(operation.id, self.user, [self.collaboratoruser.id], "collaborator") + self.fm.add_bulk_permission(operation.id, self.user, [self.vieweruser.id], "viewer") + self.fm.add_bulk_permission(operation.id, self.user, [self.adminuser.id], "admin") + assert operation.path == flight_path assert self.fm.fetch_operation_creator(operation.id, self.user.id) == self.user.username + assert self.fm.fetch_operation_creator(operation.id, self.collaboratoruser.id) == self.user.username + assert self.fm.fetch_operation_creator(operation.id, self.vieweruser.id) == self.user.username + assert self.fm.fetch_operation_creator(operation.id, self.adminuser.id) == self.user.username + + # this user is not defined in that OP + assert self.fm.fetch_operation_creator(operation.id, self.op2user.id) is False def test_create_operation(self): with self.app.test_client(): @@ -120,6 +144,31 @@ def test_list_operations(self): 'path': 'second'}] assert self.fm.list_operations(self.user) == expected_result + def test_list_operations_skip_archived(self): + with self.app.test_client(): + self.fm.create_operation("first", "info about first", self.user, active=False) + self.fm.create_operation("second", "info about second", self.user) + expected_result_all = [{'access_level': 'creator', + 'active': False, + 'category': 'default', + 'description': 'info about first', + 'op_id': 1, + 'path': 'first'}, + {'access_level': 'creator', + 'active': True, + 'category': 'default', + 'description': 'info about second', + 'op_id': 2, + 'path': 'second'}] + expected_result_skipped_true = [{'access_level': 'creator', + 'active': True, + 'category': 'default', + 'description': 'info about second', + 'op_id': 2, + 'path': 'second'}] + assert self.fm.list_operations(self.user, skip_archived=False) == expected_result_all + assert self.fm.list_operations(self.user, skip_archived=True) == expected_result_skipped_true + def test_is_creator(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path='third') @@ -178,11 +227,10 @@ def test_update_operation(self): assert ren_operation.id == operation.id assert ren_operation.path == rename_to - def test_delete_file(self): - # Todo rename "file" to operation + def test_delete_operation(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path='operation4') - assert self.fm.delete_file(operation.id, self.user) + assert self.fm.delete_operation(operation.id, self.user) assert Operation.query.filter_by(path=flight_path).first() is None def test_get_authorized_users(self): @@ -219,7 +267,7 @@ def test_get_change_content(self): assert self.fm.save_file(operation.id, self.content1, self.user) assert self.fm.save_file(operation.id, self.content2, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) - assert self.fm.get_change_content(all_changes[1]["id"]) == self.content1 + assert self.fm.get_change_content(all_changes[1]["id"], self.user) == self.content1 def test_set_version_name(self): with self.app.test_client(): @@ -247,15 +295,15 @@ def test_undo(self): assert self.fm.save_file(operation.id, self.content2, self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) # crestor - assert self.fm.undo(all_changes[1]["id"], self.user) + assert self.fm.undo_changes(all_changes[1]["id"], self.user) # check collaborator self.fm.add_bulk_permission(operation.id, self.user, [self.collaboratoruser.id], "collaborator") assert self.fm.is_collaborator(self.collaboratoruser.id, operation.id) - assert self.fm.undo(all_changes[1]["id"], self.collaboratoruser) + assert self.fm.undo_changes(all_changes[1]["id"], self.collaboratoruser) # check viewer self.fm.add_bulk_permission(operation.id, self.user, [self.vieweruser.id], "viewer") assert self.fm.is_viewer(self.vieweruser.id, operation.id) - assert self.fm.undo(all_changes[1]["id"], self.vieweruser) is False + assert self.fm.undo_changes(all_changes[1]["id"], self.vieweruser) is False def test_fetch_users_without_permission(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_files.py b/tests/_test_mscolab/test_files.py index 6db498a21..ee42c4687 100644 --- a/tests/_test_mscolab/test_files.py +++ b/tests/_test_mscolab/test_files.py @@ -25,39 +25,21 @@ limitations under the License. """ # ToDo have to be merged into test_file_manager -from flask_testing import TestCase import os import pytest from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import User, Operation, Permission, Change, Message -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.utils import get_recent_op_id -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_Files(TestCase): - render_templates = False +class Test_Files: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() - - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) self.userdata = 'UV11@uv11', 'UV11', 'uv11' self.userdata2 = 'UV12@uv12', 'UV12', 'uv12' @@ -69,9 +51,8 @@ def setUp(self): assert self.user is not None self.file_message_counter = [0] * 2 self._example_data() - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_create_operation(self): with self.app.test_client(): @@ -129,7 +110,7 @@ def test_undo(self): changes = Change.query.filter_by(op_id=operation.id).all() assert changes is not None assert changes[0].id == 1 - assert self.fm.undo(changes[0].id, self.user) is True + assert self.fm.undo_changes(changes[0].id, self.user) is True assert len(self.fm.get_all_changes(operation.id, self.user)) == 3 assert "beta" in self.fm.get_file(operation.id, self.user) @@ -162,9 +143,9 @@ def test_delete_operation(self): with self.app.test_client(): self._create_operation(flight_path="f3") op_id = get_recent_op_id(self.fm, self.user) - assert self.fm.delete_file(op_id, self.user2) is False - assert self.fm.delete_file(op_id, self.user) is True - assert self.fm.delete_file(op_id, self.user) is False + assert self.fm.delete_operation(op_id, self.user2) is False + assert self.fm.delete_operation(op_id, self.user) is True + assert self.fm.delete_operation(op_id, self.user) is False permissions = Permission.query.filter_by(op_id=op_id).all() assert len(permissions) == 0 operations_db = Operation.query.filter_by(id=op_id).all() diff --git a/tests/_test_mscolab/test_files_api.py b/tests/_test_mscolab/test_files_api.py index 0aeb89473..1523eb18e 100644 --- a/tests/_test_mscolab/test_files_api.py +++ b/tests/_test_mscolab/test_files_api.py @@ -24,49 +24,26 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase -import os import fs import pytest -from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user -from mslib.mscolab.mscolab import handle_db_reset -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_Files(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() - - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) +class Test_Files: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' - assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) self.user = get_user(self.userdata[0]) assert self.user is not None assert add_user('UV20@uv20', 'UV20', 'uv20') self.user_2 = get_user('UV20@uv20') - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_create_operation(self): with self.app.test_client(): @@ -182,12 +159,11 @@ def test_update_operation(self): operation = Operation.query.filter_by(path=new_flight_path).first() assert operation.description == new_description - def test_delete_file(self): - # ToDo rename to operation + def test_delete_operation(self): with self.app.test_client(): flight_path, operation = self._create_operation(flight_path="V10") assert operation.path == flight_path - assert self.fm.delete_file(operation.id, self.user) + assert self.fm.delete_operation(operation.id, self.user) operation = Operation.query.filter_by(path=flight_path).first() assert operation is None @@ -206,9 +182,9 @@ def test_get_change_content(self): assert self.fm.save_file(operation.id, "content2", self.user) assert self.fm.save_file(operation.id, "content3", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) - previous_change = self.fm.get_change_content(all_changes[2]["id"]) + previous_change = self.fm.get_change_content(all_changes[2]["id"], self.user) assert previous_change == "content1" - previous_change = self.fm.get_change_content(all_changes[1]["id"]) + previous_change = self.fm.get_change_content(all_changes[1]["id"], self.user) assert previous_change == "content2" def test_set_version_name(self): diff --git a/tests/_test_mscolab/test_models.py b/tests/_test_mscolab/test_models.py index 63b3f3de5..5b39025ce 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -24,33 +24,20 @@ See the License for the specific language governing permissions and limitations under the License. """ +import pytest -from flask_testing import TestCase -from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import register_user, APP +from mslib.mscolab.server import register_user from mslib.mscolab.models import User -class Test_User(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_User: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): self.userdata = 'UV10@uv10', 'UV10', 'uv10' - result = register_user(self.userdata[0], self.userdata[1], self.userdata[2]) - assert result["success"] is True + with mscolab_app.app_context(): + result = register_user(self.userdata[0], self.userdata[1], self.userdata[2]) + assert result["success"] is True + yield def test_generate_auth_token(self): user = User(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -61,8 +48,8 @@ def test_generate_auth_token(self): def test_verify_auth_token(self): user = User(self.userdata[0], self.userdata[1], self.userdata[2]) token = user.generate_auth_token() - id = user.verify_auth_token(token) - assert user.id == id + uid = user.verify_auth_token(token) + assert user.id == uid def test_verify_password(self): user = User(self.userdata[0], self.userdata[1], self.userdata[2]) diff --git a/tests/_test_mscolab/test_mscolab.py b/tests/_test_mscolab/test_mscolab.py index 339d7fb94..5c28e89f9 100644 --- a/tests/_test_mscolab/test_mscolab.py +++ b/tests/_test_mscolab/test_mscolab.py @@ -28,12 +28,10 @@ import pytest import mock import argparse -from flask_testing import TestCase from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation, User, Permission from mslib.mscolab.mscolab import handle_db_reset, handle_db_seed, confirm_action, main -from mslib.mscolab.server import APP from mslib.mscolab.seed import add_operation @@ -62,20 +60,13 @@ def test_main(): # currently only checking precedence of all args -class Test_Mscolab(TestCase): - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app +class Test_Mscolab: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): + with mscolab_app.app_context(): + yield - def setUp(self): - handle_db_reset() + def test_initial_state(self): assert Operation.query.all() == [] assert User.query.all() == [] assert Permission.query.all() == [] diff --git a/tests/_test_mscolab/test_seed.py b/tests/_test_mscolab/test_seed.py index 95090c24f..1de296389 100644 --- a/tests/_test_mscolab/test_seed.py +++ b/tests/_test_mscolab/test_seed.py @@ -24,34 +24,18 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase +import pytest -from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import User, Operation -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import APP -from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import (add_user, get_user, add_operation, add_user_to_operation, delete_user, delete_operation, add_all_users_default_operation) -class Test_Seed(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() - self.fm = FileManager(self.app.config["MSCOLAB_DATA_DIR"]) +class Test_Seed: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.operation_name = "XYZ" self.description = "Template" self.userdata_0 = 'UV0@uv0', 'UV0', 'uv0' @@ -62,9 +46,8 @@ def setUp(self): assert add_operation(self.operation_name, self.description) assert add_user_to_operation(path=self.operation_name, emailid=self.userdata_0[0]) self.user = User(self.userdata_0[0], self.userdata_0[1], self.userdata_0[2]) - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_add_operation(self): with self.app.test_client(): diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 63ddf4846..d7e8995c0 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -24,43 +24,24 @@ See the License for the specific language governing permissions and limitations under the License. """ - -from flask_testing import TestCase -import os import pytest import json import io from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import User, Operation -from mslib.mscolab.mscolab import handle_db_reset -from mslib.mscolab.server import initialize_managers, check_login, register_user, APP +from mslib.mscolab.server import initialize_managers, check_login, register_user from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_Server(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_reset() +class Test_Server: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): + self.app = mscolab_app self.userdata = 'UV10@uv10', 'UV10', 'uv10' - - def tearDown(self): - pass + with self.app.app_context(): + yield def test_initialize_managers(self): app, sockio, cm, fm = initialize_managers(self.app) @@ -79,8 +60,9 @@ def test_home(self): def test_hello(self): with self.app.test_client() as test_client: response = test_client.get('/status') - assert response.status_code == 200 - assert b"Mscolab server" in response.data + data = json.loads(response.text) + assert "Mscolab server" in data['message'] + assert True or False in data['use_saml2 '] def test_register_user(self): with self.app.test_client(): @@ -88,13 +70,13 @@ def test_register_user(self): assert result["success"] is True result = register_user(self.userdata[0], self.userdata[1], self.userdata[2]) assert result["success"] is False - assert result["message"] == "Oh no, this email ID is already taken!" + assert result["message"] == "This email ID is already taken!" result = register_user("UV", self.userdata[1], self.userdata[2]) assert result["success"] is False - assert result["message"] == "Oh no, your email ID is not valid!" + assert result["message"] == "Your email ID is not valid!" result = register_user(self.userdata[0], self.userdata[1], self.userdata[0]) assert result["success"] is False - assert result["message"] == "Oh no, your username cannot contain @ symbol!" + assert result["message"] == "Your username cannot contain @ symbol!" def test_check_login(self): with self.app.test_client(): @@ -149,11 +131,11 @@ def test_delete_user(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: token = self._get_token(test_client, self.userdata) - response = test_client.post('/delete_user', data={"token": token}) + response = test_client.post('/delete_own_account', data={"token": token}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) assert data["success"] is True - response = test_client.post('/delete_user', data={"token": "dsdsds"}) + response = test_client.post('/delete_own_account', data={"token": "dsdsds"}) assert response.status_code == 200 assert response.data.decode('utf-8') == "False" @@ -204,6 +186,12 @@ def test_create_operation(self): with self.app.test_client() as test_client: operation, token = self._create_operation(test_client, self.userdata) assert operation is not None + assert operation.active is True + assert token is not None + operation, token = self._create_operation(test_client, + self.userdata, path="archived_operation", active=False) + assert operation is not None + assert operation.active is False assert token is not None def test_get_operation_by_id(self): @@ -227,6 +215,19 @@ def test_get_operations(self): assert data["operations"][0]["path"] == "firstflightpath1" assert data["operations"][1]["path"] == "firstflightpath2" + def test_get_operations_skip_archived(self): + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) + with self.app.test_client() as test_client: + self._create_operation(test_client, self.userdata, path="firstflightpath1") + operation, token = self._create_operation(test_client, self.userdata, path="firstflightpath2", active=False) + response = test_client.get('/operations', data={"token": token, + "skip_archived": "True"}) + assert response.status_code == 200 + data = json.loads(response.data.decode('utf-8')) + assert len(data["operations"]) == 1 + assert data["operations"][0]["path"] == "firstflightpath1" + assert "firstflightpath2" not in data["operations"] + def test_get_all_changes(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) with self.app.test_client() as test_client: @@ -329,15 +330,6 @@ def test_set_last_used(self): data = json.loads(response.data.decode('utf-8')) assert data["success"] is True - def test_update_last_used(self): - assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) - with self.app.test_client() as test_client: - operation, token = self._create_operation(test_client, self.userdata) - response = test_client.post('/update_last_used', data={"token": token}) - assert response.status_code == 200 - data = json.loads(response.data.decode('utf-8')) - assert data["success"] is True - def test_get_users_without_permission(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) unprevileged_user = 'UV20@uv20', 'UV20', 'uv20' @@ -383,7 +375,7 @@ def test_import_permissions(self): # creator is not listed assert data["success"] is True - def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test"): + def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test", active=True): if userdata is None: userdata = self.userdata response = test_client.post('/token', data={"email": userdata[0], "password": userdata[2]}) @@ -391,7 +383,8 @@ def _create_operation(self, test_client, userdata=None, path="firstflight", desc token = data["token"] response = test_client.post('/create_operation', data={"token": token, "path": path, - "description": description}) + "description": description, + "active": str(active)}) assert response.status_code == 200 assert response.data.decode('utf-8') == "True" operation = Operation.query.filter_by(path=path).first() diff --git a/tests/_test_mscolab/test_server_auth_required.py b/tests/_test_mscolab/test_server_auth_required.py new file mode 100644 index 000000000..05fbf46f1 --- /dev/null +++ b/tests/_test_mscolab/test_server_auth_required.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" + + tests._test_mscolab.test_server_auth_required + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + tests for server basics when auth is enabled + + This file is part of MSS. + + :copyright: Copyright 2020 Reimar Bauer + :copyright: Copyright 2020-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import pytest + +from mslib.mscolab.conf import mscolab_settings + +mscolab_settings.enable_basic_http_authentication = True +try: + from mslib.mscolab.server import authfunc, verify_pw, initialize_managers, get_auth_token, register_user +except ImportError: + pytest.skip("this test runs only by an explicit call " + "e.g. pytest tests/_test_mscolab/test_server_auth_required.py", allow_module_level=True) + + +class Test_Server_Auth_Not_Valid: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app): + self.app = mscolab_app + self.userdata = 'UV10@uv10', 'UV10', 'uv10' + + def test_initialize_managers(self): + app, sockio, cm, fm = initialize_managers(self.app) + + assert app.config['MSCOLAB_DATA_DIR'] == mscolab_settings.MSCOLAB_DATA_DIR + assert 'Create a Flask-SocketIO server.' in sockio.__doc__ + assert 'Class with handler functions for chat related functionalities' in cm.__doc__ + assert 'Class with handler functions for file related functionalities' in fm.__doc__ + + def test_authfunc(self): + mscolab_settings.enable_basic_http_authentication = True + assert authfunc("user", "testvaluepassword") + assert authfunc("user", "wrong") is False + + def test_verify_pw(self): + assert verify_pw("user", "testvaluepassword") + assert verify_pw("unknown", "unknow") is False + assert verify_pw("user", "wrong") is False + + def test_register_user(self): + r = register_user("test@test.io", "test", "pwdtest") + assert r.status_code == 401 + + def test_get_auth_token(self): + r = get_auth_token() + assert r.status_code == 401 diff --git a/tests/_test_mscolab/test_sockets_manager.py b/tests/_test_mscolab/test_sockets_manager.py index 5e5c7e9e6..55cac4182 100644 --- a/tests/_test_mscolab/test_sockets_manager.py +++ b/tests/_test_mscolab/test_sockets_manager.py @@ -26,46 +26,26 @@ """ import os import pytest +import socket import socketio import datetime import requests +from urllib.parse import urljoin, urlparse -from werkzeug.urls import url_join from mslib.msui.icons import icons from mslib.mscolab.conf import mscolab_settings -from tests.utils import mscolab_check_free_port, LiveSocketTestCase -from mslib.mscolab.server import APP, initialize_managers from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation, get_operation -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.sockets_manager import SocketsManager from mslib.mscolab.models import Permission, User, Message, MessageType -PORTS = list(range(27000, 27500)) - - -class Test_Socket_Manager(LiveSocketTestCase): - run_gc_after_test = True - chat_messages_counter = [0, 0, 0] # three sockets connected a, b, and c - chat_messages_counter_a = 0 # only for first test - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 1 - app.config['LIVESERVER_PORT'] = mscolab_check_free_port(PORTS, PORTS.pop()) - return app - - def setUp(self): - handle_db_reset() +class Test_Socket_Manager: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers, mscolab_server): + self.app = mscolab_app + _, self.cm, self.fm = mscolab_managers + self.url = mscolab_server self.sockets = [] - # self.app = APP - self.app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - self.app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - self.app, _, self.cm, self.fm = initialize_managers(self.app) self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' self.operation_name = "europe" @@ -77,12 +57,22 @@ def setUp(self): self.anotheruser = get_user(self.anotheruserdata[0]) self.token = self.user.generate_auth_token() self.operation = get_operation(self.operation_name) - self.url = self.get_server_url() self.sm = SocketsManager(self.cm, self.fm) - - def tearDown(self): - for socket in self.sockets: - socket.disconnect() + yield + for sock in self.sockets: + sock.disconnect() + + def _can_ping_server(self): + parsed_url = urlparse(self.url) + host, port = parsed_url.hostname, parsed_url.port + try: + sock = socket.create_connection((host, port)) + success = True + except socket.error: + success = False + finally: + sock.close() + return success def _connect(self): sio = socketio.Client(reconnection_attempts=5) @@ -109,7 +99,8 @@ def test_handle_connect(self): def test_join_creator_to_operatiom(self): sio = self._connect() operation = self._new_operation('new_operation', "example decription") - assert self.fm.get_file(int(operation.id), self.user) is False + with self.app.app_context(): + assert self.fm.get_file(int(operation.id), self.user) is False json_config = {"token": self.token, "op_id": operation.id} @@ -194,7 +185,7 @@ def test_get_messages(self): "message_text": "message from 1", "reply_id": -1 }) - sio.sleep(1) + sio.sleep(5) with self.app.app_context(): messages = self.cm.get_messages(1) assert messages[0]["text"] == "message from 1" @@ -204,7 +195,7 @@ def test_get_messages(self): messages = self.cm.get_messages(1, timestamp) assert len(messages) == 2 assert messages[0]["u_id"] == self.user.id - timestamp = datetime.datetime.now().strftime("%Y-%m-%d, %H:%M:%S") + timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d, %H:%M:%S") messages = self.cm.get_messages(1, timestamp) assert len(messages) == 0 @@ -233,7 +224,7 @@ def test_get_messages_api(self): "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") } # returns an array of messages - url = url_join(self.url, 'messages') + url = urljoin(self.url, 'messages') res = requests.get(url, data=data, timeout=(2, 10)).json() assert len(res["messages"]) == 2 @@ -269,7 +260,7 @@ def test_edit_message(self): "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") } # returns an array of messages - url = url_join(self.url, 'messages') + url = urljoin(self.url, 'messages') res = requests.get(url, data=data, timeout=(2, 10)).json() assert len(res["messages"]) == 1 messages = res["messages"][0] @@ -307,7 +298,7 @@ def test_upload_file(self): "op_id": self.operation.id, "message_type": int(MessageType.IMAGE) } - url = url_join(self.url, 'message_attachment') + url = urljoin(self.url, 'message_attachment') requests.post(url, data=data, files=files, timeout=(2, 10)) upload_dir = os.path.join(mscolab_settings.UPLOAD_FOLDER, str(self.user.id)) assert os.path.exists(upload_dir) diff --git a/tests/_test_mscolab/test_utils.py b/tests/_test_mscolab/test_utils.py index 3d55acffc..897da0672 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -23,7 +23,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase import os import pytest import json @@ -31,56 +30,36 @@ from fs.tempfs import TempFS from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Operation, MessageType -from mslib.mscolab.mscolab import handle_db_init, handle_db_reset -from mslib.mscolab.server import APP from mslib.mscolab.seed import add_user, get_user from mslib.mscolab.utils import get_recent_op_id, get_session_id, get_message_dict, create_files, os_fs_create_dir -from mslib.mscolab.sockets_manager import setup_managers -class Message(): - id = 1 +class Message: + id = 1 # noqa: A003 u_id = 2 - class user(): + class user: username = "name" text = "Moin" message_type = MessageType.TEXT reply_id = 0 replies = [] - class created_at(): + class created_at: def strftime(value): pass -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_Utils(TestCase): - render_templates = False - - def create_app(self): - app = APP - app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config["TESTING"] = True - app.config['LIVESERVER_TIMEOUT'] = 10 - app.config['LIVESERVER_PORT'] = 0 - return app - - def setUp(self): - handle_db_init() +class Test_Utils: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' - socketio, cm, self.fm = setup_managers(self.app) + with self.app.app_context(): + yield - def tearDown(self): - handle_db_reset() - - @pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") def test_get_recent_oid(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) assert add_user(self.anotheruserdata[0], self.anotheruserdata[1], self.anotheruserdata[2]) diff --git a/tests/_test_msui/test_aircrafts.py b/tests/_test_msui/test_aircrafts.py index 3fc003ffd..9ab17dc12 100644 --- a/tests/_test_msui/test_aircrafts.py +++ b/tests/_test_msui/test_aircrafts.py @@ -42,7 +42,7 @@ } -class Test_SimpleAircraft(object): +class Test_SimpleAircraft: def setup_method(self): self.simple_aircraft = SimpleAircraft(AIRCRAFT_DUMMY) @@ -78,7 +78,7 @@ def test_get_ceiling_alt(self): assert self.simple_aircraft.get_ceiling_altitude(85000) == 410 -class Test_SimpleAircraft2(object): +class Test_SimpleAircraft2: def setup_method(self): self.simple_aircraft = SimpleAircraft(AIRCRAFT_DUMMY2) diff --git a/tests/_test_msui/test_editor.py b/tests/_test_msui/test_editor.py index d69ea4e9b..2d1bfba5b 100644 --- a/tests/_test_msui/test_editor.py +++ b/tests/_test_msui/test_editor.py @@ -28,34 +28,29 @@ import mock import os import fs -import sys from PyQt5 import QtWidgets from mslib.msui import editor from tests.constants import ROOT_DIR @pytest.mark.skip("To be done for new UI") -class Test_Editor(object): +class Test_Editor: sample_file = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data", "msui_settings.json")) sample_file = sample_file.replace('\\', '/') save_file_name = fs.path.join(ROOT_DIR, "testeditor_save.json") - @mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes) - def setup_method(self, mockmessage): - self.application = QtWidgets.QApplication(sys.argv) - - self.window = editor.EditorMainWindow() - self.save_file_name = self.save_file_name - self.window.show() - - def teardown_method(self): - if os.path.exists(self.save_file_name): - os.remove(self.save_file_name) - self.window.hide() - QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() + @pytest.fixture(autouse=True) + def setup(self, qapp): + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window = editor.EditorMainWindow() + self.save_file_name = self.save_file_name + self.window.show() + yield + if os.path.exists(self.save_file_name): + os.remove(self.save_file_name) + self.window.hide() + QtWidgets.QApplication.processEvents() @mock.patch("mslib.msui.editor.get_open_filename", return_value=sample_file) def test_file_open(self, mockfile): diff --git a/tests/_test_msui/test_kmloverlay_dockwidget.py b/tests/_test_msui/test_kmloverlay_dockwidget.py index d3eaf43a7..4440f32bb 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -27,8 +27,8 @@ import os import fs -import sys import mock +import pytest from PyQt5 import QtWidgets, QtCore, QtTest, QtGui from tests.constants import ROOT_DIR import mslib.msui.kmloverlay_dockwidget as kd @@ -39,10 +39,10 @@ # ToDo refactoring, extract helper methods into functions # ToDo review needed helper functions -class Test_KmlOverlayDockWidget(object): +class Test_KmlOverlayDockWidget: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.view = mock.Mock() self.view.map = mock.Mock(side_effect=lambda x, y: (x, y)) self.view.map.plot = mock.Mock(return_value=[mock.Mock()]) @@ -56,10 +56,7 @@ def setup_method(self): self.window.select_all() self.window.remove_file() QtWidgets.QApplication.processEvents() - - def teardown_method(self): - QtWidgets.QApplication.processEvents() - self.application.quit() + yield QtWidgets.QApplication.processEvents() self.window.close() if os.path.exists(save_kml): @@ -90,8 +87,7 @@ def test_get_file(self, mockopen): # Tests opening of QFileDialog QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_select_file(self, mockbox): + def test_select_file(self): """ Test All geometries and styles are being parsed without crashing """ @@ -103,27 +99,26 @@ def test_select_file(self, mockbox): assert self.window.listWidget.item(index).checkState() == QtCore.Qt.Checked index = index + 1 assert self.window.directory_location == path - assert mockbox.critical.call_count == 0 assert self.window.listWidget.count() == index assert len(self.window.dict_files) == index assert self.count_patches() == 9 self.window.select_all() self.window.remove_file() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_select_file_error(self, mockbox): + def test_select_file_error(self): """ Test that program mitigates loading a non-existing file """ - # load a non existing path - self.window.select_all() - self.window.remove_file() - path = fs.path.join(sample_path, "satellite_predictor.txt") - filename = (path,) # converted to tuple - self.window.select_file(filename) - self.window.load_file() - QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: + # load a non existing path + self.window.select_all() + self.window.remove_file() + path = fs.path.join(sample_path, "satellite_predictor.txt") + filename = (path,) # converted to tuple + self.window.select_file(filename) + self.window.load_file() + QtWidgets.QApplication.processEvents() + critbox.assert_called_once() self.window.listWidget.clear() self.window.dict_files = {} @@ -153,9 +148,8 @@ def test_remove_all_files(self): assert self.window.dict_files == {} # Dictionary should be empty assert self.count_patches() == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.kmloverlay_dockwidget.get_save_filename", return_value=save_kml) - def test_merge_file(self, mocksave, mockbox): + def test_merge_file(self, mocksave): """ Test merging files into a single file without crashing """ diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 3d65df523..d6ae17ceb 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -29,47 +29,36 @@ import os import pytest import shutil -import sys -import multiprocessing import tempfile -from mslib.mswms.mswms import application from PyQt5 import QtWidgets, QtTest, QtCore from mslib.msui import flighttrack as ft import mslib.msui.linearview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_LINEARVIEW from tests.utils import wait_until_signal -PORTS = list(range(26000, 26500)) - -class Test_MSS_LV_Options_Dialog(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSS_LV_Options_Dialog: + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_LV_Options_Dialog(settings=_DEFAULT_SETTINGS_LINEARVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_show(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_show(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_get(self, mockcrit): + def test_get(self): self.window.get_settings() - assert mockcrit.critical.call_count == 0 -class Test_MSSLinearViewWindow(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSSLinearViewWindow: + @pytest.fixture(autouse=True) + def setup(self, qapp): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -81,52 +70,38 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_wms(self, mockbox): + def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_mouse_over(self, mockbox): + def test_mouse_over(self): # Test mouse over QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(782, 266), -1) QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(100, 100), -1) QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.linearview.MSUI_LV_Options_Dialog") - def test_options(self, mockdlg, mockbox): + def test_options(self, mockdlg): QtTest.QTest.mouseClick(self.window.lvoptionbtn, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert mockdlg.call_count == 1 assert mockdlg.return_value.setModal.call_count == 1 assert mockdlg.return_value.exec_.call_count == 1 assert mockdlg.return_value.destroy.call_count == 1 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_LinearViewWMS(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - self.port = PORTS.pop() +class Test_LinearViewWMS: + @pytest.fixture(autouse=True) + def setup(self, qapp, mswms_server): + self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") @@ -142,14 +117,10 @@ def setup_method(self): QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): QtWidgets.QApplication.processEvents() @@ -159,13 +130,10 @@ def query_server(self, url): QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") - QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.wms_control.image_displayed) - assert mockbox.critical.call_count == 0 + self.query_server(self.url) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) diff --git a/tests/_test_msui/test_mpl_map.py b/tests/_test_msui/test_mpl_map.py index 19d28661a..bd477c666 100644 --- a/tests/_test_msui/test_mpl_map.py +++ b/tests/_test_msui/test_mpl_map.py @@ -24,12 +24,15 @@ See the License for the specific language governing permissions and limitations under the License. """ +import pytest + from matplotlib import pyplot as plt from mslib.msui.mpl_map import MapCanvas class Test_MapCanvas: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): kwargs = {'resolution': 'l', 'area_thresh': 1000.0, 'ax': plt.gca(), 'llcrnrlon': -15.0, 'llcrnrlat': 35.0, 'urcrnrlon': 30.0, 'urcrnrlat': 65.0, 'epsg': '4326'} self.map = MapCanvas(**kwargs) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 947bfc831..5b8a8fc23 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -24,7 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys import os import fs import fs.errors @@ -38,22 +37,17 @@ from mslib.mscolab.models import Permission, User from mslib.msui.flighttrack import WaypointsTableModel from PyQt5 import QtCore, QtTest, QtWidgets -from mslib.utils.config import read_config_file, config_loader -from tests.utils import mscolab_start_server, create_msui_settings_file, ExceptionMock +from mslib.utils.config import read_config_file, config_loader, modify_config_file +from tests.utils import create_msui_settings_file, ExceptionMock from mslib.msui import msui from mslib.msui import mscolab -from mslib.mscolab.mscolab import handle_db_reset -from tests.constants import MSUI_CONFIG_PATH from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation -PORTS = list(range(25000, 25500)) - -class Test_Mscolab_connect_window(): - def setup_method(self): - handle_db_reset() - self._reset_config_file() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) +class Test_Mscolab_connect_window: + @pytest.fixture(autouse=True) + def setup(self, qapp, mscolab_server): + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -62,8 +56,8 @@ def setup_method(self): self.user = get_user(self.userdata[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.main_window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.main_window.create_new_flight_track() self.main_window.show() self.window = mscolab.MSColab_ConnectDialog(parent=self.main_window, mscolab=self.main_window.mscolab) self.window.urlCb.setEditText(self.url) @@ -73,41 +67,75 @@ def setup_method(self): "berta@something.org", "anton@something.org", "other@something.org"]: mslib.utils.auth.del_password_from_keyring(service_name="MSCOLAB", username=email) - - def teardown_method(self): + yield self.main_window.mscolab.logout() self.window.hide() self.main_window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - self.process.terminate() def test_url_combo(self): assert self.window.urlCb.count() >= 1 + @pytest.mark.parametrize( + "exc", + [requests.exceptions.ConnectionError, requests.exceptions.InvalidSchema, + requests.exceptions.InvalidURL, requests.exceptions.SSLError, + Exception]) @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") - def test_connect(self, mockset): - for exc in [requests.exceptions.ConnectionError, requests.exceptions.InvalidSchema, - requests.exceptions.InvalidURL, requests.exceptions.SSLError, Exception("")]: - with mock.patch("requests.get", new=ExceptionMock(exc).raise_exc): - self.window.connect_handler() + def test_connect_except(self, mockset, exc): + with mock.patch("requests.Session.get", new=ExceptionMock(exc).raise_exc): + self.window.connect_handler() + assert mockset.call_args_list == [mock.call("color: red;")] - self._connect_to_mscolab() - assert mockset.call_args_list == [mock.call("color: red;") for _ in range(5)] + [mock.call("color: green;")] + @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") + def test_connect_denied(self, mockset): + with mock.patch("requests.Session.get", return_value=mock.Mock(status_code=401)): + self.window.connect_handler() + assert mockset.call_args_list == [mock.call("color: red;")] + + @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") + @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) + def test_connect_success(self, mockbox, mockset): + assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") != "fnord" + self._connect_to_mscolab(password="fnord") + + assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") == "fnord" + assert mockset.call_args_list == [mock.call("color: green;")] def test_disconnect(self): self._connect_to_mscolab() assert self.window.mscolab_server_url is not None - QtTest.QTest.mouseClick(self.window.connectBtn, QtCore.Qt.LeftButton) + QtTest.QTest.mouseClick(self.window.disconnectBtn, QtCore.Qt.LeftButton) assert self.window.mscolab_server_url is None # set ui_name_winodw default assert self.main_window.usernameLabel.text() == 'User' def test_login(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(self.userdata[0], self.userdata[2]) + QtWidgets.QApplication.processEvents() + # show logged in widgets + assert self.main_window.usernameLabel.text() == self.userdata[1] + assert self.main_window.connectBtn.isVisible() is False + assert self.main_window.mscolab.connect_window is None + assert self.main_window.local_active is True + # test operation listing visibility + assert self.main_window.listOperationsMSC.model().rowCount() == 1 + + @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) + def test_login_with_different_account_shows_update_credentials_popup(self, mockbox): + self._connect_to_mscolab() + connect_window = self.main_window.mscolab.connect_window self._login(self.userdata[0], self.userdata[2]) QtWidgets.QApplication.processEvents() + mockbox.assert_called_once_with( + connect_window, + "Update Credentials", + "You are using new credentials. Should your settings file be updated with the new credentials?", + mock.ANY, + mock.ANY, + ) # show logged in widgets assert self.main_window.usernameLabel.text() == self.userdata[1] assert self.main_window.connectBtn.isVisible() is False @@ -119,6 +147,7 @@ def test_login(self): def test_logout_action_trigger(self): # Login self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) QtWidgets.QApplication.processEvents() assert self.main_window.usernameLabel.text() == self.userdata[1] @@ -133,6 +162,7 @@ def test_logout_action_trigger(self): def test_logout(self): # Login self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) QtWidgets.QApplication.processEvents() assert self.main_window.usernameLabel.text() == self.userdata[1] @@ -147,8 +177,9 @@ def test_logout(self): @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_add_user(self, mockmessage): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" assert mslib.utils.auth.get_password_from_keyring("MSCOLAB", "something@something.org") == "password from TestKeyring" # assert self.window.stackedWidget.currentWidget() == self.window.newuserPage @@ -157,72 +188,41 @@ def test_add_user(self, mockmessage): @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) def test_add_users_without_updating_credentials_in_config_file(self, mockmessage): - create_msui_settings_file('{"MSCOLAB_mailid": "something@something.org"}') + create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') read_config_file() # check current settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" self._connect_to_mscolab() assert self.window.mscolab_server_url is not None - self._create_user("anand", "anand@something.org", "anand") + self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" + assert mslib.utils.auth.get_password_from_keyring(service_name=self.url, + username="anand@something.org") == "anand_pass" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" # check user is logged in assert self.main_window.usernameLabel.text() == "anand" @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) def test_add_users_with_updating_credentials_in_config_file(self, mockmessage): - create_msui_settings_file('{"MSCOLAB_mailid": "something@something.org" }') - mslib.utils.auth.save_password_to_keyring(service_name="MSCOLAB", + create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') + mslib.utils.auth.save_password_to_keyring(service_name=self.url, username="something@something.org", password="something") read_config_file() # check current settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" - assert mslib.utils.auth.get_password_from_keyring("MSCOLAB", - "something@something.org") == "password from TestKeyring" + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" self._connect_to_mscolab() assert self.window.mscolab_server_url is not None - self._create_user("anand", "anand@something.org", "anand") + self._create_user("anand", "anand@something.org", "anand_pass") # check changed settings - assert config_loader(dataset="MSCOLAB_mailid") == "anand@something.org" - assert mslib.utils.auth.get_password_from_keyring(service_name="MSCOLAB", - username="anand@something.org") == "password from TestKeyring" + assert config_loader(dataset="MSS_auth").get(self.url) == "anand@something.org" + assert mslib.utils.auth.get_password_from_keyring(service_name=self.url, + username="anand@something.org") == "anand_pass" # check user is logged in assert self.main_window.usernameLabel.text() == "anand" - def test_failed_authorize(self): - class response: - def __init__(self, code, text): - self.status_code = code - self.text = text - - # case: connection error when trying to login after connecting to server - self._connect_to_mscolab() - with mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") as mockset: - with mock.patch("requests.Session.post", new=ExceptionMock(requests.exceptions.ConnectionError).raise_exc): - self._login() - mockset.assert_has_calls([mock.call("color: red;"), mock.call("")]) - - # case: when the credentials are incorrect for login - self._connect_to_mscolab() - with mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") as mockset: - with mock.patch("requests.Session.post", return_value=response(201, "False")): - self._login() - mockset.assert_has_calls([mock.call("color: red;")]) - - # case: when http auth fails - with mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") as mockset: - with mock.patch("requests.Session.post", return_value=response(401, "Unauthorized Access")): - self._login() - # check if switched to HTTP Auth Page - assert self.window.stackedWidget.currentIndex() == 2 - # press ok without entering server auth details - okWidget = self.window.httpBb.button(self.window.httpBb.Ok) - QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - mockset.assert_has_calls([mock.call("color: red;")]) - - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, password=""): self.window.urlCb.setEditText(self.url) + self.window.httpPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.connectBtn, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) @@ -249,29 +249,22 @@ def _create_user(self, username, email, password): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - def _reset_config_file(self): - create_msui_settings_file('{ }') - config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") - read_config_file(path=config_file) - -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_Mscolab(object): +class Test_Mscolab: sample_path = os.path.join(os.path.dirname(__file__), "..", "data") # import/export plugins import_plugins = { - "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"], - "FliteStar": ["fls", "mslib.plugins.io.flitestar", "load_from_flitestar"], + "TXT": ["txt", "mslib.plugins.io.text", "load_from_txt"], + "FliteStar": ["txt", "mslib.plugins.io.flitestar", "load_from_flitestar"], } export_plugins = { "Text": ["txt", "mslib.plugins.io.text", "save_to_txt"], } - def setup_method(self): - handle_db_reset() - self._reset_config_file() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) + @pytest.fixture(autouse=True) + def setup(self, qapp, mscolab_app, mscolab_server): + self.app = mscolab_app + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -290,11 +283,12 @@ def setup_method(self): assert add_user_to_operation(path=self.operation_name3, access_level="collaborator", emailid=self.userdata3[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() - def teardown_method(self): + self.total_created_operations = 0 + yield self.window.mscolab.logout() if self.window.mscolab.version_window: self.window.mscolab.version_window.close() @@ -305,21 +299,19 @@ def teardown_method(self): self.window.listViews.item(0).window.handle_force_close() # close all hanging operation option windows self.window.mscolab.close_external_windows() - self.application.quit() - QtWidgets.QApplication.processEvents() - self.process.terminate() def test_activate_operation(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # activate a operation self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_name == self.operation_name - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_view_open(self, mockbox): + def test_view_open(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # test after activating operation self._activate_operation_at_index(0) @@ -341,10 +333,14 @@ def test_view_open(self, mockbox): active_windows = self.window.get_active_views() topview = active_windows[1] tableview = active_windows[0] - self.window.mscolab.handle_update_permission(operation, uid, "viewer") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.mscolab.handle_update_permission(operation, uid, "viewer") + m.assert_called_once() assert not tableview.btAddWayPointToFlightTrack.isEnabled() assert any(action.text() == "Ins WP" and not action.isEnabled() for action in topview.mpl.navbar.actions()) - self.window.mscolab.handle_update_permission(operation, uid, "creator") + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.mscolab.handle_update_permission(operation, uid, "creator") + m.assert_called_once() assert tableview.btAddWayPointToFlightTrack.isEnabled() assert any(action.text() == "Ins WP" and action.isEnabled() for action in topview.mpl.navbar.actions()) @@ -353,6 +349,7 @@ def test_view_open(self, mockbox): "Flight track (*.ftml)")) def test_handle_export(self, mockbox): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.actionExportFlightTrackFTML.trigger() @@ -364,50 +361,37 @@ def test_handle_export(self, mockbox): for i in range(wp_count): assert exported_waypoints.waypoint_data(i).lat == self.window.mscolab.waypoints_model.waypoint_data(i).lat - @pytest.mark.skip("fails on github with WebSocket transport is not available") - @pytest.mark.parametrize("ext", [".ftml", ".csv", ".txt"]) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_import_file(self, mockbox, ext): + @pytest.mark.parametrize("name", [("example.ftml", "actionImportFlightTrackFTML", 5), + ("example.csv", "actionImportFlightTrackCSV", 5), + ("example.txt", "actionImportFlightTrackTXT", 5), + ("flitestar.txt", "actionImportFlightTrackFliteStar", 10)]) + def test_import_file(self, name): self.window.remove_plugins() - with mock.patch("mslib.msui.msui.config_loader", return_value=self.import_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") - with mock.patch("mslib.msui.msui.config_loader", return_value=self.export_plugins): - self.window.add_export_plugins("qt") - file_path = fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, f'test_import{ext}') - with mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(file_path, None)): - with mock.patch("PyQt5.QtWidgets.QFileDialog.getOpenFileName", return_value=(file_path, None)): - self._connect_to_mscolab() - self._login(emailid=self.userdata[0], password=self.userdata[2]) - self._activate_operation_at_index(0) - exported_wp = WaypointsTableModel(waypoints=self.window.mscolab.waypoints_model.waypoints) - full_name = f"actionExportFlightTrack{ext[1:]}" - for action in self.window.menuExportActiveFlightTrack.actions(): - if action.objectName() == full_name: + file_path = fs.path.join(self.sample_path, name[0]) + with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[file_path]) as mockopen: + # with parametrize it is maybe too fast + QtTest.QTest.qWait(100) + self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(emailid=self.userdata[0], password=self.userdata[2]) + self._activate_operation_at_index(0) + wp = self.window.mscolab.waypoints_model + assert len(wp.waypoints) == 2 + for action in self.window.menuImportFlightTrack.actions(): + if action.objectName() == name[1]: + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: action.trigger() - break - assert os.path.exists(fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, f'test_import{ext}')) - QtWidgets.QApplication.processEvents() - self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - assert exported_wp.waypoint_data(0).lat != self.window.mscolab.waypoints_model.waypoint_data(0).lat - full_name = f"actionImportFlightTrack{ext[1:]}" - for action in self.window.menuImportFlightTrack.actions(): - if action.objectName() == full_name: - action.trigger() - break - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - assert len(self.window.mscolab.waypoints_model.waypoints) == 2 - imported_wp = self.window.mscolab.waypoints_model - wp_count = len(imported_wp.waypoints) - assert wp_count == 2 - for i in range(wp_count): - assert exported_wp.waypoint_data(i).lat == imported_wp.waypoint_data(i).lat - - @pytest.mark.skip("Runs in a timeout locally > 60s") + m.assert_called_once() + break + assert mockopen.call_count == 1 + imported_wp = self.window.mscolab.waypoints_model + assert len(imported_wp.waypoints) == name[2] + def test_work_locally_toggle(self): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.workLocallyCheckbox.setChecked(True) @@ -423,12 +407,11 @@ def test_work_locally_toggle(self): wpdata_server = self.window.mscolab.waypoints_model.waypoint_data(0) assert wpdata_local.lat != wpdata_server.lat - @pytest.mark.skip("fails often on github on a timeout >60s") - @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") @mock.patch("mslib.msui.mscolab.get_open_filename", return_value=os.path.join(sample_path, u"example.ftml")) - def test_browse_add_operation(self, mockopen, mockmessage): + def test_browse_add_operation(self, mockopen, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") assert self.window.listOperationsMSC.model().rowCount() == 0 self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() @@ -442,7 +425,9 @@ def test_browse_add_operation(self, mockopen, mockmessage): QtWidgets.QApplication.processEvents() okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) - QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) + m.assert_called_once() # we need to wait for the update of the operation list QtTest.QTest.qWait(200) QtWidgets.QApplication.processEvents() @@ -451,59 +436,62 @@ def test_browse_add_operation(self, mockopen, mockmessage): assert item.operation_path == "example" assert item.access_level == "creator" - @mock.patch("PyQt5.QtWidgets.QErrorMessage") - def test_add_operation(self, mockbox): + def test_add_operation(self, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - assert self.window.usernameLabel.text() == 'something' - assert self.window.connectBtn.isVisible() is False - self._create_operation("Alpha", "Description Alpha") - assert mockbox.return_value.showMessage.call_count == 1 - with mock.patch("PyQt5.QtWidgets.QLineEdit.text", return_value=None): - self._create_operation("Alpha2", "Description Alpha") - with mock.patch("PyQt5.QtWidgets.QTextEdit.toPlainText", return_value=None): - self._create_operation("Alpha3", "Description Alpha") - self._create_operation("/", "Description Alpha") - assert mockbox.return_value.showMessage.call_count == 4 - assert self.window.listOperationsMSC.model().rowCount() == 1 - self._create_operation("reproduce-test", "Description Test") - assert self.window.listOperationsMSC.model().rowCount() == 2 + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "Alpha", "Description Alpha") + with (mock.patch("PyQt5.QtWidgets.QLineEdit.text", return_value=None), + mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m): + self._create_operation_unchecked("Alpha2", "Description Alpha") + m.assert_called_once_with("Path can't be empty") + with (mock.patch("PyQt5.QtWidgets.QTextEdit.toPlainText", return_value=None), + mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m): + self._create_operation_unchecked("Alpha3", "Description Alpha") + m.assert_called_once_with("Description can't be empty") + with mock.patch("PyQt5.QtWidgets.QErrorMessage.showMessage") as m: + self._create_operation_unchecked("/", "Description Alpha") + m.assert_called_once_with("Path can't contain spaces or special characters") + self._create_operation(qtbot, "reproduce-test", "Description Test") self._activate_operation_at_index(0) assert self.window.mscolab.active_operation_name == "Alpha" self._activate_operation_at_index(1) assert self.window.mscolab.active_operation_name == "reproduce-test" - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("flight7", True)) - def test_handle_delete_operation(self, mocktext, mockbox): - # pytest.skip('needs a review for the delete button pressed. Seems to delete a None operation') + def test_handle_delete_operation(self, mocktext, qtbot): self._connect_to_mscolab() - self._create_user("berta", "berta@something.org", "something") + modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) + self._create_user(qtbot, "berta", "berta@something.org", "something") assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 operation_name = "flight7" - self._create_operation(operation_name, "Description 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)) self._activate_operation_at_index(0) op_id = self.window.mscolab.get_recent_op_id() assert op_id is not None assert self.window.listOperationsMSC.model().rowCount() == 1 - self.window.actionDeleteOperation.trigger() - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionDeleteOperation.trigger() + QtWidgets.QApplication.processEvents() + qtbot.wait_until( + lambda: m.assert_called_once_with(self.window, "Success", 'Operation "flight7" was deleted!') + ) op_id = self.window.mscolab.get_recent_op_id() assert op_id is None QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) # check operation dir name removed assert os.path.isdir(os.path.join(mscolab_settings.MSCOLAB_DATA_DIR, operation_name)) is False - assert mockbox.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_handle_leave_operation(self, mockmessage): + def test_handle_leave_operation(self, mockmessage, qtbot): self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata3[0]}}) self._login(self.userdata3[0], self.userdata3[2]) QtWidgets.QApplication.processEvents() assert self.window.usernameLabel.text() == self.userdata3[1] @@ -525,71 +513,122 @@ def test_handle_leave_operation(self, mockmessage): self.window.actionLeaveOperation.trigger() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(0) - assert self.window.mscolab.active_op_id is None - assert self.window.listViews.count() == 0 - assert self.window.listOperationsMSC.model().rowCount() == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") + def assert_leave_operation_done(): + assert self.window.mscolab.active_op_id is None + assert self.window.listViews.count() == 0 + assert self.window.listOperationsMSC.model().rowCount() == 0 + qtbot.wait_until(assert_leave_operation_done) + @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_name", True)) - def test_handle_rename_operation(self, mockbox, mockpatch): + def test_handle_rename_operation(self, mocktext, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + 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 - self.window.actionRenameOperation.trigger() - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionRenameOperation.trigger() + QtWidgets.QApplication.processEvents() + m.assert_called_once_with(self.window, "Rename successful", "Operation is renamed successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None assert self.window.mscolab.active_operation_name == "new_name" - @mock.patch("PyQt5.QtWidgets.QMessageBox.information") - @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_desciption", True)) - def test_update_description(self, mockbox, mockpatch): + @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_description", True)) + def test_update_description(self, mocktext, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + 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 - self.window.actionUpdateOperationDesc.trigger() - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionChangeDescription.trigger() + QtWidgets.QApplication.processEvents() + m.assert_called_once_with(self.window, "Update successful", "Description is updated successfully.") + QtTest.QTest.qWait(0) + assert self.window.mscolab.active_op_id is not None + assert self.window.mscolab.active_operation_description == "new_description" + + @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_category", True)) + def test_update_category(self, mocktext, qtbot): + self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") + assert self.window.listOperationsMSC.model().rowCount() == 1 + assert self.window.mscolab.active_operation_category == "example" + self._activate_operation_at_index(0) + assert self.window.mscolab.active_op_id is not None + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionChangeCategory.trigger() + QtWidgets.QApplication.processEvents() + m.assert_called_once_with(self.window, "Update successful", "Category is updated successfully.") QtTest.QTest.qWait(0) assert self.window.mscolab.active_op_id is not None - assert self.window.mscolab.active_operation_desc == "new_desciption" + assert self.window.mscolab.active_operation_category == "new_category" - def test_get_recent_op_id(self): + def test_any_special_category(self, qtbot): + self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") + QtTest.QTest.qWait(0) + self._create_operation(qtbot, "flight5678", "Description flight5678", category="furtherexample") + # all operations of two defined categories are found + assert self.window.mscolab.selected_category == "*ANY*" + operation_pathes = [self.window.mscolab.ui.listOperationsMSC.item(i).operation_path for i in + range(self.window.mscolab.ui.listOperationsMSC.count())] + assert ["flight1234", "flight5678"] == operation_pathes + self.window.mscolab.ui.filterCategoryCb.setCurrentIndex(2) + QtWidgets.QApplication.processEvents() + # only operation of furtherexample are found + assert self.window.mscolab.selected_category == "furtherexample" + operation_pathes = [self.window.mscolab.ui.listOperationsMSC.item(i).operation_path for i in + range(self.window.mscolab.ui.listOperationsMSC.count())] + assert ["flight5678"] == operation_pathes + + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_get_recent_op_id(self, mockbox, qtbot): self._connect_to_mscolab() - self._create_user("anton", "anton@something.org", "something") + modify_config_file({"MSS_auth": {self.url: "anton@something.org"}}) + self._create_user(qtbot, "anton", "anton@something.org", "something") QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'anton' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 - self._create_operation("flight2", "Description flight2") + self._create_operation(qtbot, "flight2", "Description flight2") current_op_id = self.window.mscolab.get_recent_op_id() - self._create_operation("flight3", "Description flight3") - self._create_operation("flight4", "Description flight4") + self._create_operation(qtbot, "flight3", "Description flight3") + self._create_operation(qtbot, "flight4", "Description flight4") # ToDo fix number after cleanup initial data assert self.window.mscolab.get_recent_op_id() == current_op_id + 2 - def test_get_recent_operation(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_get_recent_operation(self, mockbox, qtbot): self._connect_to_mscolab() - self._create_user("berta", "berta@something.org", "something") + modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) + self._create_user(qtbot, "berta", "berta@something.org", "something") QtTest.QTest.qWait(100) assert self.window.usernameLabel.text() == 'berta' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 - self._create_operation("flight1234", "Description flight1234") + self._create_operation(qtbot, "flight1234", "Description flight1234") self._activate_operation_at_index(0) operation = self.window.mscolab.get_recent_operation() assert operation["path"] == "flight1234" assert operation["access_level"] == "creator" - def test_open_chat_window(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_open_chat_window(self, mockbox, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + 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 @@ -598,11 +637,12 @@ def test_open_chat_window(self): QtTest.QTest.qWait(0) assert self.window.mscolab.chat_window is not None - def test_close_chat_window(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_close_chat_window(self, mockbox, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") - assert self.window.listOperationsMSC.model().rowCount() == 1 + 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") self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None self.window.actionChat.trigger() @@ -610,22 +650,25 @@ def test_close_chat_window(self): self.window.mscolab.close_chat_window() assert self.window.mscolab.chat_window is None - def test_delete_operation_from_list(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_delete_operation_from_list(self, mockbox, qtbot): self._connect_to_mscolab() - self._create_user("other", "other@something.org", "something") + modify_config_file({"MSS_auth": {self.url: "other@something.org"}}) + self._create_user(qtbot, "other", "other@something.org", "something") assert self.window.usernameLabel.text() == 'other' assert self.window.connectBtn.isVisible() is False assert self.window.listOperationsMSC.model().rowCount() == 0 - self._create_operation("flight3", "Description flight3") + self._create_operation(qtbot, "flight3", "Description flight3") self._activate_operation_at_index(0) op_id = self.window.mscolab.get_recent_op_id() self.window.mscolab.delete_operation_from_list(op_id) assert self.window.mscolab.active_op_id is None @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_user_delete(self, mockmessage): + def test_user_delete(self, mockmessage, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") u_id = self.window.mscolab.user['id'] self.window.mscolab.open_profile_window() QtTest.QTest.mouseClick(self.window.mscolab.profile_dialog.deleteAccountBtn, QtCore.Qt.LeftButton) @@ -653,33 +696,35 @@ def test_close_help_dialog(self): QtWidgets.QApplication.processEvents() assert self.window.mscolab.help_dialog is None - @mock.patch("PyQt5.QtWidgets.QMessageBox") - @mock.patch("sys.exit") - def test_create_dir_exceptions(self, mockexit, mockbox): - with mock.patch("fs.open_fs", new=ExceptionMock(fs.errors.CreateFailed).raise_exc): + def test_create_dir_exceptions(self): + with mock.patch("fs.open_fs", new=ExceptionMock(fs.errors.CreateFailed).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox, \ + mock.patch("sys.exit") as mockexit: self.window.mscolab.data_dir = "://" self.window.mscolab.create_dir() - assert mockbox.critical.call_count == 1 - assert mockexit.call_count == 1 + critbox.assert_called_once() + mockexit.assert_called_once() - with mock.patch("fs.open_fs", new=ExceptionMock(fs.opener.errors.UnsupportedProtocol).raise_exc): + with mock.patch("fs.open_fs", new=ExceptionMock(fs.opener.errors.UnsupportedProtocol).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox, \ + mock.patch("sys.exit") as mockexit: self.window.mscolab.data_dir = "://" self.window.mscolab.create_dir() - assert mockbox.critical.call_count == 2 - assert mockexit.call_count == 2 + critbox.assert_called_once() + mockexit.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_profile_dialog(self, mockbox): + def test_profile_dialog(self, qtbot): self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") self.window.mscolab.profile_action.trigger() QtWidgets.QApplication.processEvents() # case: default gravatar is set and no messagebox is called - assert mockbox.critical.call_count == 0 assert self.window.mscolab.prof_diag is not None # case: trying to fetch non-existing gravatar - self.window.mscolab.fetch_gravatar(refresh=True) - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: + self.window.mscolab.fetch_gravatar(refresh=True) + critbox.assert_called_once() assert not self.window.mscolab.profile_dialog.gravatarLabel.pixmap().isNull() def _connect_to_mscolab(self): @@ -698,7 +743,7 @@ def _login(self, emailid, password): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(500) - def _create_user(self, username, email, password): + def _create_user(self, qtbot, username, email, password): QtTest.QTest.mouseClick(self.connect_window.addUserBtn, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() self.connect_window.newUsernameLe.setText(str(username)) @@ -713,26 +758,39 @@ def _create_user(self, username, email, password): QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - def _reset_config_file(self): - create_msui_settings_file('{ }') - config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") - read_config_file(path=config_file) + def assert_user_created(): + assert self.window.usernameLabel.text() == username + assert self.window.connectBtn.isVisible() is False + qtbot.wait_until(assert_user_created) - @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") - def _create_operation(self, path, description, mockbox): + def _create_operation_unchecked(self, path, description, category="example"): self.window.actionAddOperation.trigger() QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.path.setText(str(path)) QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.description.setText(str(description)) QtWidgets.QApplication.processEvents() - self.window.mscolab.add_proj_dialog.category.setText("example") + self.window.mscolab.add_proj_dialog.category.setText(category) QtWidgets.QApplication.processEvents() okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() + def _create_operation(self, qtbot, path, description, category="example"): + self.total_created_operations = self.total_created_operations + 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self._create_operation_unchecked(path, description, category) + m.assert_called_once_with( + self.window, + "Creation successful", + "Your operation was created successfully.", + ) + + def assert_operation_is_listed(): + assert self.window.listOperationsMSC.model().rowCount() == self.total_created_operations + qtbot.wait_until(assert_operation_is_listed) + def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index 4427d3083..e29a3d1b0 100644 --- a/tests/_test_msui/test_mscolab_admin_window.py +++ b/tests/_test_msui/test_mscolab_admin_window.py @@ -24,28 +24,21 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os +import mock import pytest -import sys from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets -from tests.utils import mscolab_start_server from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.utils.config import modify_config_file -PORTS = list(range(24000, 24500)) - - -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_MscolabAdminWindow(object): - def setup_method(self): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) +class Test_MscolabAdminWindow: + @pytest.fixture(autouse=True) + def setup(self, qapp, mscolab_server): + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -66,11 +59,12 @@ def setup_method(self): assert add_user_to_operation(path="tokyo", emailid=self.userdata[0], access_level="creator") QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() # connect and login to mscolab self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) @@ -79,16 +73,15 @@ def setup_method(self): self.admin_window = self.window.mscolab.admin_window QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.admin_window: self.window.mscolab.admin_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - self.application.quit() + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window.close() QtWidgets.QApplication.processEvents() - self.process.terminate() def test_permission_filter(self): len_added_users = self.admin_window.modifyUsersTable.rowCount() @@ -155,6 +148,7 @@ def test_add_permissions(self): self._check_users_present(self.admin_window.modifyUsersTable, users, "admin") assert len_unadded_users - 2 == self.admin_window.addUsersTable.rowCount() assert len_added_users + 2 == self.admin_window.modifyUsersTable.rowCount() + QtTest.QTest.qWait(1000) def test_modify_permissions(self): users = ["name1", "name2"] @@ -171,6 +165,7 @@ def test_modify_permissions(self): QtWidgets.QApplication.processEvents() # Check if the permission has been updated self._check_users_present(self.admin_window.modifyUsersTable, users, "viewer") + QtTest.QTest.qWait(1000) def test_delete_permissions(self): # Select users in the add users table @@ -190,6 +185,7 @@ def test_delete_permissions(self): self._check_users_present(self.admin_window.addUsersTable, users) assert len_unadded_users + 2 == self.admin_window.addUsersTable.rowCount() assert len_added_users - 2 == self.admin_window.modifyUsersTable.rowCount() + QtTest.QTest.qWait(1000) def test_import_permissions(self): index = self.admin_window.importPermissionsCB.findText("paris", QtCore.Qt.MatchFixedString) diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 6ac974510..1803929b5 100644 --- a/tests/_test_msui/test_mscolab_merge_waypoints.py +++ b/tests/_test_msui/test_mscolab_merge_waypoints.py @@ -24,8 +24,6 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os -import sys import fs import mock import pytest @@ -34,28 +32,23 @@ from mslib.msui import flighttrack as ft from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets -from tests.utils import (mscolab_start_server, mscolab_register_and_login, mscolab_create_operation, +from tests.utils import (mscolab_register_and_login, mscolab_create_operation, mscolab_delete_all_operations, mscolab_delete_user) from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset +from mslib.utils.config import modify_config_file -PORTS = list(range(23000, 23500)) - - -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_Mscolab_Merge_Waypoints(object): - def setup_method(self): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) +class Test_Mscolab_Merge_Waypoints: + @pytest.fixture(autouse=True) + def setup(self, qapp, mscolab_app, mscolab_server): + self.app = mscolab_app + self.url = mscolab_server QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.emailid = 'merge@alpha.org' - - def teardown_method(self): + yield self.window.mscolab.logout() mslib.utils.auth.del_password_from_keyring("merge@alpha.org") with self.app.app_context(): @@ -69,9 +62,7 @@ def teardown_method(self): self.window.mscolab.version_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - self.application.quit() QtWidgets.QApplication.processEvents() - self.process.terminate() def _create_user_data(self, emailid='merge@alpha.org'): with self.app.app_context(): @@ -95,6 +86,7 @@ def _connect_to_mscolab(self): QtTest.QTest.qWait(500) def _login(self, emailid="merge_waypoints_user", password="password"): + modify_config_file({"MSS_auth": {self.url: self.emailid}}) self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) @@ -115,92 +107,107 @@ def _select_waypoints(self, table): QtWidgets.QApplication.processEvents() -@pytest.mark.skip("timeout on github") +class AutoClickOverwriteMscolabMergeWaypointsDialog(mslib.msui.mscolab.MscolabMergeWaypointsDialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.overwriteBtn.animateClick() + + class Test_Overwrite_To_Server(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_overwrite_to_server(self, mockbox): + def test_save_overwrite_to_server(self, qtbot): self.emailid = "save_overwrite@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_local.lat == wp_server_before.lat + + def assert_(): + wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_local.lat == wp_server_before.lat + qtbot.wait_until(assert_) + self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_server_before.lat != wp_local_before.lat - # ToDo mock this messagebox - def handle_merge_dialog(): - QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.overwriteBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) + def assert_(): + wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_server_before.lat != wp_local_before.lat + qtbot.wait_until(assert_) + wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) - QtCore.QTimer.singleShot(3000, handle_merge_dialog) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(2) - QtWidgets.QApplication.processEvents() - # get the updated waypoints model from the server - # ToDo understand why requesting in follow up test self.window.waypoints_model not working - server_xml = self.window.mscolab.request_wps_from_server() - server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) - new_local_wp = server_waypoints_model.waypoint_data(0) - assert wp_local_before.lat == new_local_wp.lat + with mock.patch( + "mslib.msui.mscolab.MscolabMergeWaypointsDialog", + AutoClickOverwriteMscolabMergeWaypointsDialog), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.serverOptionsCb.setCurrentIndex(2) + m.assert_called_once() + + def assert_(): + # get the updated waypoints model from the server + server_xml = self.window.mscolab.request_wps_from_server() + server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) + new_local_wp = server_waypoints_model.waypoint_data(0) + assert wp_local_before.lat == new_local_wp.lat + qtbot.wait_until(assert_) + self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_local_before.lat == new_server_wp.lat + + def assert_(): + new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_local_before.lat == new_server_wp.lat + qtbot.wait_until(assert_) + + +class AutoClickKeepMscolabMergeWaypointsDialog(mslib.msui.mscolab.MscolabMergeWaypointsDialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.keepServerBtn.animateClick() class Test_Save_Keep_Server_Points(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_keep_server_points(self, mockbox): + def test_save_keep_server_points(self, qtbot): self.emailid = "save_keepe@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_local.lat == wp_server_before.lat + + def assert_(): + wp_local = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_local.lat == wp_server_before.lat + qtbot.wait_until(assert_) + self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_server_before.lat != wp_local_before.lat - # ToDo mock this messagebox - def handle_merge_dialog(): - QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.keepServerBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) + def assert_(): + wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_server_before.lat != wp_local_before.lat + qtbot.wait_until(assert_) + wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) - QtCore.QTimer.singleShot(3000, handle_merge_dialog) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(2) - QtWidgets.QApplication.processEvents() - # get the updated waypoints model from the server - # ToDo understand why requesting in follow up test self.window.waypoints_model not working - server_xml = self.window.mscolab.request_wps_from_server() - server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) - new_local_wp = server_waypoints_model.waypoint_data(0) + with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.serverOptionsCb.setCurrentIndex(2) + m.assert_called_once() + + def assert_(): + # get the updated waypoints model from the server + server_xml = self.window.mscolab.request_wps_from_server() + server_waypoints_model = ft.WaypointsTableModel(xml_content=server_xml) + new_local_wp = server_waypoints_model.waypoint_data(0) + assert wp_local_before.lat != new_local_wp.lat + assert new_local_wp.lat == wp_server_before.lat + qtbot.wait_until(assert_) - assert wp_local_before.lat != new_local_wp.lat - assert new_local_wp.lat == wp_server_before.lat self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) - assert wp_server_before.lat == new_server_wp.lat + + def assert_(): + new_server_wp = self.window.mscolab.waypoints_model.waypoint_data(0) + assert wp_server_before.lat == new_server_wp.lat + qtbot.wait_until(assert_) class Test_Fetch_From_Server(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_fetch_from_server(self, mockbox): + def test_fetch_from_server(self): self.emailid = "fetch_from_server@alpha.org" self._create_user_data(emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) @@ -213,15 +220,11 @@ def test_fetch_from_server(self, mockbox): wp_local_before = self.window.mscolab.waypoints_model.waypoint_data(0) assert wp_server_before.lat != wp_local_before.lat - # ToDo mock this messagebox - def handle_merge_dialog(): - QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.keepServerBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - - QtCore.QTimer.singleShot(3000, handle_merge_dialog) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(1) + with mock.patch("mslib.msui.mscolab.MscolabMergeWaypointsDialog", AutoClickKeepMscolabMergeWaypointsDialog), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.serverOptionsCb.setCurrentIndex(1) + m.assert_called_once() QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 5ca3a48dd..fc216c865 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -24,23 +24,18 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os -import sys import pytest from mslib.mscolab.conf import mscolab_settings from mslib.mscolab.models import Message from PyQt5 import QtCore, QtTest, QtWidgets -from tests.utils import mscolab_start_server from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.utils.config import modify_config_file -PORTS = list(range(22000, 22500)) - -class Actions(object): +class Actions: DOWNLOAD = 0 COPY = 1 REPLY = 2 @@ -48,12 +43,11 @@ class Actions(object): DELETE = 4 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_MscolabOperation(object): - def setup_method(self): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) +class Test_MscolabOperation: + @pytest.fixture(autouse=True) + def setup(self, qapp, mscolab_app, mscolab_server): + self.app = mscolab_app + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -61,11 +55,12 @@ def setup_method(self): assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() # connect and login to mscolab self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) @@ -74,8 +69,7 @@ def setup_method(self): self.chat_window = self.window.mscolab.chat_window QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.chat_window: self.window.mscolab.chat_window.hide() @@ -83,19 +77,16 @@ def teardown_method(self): self.window.mscolab.conn.disconnect() self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - self.process.terminate() - def test_send_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_send_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") with self.app.app_context(): assert Message.query.filter_by(text='**test message**').count() == 2 - def test_search_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_search_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") message_index = self.chat_window.messageList.count() - 1 # self.window.chat_window.searchMessageLineEdit.setText("test message") self.chat_window.searchMessageLineEdit.setText("test message") @@ -110,38 +101,42 @@ def test_search_message(self): QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index).isSelected() is True - def test_copy_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_copy_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") self._activate_context_menu_action(Actions.COPY) assert QtWidgets.QApplication.clipboard().text() == "**test message**" - def test_reply_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def test_reply_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") parent_message_id = self._get_message_id(self.chat_window.messageList.count() - 1) self._activate_context_menu_action(Actions.REPLY) self.chat_window.messageText.setPlainText('test reply') QtTest.QTest.mouseClick(self.chat_window.sendMessageBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(100) - with self.app.app_context(): - message = Message.query.filter_by(text='test reply') - assert message.count() == 1 - assert message.first().reply_id == parent_message_id - def test_edit_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def assert_(): + with self.app.app_context(): + message = Message.query.filter_by(text='test reply') + assert message.count() == 1 + assert message.first().reply_id == parent_message_id + qtbot.wait_until(assert_) + + def test_edit_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") self._activate_context_menu_action(Actions.EDIT) self.chat_window.messageText.setPlainText('test edit') QtTest.QTest.mouseClick(self.chat_window.editMessageBtn, QtCore.Qt.LeftButton) - QtTest.QTest.qWait(100) - with self.app.app_context(): - assert Message.query.filter_by(text='test edit').count() == 1 - def test_delete_message(self): - self._send_message("**test message**") - self._send_message("**test message**") + def assert_(): + with self.app.app_context(): + assert Message.query.filter_by(text='test edit').count() == 1 + qtbot.wait_until(assert_) + + def test_delete_message(self, qtbot): + self._send_message(qtbot, "**test message**") + self._send_message(qtbot, "**test message**") self._activate_context_menu_action(Actions.DELETE) QtTest.QTest.qWait(100) with self.app.app_context(): @@ -176,11 +171,14 @@ def _activate_context_menu_action(self, action_index): message_widget = self.chat_window.messageList.itemWidget(item) message_widget.context_menu.actions()[action_index].trigger() - def _send_message(self, text): + def _send_message(self, qtbot, text): + num_messages_before = self.chat_window.messageList.count() self.chat_window.messageText.setPlainText(text) QtTest.QTest.mouseClick(self.chat_window.sendMessageBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) + + def assert_(): + assert self.chat_window.messageList.count() == num_messages_before + 1 + qtbot.wait_until(assert_) def _get_message_id(self, index): item = self.chat_window.messageList.item(index) diff --git a/tests/_test_msui/test_mscolab_save_merge_points.py b/tests/_test_msui/test_mscolab_save_merge_points.py index b5308778f..2844ac286 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -24,23 +24,14 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os import mock -import pytest from tests._test_msui.test_mscolab_merge_waypoints import Test_Mscolab_Merge_Waypoints from mslib.msui import flighttrack as ft from PyQt5 import QtCore, QtTest, QtWidgets -PORTS = list(range(21000, 21500)) - - -# ToDo Understand why this needs to be skipped, it runs when direct called -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_Save_Merge_Points(Test_Mscolab_Merge_Waypoints): - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_save_merge_points(self, mockbox): + def test_save_merge_points(self): self.emailid = "mergepoints@alpha.org" self._create_user_data(emailid=self.emailid) self.window.workLocallyCheckbox.setChecked(True) @@ -58,12 +49,12 @@ def handle_merge_dialog(): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) - if merge_waypoints_model is None: - pytest.skip("merge_waypoints_model undefined") QtCore.QTimer.singleShot(3000, handle_merge_dialog) # QtTest.QTest.mouseClick(self.window.save_ft, QtCore.Qt.LeftButton, delay=1) # trigger save to server action from server options combobox - self.window.serverOptionsCb.setCurrentIndex(2) + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.serverOptionsCb.setCurrentIndex(2) + m.assert_called_once() QtWidgets.QApplication.processEvents() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index 01e48739f..d29fb9695 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -24,29 +24,21 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os -import sys import pytest import mock -from tests.utils import mscolab_start_server from mslib.mscolab.conf import mscolab_settings from PyQt5 import QtCore, QtTest, QtWidgets from mslib.msui import mscolab from mslib.msui import msui -from mslib.mscolab.mscolab import handle_db_reset from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.utils.config import modify_config_file -PORTS = list(range(20000, 20500)) - - -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_MscolabVersionHistory(object): - def setup_method(self): - handle_db_reset() - self.process, self.url, self.app, _, self.cm, self.fm = mscolab_start_server(PORTS) +class Test_MscolabVersionHistory: + @pytest.fixture(autouse=True) + def setup(self, qapp, mscolab_server): + self.url = mscolab_server self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.operation_name = "europe" assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -54,11 +46,12 @@ def setup_method(self): assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.create_new_flight_track() self.window.show() # connect and login to mscolab self._connect_to_mscolab() + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) @@ -68,31 +61,25 @@ def setup_method(self): assert self.version_window is not None QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.version_window: self.window.mscolab.version_window.close() if self.window.mscolab.conn: self.window.mscolab.conn.disconnect() - self.application.quit() - QtWidgets.QApplication.processEvents() - self.process.terminate() - def test_changes(self): + def test_changes(self, qtbot): self._change_version_filter(1) len_prev = self.version_window.changes.count() # make a changes self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - self.version_window.load_all_changes() - QtWidgets.QApplication.processEvents() - len_after = self.version_window.changes.count() - assert len_prev == (len_after - 2) + + def assert_(): + self.version_window.load_all_changes() + len_after = self.version_window.changes.count() + assert len_prev == (len_after - 2) + qtbot.wait_until(assert_) @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) def test_set_version_name(self, mockbox): @@ -113,14 +100,19 @@ def test_version_name_delete(self, mockbox): assert self.version_window.changes.currentItem().version_name is None @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_undo(self, mockbox): + def test_undo_changes(self, mockbox, qtbot): self._change_version_filter(1) + assert self.version_window.changes.count() == 0 # make changes for i in range(2): self.window.mscolab.waypoints_model.invert_direction() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) - self.version_window.load_all_changes() + + def assert_(): + self.version_window.load_all_changes() + assert self.version_window.changes.count() == 2 + qtbot.wait_until(assert_) QtWidgets.QApplication.processEvents() changes_count = self.version_window.changes.count() self._activate_change_at_index(1) diff --git a/tests/_test_msui/test_mss.py b/tests/_test_msui/test_mss.py index 5e4cba87e..5875d31dd 100644 --- a/tests/_test_msui/test_mss.py +++ b/tests/_test_msui/test_mss.py @@ -24,18 +24,12 @@ See the License for the specific language governing permissions and limitations under the License. """ - - -import sys from PyQt5 import QtWidgets, QtTest, QtCore from mslib.msui import mss -def test_mss_rename_message(): - application = QtWidgets.QApplication(sys.argv) +def test_mss_rename_message(qapp): main_window = mss.MSSMainWindow() main_window.show() QtTest.QTest.mouseClick(main_window.pushButton, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - application.quit() - QtWidgets.QApplication.processEvents() diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 2b2161ba8..b886050bc 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -26,17 +26,18 @@ """ -import sys import mock import os +import fs import platform import argparse import pytest from urllib.request import urlopen from PyQt5 import QtWidgets, QtTest from mslib import __version__ -from tests.constants import ROOT_DIR, POSIX +from tests.constants import ROOT_DIR, POSIX, MSUI_CONFIG_PATH from mslib.msui import msui +from mslib.msui import msui_mainwindow as msui_mw from tests.utils import ExceptionMock from mslib.utils.config import read_config_file @@ -64,10 +65,42 @@ def test_main(): assert pytest_wrapped_e.typename == "SystemExit" -class Test_MSS_AboutDialog(): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - self.window = msui.MSUI_AboutDialog() +class Test_MSS_TutorialMode: + @pytest.fixture(autouse=True) + def setup(self, qapp): + qapp.setApplicationDisplayName("MSUI") + self.main_window = msui_mw.MSUIMainWindow(tutorial_mode=True) + self.main_window.create_new_flight_track() + self.main_window.show() + self.main_window.shortcuts_dlg = msui_mw.MSUI_ShortcutsDialog( + tutorial_mode=True) + self.main_window.show_shortcuts(search_mode=True) + self.tutorial_dir = fs.path.combine(MSUI_CONFIG_PATH, 'tutorial_images') + yield + self.main_window.hide() + QtWidgets.QApplication.processEvents() + + def test_tutorial_dir(self): + dir_name, name = fs.path.split(self.tutorial_dir) + with fs.open_fs(dir_name) as _fs: + assert _fs.exists(name) + # seems we don't have a window manager in the test environment on github + # checking only for a few + with (fs.open_fs(self.tutorial_dir) as _fs): + common_images = _fs.listdir('/') + assert 'menufile-file.png' in common_images + assert 'msuimainwindow-operation-archive.png' in common_images + assert 'msuimainwindow-work-asynchronously.png' in common_images + assert 'msuimainwindow-connect.png' in common_images + + +class Test_MSS_AboutDialog: + @pytest.fixture(autouse=True) + def setup(self, qapp): + self.window = msui_mw.MSUI_AboutDialog() + yield + self.window.hide() + QtWidgets.QApplication.processEvents() def test_milestone_url(self): with urlopen(self.window.milestone_url) as f: @@ -75,26 +108,17 @@ def test_milestone_url(self): pattern = f'value="is:closed milestone:{__version__[:-1]}"' assert pattern in text.decode('utf-8') - def teardown_method(self): - self.window.hide() - QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - -class Test_MSS_ShortcutDialog(): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - self.main_window = msui.MSUIMainWindow() +class Test_MSS_ShortcutDialog: + @pytest.fixture(autouse=True) + def setup(self, qapp): + self.main_window = msui_mw.MSUIMainWindow() self.main_window.show() - self.shortcuts = msui.MSUI_ShortcutsDialog() - - def teardown_method(self): + self.shortcuts = msui_mw.MSUI_ShortcutsDialog() + yield self.shortcuts.hide() self.main_window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_shortcuts_present(self): # Assert list gets filled properly @@ -124,7 +148,7 @@ def test_shortcuts_present(self): # ToDo we need a test for reset_highlight when e.g. Transparent was selected and afterwards topview was destroyed -class Test_MSSSideViewWindow(object): +class Test_MSSSideViewWindow: # temporary file paths to test open feature sample_path = os.path.join(os.path.dirname(__file__), "..", "data") open_csv = os.path.join(sample_path, "example.csv") @@ -138,7 +162,7 @@ class Test_MSSSideViewWindow(object): save_txt = os.path.join(ROOT_DIR, "example.txt") # import/export plugins import_plugins = { - "Text": ["txt", "mslib.plugins.io.text", "load_from_txt"], + "TXT": ["txt", "mslib.plugins.io.text", "load_from_txt"], "FliteStar": ["txt", "mslib.plugins.io.flitestar", "load_from_flitestar"], } export_plugins = { @@ -147,12 +171,12 @@ class Test_MSSSideViewWindow(object): # "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] } - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self.sample_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), '../', 'data/') - self.application = QtWidgets.QApplication(sys.argv) self.window = msui.MSUIMainWindow() self.window.create_new_flight_track() @@ -160,8 +184,7 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield config_file = os.path.join( self.sample_path, 'empty_msui_settings.json', @@ -171,82 +194,63 @@ def teardown_method(self): self.window.listViews.item(i).window.hide() self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_no_updater(self): assert not hasattr(self.window, "updater") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_app_start(self, mockbox): - assert mockbox.critical.call_count == 0 + def test_app_start(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_new_flightrack(self, mockbox): + def test_new_flightrack(self): assert self.window.listFlightTracks.count() == 1 self.window.actionNewFlightTrack.trigger() QtWidgets.QApplication.processEvents() assert self.window.listFlightTracks.count() == 2 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_topview(self, mockbox): + def test_open_topview(self): assert self.window.listViews.count() == 0 self.window.actionTopView.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert self.window.listViews.count() == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_sideview(self, mockbox): + def test_open_sideview(self): assert self.window.listViews.count() == 0 self.window.actionSideView.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert self.window.listViews.count() == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_tableview(self, mockbox): + def test_open_tableview(self): assert self.window.listViews.count() == 0 self.window.actionTableView.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert self.window.listViews.count() == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_linearview(self, mockbox): + def test_open_linearview(self): assert self.window.listViews.count() == 0 self.window.actionLinearView.trigger() self.window.listViews.itemActivated.emit(self.window.listViews.item(0)) QtWidgets.QApplication.processEvents() assert self.window.listViews.count() == 1 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_about(self, mockbox): + def test_open_about(self): self.window.actionAboutMSUI.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_config(self, mockbox): - pytest.skip("To be done") - self.window.actionConfigurationEditor.trigger() + def test_open_config(self): + self.window.actionConfiguration.trigger() QtWidgets.QApplication.processEvents() - self.window.config_editor.close() - assert mockbox.critical.call_count == 0 + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window.config_editor.close() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_shortcut(self, mockbox): + def test_open_shortcut(self): self.window.actionShortcuts.trigger() QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 @pytest.mark.parametrize("save_file", [[save_ftml]]) def test_plugin_saveas(self, save_file): - with mock.patch("mslib.msui.msui.config_loader", return_value=self.export_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.export_plugins): self.window.add_export_plugins("qt") - with mock.patch("mslib.msui.msui.get_save_filename", return_value=save_file[0]) as mocksave: + with mock.patch("mslib.msui.msui_mainwindow.get_save_filename", return_value=save_file[0]) as mocksave: assert self.window.listFlightTracks.count() == 1 assert mocksave.call_count == 0 self.window.last_save_directory = ROOT_DIR @@ -256,31 +260,31 @@ def test_plugin_saveas(self, save_file): assert os.path.exists(save_file[0]) os.remove(save_file[0]) - @pytest.mark.parametrize( - "open_file", [(open_ftml, "actionImportFlightTrackFTML"), - (open_txt, "actionImportFlightTrackText"), (open_fls, "actionImportFlightTrackFliteStar")]) - def test_plugin_import(self, open_file): - with mock.patch("mslib.msui.msui.config_loader", return_value=self.import_plugins): + @pytest.mark.parametrize("name", [("example.ftml", "actionImportFlightTrackFTML", 5), + ("example.csv", "actionImportFlightTrackCSV", 5), + ("example.txt", "actionImportFlightTrackTXT", 5), + ("flitestar.txt", "actionImportFlightTrackFliteStar", 10)]) + def test_plugin_import(self, name): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.import_plugins): self.window.add_import_plugins("qt") - with mock.patch("mslib.msui.msui.get_open_filenames", return_value=open_file) as mockopen: - assert self.window.listFlightTracks.count() == 1 - assert mockopen.call_count == 0 - self.window.last_save_directory = ROOT_DIR - obj_name = open_file[1] + assert self.window.listFlightTracks.count() == 1 + file_path = fs.path.join(self.sample_path, name[0]) + with mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[file_path]) as mockopen: for action in self.window.menuImportFlightTrack.actions(): - if obj_name == action.objectName(): + if action.objectName() == name[1]: action.trigger() break - QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 assert self.window.listFlightTracks.count() == 2 + assert self.window.active_flight_track.name == name[0].split(".")[0] + assert len(self.window.active_flight_track.waypoints) == name[2] @pytest.mark.parametrize("save_file", [[save_ftml, "actionExportFlightTrackFTML"], [save_txt, "actionExportFlightTrackText"]]) def test_plugin_export(self, save_file): - with mock.patch("mslib.msui.msui.config_loader", return_value=self.export_plugins): + with mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=self.export_plugins): self.window.add_export_plugins("qt") - with mock.patch("mslib.msui.msui.get_save_filename", return_value=save_file[0]) as mocksave: + with mock.patch("mslib.msui.msui_mainwindow.get_save_filename", return_value=save_file[0]) as mocksave: assert self.window.listFlightTracks.count() == 1 assert mocksave.call_count == 0 self.window.last_save_directory = ROOT_DIR @@ -294,51 +298,49 @@ def test_plugin_export(self, save_file): assert os.path.exists(save_file[0]) os.remove(save_file[0]) - @pytest.mark.skip("needs to be refactored to become independent") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - @mock.patch("mslib.msui.msui.config_loader", return_value=export_plugins) - def test_add_plugins(self, mockopen, mockbox): + @mock.patch("mslib.msui.msui_mainwindow.config_loader", return_value=export_plugins) + def test_add_plugins(self, mockopen): assert len(self.window.menuImportFlightTrack.actions()) == 2 assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 - assert len(self.window.import_plugins) == 1 - assert len(self.window.export_plugins) == 1 + assert len(self.window.import_plugins) == 0 + assert len(self.window.export_plugins) == 0 self.window.remove_plugins() self.window.add_import_plugins("qt") self.window.add_export_plugins("qt") assert len(self.window.import_plugins) == 1 assert len(self.window.export_plugins) == 1 - assert len(self.window.menuImportFlightTrack.actions()) == 2 - assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 - assert mockbox.critical.call_count == 0 + assert len(self.window.menuImportFlightTrack.actions()) == 3 + assert len(self.window.menuExportActiveFlightTrack.actions()) == 3 self.window.remove_plugins() - with mock.patch("importlib.import_module", new=ExceptionMock(Exception()).raise_exc): + with mock.patch("importlib.import_module", new=ExceptionMock(Exception()).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: self.window.add_import_plugins("qt") self.window.add_export_plugins("qt") - assert mockbox.critical.call_count == 2 + assert critbox.call_count == 2 self.window.remove_plugins() with mock.patch("mslib.msui.ms" "ui.MSUIMainWindow.add_plugin_submenu", - new=ExceptionMock(Exception()).raise_exc): + new=ExceptionMock(Exception()).raise_exc), \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as critbox: self.window.add_import_plugins("qt") self.window.add_export_plugins("qt") - assert mockbox.critical.call_count == 4 + assert critbox.call_count == 2 self.window.remove_plugins() assert len(self.window.import_plugins) == 0 assert len(self.window.export_plugins) == 0 - assert len(self.window.menuImportFlightTrack.actions()) == 1 - assert len(self.window.menuExportActiveFlightTrack.actions()) == 1 + assert len(self.window.menuImportFlightTrack.actions()) == 2 + assert len(self.window.menuExportActiveFlightTrack.actions()) == 2 - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") @mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Yes) @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - @mock.patch("mslib.msui.msui.get_save_filename", return_value=save_ftml) - @mock.patch("mslib.msui.msui.get_open_filenames", return_value=[save_ftml]) - def test_flight_track_io(self, mockload, mocksave, mockq, mocki, mockw, mockbox): + @mock.patch("mslib.msui.msui_mainwindow.get_save_filename", return_value=save_ftml) + @mock.patch("mslib.msui.msui_mainwindow.get_open_filenames", return_value=[save_ftml]) + def test_flight_track_io(self, mockload, mocksave, mockq, mocki, mockw): self.window.actionCloseSelectedFlightTrack.trigger() assert mocki.call_count == 1 self.window.actionNewFlightTrack.trigger() diff --git a/tests/_test_msui/test_multiple_flightpath_dockwidget.py b/tests/_test_msui/test_multiple_flightpath_dockwidget.py index aac22bb3f..de318a86a 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -24,7 +24,7 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys +import pytest from PyQt5 import QtWidgets, QtTest from mslib.msui import msui from mslib.msui.multiple_flightpath_dockwidget import MultipleFlightpathControlWidget @@ -32,11 +32,9 @@ import mslib.msui.topview as tv -class Test_MultipleFlightpathControlWidget(): - - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - +class Test_MultipleFlightpathControlWidget: + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = msui.MSUIMainWindow() self.window.create_new_flight_track() @@ -46,18 +44,17 @@ def setup_method(self): self.waypoints_model = ft.WaypointsTableModel("myname") self.waypoints_model.insertRows( 0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.widget = tv.MSUITopViewWindow(parent=self.window, model=self.waypoints_model) + + self.widget = tv.MSUITopViewWindow(model=self.waypoints_model, mainwindow=self.window) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_initialization(self): - widget = MultipleFlightpathControlWidget(parent=self.widget, listFlightTracks=self.widget.ui.listFlightTracks) + widget = MultipleFlightpathControlWidget(parent=self.widget, + listFlightTracks=self.window.listFlightTracks) assert widget.color == (0, 0, 1, 1) diff --git a/tests/_test_msui/test_remotesensing.py b/tests/_test_msui/test_remotesensing.py index 67d8e508b..fb00f3377 100644 --- a/tests/_test_msui/test_remotesensing.py +++ b/tests/_test_msui/test_remotesensing.py @@ -24,8 +24,16 @@ See the License for the specific language governing permissions and limitations under the License. """ + + +import datetime + +from mock import Mock +from matplotlib.collections import LineCollection +import pytest import skyfield_data from mslib.msui.remotesensing_dockwidget import RemoteSensingControlWidget +from mslib.msui import mpl_qtwidget as qt def test_skyfield_data_expiration(recwarn): @@ -33,25 +41,164 @@ def test_skyfield_data_expiration(recwarn): assert len(recwarn) == 0, [_x.message for _x in recwarn] -class TestAngles(object): +class Test_RemoteSensingControlWidget: """ - tests about angles + Tests about RemoteSensingControlWidget """ + @pytest.fixture(autouse=True) + def setup(self, qapp): + self.view = Mock() + self.map = qt.TopViewPlotter() + self.map.init_map() + self.bmap = self.map.map + self.result_test_direction_coordinates = [([79.08, 79.06, 79.03, 79.01, 78.99, 78.97, 78.95, + 78.93, 78.9, 78.88, 78.86, 78.84, 78.82, + 78.73, 78.7, 78.68, 78.66, 78.64, 78.62, + 78.59, 78.57, 78.55, 78.79, 78.77, 78.75, + 78.53, 78.50, 78.48, 78.46, 78.44, 78.41, + 78.39, 78.37, 78.35, 78.32, 78.30, 78.28, + 78.25, 78.23, 78.21, 78.19, 78.16, 78.14, + 78.12, 78.09, 78.07, 78.05, 78.03, 78.00, + 77.98, 77.96, 77.93, 77.91, 77.89, 77.86, + 77.84, 77.82, 77.79, 77.77, 77.75, 77.72, + 77.70, 77.68, 77.65, 77.63, 77.60, 77.58, + 77.56, 77.53, 77.51, 77.49, 77.46, 77.44, + 77.41, 77.39, 77.37, 77.34, 77.32, 77.29, + 77.27, 77.24, 77.22, 77.20, 77.17, 77.15, + 77.12, 77.1], [21.15, 21.23, 21.32, 21.40, 21.49, 21.58, + 22.166, 22.75, 21.84, 22.92, 22.84, 23, + 23.36, 23.12, 23.58, 24.44, 24.52, 24.82, + 25.74, 25.28, 25.84, 25.57, 25.98, 26.06, + 26.15, 26.24, 26.32, 26.41, 26.49, 26.58, + 26.67, 26.75, 26.84, 26.93, 27.01, 27.1, + 27.18, 27.27, 27.36, 27.44, 27.53, 27.61, + 27.7, 27.79, 27.87, 27.96, 28.04, 28.13, + 28.22, 28.30, 28.39, 28.47, 28.56])] + + self.lon_lin = [79.08, 79.06, 79.04, 79.02, 78.99, 78.97, 78.95, 78.93, + 78.91, 78.89, 78.86, 78.84, 78.82, 78.80, 78.78, 78.75, + 78.75, 78.71, 78.69, 78.67, 78.64, 78.62, 78.60, 78.58, + 78.56, 78.53, 78.51, 78.49, 78.46, 78.44, 78.42, 78.40, + 78.37, 78.35, 78.33, 78.31, 78.28, 78.26, 78.24, 78.21, + 78.19, 78.17, 78.15, 78.12, 78.10, 78.08, 78.05, 78.05, + 78.03, 77.98, 77.96, 77.94, 77.91, 77.89, 77.87, 77.84, + 77.82, 77.80, 77.77, 77.75, 77.73, 77.70, 77.68, 77.66, + 77.61, 77.59, 77.58, 77.54, 77.51, 77.49, 77.47, 77.44, + 77.42, 77.39, 77.37, 77.34, 77.32, 77.30, 77.27, 77.25, + 77.20, 77.18, 77.16, 77.15, 77.13] + + self.lat_lin = [21.15, 21.23, 21.32, 21.4, 21.495, 21.58, 21.66, 21.75, + 21.84, 21.92, 22.01, 22.1, 22.186, 22.27, 22.35, 22.44, + 22.53, 22.61, 22.7, 22.79, 22.87, 22.96, 23.05, 23.13, + 23.22, 23.30, 23.39, 23.48, 23.56, 23.65, 23.74, 23.82, + 23.91, 23.99, 24.08, 24.17, 24.25, 24.34, 24.43, 24.51, + 24.60, 24.68, 24.77, 24.86, 24.94, 25.03, 25.12, 25.20, + 25.29, 25.37, 25.46, 25.55, 25.63, 25.72, 25.81, 25.89, + 25.98, 26.06, 26.15, 26.24, 26.32, 26.41, 26.49, 26.58, + 26.67, 26.75, 26.84, 26.93, 27.01, 27.10, 27.18, 27.27, + 27.36, 27.44, 27.53, 27.61, 27.70, 27.79, 27.87, 27.96, + 28.04, 28.13, 28.22, 28.30, 28.39, 28.47, 28.56] + + self.cut_height = 10.0 + self.result_test_tangent_point_coordinates = [(81.2, 21.62), (81.19, 21.65), (81.17, 21.79), + (81.11, 21.98), (81.12, 21.93), (81.1, 22.05), + (81.09, 22.08), (81.07, 22.17), (81.04, 22.3), + (80.98, 22.53), (81.01, 22.42), (80.98, 22.53), + (80.96, 22.63), (80.94, 22.73), (80.88, 22.96), + (80.95, 22.44), (80.75, 23.39), (80.86, 23.02), + (80.85, 23.11), (80.75, 23.46), (80.8, 23.28), + (80.78, 23.37), (80.75, 23.51), (80.74, 23.54), + (80.65, 23.89), (80.69, 23.71), (80.68, 23.8), + (80.58, 24.15), (80.63, 23.97), (80.61, 24.06), + (80.58, 24.2), (80.52, 24.42), (80.54, 24.37), + (80.53, 24.4), (80.51, 24.49), (80.42, 24.83), + (80.46, 24.66), (80.44, 24.75), (80.35, 25.09), + (80.39, 24.92), (80.37, 25.06), (80.36, 25.09), + (80.29, 25.36), (80.3, 25.31), (80.29, 25.35), + (80.22, 25.62), (80.29, 25.12), (80.25, 25.6), + (79.98, 26.3), (80.18, 25.77), (80.16, 25.86), + (80.07, 26.21), (80.11, 26.03), (80.1, 26.12), + (80.01, 26.47), (80.05, 26.29), (80.02, 26.43), + (79.96, 26.65), (79.98, 26.55), (79.96, 26.69), + (79.9, 26.91), (79.91, 26.86), (79.9, 26.89), + (79.69, 27.49), (79.83, 27.12), (79.85, 26.95), + (79.69, 27.6), (79.7, 27.58), (79.74, 27.41), + (79.71, 27.55), (79.65, 27.76), (79.68, 27.67), + (79.59, 28.01), (79.63, 27.84), (79.54, 28.18), + (79.58, 28.01), (79.56, 28.1), (79.48, 28.44), + (79.52, 28.27), (79.26, 28.95), (79.45, 28.44), + (79.43, 28.52), (79.45, 28.44), (79.41, 28.69)] + + self.wp_vertices = [(0, 0), (1, 4)] + self.wp_heights = [0, 1000] + self.coordinates = [[79.083, 21.15], [77.103, 28.566]] + self.heights = [0.0, 0.0] + self.times = [datetime.datetime(2023, 4, 15, 10, 9, 59, 174000), + datetime.datetime(2023, 4, 15, 11, 18, 27, 735581)] + self.solar_type = ('sun', 'total (horizon)') + self.remote_widget = RemoteSensingControlWidget(view=self.view) + + @pytest.mark.parametrize( + "lon0, lat0, h0, lon1, lat1, h1, obs_azi, expected", + [ + (0, 0, 0, 1, 0, 0, 0, (90.0, -1)), + (0, 0, 0, -1, 0, 0, 0, (270.0, -1)), + (0, 0, 0, 1, 0, 0, 90, (180.0, -1)), + (0, 0, 0, 0, 1, 0, 0, (0.0, -1)), + (0, 0, 0, 0, -1, 0, 0, (180.0, -1)), + ], + ) + def test_view_angles(self, lon0, lat0, h0, lon1, lat1, h1, obs_azi, expected): + compute_view_angles = self.remote_widget.compute_view_angles + angle = compute_view_angles(lon0, lat0, h0, lon1, lat1, h1, obs_azi, -1) + assert angle[0] == expected[0] + assert angle[1] == expected[1] + + @pytest.mark.parametrize("body, lat, lon, alt, expected_angle", [ + ("sun", 73.56, 78.01, 25.27, (106.71, -20.28)), + ("sun", 73.07, 77.78, 26.07, (106.35, -20.71)), + ("sun", 73.58, 77.56, 26.92, (105.96, -21.13)), + ("sun", 73.08, 77.33, 27.74, (105.57, -21.55)), + ("sun", 73.56, 78.01, 25.27, (106.71, -20.28)) + ]) + def test_body_angle(self, body, lat, lon, alt, expected_angle): + compute_body_angle = self.remote_widget.compute_body_angle + angle = compute_body_angle(body, lat, lon, alt) + assert angle[0] == pytest.approx(expected_angle[0], rel=1e-3) + assert angle[1] == pytest.approx(expected_angle[1], rel=1e-3) + + def test_direction_coordinates(self): + compute_direction_coordinates = self.remote_widget.direction_coordinates + coordinates = compute_direction_coordinates(self.result_test_direction_coordinates) + result = [[(round(x, 2), round(y, 2)) for x, y in inner_list] for inner_list in coordinates] + assert result == [[(78.1, 27.83), (79.18, 28.14)]] + + def test_compute_tangent_lines(self): + result = self.remote_widget.compute_tangent_lines(self.bmap, + self.wp_vertices, self.wp_heights) + assert isinstance(result, LineCollection) + assert len(result.get_segments()) == len(self.wp_heights) + + def test_compute_solar_lines(self): + result = self.remote_widget + result = result.compute_solar_lines(self.bmap, self.coordinates, self.heights, self.times, self.solar_type) + assert isinstance(result, LineCollection) + + def test_tangent_point_coordinates(self): + tangent_point_coordinates = self.remote_widget.tangent_point_coordinates + coordinates = tangent_point_coordinates(lon_lin=self.lon_lin, lat_lin=self.lat_lin, cut_height=self.cut_height) + result = [(round(x, 2), round(y, 2)) for x, y in coordinates] + assert result == self.result_test_tangent_point_coordinates - def test_view_angles(self): - compute_view_angles = RemoteSensingControlWidget.compute_view_angles - angle = compute_view_angles(0, 0, 0, 1, 0, 0, 0, -1) - assert angle[0] == 90.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, -1, 0, 0, 0, -1) - assert angle[0] == 270.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, 1, 0, 0, 90, -1) - assert angle[0] == 180.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, 0, 1, 0, 0, -1) - assert angle[0] == 0.0 - assert angle[1] == -1 - angle = compute_view_angles(0, 0, 0, 0, -1, 0, 0, -1) - assert angle[0] == 180.0 - assert angle[1] == -1 + @pytest.mark.parametrize("obs_azi, obs_ele, sol_azi, sol_ele, expected_rating", [ + (76.00, -1.0, 240.70, 58.33, 175.06), + (76.11, -1.0, 239.90, 60.03, 174.79), + (76.50, -1.0, 236.15, 66.92, 173.5), + ]) + def test_calc_view_rating(self, obs_azi, obs_ele, sol_azi, sol_ele, expected_rating): + height = 0.0 + difftype = "total (horizon)" + calc_view_rating = self.remote_widget.calc_view_rating + view_rating = calc_view_rating(obs_azi=obs_azi, obs_ele=obs_ele, sol_azi=sol_azi, + sol_ele=sol_ele, height=height, difftype=difftype) + assert round(view_rating, 2) == pytest.approx(expected_rating, rel=1e-3) diff --git a/tests/_test_msui/test_satellite_dockwidget.py b/tests/_test_msui/test_satellite_dockwidget.py index e558fcd1c..6ff633e71 100644 --- a/tests/_test_msui/test_satellite_dockwidget.py +++ b/tests/_test_msui/test_satellite_dockwidget.py @@ -26,27 +26,24 @@ """ import os -import sys import mock +import pytest from PyQt5 import QtWidgets, QtCore, QtTest import mslib.msui.satellite_dockwidget as sd -class Test_SatelliteDockWidget(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_SatelliteDockWidget: + @pytest.fixture(autouse=True) + def setup(self, qapp): self.view = mock.Mock() self.window = sd.SatelliteControlWidget(view=self.view) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_load(self): path = os.path.join(os.path.dirname(__file__), "../", "data", "satellite_predictor.txt") @@ -61,7 +58,13 @@ def test_load(self): assert self.view.plot_satellite_overpass.call_count == 2 self.view.reset_mock() - def test_load_no_file(self): + @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") + def test_load_no_file(self, mockbox): QtTest.QTest.mouseClick(self.window.btLoadFile, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() assert self.window.cbSatelliteOverpasses.count() == 0 + mockbox.assert_called_once_with( + self.window, + "Satellite Overpass Tool", + "ERROR:\n\npath '' should be a file", + ) diff --git a/tests/_test_msui/test_sideview.py b/tests/_test_msui/test_sideview.py index c3c6cc7bf..152edd144 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -30,54 +30,39 @@ import os import pytest import shutil -import sys -import multiprocessing import tempfile -from mslib.mswms.mswms import application from PyQt5 import QtWidgets, QtTest, QtCore, QtGui from mslib.msui import flighttrack as ft import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW from tests.utils import wait_until_signal -PORTS = list(range(19000, 19500)) - -class Test_MSS_SV_OptionsDialog(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSS_SV_OptionsDialog: + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_show(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_show(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_get(self, mockcrit): + def test_get(self): self.window.get_settings() - assert mockcrit.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_addLevel(self, mockcrit): + def test_addLevel(self): QtTest.QTest.mouseClick(self.window.btAdd, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockcrit.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_removeLevel(self, mockcrit): + def test_removeLevel(self): QtTest.QTest.mouseClick(self.window.btDelete, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockcrit.critical.call_count == 0 def test_getFlightLevels(self): levels = self.window.get_flight_levels() @@ -94,9 +79,9 @@ def test_setColour(self, mockdlg): assert mockdlg.call_count == 1 -class Test_MSSSideViewWindow(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSSSideViewWindow: + @pytest.fixture(autouse=True) + def setup(self, qapp): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -108,41 +93,31 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_wms(self, mockbox): + def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_mouse_over(self, mockbox): + def test_mouse_over(self): # Test mouse over QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(782, 266), -1) QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove(self.window.mpl.canvas, QtCore.QPoint(20, 20), -1) QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.sideview.MSUI_SV_OptionsDialog") - def test_options(self, mockdlg, mockbox): + def test_options(self, mockdlg): QtTest.QTest.mouseClick(self.window.btOptions, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 assert mockdlg.call_count == 1 assert mockdlg.return_value.setModal.call_count == 1 assert mockdlg.return_value.exec_.call_count == 1 assert mockdlg.return_value.destroy.call_count == 1 - @pytest.mark.skip("fails with mockbox.critical.call_count in reverse order") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_insert_point(self, mockbox): + def test_insert_point(self): """ Test inserting a point inside and outside the canvas """ @@ -160,30 +135,21 @@ def test_insert_point(self, mockbox): # click again on same position QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_y_axes(self, mockbox): + def test_y_axes(self): self.window.getView().get_settings()["secondary_axis"] = "pressure altitude" self.window.getView().set_settings(self.window.getView().get_settings()) self.window.getView().get_settings()["secondary_axis"] = "flight level" self.window.getView().set_settings(self.window.getView().get_settings()) - assert mockbox.critical.call_count == 0 -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_SideViewWMS(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - self.port = PORTS.pop() +class Test_SideViewWMS: + @pytest.fixture(autouse=True) + def setup(self, qapp, mswms_server): + self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") @@ -199,14 +165,10 @@ def setup_method(self): QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): QtWidgets.QApplication.processEvents() @@ -216,16 +178,14 @@ def query_server(self, url): QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") - QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.wms_control.image_displayed) + self.query_server(self.url) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) assert self.window.getView().plotter.image is not None + self.window.getView().plotter.clear_figure() assert self.window.getView().plotter.image is None - assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_suffix.py b/tests/_test_msui/test_suffix.py index 0c2f7d1c4..b639322d9 100644 --- a/tests/_test_msui/test_suffix.py +++ b/tests/_test_msui/test_suffix.py @@ -26,27 +26,24 @@ See the License for the specific language governing permissions and limitations under the License. """ +import pytest -import sys from PyQt5 import QtWidgets, QtTest import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW -class Test_SuffixChange(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_SuffixChange: + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_SV_OptionsDialog(settings=_DEFAULT_SETTINGS_SIDEVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_suffixchange(self): suffix = [' hPa', ' km', ' hft'] diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index a2ebe8b6b..d1b4a1822 100644 --- a/tests/_test_msui/test_tableview.py +++ b/tests/_test_msui/test_tableview.py @@ -28,7 +28,6 @@ import mock import os import pytest -import sys from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft @@ -36,10 +35,9 @@ import mslib.msui.tableview as tv -class Test_TableView(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) - +class Test_TableView: + @pytest.fixture(autouse=True) + def setup(self, qapp): # Create an initital flight track. initial_waypoints = [ft.Waypoint(flightlevel=0, location="EDMO", comments="take off OP"), ft.Waypoint(48.10, 10.27, 200), @@ -57,12 +55,9 @@ def setup_method(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_open_hex(self): """ @@ -102,11 +97,10 @@ def test_insertremove_hexagon(self, mockbox): assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 5 - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") @mock.patch("mslib.msui.performance_settings.get_open_filename", return_value=os.path.join( os.path.dirname(__file__), "..", "data", "performance_simple.json")) - def test_performance(self, mockopen, mockcrit): + def test_performance(self, mockopen): """ Check effect of performance settings on TableView """ @@ -131,7 +125,6 @@ def test_performance(self, mockopen, mockcrit): QtTest.QTest.mouseClick(self.window.docks[1].widget().pbLoadPerformance, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 - assert mockcrit.call_count == 0 def test_insert_point(self): """ @@ -229,8 +222,7 @@ def test_drag_point(self): wps_after = list(self.window.waypoints_model.waypoints) assert wps_before != wps_after, (wps_before, wps_after) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_roundtrip(self, mockbox): + def test_roundtrip(self): """ Test connecting the last and first point Test connecting the first point to itself @@ -256,4 +248,3 @@ def test_roundtrip(self, mockbox): # Remove connection self.window.waypoints_model.removeRows(count, 1) assert len(self.window.waypoints_model.waypoints) == count - assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_topview.py b/tests/_test_msui/test_topview.py index 2f1c3d84d..fe03480a0 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -29,78 +29,60 @@ import os import pytest import shutil -import sys -import multiprocessing import tempfile -from mslib.mswms.mswms import application +import mslib.msui.topview as tv from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft -import mslib.msui.topview as tv +from mslib.msui.msui import MSUIMainWindow from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_TOPVIEW from tests.utils import wait_until_signal -PORTS = list(range(28000, 28500)) - -class Test_MSS_TV_MapAppearanceDialog(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSS_TV_MapAppearanceDialog: + @pytest.fixture(autouse=True) + def setup(self, qapp): self.window = tv.MSUI_TV_MapAppearanceDialog(settings=_DEFAULT_SETTINGS_TOPVIEW) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_show(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_show(self): + pass - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_get(self, mockcrit): - assert mockcrit.critical.call_count == 0 + def test_get(self): self.window.get_settings() - assert mockcrit.critical.call_count == 0 -class Test_MSSTopViewWindow(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSSTopViewWindow: + @pytest.fixture(autouse=True) + def setup(self, qapp): + mainwindow = MSUIMainWindow() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") waypoints_model.insertRows( 0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.window = tv.MSUITopViewWindow(model=waypoints_model) + self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_wms(self, mockbox): + def test_open_wms(self): self.window.cbTools.currentIndexChanged.emit(1) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_sat(self, mockbox): + def test_open_sat(self): self.window.cbTools.currentIndexChanged.emit(2) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_rs(self, mockcrit): + def test_open_rs(self): self.window.cbTools.currentIndexChanged.emit(3) QtWidgets.QApplication.processEvents() rsdock = self.window.docks[2].widget() @@ -113,16 +95,12 @@ def test_open_rs(self, mockcrit): QtTest.QTest.mouseClick(rsdock.cbDrawTangents, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() rsdock.cbShowSolarAngle.setChecked(True) - assert mockcrit.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_kml(self, mockbox): + def test_open_kml(self): self.window.cbTools.currentIndexChanged.emit(4) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_insert_point(self, mockbox): + def test_insert_point(self): """ Test inserting a point inside and outside the canvas """ @@ -139,12 +117,10 @@ def test_insert_point(self, mockbox): # click again on same position QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 - assert mockbox.critical.call_count == 0 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") - def test_remove_point_yes(self, mockcrit, mockbox): + def test_remove_point_yes(self, mockbox): self.window.mpl.navbar._actions['insert_wp'].trigger() QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 @@ -156,14 +132,12 @@ def test_remove_point_yes(self, mockcrit, mockbox): QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockcrit.call_count == 0 assert len(self.window.waypoints_model.waypoints) == 3 assert mockbox.call_count == 1 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) - @mock.patch("PyQt5.QtWidgets.QMessageBox.critical") - def test_remove_point_no(self, mockcrit, mockbox): + def test_remove_point_no(self, mockbox): self.window.mpl.navbar._actions['insert_wp'].trigger() QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 @@ -181,10 +155,8 @@ def test_remove_point_no(self, mockcrit, mockbox): QtWidgets.QApplication.processEvents() assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 4 - assert mockcrit.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_move_point(self, mockbox): + def test_move_point(self): self.window.mpl.navbar._actions['insert_wp'].trigger() QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 @@ -204,10 +176,8 @@ def test_move_point(self, mockbox): self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=point) QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_roundtrip(self, mockbox): + def test_roundtrip(self): """ Test connecting the last and first point Test connecting the first point to itself @@ -233,86 +203,66 @@ def test_roundtrip(self, mockbox): # Remove connection self.window.waypoints_model.removeRows(count, 1) assert len(self.window.waypoints_model.waypoints) == count - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_map_options(self, mockbox): + def test_map_options(self): self.window.mpl.canvas.map.set_graticule_visible(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_graticule_visible(False) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_fillcontinents_visible(False) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_fillcontinents_visible(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_coastlines_visible(False) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 self.window.mpl.canvas.map.set_coastlines_visible(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[{"type": "small_airport", "name": "Test", "latitude_deg": 52, "longitude_deg": 13, "elevation_ft": 0}]): self.window.mpl.canvas.map.set_draw_airports(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[]): self.window.mpl.canvas.map.set_draw_airports(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airports", return_value=[{"type": "small_airport", "name": "Test", "latitude_deg": -52, "longitude_deg": -13, "elevation_ft": 0}]): self.window.mpl.canvas.map.set_draw_airports(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[{"name": "Test", "top": 1, "bottom": 0, "polygon": [(13, 52), (14, 53), (13, 52)], "country": "DE"}]): self.window.mpl.canvas.map.set_draw_airspaces(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[]): self.window.mpl.canvas.map.set_draw_airspaces(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 with mock.patch("mslib.msui.mpl_map.get_airspaces", return_value=[{"name": "Test", "top": 1, "bottom": 0, "polygon": [(-13, -52), (-14, -53), (-13, -52)], "country": "DE"}]): self.window.mpl.canvas.map.set_draw_airspaces(True) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 - -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") -class Test_TopViewWMS(object): - def setup_method(self): - self.port = PORTS.pop() - self.application = QtWidgets.QApplication(sys.argv) +class Test_TopViewWMS: + @pytest.fixture(autouse=True) + def setup(self, qapp, mswms_server): + self.url = mswms_server self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") waypoints_model.insertRows( 0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.window = tv.MSUITopViewWindow(model=waypoints_model) + mainwindow = MSUIMainWindow() + self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) self.window.show() QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(2000) @@ -322,14 +272,10 @@ def setup_method(self): QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): QtWidgets.QApplication.processEvents() @@ -339,12 +285,11 @@ def query_server(self, url): QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.cpdlg.canceled) - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() wait_until_signal(self.wms_control.image_displayed) @@ -353,18 +298,19 @@ def test_server_getmap(self, mockbox): self.window.getView().clear_figure() assert self.window.getView().map.image is None self.window.mpl.canvas.redraw_map() - assert mockbox.critical.call_count == 0 -class Test_MSUITopViewWindow(): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSUITopViewWindow: + @pytest.fixture(autouse=True) + def setup(self, qapp): + pass def test_kwargs_update_does_not_harm(self): initial_waypoints = [ft.Waypoint(40., 25., 0), ft.Waypoint(60., -10., 0), ft.Waypoint(40., 10, 0)] waypoints_model = ft.WaypointsTableModel("") waypoints_model.insertRows(0, rows=len(initial_waypoints), waypoints=initial_waypoints) - self.window = tv.MSUITopViewWindow(model=waypoints_model) + mainwindow = MSUIMainWindow() + self.window = tv.MSUITopViewWindow(model=waypoints_model, mainwindow=mainwindow) # user_options is a global var from mslib.utils.config import user_options diff --git a/tests/_test_msui/test_updater.py b/tests/_test_msui/test_updater.py index fc161889c..5670ec952 100644 --- a/tests/_test_msui/test_updater.py +++ b/tests/_test_msui/test_updater.py @@ -24,8 +24,8 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys import mock +import pytest from PyQt5 import QtWidgets, QtTest from mslib.msui.updater import UpdaterUI, Updater @@ -53,19 +53,10 @@ def __init__(self, args=None, **named_args): self.args = args -def create_mock(function, on_success=None, on_failure=None, start=True): - worker = Worker(function) - if on_success: - worker.finished.connect(on_success) - if on_failure: - worker.failed.connect(on_failure) - if start: - worker.run() - return worker - - +@mock.patch("mslib.utils.qt.Worker.start", Worker.run) class Test_MSS_ShortcutDialog: - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self.updater = Updater() self.status = "" self.update_available = False @@ -83,15 +74,11 @@ def status_signal(s): self.updater.on_update_available.connect(update_signal) self.updater.on_status_update.connect(status_signal) self.updater.on_update_finished.connect(update_finished_signal) - self.application = QtWidgets.QApplication(sys.argv) - - def teardown_method(self): - self.application.quit() + yield QtWidgets.QApplication.processEvents() @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) @mock.patch("subprocess.run", new=SubprocessDifferentVersionMock) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_update_recognised(self): self.updater.run() @@ -105,7 +92,6 @@ def test_update_recognised(self): @mock.patch("subprocess.Popen", new=SubprocessSameMock) @mock.patch("subprocess.run", new=SubprocessSameMock) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_no_update(self): self.updater.run() assert self.status == "Your MSS is up to date." @@ -114,7 +100,6 @@ def test_no_update(self): @mock.patch("subprocess.Popen", new=SubprocessDifferentVersionMock) @mock.patch("subprocess.run", new=SubprocessDifferentVersionMock) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_update_failed(self): self.updater.run() assert self.updater.new_version == "999.999.999" @@ -126,7 +111,6 @@ def test_update_failed(self): @mock.patch("subprocess.Popen", new=no_conda) @mock.patch("subprocess.run", new=no_conda) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_no_conda(self): self.updater.run() assert self.updater.new_version is None and self.updater.old_version is None @@ -135,7 +119,6 @@ def test_no_conda(self): @mock.patch("subprocess.Popen", new=no_conda) @mock.patch("subprocess.run", new=no_conda) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_exception(self): self.updater.new_version = "999.999.999" self.updater.old_version = "999.999.999" @@ -146,7 +129,6 @@ def test_exception(self): @mock.patch("subprocess.Popen", new=SubprocessSameMock) @mock.patch("subprocess.run", new=SubprocessSameMock) @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Yes) - @mock.patch("mslib.utils.qt.Worker.create", create_mock) def test_ui(self, mock): ui = UpdaterUI() ui.updater.on_update_available.emit("", "") diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index 4447ad417..da3b76041 100644 --- a/tests/_test_msui/test_wms_capabilities.py +++ b/tests/_test_msui/test_wms_capabilities.py @@ -25,17 +25,17 @@ limitations under the License. """ -import sys import mock +import pytest from PyQt5 import QtWidgets, QtTest, QtCore import mslib.msui.wms_capabilities as wc -class Test_WMSCapabilities(object): +class Test_WMSCapabilities: - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.capabilities = mock.Mock() self.capabilities.capabilities_document = u"Hölla die Waldfee".encode("utf-8") self.capabilities.provider = mock.Mock() @@ -47,6 +47,8 @@ def setup_method(self): self.capabilities.provider.contact.address = None self.capabilities.provider.contact.postcode = None self.capabilities.provider.contact.city = None + yield + QtWidgets.QApplication.processEvents() def start_window(self): self.window = wc.WMSCapabilitiesBrowser( @@ -56,28 +58,16 @@ def start_window(self): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(100) - def teardown_method(self): - QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() - - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_window_start(self, mockbox): + def test_window_start(self): self.start_window() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_window_contact_none(self, mockbox): + def test_window_contact_none(self): self.capabilities.provider.contact = None self.start_window() - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_switch_view(self, mockbox): + def test_switch_view(self): self.start_window() QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 QtTest.QTest.mouseClick(self.window.cbFullView, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - assert mockbox.critical.call_count == 0 diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index 7f75d5cf6..353c04a91 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -26,24 +26,18 @@ """ import os -import sys import mock import shutil import tempfile import pytest import hashlib -import multiprocessing -from mslib.mswms.mswms import application +import urllib from PyQt5 import QtWidgets, QtCore, QtTest from mslib.msui import flighttrack as ft import mslib.msui.wms_control as wc -from mslib.msui.msui import MSUIMainWindow from tests.utils import wait_until_signal -PORTS = list(range(18000, 18500)) - - class HSecViewMockup(mock.Mock): get_crs = mock.Mock(return_value="EPSG:4326") getBBOX = mock.Mock(return_value=(0, 0, 10, 10)) @@ -56,11 +50,15 @@ class VSecViewMockup(mock.Mock): get_plot_size_in_px = mock.Mock(return_value=(200, 100)) -class WMSControlWidgetSetup(object): +class WMSControlWidgetSetup: + @pytest.fixture(autouse=True) + def _with_mswms_server(self, mswms_server): + self.url = mswms_server + parsed_url = urllib.parse.urlparse(self.url) + self.scheme, self.host, self.port = parsed_url.scheme, parsed_url.hostname, parsed_url.port + def _setup(self, widget_type): wc.WMS_SERVICE_CACHE = {} - self.port = PORTS.pop() - self.application = QtWidgets.QApplication(sys.argv) if widget_type == "hsec": self.view = HSecViewMockup() else: @@ -69,10 +67,6 @@ def _setup(self, widget_type): if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) QtTest.QTest.qWait(3000) - self.thread = multiprocessing.Process( - target=application.run, - args=("127.0.0.1", self.port)) - self.thread.start() if widget_type == "hsec": self.window = wc.HSecWMSControlWidget(view=self.view, wms_cache=self.tempdir) else: @@ -94,13 +88,10 @@ def _setup(self, widget_type): QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() - def teardown_method(self): + def _teardown(self): self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() shutil.rmtree(self.tempdir) - self.thread.terminate() def query_server(self, url): while len(self.window.multilayers.cbWMS_URL.currentText()) > 0: @@ -114,57 +105,56 @@ def query_server(self, url): wait_until_signal(self.window.cpdlg.canceled) -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_HSecWMSControlWidget(WMSControlWidgetSetup): - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self._setup("hsec") + yield + self._teardown() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_no_server(self, mockbox): + def test_no_server(self): """ assert that a message box informs about server troubles """ - self.query_server("http://127.0.0.1:8882") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.scheme}://{self.host}:{self.port-1}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_no_schema(self, mockbox): + def test_no_schema(self): """ assert that a message box informs about server troubles """ - self.query_server(f"127.0.0.1:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.host}:{self.port}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_invalid_schema(self, mockbox): + def test_invalid_schema(self): """ assert that a message box informs about server troubles """ - self.query_server(f"hppd://127.0.0.1:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"hppd://{self.host}:{self.port}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_invalid_url(self, mockbox): + def test_invalid_url(self): """ assert that a message box informs about server troubles """ - self.query_server(f"http://???127.0.0.1:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.scheme}://???{self.host}:{self.port}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_connection_error(self, mockbox): - if sys.version_info.major == 3: - pytest.skip("problem in urllib3") + def test_connection_error(self): """ assert that a message box informs about server troubles """ - self.query_server(f"http://.....127.0.0.1:{self.port}") - assert mockbox.critical.call_count == 1 + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as mock_critical: + self.query_server(f"{self.scheme}://.....{self.host}:{self.port}") + mock_critical.assert_called_once() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_forward_backward_clicks(self, mockbox): - self.query_server(f"http://127.0.0.1:{self.port}") + @pytest.mark.skip("Breaks other tests in this class because of a lingering message box, for some reason") + def test_forward_backward_clicks(self): + self.query_server(self.url) self.window.init_time_back_click() self.window.init_time_fwd_click() self.window.valid_time_fwd_click() @@ -179,14 +169,13 @@ def test_forward_backward_clicks(self, mockbox): self.window.secs_from_timestep("Wrong") except ValueError: pass - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_abort_getmap(self, mockbox): + @pytest.mark.skip("Has a race condition where the abort might not happen fast enough") + def test_server_abort_getmap(self): """ assert that an aborted getmap call does not change the displayed image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(20) @@ -199,35 +188,27 @@ def test_server_abort_getmap(self, mockbox): assert self.view.draw_metadata.call_count == 0 self.view.reset_mock() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - self.view.reset_mock() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap_cached(self, mockbox): + def test_server_getmap_cached(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") + self.query_server(self.url) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) - - # assert mockbox.critical.call_count == 0 + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 @@ -240,61 +221,49 @@ def test_server_getmap_cached(self, mockbox): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 - assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @pytest.mark.skip("needs a review") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_service_cache(self, mockbox): + def test_server_service_cache(self, qtbot): """ assert that changing between servers still allows image retrieval """ - self.query_server(f"http://127.0.0.1:{self.port}") - assert mockbox.critical.call_count == 0 - - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.cpdlg.canceled) - assert mockbox.critical.call_count == 1 + self.query_server(self.url) + + with mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as qm_critical: + with qtbot.wait_signal(self.window.cpdlg.canceled): + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) + qm_critical.assert_called_once() assert self.view.draw_image.call_count == 0 assert self.view.draw_legend.call_count == 0 assert self.view.draw_metadata.call_count == 0 - mockbox.reset_mock() - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, ord(str(self.port)[3])) - QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Slash) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.cpdlg.canceled) - assert mockbox.critical.call_count == 0 - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.cpdlg.canceled): + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, ord(str(self.port)[-1])) + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Slash) + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) + + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_handling(self, mockbox): + def test_multilayer_handling(self): """ assert that multilayers get created, handled and drawn properly """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) assert server is not None - assert "header" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] - assert "wms" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] + assert "header" in self.window.multilayers.layers[f"{self.url}/"] + assert "wms" in self.window.multilayers.layers[f"{self.url}/"] self.window.multilayers.cbMultilayering.setChecked(True) for i in range(0, server.childCount()): @@ -322,29 +291,26 @@ def test_multilayer_handling(self, mockbox): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @pytest.mark.skip("Fails testing reverse order") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_filter_handling(self, mockbox): - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + def test_filter_handling(self): + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) assert server is not None - assert "header" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] - assert "wms" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] + assert "header" in self.window.multilayers.layers[f"{self.url}/"] + assert "wms" in self.window.multilayers.layers[f"{self.url}/"] - starts_at = 40 * self.window.multilayers.scale + starts_at = int(40 * self.window.multilayers.scale) icon_start_fav = starts_at + 3 if self.window.multilayers.cbMultilayering.isChecked(): checkbox_width = round(self.window.multilayers.height * 0.75) icon_start_fav += checkbox_width + 6 - starts_at = 20 * self.window.multilayers.scale + starts_at = int(20 * self.window.multilayers.scale) icon_start_del = starts_at + 3 # Check layer filter is working @@ -370,22 +336,20 @@ def test_filter_handling(self, mockbox): QtTest.QTest.mouseMove(self.window.multilayers.listLayers, QtCore.QPoint(icon_start_del + 3, 0), -1) QtWidgets.QApplication.processEvents() self.window.multilayers.check_icon_clicked(server) - assert len(self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + assert len(self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)) == 0 - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_singlelayer_handling(self, mockbox): + def test_singlelayer_handling(self): """ assert that singlelayer mode behaves as expected """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) assert server is not None - assert "header" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] - assert "wms" in self.window.multilayers.layers[f"http://127.0.0.1:{self.port}/"] + assert "header" in self.window.multilayers.layers[f"{self.url}/"] + assert "wms" in self.window.multilayers.layers[f"{self.url}/"] self.window.multilayers.cbMultilayering.setChecked(True) self.window.multilayers.cbMultilayering.setChecked(False) @@ -406,18 +370,16 @@ def test_singlelayer_handling(self, mockbox): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_syncing(self, mockbox): + def test_multilayer_syncing(self): """ assert that synced layers share their options """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) server.setExpanded(True) @@ -441,13 +403,11 @@ def test_multilayer_syncing(self, mockbox): assert layer_a.get_level() == layer_b.get_level() assert layer_a.get_vtime() == layer_b.get_vtime() assert layer_a.get_itime() == layer_a.get_itimes()[-1] - assert mockbox.critical.call_count == 0 - @mock.patch("PyQt5.QtWidgets.QMessageBox") @mock.patch("mslib.msui.wms_control.WMSMapFetcher.moveToThread") - def test_server_no_thread(self, mockbox, mockthread): - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + def test_server_no_thread(self, mockthread): + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) server.setExpanded(True) @@ -459,62 +419,47 @@ def test_server_no_thread(self, mockbox, mockthread): QtWidgets.QApplication.processEvents() wait_until_signal(self.window.image_displayed) - urlstr = f"http://127.0.0.1:{self.port}/mss/logo.png" + urlstr = f"{self.url}/mss/logo.png" md5_filname = os.path.join(self.window.wms_cache, hashlib.md5(urlstr.encode('utf-8')).hexdigest() + ".png") self.window.fetcher.fetch_legend(urlstr, use_cache=False, md5_filename=md5_filname) self.window.fetcher.fetch_legend(urlstr, use_cache=True, md5_filename=md5_filname) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - def test_preload(self): - assert len(wc.WMS_SERVICE_CACHE) == 0 - assert f"http://127.0.0.1:{self.port}/" not in wc.WMS_SERVICE_CACHE - MSUIMainWindow.preload_wms([f"http://127.0.0.1:{self.port}/"]) - assert f"http://127.0.0.1:{self.port}/" in wc.WMS_SERVICE_CACHE - -@pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") class Test_VSecWMSControlWidget(WMSControlWidgetSetup): - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qapp): self._setup("vsec") + yield + self._teardown() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_server_getmap(self, mockbox): - pytest.skip("unknown problem") + def test_server_getmap(self, qtbot): """ assert that a getmap call to a WMS server displays an image """ - self.query_server(f"http://127.0.0.1:{self.port}") - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + self.query_server(self.url) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - assert mockbox.critical.call_count == 0 assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - self.view.reset_mock() - @pytest.mark.skip("IndexError: list index out of range") - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_drawing(self, mockbox): + def test_multilayer_drawing(self): """ assert that drawing a layer through code doesn't fail for vsec """ - self.query_server(f"http://127.0.0.1:{self.port}") - server = self.window.multilayers.listLayers.findItems(f"http://127.0.0.1:{self.port}/", + self.query_server(self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] server.child(0).draw() wait_until_signal(self.window.image_displayed) - assert mockbox.critical.call_count == 0 - -class TestWMSControlWidgetSetupSimple(object): +class TestWMSControlWidgetSetupSimple: xml = """ @@ -571,8 +516,8 @@ class TestWMSControlWidgetSetupSimple(object): 500.0,600.0,700.0,900.0 """ - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) + @pytest.fixture(autouse=True) + def setup(self, qapp): self.view = HSecViewMockup() self.window = wc.HSecWMSControlWidget(view=self.view) self.window.show() @@ -583,12 +528,9 @@ def setup_method(self): self.window.multilayers.delete_server(server) QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.hide() QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() def test_xml(self): testxml = self.xml.format("", self.srs_base, self.dimext_time + self.dimext_inittime + self.dimext_elevation) @@ -814,8 +756,7 @@ def test_xml_othertimeformat(self): assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ ['2012-10-16T12:00:00Z', '2012-10-17T12:00:00Z'] - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_xml_time_error(self, mockbox): + def test_xml_time_error(self): dimext_time_error = """ a2012-10-17T12:00:00Z/2012-10-18T00:00:00Z/PT6H """ diff --git a/tests/_test_mswms/test_dataaccess.py b/tests/_test_mswms/test_dataaccess.py index 3c5af291a..365d2b299 100644 --- a/tests/_test_mswms/test_dataaccess.py +++ b/tests/_test_mswms/test_dataaccess.py @@ -35,7 +35,7 @@ from tests.constants import DATA_DIR -class Test_DefaultDataAccess(object): +class Test_DefaultDataAccess: def setup_method(self): self.dut = DefaultDataAccess(DATA_DIR, "EUR_LL015") self.dut.setup() @@ -134,7 +134,7 @@ def test_cache_too_large(self): assert "nothere" not in self.dut._file_cache -class Test_DefaultDataAccessNoInit(object): +class Test_DefaultDataAccessNoInit: def setup_method(self): self.dut = DefaultDataAccess(DATA_DIR, "EUR_LL015", uses_init_time=False) self.dut.setup() diff --git a/tests/_test_mswms/test_demodata.py b/tests/_test_mswms/test_demodata.py index 4e5cf951f..46f3be8d5 100644 --- a/tests/_test_mswms/test_demodata.py +++ b/tests/_test_mswms/test_demodata.py @@ -31,7 +31,7 @@ import mslib.mswms.demodata as demodata -class TestDemodata(object): +class TestDemodata: def test_data_creation(self): assert ROOT_FS.exists(u'.') assert DATA_FS.exists(u'.') diff --git a/tests/_test_mswms/test_mplhsec.py b/tests/_test_mswms/test_mplhsec.py index 19f435e8e..aa9279060 100644 --- a/tests/_test_mswms/test_mplhsec.py +++ b/tests/_test_mswms/test_mplhsec.py @@ -32,7 +32,7 @@ from tests.constants import SERVER_CONFIG_FILE -class TestMPLBasemapHorizontalSectionStyle(object): +class TestMPLBasemapHorizontalSectionStyle: def setup_method(self): self.mswms_settings = importlib.import_module("mswms_settings", SERVER_CONFIG_FILE) diff --git a/tests/_test_mswms/test_mss_plot_driver.py b/tests/_test_mswms/test_mss_plot_driver.py index f1cd54026..b4c0143ef 100644 --- a/tests/_test_mswms/test_mss_plot_driver.py +++ b/tests/_test_mswms/test_mss_plot_driver.py @@ -56,7 +56,7 @@ def is_image_transparent(img): return False -class Test_VSec(object): +class Test_VSec: def setup_method(self): p1 = [45.00, 8.] p2 = [50.00, 12.] @@ -186,7 +186,6 @@ def test_VS_ProbabilityOfWCBStyle_01(self): assert noframe != img def test_VS_LagrantoTrajStyle_PL_01(self): - pytest.skip("data not available") img = self.plot(mpl_vsec_styles.VS_LagrantoTrajStyle_PL_01(driver=self.vsec)) assert img is not None noframe = self.plot(mpl_vsec_styles.VS_LagrantoTrajStyle_PL_01(driver=self.vsec), noframe=True) @@ -199,7 +198,6 @@ def test_VS_EMACEyja_Style_01(self): assert noframe != img def test_VS_gallery_template(self): - pytest.skip('Test can be biased. In pytest-reverse when there is not a plot_examples it can''t import') # ToDo Test Data have to be written to a random tmp dir and that may become purged afterwards templates_location = os.path.join(mslib.mswms.gallery_builder.DOCS_LOCATION, "plot_examples") sys.path.append(templates_location) @@ -209,7 +207,7 @@ def test_VS_gallery_template(self): assert img is not None -class Test_LSec(object): +class Test_LSec: def setup_method(self): p1 = [45.00, 8., 25000] p2 = [50.00, 12., 25000] @@ -278,7 +276,7 @@ def test_LS_wrong_mime_type(self): self.plot(mpl_lsec_styles.LS_RelativeHumdityStyle_01(driver=self.lsec), mime_type="image/png") -class Test_HSec(object): +class Test_HSec: def setup_method(self): data = mswms_settings.data["ecmwf_EUR_LL015"] data.setup() @@ -504,7 +502,6 @@ def test_HS_Meteosat_BT108_01(self): assert noframe != img def test_HS_gallery_template(self): - pytest.skip('Test can be biased. In pytest-reverse when there is not a plot_examples it can''t import') # ToDo Test Data have to be written to a random tmp dir and that may become purged afterwards templates_location = os.path.join(mslib.mswms.gallery_builder.DOCS_LOCATION, "plot_examples") sys.path.append(templates_location) diff --git a/tests/_test_mswms/test_mswms.py b/tests/_test_mswms/test_mswms.py index b2e8a4dd1..87f53a3de 100644 --- a/tests/_test_mswms/test_mswms.py +++ b/tests/_test_mswms/test_mswms.py @@ -32,7 +32,7 @@ from mslib.mswms import mswms -class _Application(): +class _Application: """ dummy to skip starting the wms server""" @staticmethod def run(host, port): diff --git a/tests/_test_mswms/test_wms.py b/tests/_test_mswms/test_wms.py index 262fbc84d..937c900ad 100644 --- a/tests/_test_mswms/test_wms.py +++ b/tests/_test_mswms/test_wms.py @@ -35,20 +35,23 @@ import mslib.mswms.wms import mslib.mswms.gallery_builder -import mslib.mswms.mswms as mswms from importlib import reload from tests.utils import callback_ok_image, callback_ok_xml, callback_ok_html, callback_404_plain from tests.constants import DATA_DIR -class Test_WMS(object): +class Test_WMS: + @pytest.fixture(autouse=True) + def setup(self, mswms_app): + self.app = mswms_app + def test_get_query_string_missing_parameters(self): environ = { 'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=GetCapabilities'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_404_plain(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -60,7 +63,7 @@ def test_get_query_string_wrong_values(self): 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=GetCapabilities&service=WMS&version=1.4.0'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_404_plain(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -102,7 +105,7 @@ def test_get_capabilities(self): ) for tst_case in cases: - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(tst_case["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -112,7 +115,7 @@ def test_get_capabilities_lowercase(self): 'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=getcapabilities&service=wms&version=1.1.1'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -126,7 +129,7 @@ def test_produce_hsec_plot(self): 'request=GetMap&bgcolor=0xFFFFFF&height=376&dim_init_time=2012-10-17T12%3A00%3A00Z&width=479&' 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_image(result.status, result.headers) @@ -151,7 +154,7 @@ def test_produce_hsec_service_exception(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} query_string = environ["QUERY_STRING"] - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(query_string)) callback_ok_image(result.status, result.headers) assert result.data.count(b"ServiceExceptionReport") == 0, result @@ -185,7 +188,7 @@ def test_produce_vsec_plot(self): 'version=1.1.1&bbox=201%2C500.0%2C10%2C100.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C48.08%2C11.28&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_image(result.status, result.headers) @@ -212,7 +215,7 @@ def test_produce_vsec_service_exception(self): 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C48.08%2C11.28&transparent=FALSE'} query_string = environ["QUERY_STRING"] - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(query_string)) callback_ok_image(result.status, result.headers) assert result.data.count(b"ServiceExceptionReport") == 0, result @@ -243,7 +246,7 @@ def test_produce_lsec_plot(self): 'version=1.1.1&bbox=201&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C25000%2C48.08%2C11.28%2C25000'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) @@ -270,7 +273,7 @@ def test_produce_lsec_service_exception(self): 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C25000%2C48.08%2C11.28%2C25000'} query_string = environ["QUERY_STRING"] - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(query_string)) callback_ok_xml(result.status, result.headers) assert result.data.count(b"ServiceExceptionReport") == 0, result @@ -302,7 +305,7 @@ def test_application_request(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) assert isinstance(result.data, bytes), result @@ -316,7 +319,7 @@ def test_application_request_lowercase(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) assert isinstance(result.data, bytes), result @@ -327,7 +330,7 @@ def test_application_norequest(self): 'QUERY_STRING': '', } - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_html(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -339,7 +342,7 @@ def test_application_unkown_request(self): 'REQUEST_METHOD': 'GET', 'PATH_INFO': '/', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:8081', 'QUERY_STRING': 'request=abraham', } - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_404_plain(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -356,7 +359,7 @@ def test_multiple_images(self): 'version=1.1.1&bbox=-50.0%2C20.0%2C20.0%2C75.0&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&transparent=FALSE'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_image(result.status, result.headers) assert isinstance(result.data, bytes), result @@ -371,7 +374,7 @@ def test_multiple_xml(self): 'version=1.1.1&bbox=201&time=2012-10-17T12%3A00%3A00Z&' 'exceptions=application%2Fvnd.ogc.se_xml&path=52.78%2C-8.93%2C25000%2C48.08%2C11.28%2C25000'} - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) callback_ok_xml(result.status, result.headers) @@ -397,7 +400,7 @@ def do_test(): 'exceptions=XML&transparent=FALSE'} pl_file = next(file for file in os.listdir(DATA_DIR) if ".pl" in file) - self.client = mswms.application.test_client() + self.client = self.app.test_client() result = self.client.get('/?{}'.format(environ["QUERY_STRING"])) # Assert modified file was reloaded and now looks different diff --git a/tests/_test_plugins/test_io_kml.py b/tests/_test_plugins/test_io_kml.py index 6e93df703..f4481f168 100644 --- a/tests/_test_plugins/test_io_kml.py +++ b/tests/_test_plugins/test_io_kml.py @@ -50,12 +50,20 @@ def test_save_to_kml(): '#flighttrack\n', '\n', '1absolute\n', - '\n', - '-149.960,61.168,10668.000\n', + '-149.960,61.168,10668.000\n', '-176.646,51.878,10668.000\n', '\n', - '\n', - '\n', + '\n', + 'Anchorage\n', + '\n', + ' -149.960,61.168,10668.000\n', + '\n', + '\n', + 'Adak\n', + '\n', + ' -176.646,51.878,10668.000\n', + '\n', + '\n', '' ] diff --git a/tests/_test_utils/test_airdata.py b/tests/_test_utils/test_airdata.py index 10299f380..817a958dc 100644 --- a/tests/_test_utils/test_airdata.py +++ b/tests/_test_utils/test_airdata.py @@ -44,6 +44,7 @@ def _download_progress_airports(path, url): 323361,"00AA","small_airport","Aero B Ranch Airport",38.704022,-101.473911,3435,"NA",\ "US","US-KS","Leoti","no","00AA",,"00AA",,,''' file_path = os.path.join(ROOT_DIR, "downloads", "aip", "airports.csv") + os.makedirs(os.path.dirname(file_path)) with open(file_path, "w") as f: f.write(text) @@ -76,6 +77,7 @@ def _download_progress_airspace(path, url): ''' file_path = os.path.join(ROOT_DIR, "downloads", "aip", "bg_asp.xml") + os.makedirs(os.path.dirname(file_path)) with open(file_path, "w") as f: f.write(text) @@ -94,6 +96,7 @@ def _download_incomplete_airspace(path, url): ''' file_path = os.path.join(ROOT_DIR, "downloads", "aip", "bg_asp.xml") + os.makedirs(os.path.dirname(file_path)) with open(file_path, "w") as f: f.write(text) @@ -111,6 +114,7 @@ def _cleanup_test_files(): def test_download_progress(): file_path = os.path.join(ROOT_DIR, "downloads", "aip", "airdata") + os.makedirs(os.path.dirname(file_path)) download_progress(file_path, 'http://speedtest.ftp.otenet.gr/files/test100k.db') assert os.path.exists(file_path) @@ -128,7 +132,6 @@ def test_get_downloaded_airports(mockbox): airports = get_airports(force_download=True) assert len(airports) > 0 assert 'continent' in airports[0].keys() - assert mockbox.critical.call_count == 0 def test_get_available_airspaces(): @@ -146,7 +149,6 @@ def test_update_airspace(mockbox): with open(example_file, 'r') as f: text = f.read() assert "" in text - assert mockbox.critical.call_count == 0 @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) @@ -200,14 +202,15 @@ def test_get_airspaces(mockbox): (22.739444444444445, 42.88527777777778)] } ] - assert mockbox.critical.call_count == 0 @mock.patch("mslib.utils.airdata.download_progress", _download_incomplete_airspace) +@mock.patch("PyQt5.QtWidgets.QMessageBox.information") @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) -def test_get_airspaces_missing_data(mockbox): +def test_get_airspaces_missing_data(mockbox, infobox): """ We use a test file without the need for downloading to check handling """ # update_airspace would only update after 30 days _cleanup_test_files() airspaces = get_airspaces(countries=["bg"]) assert airspaces == [] + infobox.assert_called_once_with(None, 'No Airspaces data in file:', 'bg_asp.xml') diff --git a/tests/_test_utils/test_auth.py b/tests/_test_utils/test_auth.py index 54a839047..693886a8b 100644 --- a/tests/_test_utils/test_auth.py +++ b/tests/_test_utils/test_auth.py @@ -33,23 +33,31 @@ def test_keyring(): username = "something@something.org" - password = "x-*\\M#.U6R(HPNW2}" + password = "abcdef" auth.save_password_to_keyring(service_name="MSCOLAB", username=username, password=password) - assert auth.get_password_from_keyring(service_name="MSCOLAB", - username=username) == "password from TestKeyring" + assert auth.get_password_from_keyring( + service_name="MSCOLAB", username=username) == password + password = "123456" + auth.save_password_to_keyring(service_name="MSCOLAB", username=username, password=password) + assert auth.get_password_from_keyring( + service_name="MSCOLAB", username=username) == "123456" auth.del_password_from_keyring(service_name="MSCOLAB", username=username) # the testsetu returns the same string per definition - assert auth.get_password_from_keyring(service_name="MSCOLAB", - username=username) == "password from TestKeyring" + assert auth.get_password_from_keyring( + service_name="MSCOLAB", username=username) == "password from TestKeyring" def test_get_auth_from_url_and_name(): + # set start condition to prevent definitions from a test earlier + constants.AUTH_LOGIN_CACHE = {} # empty http_auth definition server_url = "http://example.com" http_auth = config_loader(dataset="MSS_auth") assert http_auth == {} data = auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=False) assert data == (None, None) + # checking if the test setup changes this + assert constants.AUTH_LOGIN_CACHE == {} # auth username and url defined auth_username = 'mss' create_msui_settings_file(f'{{"MSS_auth": {{"http://example.com": "{auth_username}"}}}}') @@ -59,18 +67,20 @@ def test_get_auth_from_url_and_name(): data = auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=False) # no password yet assert data == (auth_username, None) + # checking if the test setup changes this + assert constants.AUTH_LOGIN_CACHE == {} # store a password auth.save_password_to_keyring(server_url, auth_username, "password") # return the test password - assert auth.get_password_from_keyring(server_url, auth_username) == 'password from TestKeyring' + assert auth.get_password_from_keyring(server_url, auth_username) == "password" assert constants.AUTH_LOGIN_CACHE == {} auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=False) # password is set but doesn't go into the login cache assert constants.AUTH_LOGIN_CACHE == {} # now we overwrite_login_cache=True data = auth.get_auth_from_url_and_name(server_url, http_auth, overwrite_login_cache=True) - assert data == (auth_username, 'password from TestKeyring') - assert constants.AUTH_LOGIN_CACHE[server_url] == (auth_username, 'password from TestKeyring') + assert data == (auth_username, 'password') + assert constants.AUTH_LOGIN_CACHE[server_url] == (auth_username, 'password') # restart and use a different url create_msui_settings_file(f'{{"MSS_auth": {{"http://example.com": "{auth_username}"}}}}') read_config_file() @@ -78,4 +88,4 @@ def test_get_auth_from_url_and_name(): assert data == (None, None) # check storage of MSCOLAB password auth.save_password_to_keyring('MSCOLAB', auth_username, "password") - assert auth.get_password_from_keyring("MSCOLAB", auth_username) == 'password from TestKeyring' + assert auth.get_password_from_keyring("MSCOLAB", auth_username) == 'password' diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index d606da1a1..30c50f167 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -40,7 +40,7 @@ LOGGER = logging.getLogger(__name__) -class TestSettingsSave(object): +class TestSettingsSave: """ tests save_settings_qsettings and load_settings_qsettings from ./utils.py # TODO make sure do a clean setup, not inside the 'msui' config file. @@ -59,7 +59,7 @@ def test_load_settings(self): assert settings["foo"] == "bar" -class TestConfigLoader(object): +class TestConfigLoader: """ tests config file for client """ @@ -121,9 +121,6 @@ def test_existing_empty_config_file(self): """ on a user defined empty msui_settings_json this test should return the default value for num_labels """ - create_msui_settings_file('{ }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert ":" not in file_content @@ -144,8 +141,6 @@ def test_existing_config_file_different_parameters(self): on a user defined msui_settings_json without a defined num_labels this test should return its default value """ create_msui_settings_file('{"num_interpolation_points": 20 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_labels" not in file_content @@ -169,8 +164,6 @@ def test_existing_config_file_defined_parameters(self): on a user defined msui_settings_json without a defined num_labels this test should return its default value """ create_msui_settings_file('{"num_interpolation_points": 201, "num_labels": 10 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_labels" in file_content @@ -188,8 +181,6 @@ def test_existing_config_file_invalid_parameters(self): on a user defined msui_settings_json with duplicate and empty keys should raise FatalUserError """ create_msui_settings_file('{"num_interpolation_points": 201, "num_interpolation_points": 10 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_interpolation_points" in file_content @@ -198,8 +189,6 @@ def test_existing_config_file_invalid_parameters(self): read_config_file(path=config_file) create_msui_settings_file('{"": 201, "num_labels": 10 }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: file_content = file_dir.readtext("msui_settings.json") assert "num_labels" in file_content @@ -210,44 +199,36 @@ def test_modify_config_file_with_empty_parameters(self): """ Test to check if modify_config_file properly stores a key-value pair in an empty config file """ - create_msui_settings_file('{ }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { - "MSCOLAB_mailid": "something@something.org" + "num_labels": 20 } modify_config_file(data_to_save_in_config_file) config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") read_config_file(path=config_file) data = config_loader() - assert data["MSCOLAB_mailid"] == "something@something.org" + assert data["num_labels"] == 20 def test_modify_config_file_with_existing_parameters(self): """ Test to check if modify_config_file properly modifies a key-value pair in the config file """ - create_msui_settings_file('{"MSCOLAB_mailid": "anand@something.org"}') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') + create_msui_settings_file('{"num_labels": 14}') data_to_save_in_config_file = { - "MSCOLAB_mailid": "sree@something.org" + "num_labels": 20 } modify_config_file(data_to_save_in_config_file) config_file = fs.path.combine(MSUI_CONFIG_PATH, "msui_settings.json") read_config_file(path=config_file) data = config_loader() - assert data["MSCOLAB_mailid"] == "sree@something.org" + assert data["num_labels"] == 20 def test_modify_config_file_with_invalid_parameters(self): """ Test to check if modify_config_file raises a KeyError when a key is empty """ - create_msui_settings_file('{ }') - if not fs.open_fs(MSUI_CONFIG_PATH).exists("msui_settings.json"): - pytest.skip('undefined test msui_settings.json') data_to_save_in_config_file = { "": "sree", - "MSCOLAB_mailid": "sree@something.org" + "num_labels": "20" } with pytest.raises(KeyError): modify_config_file(data_to_save_in_config_file) diff --git a/tests/_test_utils/test_coordinate.py b/tests/_test_utils/test_coordinate.py index e38582aeb..681e1ffaf 100644 --- a/tests/_test_utils/test_coordinate.py +++ b/tests/_test_utils/test_coordinate.py @@ -35,7 +35,7 @@ LOGGER = logging.getLogger(__name__) -class TestGetDistance(object): +class TestGetDistance: """ tests for distance based calculations """ @@ -52,7 +52,7 @@ def test_find_location(self): assert coordinate.find_location(50.9200002, 6.36) == ([50.92, 6.36], 'Juelich') -class TestProjections(object): +class TestProjections: def test_get_projection_params(self): assert coordinate.get_projection_params("epsg:4839") == {'basemap': {'epsg': '4839'}, 'bbox': 'meter(10.5,51)'} with pytest.raises(ValueError): @@ -63,7 +63,7 @@ def test_get_projection_params(self): coordinate.get_projection_params('crs:83') -class TestAngles(object): +class TestAngles: """ tests about angles """ @@ -84,7 +84,7 @@ def test_rotate_point(self): assert coordinate.rotate_point([100, 90], 90) == (-90, 100) -class TestLatLonPoints(object): +class TestLatLonPoints: def test_linear(self): ref_lats = [0, 10] ref_lons = [0, 0] diff --git a/tests/_test_utils/test_migration.py b/tests/_test_utils/test_migration.py index f26fd45c1..ec78674fa 100644 --- a/tests/_test_utils/test_migration.py +++ b/tests/_test_utils/test_migration.py @@ -27,7 +27,7 @@ import pytest import fs from packaging import version -from mslib.utils.migration.update_json_file_to_version_eight import JsonConversion + from mslib.utils import auth from mslib.version import __version__ from mslib.msui.constants import MSUI_SETTINGS @@ -43,7 +43,8 @@ def test_upgrade_json_file_to_version_eight(self): The test checks on version 8 if an old msui_settings.json can become migrated It adds the new attributes and stores passwords in the keyring """ - if version.parse(__version__) >= version.parse('8.0.0'): + if version.parse(__version__) >= version.parse('8.0.0') and version.parse(__version__) < version.parse('9.0.0'): + from mslib.utils.migration.update_json_file_to_version_eight import JsonConversion # old attributes wms_def = '"http://www.your-server.de/forecasts": ["youruser", "yourpassword"]' msc_def = '"http://www.your-mscolab-server.de": ["youruser", "yourpassword"]' @@ -91,8 +92,8 @@ def test_upgrade_json_file_to_version_eight(self): } # verify keyring data = auth.get_auth_from_url_and_name("http://www.your-server.de/forecasts", http_auth) - assert data == ("youruser", 'password from TestKeyring') - assert auth.get_password_from_keyring("MSCOLAB", "something@something.org") == 'password from TestKeyring' + assert data == ("youruser", 'yourpassword') + assert auth.get_password_from_keyring("MSCOLAB", "something@something.org") == mail_password # check removed old attributes with pytest.raises(KeyError): @@ -101,3 +102,58 @@ def test_upgrade_json_file_to_version_eight(self): config_loader(dataset="MSC_login") with pytest.raises(KeyError): config_loader(dataset="MSCOLAB_password") + + def test_upgrade_json_file_to_version_nine(self): + """ + The test checks on version 8 if an old msui_settings.json can become migrated + It adds the new attributes and stores passwords in the keyring + """ + if version.parse(__version__) >= version.parse('9.0.0') and\ + version.parse(__version__) < version.parse('10.0.0'): + from mslib.utils.migration.update_json_file_to_version_nine import JsonConversion + # old attributes + mailid = 'something@something.org' + auth = '"https://www.your-mscolab-server.de": "youruser"' + default = '"https://www.your-mscolab-server.de"' + data = f"""{{ + "MSCOLAB_mailid": "{mailid}", + + "MSS_auth": {{ + {auth} + }}, + "default_MSCOLAB": [ + {default} + ] + }}""" + # store old configuration + create_msui_settings_file(data) + + from mslib.utils.migration.config_before_nine import read_config_file as read_config_file_before_nine + from mslib.utils.migration.config_before_nine import config_loader as config_loader_before_nine + read_config_file_before_nine() + + result = dict() + result[default] = mailid + assert config_loader_before_nine(dataset="MSS_auth") == {"https://www.your-mscolab-server.de": "youruser"} + config = config_loader_before_nine() + # old version knows MSCOLAB_mailid + assert "MSCOLAB_mailid" in config.keys() + new_version = JsonConversion() + # converting and storing + new_version.change_parameters() + filename = MSUI_SETTINGS.replace('\\', '/') + dir_name, file_name = fs.path.split(filename) + # check that we have a backup file + bak_file = f"{file_name}.bak" + _fs = fs.open_fs(dir_name) + assert _fs.exists(bak_file) + + # using current configuration + from mslib.utils.config import read_config_file, config_loader + read_config_file() + # added MSCOLAB_mailid to the url based on default_MSCOLAB + mss_auth = config_loader(dataset="MSS_auth") + assert mss_auth == {"https://www.your-mscolab-server.de": mailid} + config = config_loader() + # new version forgot about MSCOLAB_mailid + assert "MSCOLAB_mailid" not in config.keys() diff --git a/tests/_test_utils/test_multidict.py b/tests/_test_utils/test_multidict.py index 035278ad5..f9ecff433 100644 --- a/tests/_test_utils/test_multidict.py +++ b/tests/_test_utils/test_multidict.py @@ -32,7 +32,7 @@ LOGGER = logging.getLogger(__name__) -class TestCIMultiDict(object): +class TestCIMultiDict: class CaseInsensitiveMultiDict(werkzeug.datastructures.ImmutableMultiDict): """Extension to werkzeug.datastructures.ImmutableMultiDict @@ -55,9 +55,9 @@ def __getitem__(self, key): return v raise KeyError(repr(key)) - def test_multidict(object): - dict = TestCIMultiDict.CaseInsensitiveMultiDict([('title', 'MSS')]) + def test_multidict(self): + test_dict = TestCIMultiDict.CaseInsensitiveMultiDict([('title', 'MSS')]) dict_multidict = multidict.CIMultiDict([('title', 'MSS')]) assert 'title' in dict_multidict assert 'tiTLE' in dict_multidict - assert dict_multidict['Title'] == dict['tITLE'] + assert dict_multidict['Title'] == test_dict['tITLE'] diff --git a/tests/_test_utils/test_netCDF4tools.py b/tests/_test_utils/test_netCDF4tools.py index 2ca149a9b..fdc33daee 100644 --- a/tests/_test_utils/test_netCDF4tools.py +++ b/tests/_test_utils/test_netCDF4tools.py @@ -42,7 +42,7 @@ DATA_FILE_AL = os.path.join(DATA_DIR, "20121017_12_ecmwf_forecast.ALTITUDE_LEVELS.EUR_LL015.036.al.nc") -class Test_netCDF4tools(object): +class Test_netCDF4tools: def setup_method(self): self.ncfile_ml = Dataset(DATA_FILE_ML, 'r') self.ncfile_pl = Dataset(DATA_FILE_PL, 'r') diff --git a/tests/_test_utils/test_thermolib.py b/tests/_test_utils/test_thermolib.py index 4a6c00b05..ff67f7983 100644 --- a/tests/_test_utils/test_thermolib.py +++ b/tests/_test_utils/test_thermolib.py @@ -89,7 +89,7 @@ def test_isa_temperature(): assert thermolib.isa_temperature(51000 * units.m).magnitude == pytest.approx(270.65) -class TestConverter(object): +class TestConverter: def test_convert_pressure_to_vertical_axis_measure(self): assert thermolib.convert_pressure_to_vertical_axis_measure('pressure', 10000) == 100 assert thermolib.convert_pressure_to_vertical_axis_measure('flightlevel', 400) == 400 diff --git a/tests/_test_utils/test_time.py b/tests/_test_utils/test_time.py index 16c4d5c52..4077fe9e0 100644 --- a/tests/_test_utils/test_time.py +++ b/tests/_test_utils/test_time.py @@ -31,7 +31,7 @@ LOGGER = logging.getLogger(__name__) -class TestParseTime(object): +class TestParseTime: def test_parse_iso_datetime(self): assert time.parse_iso_datetime("2009-05-28T16:15:00") == datetime.datetime(2009, 5, 28, 16, 15) @@ -39,7 +39,7 @@ def test_parse_iso_duration(self): assert time.parse_iso_duration('P01W') == datetime.timedelta(days=7) -class TestTimes(object): +class TestTimes: """ tests about times """ diff --git a/tests/constants.py b/tests/constants.py index 7f92f7b08..be503bf8e 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -44,6 +44,7 @@ CACHED_CONFIG_FILE = None SERVER_CONFIG_FILE = "mswms_settings.py" MSCOLAB_CONFIG_FILE = "mscolab_settings.py" +MSCOLAB_AUTH_FILE = "mscolab_auth.py" ROOT_FS = TempFS(identifier=f"msui{SHA}") OSFS_URL = ROOT_FS.geturl("", purpose="fs") diff --git a/tests/data/msui_settings.json b/tests/data/msui_settings.json index c5125c211..be8ecf5b9 100644 --- a/tests/data/msui_settings.json +++ b/tests/data/msui_settings.json @@ -72,7 +72,5 @@ "MSS_auth": { "http://www.your-server.de/forecasts" : "authuser", "http://www.your-mscolab-server.de" : "authuser" - }, - - "MSCOLAB_mailid": "" + } } diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 000000000..bd8fee4b7 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +""" + + tests.fixtures + ~~~~~~~~~~~~~~ + + This module provides utils for pytest to test mslib modules + + This file is part of MSS. + + :copyright: Copyright 2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import pytest +import mock +import multiprocessing +import time +import urllib +import mslib.mswms.mswms +import eventlet +import eventlet.wsgi + +from PyQt5 import QtWidgets +from contextlib import contextmanager +from mslib.mscolab.conf import mscolab_settings +from mslib.mscolab.server import APP, initialize_managers +from mslib.mscolab.mscolab import handle_db_init, handle_db_reset +from mslib.utils.config import modify_config_file +from tests.utils import is_url_response_ok + + +@pytest.fixture +def fail_if_open_message_boxes_left(): + # Mock every MessageBox widget in the test suite to avoid unwanted freezes on unhandled error popups etc. + with mock.patch("PyQt5.QtWidgets.QMessageBox.question") as q, \ + mock.patch("PyQt5.QtWidgets.QMessageBox.information") as i, \ + mock.patch("PyQt5.QtWidgets.QMessageBox.critical") as c, \ + mock.patch("PyQt5.QtWidgets.QMessageBox.warning") as w: + yield + + # Fail a test if there are any Qt message boxes left open at the end + if any(box.call_count > 0 for box in [q, i, c, w]): + summary = "\n".join([f"PyQt5.QtWidgets.QMessageBox.{box()._extract_mock_name()}: {box.mock_calls[:-1]}" + for box in [q, i, c, w] if box.call_count > 0]) + pytest.fail(f"An unhandled message box popped up during your test!\n{summary}") + + +@pytest.fixture +def close_remaining_widgets(): + yield + # Try to close all remaining widgets after each test + for qobject in set(QtWidgets.QApplication.topLevelWindows() + QtWidgets.QApplication.topLevelWidgets()): + try: + qobject.destroy() + # Some objects deny permission, pass in that case + except RuntimeError: + pass + + +@pytest.fixture +def qapp(qapp, fail_if_open_message_boxes_left, close_remaining_widgets): + yield qapp + + +@pytest.fixture(scope="session") +def mscolab_session_app(): + """Session-scoped fixture that provides the WSGI app instance for MSColab. + + This fixture should not be used in tests. Instead use :func:`mscolab_app`, which + handles per-test cleanup as well. + """ + _app = APP + _app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI + _app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR + _app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER + handle_db_init() + return _app + + +@pytest.fixture(scope="session") +def mscolab_session_managers(mscolab_session_app): + """Session-scoped fixture that provides the managers for the MSColab app. + + This fixture should not be used in tests. Instead use :func:`mscolab_managers`, + which handles per-test cleanup as well. + """ + return initialize_managers(mscolab_session_app)[1:] + + +@pytest.fixture(scope="session") +def mscolab_session_server(mscolab_session_app, mscolab_session_managers): + """Session-scoped fixture that provides a running MSColab server. + + This fixture should not be used in tests. Instead use :func:`mscolab_server`, which + handles per-test cleanup as well. + """ + with _running_eventlet_server(mscolab_session_app) as url: + yield url + + +@pytest.fixture +def reset_mscolab(mscolab_session_app): + """Cleans up before every test that uses MSColab. + + This fixture is not explicitly needed in tests, it is used in the other fixtures to + do the cleanup actions. + """ + handle_db_reset() + + +@pytest.fixture +def mscolab_app(mscolab_session_app, reset_mscolab): + """Fixture that provides the MSColab WSGI app instance and does cleanup actions. + + :returns: A WSGI app instance. + """ + return mscolab_session_app + + +@pytest.fixture +def mscolab_managers(mscolab_session_managers, reset_mscolab): + """Fixture that provides the MSColab managers and does cleanup actions. + + :returns: A tuple (SocketIO, ChatManager, FileManager) as returned by + initialize_managers. + """ + return mscolab_session_managers + + +@pytest.fixture +def mscolab_server(mscolab_session_server, reset_mscolab): + """Fixture that provides a running MSColab server and does cleanup actions. + + :returns: The URL where the server is running. + """ + # Update mscolab URL to avoid "Update Server List" message boxes + modify_config_file({"default_MSCOLAB": [mscolab_session_server]}) + return mscolab_session_server + + +@pytest.fixture(scope="session") +def mswms_app(): + """Fixture that provides the MSWMS WSGI app instance.""" + return mslib.mswms.mswms.application + + +@pytest.fixture(scope="session") +def mswms_server(mswms_app): + """Fixture that provides a running MSWMS server. + + :returns: The URL where the server is running. + """ + with _running_eventlet_server(mswms_app) as url: + yield url + + +@contextmanager +def _running_eventlet_server(app): + """Context manager that starts the app in an eventlet server and returns its URL.""" + scheme = "http" + host = "127.0.0.1" + socket = eventlet.listen((host, 0)) + port = socket.getsockname()[1] + url = f"{scheme}://{host}:{port}" + app.config['URL'] = url + if "fork" not in multiprocessing.get_all_start_methods(): + pytest.skip("requires the multiprocessing start_method 'fork', which is unavailable on this system") + ctx = multiprocessing.get_context("fork") + process = ctx.Process(target=eventlet.wsgi.server, args=(socket, app), daemon=True) + try: + process.start() + while not is_url_response_ok(urllib.parse.urljoin(url, "index")): + time.sleep(0.5) + yield url + finally: + process.terminate() + process.join(10) + process.close() diff --git a/tests/utils.py b/tests/utils.py index 0c95536da..8e4b226ee 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -28,19 +28,12 @@ import requests import time import fs -import socket -import multiprocessing - -from flask_testing import LiveServerTestCase from PyQt5 import QtTest -from werkzeug.urls import url_join +from urllib.parse import urljoin from mslib.mscolab.server import register_user from flask import json from tests.constants import MSUI_CONFIG_PATH -from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.server import APP, initialize_managers, start_server -from mslib.mscolab.mscolab import handle_db_init def callback_ok_image(status, response_headers): @@ -75,7 +68,7 @@ def mscolab_register_user(app, msc_url, email, password, username): 'password': password, 'username': username } - url = url_join(msc_url, 'register') + url = urljoin(msc_url, 'register') response = app.test_client().post(url, data=data) return response @@ -86,7 +79,7 @@ def mscolab_register_and_login(app, msc_url, email, password, username): 'email': email, 'password': password } - url = url_join(msc_url, 'token') + url = urljoin(msc_url, 'token') response = app.test_client().post(url, data=data) return response @@ -96,7 +89,7 @@ def mscolab_login(app, msc_url, email='a', password='a'): 'email': email, 'password': password } - url = url_join(msc_url, 'token') + url = urljoin(msc_url, 'token') response = app.test_client().post(url, data=data) return response @@ -106,7 +99,7 @@ def mscolab_delete_user(app, msc_url, email, password): response = mscolab_login(app, msc_url, email, password) if response.status == '200 OK': data = json.loads(response.get_data(as_text=True)) - url = url_join(msc_url, 'delete_user') + url = urljoin(msc_url, 'delete_own_account') response = app.test_client().post(url, data=data) if response.status == '200 OK': data = json.loads(response.get_data(as_text=True)) @@ -132,7 +125,7 @@ def mscolab_create_content(app, msc_url, data, path_name='example', content=None data["path"] = path_name data['description'] = path_name data['content'] = content - url = url_join(msc_url, 'create_operation') + url = urljoin(msc_url, 'create_operation') response = app.test_client().post(url, data=data) return response @@ -140,12 +133,12 @@ def mscolab_create_content(app, msc_url, data, path_name='example', content=None def mscolab_delete_all_operations(app, msc_url, email, password, username): response = mscolab_register_and_login(app, msc_url, email, password, username) data = json.loads(response.get_data(as_text=True)) - url = url_join(msc_url, 'operations') + url = urljoin(msc_url, 'operations') response = app.test_client().get(url, data=data) response = json.loads(response.get_data(as_text=True)) for p in response['operations']: data['op_id'] = p['op_id'] - url = url_join(msc_url, 'delete_operation') + url = urljoin(msc_url, 'delete_operation') response = app.test_client().post(url, data=data) @@ -153,7 +146,7 @@ def mscolab_create_operation(app, msc_url, response, path='f', description='desc data = json.loads(response.get_data(as_text=True)) data["path"] = path data['description'] = description - url = url_join(msc_url, 'create_operation') + url = urljoin(msc_url, 'create_operation') response = app.test_client().post(url, data=data) return data, response @@ -161,7 +154,7 @@ def mscolab_create_operation(app, msc_url, response, path='f', description='desc def mscolab_get_operation_id(app, msc_url, email, password, username, path): response = mscolab_register_and_login(app, msc_url, email, password, username) data = json.loads(response.get_data(as_text=True)) - url = url_join(msc_url, 'operations') + url = urljoin(msc_url, 'operations') response = app.test_client().get(url, data=data) response = json.loads(response.get_data(as_text=True)) for p in response['operations']: @@ -169,68 +162,17 @@ def mscolab_get_operation_id(app, msc_url, email, password, username, path): return p['op_id'] -def mscolab_check_free_port(all_ports, port): - _s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - _s.bind(("127.0.0.1", port)) - except (socket.error, IOError): - port = all_ports.pop() - port = mscolab_check_free_port(all_ports, port) - else: - _s.close() - return port +def create_msui_settings_file(content): + with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: + file_dir.writetext("msui_settings.json", content) -def mscolab_ping_server(port): - url = f"http://127.0.0.1:{port}/status" +def is_url_response_ok(url): try: - r = requests.get(url, timeout=(2, 10)) - if r.text == "Mscolab server": - return True - except requests.exceptions.ConnectionError: + response = requests.get(url) + return response.status_code == 200 + except: # noqa: E722 return False - return False - - -def mscolab_start_server(all_ports, mscolab_settings=mscolab_settings, timeout=10): - handle_db_init() - port = mscolab_check_free_port(all_ports, all_ports.pop()) - - url = f"http://localhost:{port}" - - _app = APP - _app.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI - _app.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR - _app.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER - _app.config['URL'] = url - - _app, sockio, cm, fm = initialize_managers(_app) - - # ToDo refactoring for spawn needed, fork is not implemented on windows, spawn is default on MAC and Windows - if multiprocessing.get_start_method(allow_none=True) != 'fork': - multiprocessing.set_start_method("fork") - process = multiprocessing.Process( - target=start_server, - args=(_app, sockio, cm, fm,), - kwargs={'port': port}) - process.start() - start_time = time.time() - while True: - elapsed_time = (time.time() - start_time) - if elapsed_time > timeout: - raise RuntimeError( - "Failed to start the server after %d seconds. " % timeout - ) - - if mscolab_ping_server(port): - break - - return process, url, _app, sockio, cm, fm - - -def create_msui_settings_file(content): - with fs.open_fs(MSUI_CONFIG_PATH) as file_dir: - file_dir.writetext("msui_settings.json", content) def wait_until_signal(signal, timeout=5): @@ -268,32 +210,3 @@ def __init__(self, exc): def raise_exc(self, *args, **kwargs): raise self.exc - - -class LiveSocketTestCase(LiveServerTestCase): - - def _spawn_live_server(self): - self._process = None - port_value = self._port_value - app, sockio, cm, fm = initialize_managers(self.app) - self._process = multiprocessing.Process( - target=start_server, - args=(app, sockio, cm, fm,), - kwargs={'port': port_value.value}) - - self._process.start() - - # We must wait for the server to start listening, but give up - # after a specified maximum timeout - timeout = self.app.config.get('LIVESERVER_TIMEOUT', 5) - start_time = time.time() - - while True: - elapsed_time = (time.time() - start_time) - if elapsed_time > timeout: - raise RuntimeError( - "Failed to start the server after %d seconds. " % timeout - ) - - if self._can_ping_server(): - break diff --git a/tutorials/pictures/__init__.py b/tutorials/pictures/__init__.py deleted file mode 100644 index f78acf224..000000000 --- a/tutorials/pictures/__init__.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -""" - - mslib.tutorials.pictures - ~~~~~~~~~~~~~~~~~~~~~~~~ - - This module provides functions to read images for the different tutorials for comparison - - This file is part of MSS. - - :copyright: Copyright 2016-2022 by the MSS team, see AUTHORS. - :license: APACHE-2.0, see LICENSE for details. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -""" -import os -import sys - -use_platform = sys.platform -if sys.platform in ('linux', 'linux2', 'darwin'): - use_platform = 'linux' - -TUTORIALS = ["hexagoncontrol", - "kml", - "mscolab", - "performancesettings", - "remotesensing", - "satellitetrack", - "views", - "waypoints", - "wms"] - - -def picture(tutorial="wms", name="layers.png"): - if tutorial in TUTORIALS: - return os.path.join(os.path.abspath(os.path.normpath(os.path.dirname(__file__))), tutorial, use_platform, name) diff --git a/tutorials/pictures/cursor_image.png b/tutorials/pictures/cursor_image.png deleted file mode 100644 index c4b15bd91..000000000 Binary files a/tutorials/pictures/cursor_image.png and /dev/null differ diff --git a/tutorials/pictures/hexagoncontrol/linux/add_hexagon.png b/tutorials/pictures/hexagoncontrol/linux/add_hexagon.png deleted file mode 100644 index 73d060ede..000000000 Binary files a/tutorials/pictures/hexagoncontrol/linux/add_hexagon.png and /dev/null differ diff --git a/tutorials/pictures/hexagoncontrol/linux/center_latitude.png b/tutorials/pictures/hexagoncontrol/linux/center_latitude.png deleted file mode 100644 index 9de0af68a..000000000 Binary files a/tutorials/pictures/hexagoncontrol/linux/center_latitude.png and /dev/null differ diff --git a/tutorials/pictures/hexagoncontrol/linux/radius.png b/tutorials/pictures/hexagoncontrol/linux/radius.png deleted file mode 100644 index 392202ad2..000000000 Binary files a/tutorials/pictures/hexagoncontrol/linux/radius.png and /dev/null differ diff --git a/tutorials/pictures/hexagoncontrol/linux/remove_hexagon.png b/tutorials/pictures/hexagoncontrol/linux/remove_hexagon.png deleted file mode 100644 index 7895fb75e..000000000 Binary files a/tutorials/pictures/hexagoncontrol/linux/remove_hexagon.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/add_kml_files.png b/tutorials/pictures/kml/linux/add_kml_files.png deleted file mode 100644 index bd20403f7..000000000 Binary files a/tutorials/pictures/kml/linux/add_kml_files.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/changecolor.png b/tutorials/pictures/kml/linux/changecolor.png deleted file mode 100644 index 6395be690..000000000 Binary files a/tutorials/pictures/kml/linux/changecolor.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/colored_line.png b/tutorials/pictures/kml/linux/colored_line.png deleted file mode 100644 index 1a3df8652..000000000 Binary files a/tutorials/pictures/kml/linux/colored_line.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/kmloverlay.png b/tutorials/pictures/kml/linux/kmloverlay.png deleted file mode 100644 index 28892fbe5..000000000 Binary files a/tutorials/pictures/kml/linux/kmloverlay.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/mergeandexport.png b/tutorials/pictures/kml/linux/mergeandexport.png deleted file mode 100644 index d62fd1292..000000000 Binary files a/tutorials/pictures/kml/linux/mergeandexport.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/pick_screen_color.png b/tutorials/pictures/kml/linux/pick_screen_color.png deleted file mode 100644 index 82c169e24..000000000 Binary files a/tutorials/pictures/kml/linux/pick_screen_color.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/remove_files.png b/tutorials/pictures/kml/linux/remove_files.png deleted file mode 100644 index 9f333f1c5..000000000 Binary files a/tutorials/pictures/kml/linux/remove_files.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/select_all_files.png b/tutorials/pictures/kml/linux/select_all_files.png deleted file mode 100644 index c1bf78685..000000000 Binary files a/tutorials/pictures/kml/linux/select_all_files.png and /dev/null differ diff --git a/tutorials/pictures/kml/linux/unselect_all_files.png b/tutorials/pictures/kml/linux/unselect_all_files.png deleted file mode 100644 index 0ade36eef..000000000 Binary files a/tutorials/pictures/kml/linux/unselect_all_files.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/active_operations.png b/tutorials/pictures/mscolab/linux/active_operations.png deleted file mode 100644 index 8e693a71a..000000000 Binary files a/tutorials/pictures/mscolab/linux/active_operations.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/add_user.png b/tutorials/pictures/mscolab/linux/add_user.png deleted file mode 100644 index d1ef341c7..000000000 Binary files a/tutorials/pictures/mscolab/linux/add_user.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/addop_ok.png b/tutorials/pictures/mscolab/linux/addop_ok.png deleted file mode 100644 index 1ad28896f..000000000 Binary files a/tutorials/pictures/mscolab/linux/addop_ok.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/chat_previous.png b/tutorials/pictures/mscolab/linux/chat_previous.png deleted file mode 100644 index 6e1c91c53..000000000 Binary files a/tutorials/pictures/mscolab/linux/chat_previous.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/chat_send.png b/tutorials/pictures/mscolab/linux/chat_send.png deleted file mode 100644 index 4a06d9438..000000000 Binary files a/tutorials/pictures/mscolab/linux/chat_send.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/connect.png b/tutorials/pictures/mscolab/linux/connect.png deleted file mode 100644 index d4d8893df..000000000 Binary files a/tutorials/pictures/mscolab/linux/connect.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/connect_to_mscolab.png b/tutorials/pictures/mscolab/linux/connect_to_mscolab.png deleted file mode 100644 index 1ede59de0..000000000 Binary files a/tutorials/pictures/mscolab/linux/connect_to_mscolab.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/emailid_taken.png b/tutorials/pictures/mscolab/linux/emailid_taken.png deleted file mode 100644 index 40a454b38..000000000 Binary files a/tutorials/pictures/mscolab/linux/emailid_taken.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/file.png b/tutorials/pictures/mscolab/linux/file.png deleted file mode 100644 index af746b9a6..000000000 Binary files a/tutorials/pictures/mscolab/linux/file.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/johndoe_profile.png b/tutorials/pictures/mscolab/linux/johndoe_profile.png deleted file mode 100644 index 2cfd60ea5..000000000 Binary files a/tutorials/pictures/mscolab/linux/johndoe_profile.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/login.png b/tutorials/pictures/mscolab/linux/login.png deleted file mode 100644 index ebb64deb7..000000000 Binary files a/tutorials/pictures/mscolab/linux/login.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/manageusers_add.png b/tutorials/pictures/mscolab/linux/manageusers_add.png deleted file mode 100644 index da622257b..000000000 Binary files a/tutorials/pictures/mscolab/linux/manageusers_add.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/manageusers_left_selectall.png b/tutorials/pictures/mscolab/linux/manageusers_left_selectall.png deleted file mode 100644 index bc9260697..000000000 Binary files a/tutorials/pictures/mscolab/linux/manageusers_left_selectall.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/manageusers_modify.png b/tutorials/pictures/mscolab/linux/manageusers_modify.png deleted file mode 100644 index 0c87648e6..000000000 Binary files a/tutorials/pictures/mscolab/linux/manageusers_modify.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/manageusers_right_selectall.png b/tutorials/pictures/mscolab/linux/manageusers_right_selectall.png deleted file mode 100644 index 14fc48d4f..000000000 Binary files a/tutorials/pictures/mscolab/linux/manageusers_right_selectall.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/name_version.png b/tutorials/pictures/mscolab/linux/name_version.png deleted file mode 100644 index 2515a2014..000000000 Binary files a/tutorials/pictures/mscolab/linux/name_version.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/openviews.png b/tutorials/pictures/mscolab/linux/openviews.png deleted file mode 100644 index 8d787cb3d..000000000 Binary files a/tutorials/pictures/mscolab/linux/openviews.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/overwrite_waypoints.png b/tutorials/pictures/mscolab/linux/overwrite_waypoints.png deleted file mode 100644 index 1fe65ca8d..000000000 Binary files a/tutorials/pictures/mscolab/linux/overwrite_waypoints.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/refresh_window.png b/tutorials/pictures/mscolab/linux/refresh_window.png deleted file mode 100644 index f8782c122..000000000 Binary files a/tutorials/pictures/mscolab/linux/refresh_window.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/server_options.png b/tutorials/pictures/mscolab/linux/server_options.png deleted file mode 100644 index a757e1297..000000000 Binary files a/tutorials/pictures/mscolab/linux/server_options.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/topview_point2.png b/tutorials/pictures/mscolab/linux/topview_point2.png deleted file mode 100644 index 25cb8a969..000000000 Binary files a/tutorials/pictures/mscolab/linux/topview_point2.png and /dev/null differ diff --git a/tutorials/pictures/mscolab/linux/work_asynchronously.png b/tutorials/pictures/mscolab/linux/work_asynchronously.png deleted file mode 100644 index b5b1e2f45..000000000 Binary files a/tutorials/pictures/mscolab/linux/work_asynchronously.png and /dev/null differ diff --git a/tutorials/pictures/options.png b/tutorials/pictures/options.png deleted file mode 100644 index aa1a6c935..000000000 Binary files a/tutorials/pictures/options.png and /dev/null differ diff --git a/tutorials/pictures/performancesettings/linux/aircraft_weight.png b/tutorials/pictures/performancesettings/linux/aircraft_weight.png deleted file mode 100644 index d6625929e..000000000 Binary files a/tutorials/pictures/performancesettings/linux/aircraft_weight.png and /dev/null differ diff --git a/tutorials/pictures/performancesettings/linux/maximum_takeoff_weight.png b/tutorials/pictures/performancesettings/linux/maximum_takeoff_weight.png deleted file mode 100644 index d2ce02d46..000000000 Binary files a/tutorials/pictures/performancesettings/linux/maximum_takeoff_weight.png and /dev/null differ diff --git a/tutorials/pictures/performancesettings/linux/select.png b/tutorials/pictures/performancesettings/linux/select.png deleted file mode 100644 index 8107f3c25..000000000 Binary files a/tutorials/pictures/performancesettings/linux/select.png and /dev/null differ diff --git a/tutorials/pictures/performancesettings/linux/selecttoopencontrol.png b/tutorials/pictures/performancesettings/linux/selecttoopencontrol.png deleted file mode 100644 index aa3f705dc..000000000 Binary files a/tutorials/pictures/performancesettings/linux/selecttoopencontrol.png and /dev/null differ diff --git a/tutorials/pictures/performancesettings/linux/show_performance.png b/tutorials/pictures/performancesettings/linux/show_performance.png deleted file mode 100644 index e21d64205..000000000 Binary files a/tutorials/pictures/performancesettings/linux/show_performance.png and /dev/null differ diff --git a/tutorials/pictures/performancesettings/linux/take_off_time.png b/tutorials/pictures/performancesettings/linux/take_off_time.png deleted file mode 100644 index 18637bb36..000000000 Binary files a/tutorials/pictures/performancesettings/linux/take_off_time.png and /dev/null differ diff --git a/tutorials/pictures/remotesensing/linux/azimuth.png b/tutorials/pictures/remotesensing/linux/azimuth.png deleted file mode 100644 index e691ce443..000000000 Binary files a/tutorials/pictures/remotesensing/linux/azimuth.png and /dev/null differ diff --git a/tutorials/pictures/remotesensing/linux/drawtangent.png b/tutorials/pictures/remotesensing/linux/drawtangent.png deleted file mode 100644 index 5c7f67908..000000000 Binary files a/tutorials/pictures/remotesensing/linux/drawtangent.png and /dev/null differ diff --git a/tutorials/pictures/remotesensing/linux/elevation.png b/tutorials/pictures/remotesensing/linux/elevation.png deleted file mode 100644 index abc4381a2..000000000 Binary files a/tutorials/pictures/remotesensing/linux/elevation.png and /dev/null differ diff --git a/tutorials/pictures/remotesensing/linux/showangle.png b/tutorials/pictures/remotesensing/linux/showangle.png deleted file mode 100644 index 1830ce6e9..000000000 Binary files a/tutorials/pictures/remotesensing/linux/showangle.png and /dev/null differ diff --git a/tutorials/pictures/satellitetrack/linux/load.png b/tutorials/pictures/satellitetrack/linux/load.png deleted file mode 100644 index d66ad91a7..000000000 Binary files a/tutorials/pictures/satellitetrack/linux/load.png and /dev/null differ diff --git a/tutorials/pictures/satellitetrack/linux/predicted_satellite_overpasses.png b/tutorials/pictures/satellitetrack/linux/predicted_satellite_overpasses.png deleted file mode 100644 index c0549e640..000000000 Binary files a/tutorials/pictures/satellitetrack/linux/predicted_satellite_overpasses.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/add_waypoint.png b/tutorials/pictures/views/linux/add_waypoint.png deleted file mode 100644 index e64a1119b..000000000 Binary files a/tutorials/pictures/views/linux/add_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/delete_waypoint.png b/tutorials/pictures/views/linux/delete_waypoint.png deleted file mode 100644 index 33c755482..000000000 Binary files a/tutorials/pictures/views/linux/delete_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/get_capabilities.png b/tutorials/pictures/views/linux/get_capabilities.png deleted file mode 100644 index d05f7a3de..000000000 Binary files a/tutorials/pictures/views/linux/get_capabilities.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/horizontal_wind.png b/tutorials/pictures/views/linux/horizontal_wind.png deleted file mode 100644 index 827758fc0..000000000 Binary files a/tutorials/pictures/views/linux/horizontal_wind.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/layers.png b/tutorials/pictures/views/linux/layers.png deleted file mode 100644 index fdb194115..000000000 Binary files a/tutorials/pictures/views/linux/layers.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/move_waypoint.png b/tutorials/pictures/views/linux/move_waypoint.png deleted file mode 100644 index 5ad659a40..000000000 Binary files a/tutorials/pictures/views/linux/move_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/secondary_axis.png b/tutorials/pictures/views/linux/secondary_axis.png deleted file mode 100644 index e92921325..000000000 Binary files a/tutorials/pictures/views/linux/secondary_axis.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/selecttoopencontrol.png b/tutorials/pictures/views/linux/selecttoopencontrol.png deleted file mode 100644 index 7bf4ddbb8..000000000 Binary files a/tutorials/pictures/views/linux/selecttoopencontrol.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/sideview_point1.png b/tutorials/pictures/views/linux/sideview_point1.png deleted file mode 100644 index e12eedad2..000000000 Binary files a/tutorials/pictures/views/linux/sideview_point1.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/topview_point2.png b/tutorials/pictures/views/linux/topview_point2.png deleted file mode 100644 index 5c2c498bb..000000000 Binary files a/tutorials/pictures/views/linux/topview_point2.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/vertical_axis.png b/tutorials/pictures/views/linux/vertical_axis.png deleted file mode 100644 index d3cb4ad55..000000000 Binary files a/tutorials/pictures/views/linux/vertical_axis.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/vertical_velocity.png b/tutorials/pictures/views/linux/vertical_velocity.png deleted file mode 100644 index a7701ab02..000000000 Binary files a/tutorials/pictures/views/linux/vertical_velocity.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/wms_url.png b/tutorials/pictures/views/linux/wms_url.png deleted file mode 100644 index 6c45bf0d1..000000000 Binary files a/tutorials/pictures/views/linux/wms_url.png and /dev/null differ diff --git a/tutorials/pictures/views/linux/zoom.png b/tutorials/pictures/views/linux/zoom.png deleted file mode 100644 index ed8df6094..000000000 Binary files a/tutorials/pictures/views/linux/zoom.png and /dev/null differ diff --git a/tutorials/pictures/views/win/add_waypoint.png b/tutorials/pictures/views/win/add_waypoint.png deleted file mode 100644 index e64a1119b..000000000 Binary files a/tutorials/pictures/views/win/add_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/views/win/delete_waypoint.png b/tutorials/pictures/views/win/delete_waypoint.png deleted file mode 100644 index 33c755482..000000000 Binary files a/tutorials/pictures/views/win/delete_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/views/win/move_waypoint.png b/tutorials/pictures/views/win/move_waypoint.png deleted file mode 100644 index 5ad659a40..000000000 Binary files a/tutorials/pictures/views/win/move_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/views/win/secondary_axis.png b/tutorials/pictures/views/win/secondary_axis.png deleted file mode 100644 index e92921325..000000000 Binary files a/tutorials/pictures/views/win/secondary_axis.png and /dev/null differ diff --git a/tutorials/pictures/views/win/selecttoopencontrol.png b/tutorials/pictures/views/win/selecttoopencontrol.png deleted file mode 100644 index a27034504..000000000 Binary files a/tutorials/pictures/views/win/selecttoopencontrol.png and /dev/null differ diff --git a/tutorials/pictures/views/win/vertical_axis.png b/tutorials/pictures/views/win/vertical_axis.png deleted file mode 100644 index d3cb4ad55..000000000 Binary files a/tutorials/pictures/views/win/vertical_axis.png and /dev/null differ diff --git a/tutorials/pictures/views/win/zoom.png b/tutorials/pictures/views/win/zoom.png deleted file mode 100644 index ed8df6094..000000000 Binary files a/tutorials/pictures/views/win/zoom.png and /dev/null differ diff --git a/tutorials/pictures/waypoints/linux/europe_cyl.png b/tutorials/pictures/waypoints/linux/europe_cyl.png deleted file mode 100644 index 70296b484..000000000 Binary files a/tutorials/pictures/waypoints/linux/europe_cyl.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/add_waypoint.png b/tutorials/pictures/wms/linux/add_waypoint.png deleted file mode 100644 index d7595ce2e..000000000 Binary files a/tutorials/pictures/wms/linux/add_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/auto_update.png b/tutorials/pictures/wms/linux/auto_update.png deleted file mode 100644 index 243aae0a6..000000000 Binary files a/tutorials/pictures/wms/linux/auto_update.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/checkbox_unselected_divergence.png b/tutorials/pictures/wms/linux/checkbox_unselected_divergence.png deleted file mode 100644 index 8b702aa72..000000000 Binary files a/tutorials/pictures/wms/linux/checkbox_unselected_divergence.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/clear_cache.png b/tutorials/pictures/wms/linux/clear_cache.png deleted file mode 100644 index d49b7974a..000000000 Binary files a/tutorials/pictures/wms/linux/clear_cache.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/clear_filter.png b/tutorials/pictures/wms/linux/clear_filter.png deleted file mode 100644 index 3fca379c1..000000000 Binary files a/tutorials/pictures/wms/linux/clear_filter.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/clone.png b/tutorials/pictures/wms/linux/clone.png deleted file mode 100644 index 0a0cc4bf9..000000000 Binary files a/tutorials/pictures/wms/linux/clone.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/cloudcover.png b/tutorials/pictures/wms/linux/cloudcover.png deleted file mode 100644 index b27a7e3bc..000000000 Binary files a/tutorials/pictures/wms/linux/cloudcover.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/delete_layers.png b/tutorials/pictures/wms/linux/delete_layers.png deleted file mode 100644 index 1ebe0a3bf..000000000 Binary files a/tutorials/pictures/wms/linux/delete_layers.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/deleteselected.png b/tutorials/pictures/wms/linux/deleteselected.png deleted file mode 100644 index b0e16cf55..000000000 Binary files a/tutorials/pictures/wms/linux/deleteselected.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/divergence_layer.png b/tutorials/pictures/wms/linux/divergence_layer.png deleted file mode 100644 index 0b05f69c7..000000000 Binary files a/tutorials/pictures/wms/linux/divergence_layer.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/equivalent_layer.png b/tutorials/pictures/wms/linux/equivalent_layer.png deleted file mode 100644 index 7580a8c1f..000000000 Binary files a/tutorials/pictures/wms/linux/equivalent_layer.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/europe_cyl.png b/tutorials/pictures/wms/linux/europe_cyl.png deleted file mode 100644 index e0c110287..000000000 Binary files a/tutorials/pictures/wms/linux/europe_cyl.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/get_capabilities.png b/tutorials/pictures/wms/linux/get_capabilities.png deleted file mode 100644 index 2b018366f..000000000 Binary files a/tutorials/pictures/wms/linux/get_capabilities.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/home.png b/tutorials/pictures/wms/linux/home.png deleted file mode 100644 index fe573e634..000000000 Binary files a/tutorials/pictures/wms/linux/home.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/horizontalwind.png b/tutorials/pictures/wms/linux/horizontalwind.png deleted file mode 100644 index d7de751b0..000000000 Binary files a/tutorials/pictures/wms/linux/horizontalwind.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/initialization.png b/tutorials/pictures/wms/linux/initialization.png deleted file mode 100644 index 6ed94af3d..000000000 Binary files a/tutorials/pictures/wms/linux/initialization.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/insert.png b/tutorials/pictures/wms/linux/insert.png deleted file mode 100644 index 96909a14a..000000000 Binary files a/tutorials/pictures/wms/linux/insert.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/layer_filter.png b/tutorials/pictures/wms/linux/layer_filter.png deleted file mode 100644 index 597961617..000000000 Binary files a/tutorials/pictures/wms/linux/layer_filter.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/layers.png b/tutorials/pictures/wms/linux/layers.png deleted file mode 100644 index f6c4df135..000000000 Binary files a/tutorials/pictures/wms/linux/layers.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/level.png b/tutorials/pictures/wms/linux/level.png deleted file mode 100644 index 4124cc315..000000000 Binary files a/tutorials/pictures/wms/linux/level.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/makeroundtrip.png b/tutorials/pictures/wms/linux/makeroundtrip.png deleted file mode 100644 index 23ca94468..000000000 Binary files a/tutorials/pictures/wms/linux/makeroundtrip.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/move_waypoint.png b/tutorials/pictures/wms/linux/move_waypoint.png deleted file mode 100644 index 9539524e5..000000000 Binary files a/tutorials/pictures/wms/linux/move_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/multilayering.png b/tutorials/pictures/wms/linux/multilayering.png deleted file mode 100644 index 909f1605c..000000000 Binary files a/tutorials/pictures/wms/linux/multilayering.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/next.png b/tutorials/pictures/wms/linux/next.png deleted file mode 100644 index f40178102..000000000 Binary files a/tutorials/pictures/wms/linux/next.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/options.png b/tutorials/pictures/wms/linux/options.png deleted file mode 100644 index ebb820a40..000000000 Binary files a/tutorials/pictures/wms/linux/options.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/pan.png b/tutorials/pictures/wms/linux/pan.png deleted file mode 100644 index dd4e33e77..000000000 Binary files a/tutorials/pictures/wms/linux/pan.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/previous.png b/tutorials/pictures/wms/linux/previous.png deleted file mode 100644 index 8e5a93372..000000000 Binary files a/tutorials/pictures/wms/linux/previous.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/redraw.png b/tutorials/pictures/wms/linux/redraw.png deleted file mode 100644 index a91b8c35f..000000000 Binary files a/tutorials/pictures/wms/linux/redraw.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/remove.png b/tutorials/pictures/wms/linux/remove.png deleted file mode 100644 index 1371fd52d..000000000 Binary files a/tutorials/pictures/wms/linux/remove.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/remove_waypoint.png b/tutorials/pictures/wms/linux/remove_waypoint.png deleted file mode 100644 index 7e268a85b..000000000 Binary files a/tutorials/pictures/wms/linux/remove_waypoint.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/retrieve.png b/tutorials/pictures/wms/linux/retrieve.png deleted file mode 100644 index 6a37ca854..000000000 Binary files a/tutorials/pictures/wms/linux/retrieve.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/reverse.png b/tutorials/pictures/wms/linux/reverse.png deleted file mode 100644 index 26e6502b2..000000000 Binary files a/tutorials/pictures/wms/linux/reverse.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/save.png b/tutorials/pictures/wms/linux/save.png deleted file mode 100644 index 2fd8d0fa0..000000000 Binary files a/tutorials/pictures/wms/linux/save.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/selecttoopencontrol.png b/tutorials/pictures/wms/linux/selecttoopencontrol.png deleted file mode 100644 index aa3f705dc..000000000 Binary files a/tutorials/pictures/wms/linux/selecttoopencontrol.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/star_filter.png b/tutorials/pictures/wms/linux/star_filter.png deleted file mode 100644 index 6b47856c7..000000000 Binary files a/tutorials/pictures/wms/linux/star_filter.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/transparent.png b/tutorials/pictures/wms/linux/transparent.png deleted file mode 100644 index 66a039fdf..000000000 Binary files a/tutorials/pictures/wms/linux/transparent.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/unselected_divergence_layer.png b/tutorials/pictures/wms/linux/unselected_divergence_layer.png deleted file mode 100644 index 81ae29199..000000000 Binary files a/tutorials/pictures/wms/linux/unselected_divergence_layer.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/unstar_filter.png b/tutorials/pictures/wms/linux/unstar_filter.png deleted file mode 100644 index e0e1cb2f3..000000000 Binary files a/tutorials/pictures/wms/linux/unstar_filter.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/use_cache.png b/tutorials/pictures/wms/linux/use_cache.png deleted file mode 100644 index 20476bf82..000000000 Binary files a/tutorials/pictures/wms/linux/use_cache.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/valid.png b/tutorials/pictures/wms/linux/valid.png deleted file mode 100644 index b53f5713a..000000000 Binary files a/tutorials/pictures/wms/linux/valid.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/wms_url.png b/tutorials/pictures/wms/linux/wms_url.png deleted file mode 100644 index 775b0d583..000000000 Binary files a/tutorials/pictures/wms/linux/wms_url.png and /dev/null differ diff --git a/tutorials/pictures/wms/linux/zoom.png b/tutorials/pictures/wms/linux/zoom.png deleted file mode 100644 index e27228dd5..000000000 Binary files a/tutorials/pictures/wms/linux/zoom.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/auto_update.png b/tutorials/pictures/wms/win/auto_update.png deleted file mode 100644 index 969aa1a77..000000000 Binary files a/tutorials/pictures/wms/win/auto_update.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/clear_cache.png b/tutorials/pictures/wms/win/clear_cache.png deleted file mode 100644 index 1450d069f..000000000 Binary files a/tutorials/pictures/wms/win/clear_cache.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/clone.png b/tutorials/pictures/wms/win/clone.png deleted file mode 100644 index 740f63cbb..000000000 Binary files a/tutorials/pictures/wms/win/clone.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/cloudcover.png b/tutorials/pictures/wms/win/cloudcover.png deleted file mode 100644 index 0d2d9710a..000000000 Binary files a/tutorials/pictures/wms/win/cloudcover.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/delete_layers.png b/tutorials/pictures/wms/win/delete_layers.png deleted file mode 100644 index ec1d45aa2..000000000 Binary files a/tutorials/pictures/wms/win/delete_layers.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/deleteselected.png b/tutorials/pictures/wms/win/deleteselected.png deleted file mode 100644 index cd37cfe02..000000000 Binary files a/tutorials/pictures/wms/win/deleteselected.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/divergence_layer.png b/tutorials/pictures/wms/win/divergence_layer.png deleted file mode 100644 index 283cde2d0..000000000 Binary files a/tutorials/pictures/wms/win/divergence_layer.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/equivalent_potential_layer.png b/tutorials/pictures/wms/win/equivalent_potential_layer.png deleted file mode 100644 index dc8bcec56..000000000 Binary files a/tutorials/pictures/wms/win/equivalent_potential_layer.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/europe_cyl.png b/tutorials/pictures/wms/win/europe_cyl.png deleted file mode 100644 index 84ae6bb1e..000000000 Binary files a/tutorials/pictures/wms/win/europe_cyl.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/get_capabilities.png b/tutorials/pictures/wms/win/get_capabilities.png deleted file mode 100644 index 403f79b3f..000000000 Binary files a/tutorials/pictures/wms/win/get_capabilities.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/horizontalwind.png b/tutorials/pictures/wms/win/horizontalwind.png deleted file mode 100644 index 979bc249a..000000000 Binary files a/tutorials/pictures/wms/win/horizontalwind.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/initialization.png b/tutorials/pictures/wms/win/initialization.png deleted file mode 100644 index 8fe2d595e..000000000 Binary files a/tutorials/pictures/wms/win/initialization.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/insert.png b/tutorials/pictures/wms/win/insert.png deleted file mode 100644 index 347ddffe8..000000000 Binary files a/tutorials/pictures/wms/win/insert.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/layer_filter.png b/tutorials/pictures/wms/win/layer_filter.png deleted file mode 100644 index e3b751b16..000000000 Binary files a/tutorials/pictures/wms/win/layer_filter.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/layers.png b/tutorials/pictures/wms/win/layers.png deleted file mode 100644 index 753ea7168..000000000 Binary files a/tutorials/pictures/wms/win/layers.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/level.png b/tutorials/pictures/wms/win/level.png deleted file mode 100644 index 559a28bfb..000000000 Binary files a/tutorials/pictures/wms/win/level.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/multilayering.png b/tutorials/pictures/wms/win/multilayering.png deleted file mode 100644 index 696f727bf..000000000 Binary files a/tutorials/pictures/wms/win/multilayering.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/options.png b/tutorials/pictures/wms/win/options.png deleted file mode 100644 index 3620823b0..000000000 Binary files a/tutorials/pictures/wms/win/options.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/remove.png b/tutorials/pictures/wms/win/remove.png deleted file mode 100644 index 7763a6864..000000000 Binary files a/tutorials/pictures/wms/win/remove.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/retrieve.png b/tutorials/pictures/wms/win/retrieve.png deleted file mode 100644 index c53518157..000000000 Binary files a/tutorials/pictures/wms/win/retrieve.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/reverse.png b/tutorials/pictures/wms/win/reverse.png deleted file mode 100644 index 2851089ff..000000000 Binary files a/tutorials/pictures/wms/win/reverse.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/selecttoopencontrol.png b/tutorials/pictures/wms/win/selecttoopencontrol.png deleted file mode 100644 index 217dbbb07..000000000 Binary files a/tutorials/pictures/wms/win/selecttoopencontrol.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/star_filter.png b/tutorials/pictures/wms/win/star_filter.png deleted file mode 100644 index e7b9cebb1..000000000 Binary files a/tutorials/pictures/wms/win/star_filter.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/star_layer.png b/tutorials/pictures/wms/win/star_layer.png deleted file mode 100644 index 538b345e7..000000000 Binary files a/tutorials/pictures/wms/win/star_layer.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/transparent.png b/tutorials/pictures/wms/win/transparent.png deleted file mode 100644 index e8c439038..000000000 Binary files a/tutorials/pictures/wms/win/transparent.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/use_cache.png b/tutorials/pictures/wms/win/use_cache.png deleted file mode 100644 index faf40f865..000000000 Binary files a/tutorials/pictures/wms/win/use_cache.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/valid.png b/tutorials/pictures/wms/win/valid.png deleted file mode 100644 index 9b205ae49..000000000 Binary files a/tutorials/pictures/wms/win/valid.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/view.png b/tutorials/pictures/wms/win/view.png deleted file mode 100644 index 81145b321..000000000 Binary files a/tutorials/pictures/wms/win/view.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/wms_url.png b/tutorials/pictures/wms/win/wms_url.png deleted file mode 100644 index ac0c6b65d..000000000 Binary files a/tutorials/pictures/wms/win/wms_url.png and /dev/null differ diff --git a/tutorials/pictures/wms/win/zoom.png b/tutorials/pictures/wms/win/zoom.png deleted file mode 100644 index e27228dd5..000000000 Binary files a/tutorials/pictures/wms/win/zoom.png and /dev/null differ diff --git a/tutorials/start_tutorial.sh b/tutorials/start_tutorial.sh index bbd3f5dbd..33b681392 100755 --- a/tutorials/start_tutorial.sh +++ b/tutorials/start_tutorial.sh @@ -9,6 +9,7 @@ ## xvfb-run --server-args="-screen 0 1920x1080x24" ./start_tutorial.sh python ./tutorial_commands.py ## export LC_ALL=C +export MSUI_CONFIG_PATH=/tmp/msui_tutorials # fluxbox & set -e diff --git a/tutorials/tutorial_hexagoncontrol.py b/tutorials/tutorial_hexagoncontrol.py index 3dd5249df..181343134 100644 --- a/tutorials/tutorial_hexagoncontrol.py +++ b/tutorials/tutorial_hexagoncontrol.py @@ -26,10 +26,12 @@ import pyautogui as pag -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, move_window, + select_listelement, find_and_click_picture, zoom_in, type_and_key) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings + +CTRL, ENTER, WIN, ALT = platform_keys() def automate_hexagoncontrol(): @@ -37,44 +39,16 @@ def automate_hexagoncontrol(): This is the main automating script of the MSS hexagon control of table view which will be recorded and saved to a file having dateframe nomenclature with a .mp4 extension(codec). """ - # Giving time for loading of the MSS GUI. pag.sleep(5) - tv_x = None - tv_y = None - - ctrl, enter, win, alt = platform_keys() - - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(3) + msui_full_screen_and_open_first_view() # Changing map to Global - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png')) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - pag.press(enter, interval=1) - pag.sleep(5) - except (ImageNotFoundException, OSError, Exception): - print("\n Exception : Map change dropdown could not be located on the screen") - raise - + find_and_click_picture('topviewwindow-01-europe-cyl.png', + "Map change dropdown could not be located on the screen") + select_listelement(2) # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) - pag.click(x, y, interval=2) - pag.move(379, 205, duration=1) - pag.dragRel(70, 75, duration=2) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise + zoom_in('topviewwindow-zoom.png', 'Zoom button could not be located on the screen', + move=(379, 205), dragRel=(70, 75)) # Opening TableView pag.move(500, 0, duration=1) @@ -82,200 +56,96 @@ def automate_hexagoncontrol(): pag.sleep(1) pag.hotkey('ctrl', 't') pag.sleep(3) + # update images, because tableview was opened + create_tutorial_images() - # Relocating Tableview by performing operations on table view - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) - pag.moveTo(x + 250, y - 462, duration=1) - if platform == 'linux' or platform == 'linux2': - # the window need to be moved a bit below the topview window - pag.dragRel(400, 387, duration=2) - elif platform == 'win32' or platform == 'darwin': - pag.dragRel(200, 487, duration=2) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2': - # this is to select over the window manager the topview and brings it on top - # ToDo the help search function should be used for this (ctrl f) - pag.keyDown('altleft') - pag.press('tab') - pag.keyUp('tab') - pag.press('tab') - pag.keyUp('tab') - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - elif platform == 'darwin': - pag.keyDown('command') - pag.press('tab') - pag.press('right') - pag.keyUp('command') - pag.sleep(1) - if platform == 'win32' or platform == 'darwin': - pag.dragRel(None, -700, duration=2) - tv_x, tv_y = pag.position() - elif platform == 'linux' or platform == 'linux2': - tv_x, tv_y = pag.position() - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : TableView's Select to open Control option not found on the screen.") - raise - - # Opening Hexagon Control dockwidget - if tv_x is not None and tv_y is not None: - pag.moveTo(tv_x - 250, tv_y + 462, duration=2) - pag.click(duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) + # show both open windows arranged on screen and open hexagon control widget + _arrange_open_app_windows() + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + create_tutorial_images() # Entering Centre Latitude and Centre Longitude of Delhi around which hexagon will be drawn - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'center_latitude.png')) - pag.sleep(1) - pag.click(x + 370, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('28.57', interval=0.3) - pag.sleep(1) - pag.press(enter) + find_and_click_picture('tableviewwindow-0-00-degn.png', + '0.00 degN not found', + region=tableview["os_screen_region"]) + type_and_key('28.57') + find_and_click_picture('tableviewwindow-0-00-dege.png', + '0.00 degE not found', + region=tableview["os_screen_region"]) + type_and_key('77.10') + find_and_click_picture('tableviewwindow-add-hexagon.png', + "'Add Hexagon' button not found on the screen.", + region=tableview["os_screen_region"]) - pag.sleep(1) - pag.click(x + 943, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('77.10', interval=0.3) - pag.sleep(1) - pag.press(enter) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Center Latitude\' button not found on the screen.") - raise + # Changing the Radius of the hexagon + find_and_click_picture('tableviewwindow-200-00-km.png', '200 km not found', + region=tableview["os_screen_region"]) + type_and_key('500.00') + find_and_click_picture('tableviewwindow-remove-hexagon.png', + "'Remove Hexagon' button not found on the screen.", + region=tableview["os_screen_region"]) + pag.press(ENTER) + find_and_click_picture('tableviewwindow-add-hexagon.png', + "'Add Hexagon' button not found on the screen.", + region=tableview["os_screen_region"]) - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise + # Changing the angle of first point of the hexagon + find_and_click_picture('tableviewwindow-0-00-deg.png', '0.00 deg not found', + region=tableview["os_screen_region"]) + type_and_key('90.00') - # Changing the Radius of the hexagon - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'radius.png')) - pag.click(x + 400, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('500.00', interval=0.3) - pag.sleep(1) - pag.press(enter) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Radius\' button not found on the screen.") - raise + _remove_hexagon() + _add_hexagon() - # Clicking on the Remove Hexagon Button - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'remove_hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Remove Hexagon\' button not found on the screen.") - raise + create_tutorial_images() + # Changing to a different angle of first point + find_and_click_picture('tableviewwindow-90-00-deg.png', '90.00 deg not found', + region=tableview["os_screen_region"]) + type_and_key('120.00') - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise + _remove_hexagon() + create_tutorial_images() + _add_hexagon() - # Changing the angle of first point of the hexagon - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'radius.png')) - pag.sleep(1) - pag.click(x + 967, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('90.00', interval=0.3) - pag.sleep(1) - pag.press(enter) + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") + finish() - # Clicking on the Remove Hexagon Button - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'remove_hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Remove Hexagon\' button not found on the screen.") - raise - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise +def _add_hexagon(): + # Clicking on the add hexagon button + find_and_click_picture('tableviewwindow-add-hexagon.png', + "'Add Hexagon' button not found on the screen.") - # Changing to a different angle of first point - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'radius.png')) - pag.sleep(1) - pag.click(x + 967, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('120.00', interval=0.3) - pag.sleep(1) - pag.press(enter) - # Clicking on the Remove Hexagon Button - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'remove_hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Remove Hexagon\' button not found on the screen.") - raise +def _remove_hexagon(): + # Clicking on the Remove Hexagon Button + find_and_click_picture('tableviewwindow-remove-hexagon.png', + "'Remove Hexagon' button not found on the screen.") + pag.press(ENTER) - # Clicking on the add hexagon button - try: - x, y = pag.locateCenterOnScreen(picture('hexagoncontrol', 'add_hexagon.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add Hexagon\' button not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Radius (for Angle of first point)\' button not found on the screen.") - raise - pag.moveTo(tv_x, tv_y, duration=2) - pag.click(duration=2) +def _arrange_open_app_windows(): + # Relocating Tableview by performing operations on table view + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + x_drag_rel = 250 + y_drag_rel = 687 + move_window(tableview["os_screen_region"], x_drag_rel, y_drag_rel) + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + find_and_click_picture('tableviewwindow-select-to-open-control.png', + 'select to open control not found', + region=tableview["os_screen_region"]) + select_listelement(1) - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + pag.sleep(1) + create_tutorial_images() + + pag.keyDown('altleft') + pag.press('tab') + pag.keyUp('tab') + pag.press('tab') + pag.keyUp('tab') + pag.keyUp('altleft') + pag.sleep(1) if __name__ == '__main__': diff --git a/tutorials/tutorial_kml.py b/tutorials/tutorial_kml.py index ad4e75dfe..0a3d9103c 100644 --- a/tutorials/tutorial_kml.py +++ b/tutorials/tutorial_kml.py @@ -24,196 +24,83 @@ limitations under the License. """ +import os import pyautogui as pag -import os.path +from tutorials.utils import (start, finish, + change_color, create_tutorial_images, find_and_click_picture, + load_kml_file, select_listelement, type_and_key, msui_full_screen_and_open_first_view + ) +from tutorials.utils.platform_keys import platform_keys -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +CTRL, ENTER, WIN, ALT = platform_keys() def automate_kml(): - """ - This is the main automating script of the MSS remote sensing tutorial which will be recorded and saved - to a file having dateframe nomenclature with a .mp4 extension(codec). - """ - # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() - - # Satellite Predictor file path - path = os.path.normpath(os.getcwd() + os.sep + os.pardir) - kml_file_path1 = os.path.join(path, 'docs/samples/kml/folder.kml') - kml_file_path2 = os.path.join(path, 'docs/samples/kml/color.kml') - - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(3) - - # Opening KML overlay dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.press('down', presses=4, interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") - - # Adding the KML files and loading them - try: - x, y = pag.locateCenterOnScreen(picture('kml', 'add_kml_files.png')) - pag.click(x, y, duration=2) - pag.sleep(1) - pag.typewrite(kml_file_path1, interval=0.1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - pag.click(x, y, duration=2) - pag.sleep(1) - pag.typewrite(kml_file_path2, interval=0.1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add KML Files\' button not found on the screen.") - raise - - # Unselecting and Selecting Files to demonstrate visibility on the map. - try: - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'unselect_all_files.png')) - pag.click(x1, y1, duration=2) - pag.sleep(2) - try: - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'select_all_files.png')) - pag.click(x1, y1, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All Files(Unselecting & Selecting)\' button not found on the screen.") - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All Files(Unselecting & Selecting)\' button not found on the screen.") - raise - - # Selecting and Customizing the Folder.kml file - try: - x, y = pag.locateCenterOnScreen(picture('kml', 'colored_line.png')) - pag.click(x + 100, y, duration=2) - pag.sleep(4) - try: - # Changing color of folder.kml file - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'changecolor.png')) - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2, y2 - 30, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(4) - - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2 + 20, y2 - 50, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color (folder.kml)\' button not found on the screen.") - raise - try: - # Changing Linewidth of folder.kml file - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'changecolor.png')) - pag.click(x1 + 12, y1 + 50, duration=2) - pag.sleep(2) - pag.hotkey(ctrl, 'a') - for _ in range(8): - pag.press('down') - pag.sleep(3) - pag.hotkey(ctrl, 'a') - pag.typewrite('6.50', interval=1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color(folder.kml again)\' button not found on the screen.") - raise - # Selecting and Customizing the color.kml file - pag.click(x + 100, y + 38, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'KML Overlay\' fixed text not found on the screen.") - raise - - # Changing map to Global - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png')) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - pag.press(enter, interval=1) - pag.sleep(5) - except (ImageNotFoundException, OSError, Exception): - print("\n Exception : Map change dropdown could not be located on the screen") - raise - - # select the black line - try: - x, y = pag.locateCenterOnScreen(picture('kml', 'colored_line.png')) - pag.click(x + 100, y, duration=2) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'KML Overlay\' fixed text not found on the screen.") - raise - - try: - # Changing color of color.kml file - x1, y1 = pag.locateCenterOnScreen(picture('kml', 'changecolor.png')) - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2 - 20, y2 - 50, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(3) - - pag.click(x1, y1, duration=2) - pag.sleep(4) - x2, y2 = pag.locateCenterOnScreen(picture('kml', 'pick_screen_color.png')) - pag.click(x2 - 5, y2 - 120, duration=1) - pag.sleep(3) - pag.press(enter) - pag.sleep(4) - - # Changing Linewidth of color.kml file - pag.click(x1 + 12, y1 + 50, duration=2) - pag.sleep(4) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('6.53', interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(3) - - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('3.45', interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Change Color(Color.kml file)\' button not found on the screen.") - raise - + msui_full_screen_and_open_first_view() + _switch_to_europe_map() + _create_and_load_kml_files() + _change_color_and_linewidth() print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish() +def _switch_to_europe_map(): + find_and_click_picture('topviewwindow-01-europe-cyl.png', "Map change dropdown could not be located on the screen.") + select_listelement(2) + pag.sleep(1) + create_tutorial_images() + + +def _create_and_load_kml_files(): + parent_path = os.path.normpath(os.path.join(os.getcwd(), os.pardir)) + kml_folder_path = os.path.join(parent_path, 'docs/samples/kml') + _load_kml_files(kml_folder_path) + pag.sleep(1) + create_tutorial_images() + + +def _load_kml_files(kml_folder_path): + create_tutorial_images() + find_and_click_picture('topviewwindow-select-to-open-control.png', + "'select to open control' button/option not found on the screen.") + select_listelement(4) + create_tutorial_images() + _load_individual_kml_file('folder.kml', kml_folder_path) + _load_individual_kml_file('color.kml', kml_folder_path) + pag.sleep(1) + create_tutorial_images() + + +def _load_individual_kml_file(kml_filename, kml_folder_path): + kml_file_path = os.path.join(kml_folder_path, f'{kml_filename}') + load_kml_file('topviewwindow-add-kml-files.png', kml_file_path, "'Add KML Files' button not found on the screen.") + pag.sleep(1) + create_tutorial_images() + + +def _change_color_and_linewidth(): + find_and_click_picture('topviewwindow-select-all-files.png', + "'Select All Files(Unselecting & Selecting)' button not found on the screen.") + create_tutorial_images() + pag.move(-200, 0, duration=1) + pag.click(interval=2) + _change_color('topviewwindow-change-color.png', + lambda: (pag.move(-220, -300, duration=1), pag.click(interval=2), pag.press(ENTER))) + create_tutorial_images() + _change_linewidth('topviewwindow-2-00.png', lambda: (pag.hotkey(CTRL, 'a'), + [pag.press('down') for _ in range(8)], + type_and_key('2.50'), pag.sleep(1), + type_and_key('5.50'))) + + +def _change_color(img_name, actions): + change_color(img_name, "'Change Color' button not found on the screen.", actions, interval=2) + + +def _change_linewidth(img_name, actions): + change_color(img_name, "'Change Linewidth' button not found on the screen.", actions, interval=2) + + if __name__ == '__main__': start(target=automate_kml, duration=220) diff --git a/tutorials/tutorial_mscolab.py b/tutorials/tutorial_mscolab.py index f482f1280..402138b10 100644 --- a/tutorials/tutorial_mscolab.py +++ b/tutorials/tutorial_mscolab.py @@ -1,6 +1,6 @@ """ msui.tutorials.tutorial_mscolab - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This python script generates an automatic demonstration of how to use Mission Support System Collaboration for users to collaborate in flight planning and thereby explain how to use it's various functionalities. @@ -22,18 +22,30 @@ See the License for the specific language governing permissions and limitations under the License. """ - +import os import pyautogui as pag -import os.path -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, + select_listelement, find_and_click_picture, type_and_key) +from tutorials.utils.platform_keys import platform_keys +from tutorials.utils.picture import picture + +CTRL, ENTER, WIN, ALT = platform_keys() + +USERNAME = 'John Doe' +EMAIL = 'johndoe@gmail.com' +PASSWORD = 'johndoe' +OPERATION_NAME = 'operation_of_john_doe' +OPERATION_DESCRIPTION = """This is John Doe's operation. He wants his collegues and friends \ + to collaborate on this operation with him in the network. Mscolab, here, \ + will be very helpful for Joe with various features to use!""" +PATH = os.path.normpath(os.getcwd() + os.sep + os.pardir) +EXMPLE_IMAGE_PATH = os.path.join(os.path.join(PATH, 'docs', 'mss-logo.png')) +MSCOLAB_URL = 'http://localhost:8083/' -# ToDo fix waypoint movement +# ToDo fix waypoint movement def automate_mscolab(): """ This is the main automating script of the Mission Support System Collaboration or Mscolab tutorial which will be @@ -41,213 +53,243 @@ def automate_mscolab(): """ # Giving time for loading of the MSS GUI. pag.sleep(5) + msui_full_screen_and_open_first_view(view_cmd=None) + # create initial images, needs to become updated when elements on the widget change + create_tutorial_images() + _connect_to_mscolab_url() + create_tutorial_images() + _create_user() + _login_user_after_creation() + create_tutorial_images() + _create_operation() + create_tutorial_images() + open_operations_x, open_operations_y = _activate_operation() + _adminwindow() + _chatting() + wp1_x, wp1_y = _topview_wp() + _versionhistory() + create_tutorial_images() + _work_asynchronously(wp1_x, wp1_y) + _toggle_between_local_and_mscolab(open_operations_x, open_operations_y) + _delete_operation() + create_tutorial_images() + _delete_account() + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") + finish() - ctrl, enter, win, alt = platform_keys() - - # Different inputs required in mscolab - username = 'John Doe' - email = 'johndoe@gmail.com' - password = 'johndoe' - p_name = 'operation_of_john_doe' - p_description = """This is John Doe's operation. He wants his collegues and friends to collaborate on this operation - with him in the network. Mscolab, here, will be very helpful for Joe with various features to use!""" - chat_message1 = 'Hi buddy! What\'s the next plan? I have marked the points in topview for the dummy operation.' \ - 'Just have a look, please!' - chat_message2 = 'Hey there user! This is the chat feature of MSCOLAB. You can have a conversation with your ' \ - 'fellow mates about the operation and discuss ideas and plans.' - search_message = 'chat feature of MSCOLAB' - localhost_url = 'http://localhost:8083' - - # Example upload of msui logo during Chat Window demonstration. - path = os.path.normpath(os.getcwd() + os.sep + os.pardir) - example_image_path = os.path.join(path, 'docs/mss-logo.png') - modify_x, modify_y = None, None - _, sc_height = pag.size()[0] - 1, pag.size()[1] - 1 - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(4) - - # Connecting to Mscolab (Mscolab localhost server must be activated beforehand for this to work) - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'connect_to_mscolab.png')) - pag.sleep(1) - pag.click(x, y, duration=2) - pag.sleep(2) - # Entering local host URL - try: - x1, y1 = pag.locateCenterOnScreen(picture('mscolab', 'connect.png')) - pag.click(x1 - 100, y1, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.typewrite(localhost_url, interval=0.2) - pag.sleep(1) - pag.click(x1, y1, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Connect (to localhost)\' button not found on the screen.") - raise - # Adding a new user - try: - x2, y2 = pag.locateCenterOnScreen(picture('mscolab', 'add_user.png')) - pag.click(x2, y2, duration=2) - pag.sleep(4) - - # Entering details of new user - new_user_input = [username, email, password, password] - for input in new_user_input: - pag.typewrite(input, interval=0.2) - pag.sleep(1) - pag.press('tab') - pag.sleep(2) - pag.press('tab') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) +def _delete_account(): + find_and_click_picture('msuimainwindow-john-doe.png', + 'John Doe (in mscolab window) Profile/Logo button not found.', + xoffset=40) + select_listelement(1) + create_tutorial_images() + find_and_click_picture('profilewindow-delete-account.png', + 'Delete account not found.') + pag.press(ENTER) + + +def _delete_operation(): + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22)) + select_listelement(4, key=None, sleep=1) + pag.press('right') + select_listelement(4, key=None, sleep=1) + pag.press(ENTER) + # Deleting the operation + pag.sleep(2) + type_and_key(OPERATION_NAME, interval=0.3) + pag.press(ENTER) - if pag.locateCenterOnScreen(picture('mscolab', 'emailid_taken.png')) is not None: - print("The email id you have provided is already registered!") - pag.sleep(1) - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - # Entering details of the new user that's created - pag.press('tab', presses=2, interval=1) - pag.typewrite(email, interval=0.2) - pag.press('tab') - pag.typewrite(password, interval=0.2) - - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add user\' button not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Connect to Mscolab\' button not found on the screen.") - raise - - # Opening a new Mscolab Operation - try: - file_x, file_y = pag.locateCenterOnScreen(picture('mscolab', 'file.png')) - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - for _ in range(2): - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - pag.press('tab') - for input in [p_name, p_description]: - pag.typewrite(input, interval=0.05) - pag.press('tab') - pag.sleep(2) +def _toggle_between_local_and_mscolab(open_operations_x, open_operations_y): + # Activating a local flight track + if open_operations_x is not None and open_operations_y is not None: + pag.moveTo(open_operations_x - 900, open_operations_y, duration=2) + pag.sleep(1) + pag.doubleClick(open_operations_x - 900, open_operations_y, duration=2) + pag.sleep(2) + else: + print("Image Not Found : Open Operations label (for activating local flighttrack) not found, previously!") + # Opening Topview again and making some changes in it + find_and_click_picture("msuimainwindow-menubar.png", + 'Views menu not found', + bounding_box=(40, 0, 80, 22)) + select_listelement(1, sleep=1) + create_tutorial_images() + pag.sleep(4) + # Adding waypoints in a different fashion than the pevious one (for local flighttrack) + find_and_click_picture('topviewwindow-ins-wp.png', + 'Add waypoint (in topview again) button not found.') + pag.move(-50, 150, duration=1) + pag.click(interval=2) - try: - x1, y1 = pag.locateCenterOnScreen(picture('mscolab', 'addop_ok.png')) - pag.moveTo(x1, y1, duration=2) - pag.click(x1, y1, duration=2) - pag.sleep(2) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Ok\' button when adding a new operation not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'File\' menu button not found on the screen.") - raise - try: - open_operations_x, open_operations_y = pag.locateCenterOnScreen(picture('mscolab', 'active_operations.png')) + pag.sleep(1) + pag.move(65, 10, duration=1) + pag.click(duration=2) + pag.sleep(1) + pag.move(-100, 10, duration=1) + pag.click(duration=2) + pag.sleep(1) + pag.move(90, 10, duration=1) + pag.click(duration=2) + pag.sleep(3) + # Sending topview to the background + pag.hotkey('CTRL', 'up') + # Activating the opened mscolab operation + if open_operations_x is not None and open_operations_y is not None: pag.moveTo(open_operations_x, open_operations_y + 20, duration=2) pag.sleep(1) pag.doubleClick(open_operations_x, open_operations_y + 20, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Operations\' label not found on the screen.") - raise - - # Managing Users for the operation that you are working on - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right', presses=2, interval=2) - pag.press('down', presses=2, interval=2) - pag.press(enter) pag.sleep(3) - else: - print('Image not Found : File menu not found (while managing users)') - - # Demonstrating search and select of the users present in the network. - try: - selectall_left_x, selectall_left_y = pag.locateCenterOnScreen(picture('mscolab', - 'manageusers_left_selectall.png'), - region=(0, 0, 600, sc_height)) - pag.moveTo(selectall_left_x, selectall_left_y, duration=2) - pag.click(selectall_left_x, selectall_left_y, duration=1) - pag.sleep(2) - pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) - pag.click(selectall_left_x + 90, selectall_left_y, duration=1) - pag.sleep(2) - pag.click(selectall_left_x - 61, selectall_left_y, duration=1) - pag.typewrite('test', interval=1) - pag.moveTo(selectall_left_x, selectall_left_y, duration=2) - pag.click(duration=2) - pag.sleep(1) - pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) - pag.click(duration=2) - pag.sleep(2) + # Opening the topview again by double-clicking on open views + x, y = find_and_click_picture('msuimainwindow-open-views.png', 'open views not found') - # Deleting search item from the search box - pag.click(selectall_left_x - 61, selectall_left_y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') + pag.moveTo(x, y + 22, duration=2) + pag.doubleClick(x, y + 22, duration=2) + pag.sleep(3) + + # Closing the topview + pag.hotkey(ALT, 'f4') + pag.press('left') pag.sleep(1) - pag.press('backspace') + pag.press(ENTER) pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All\' leftside button not found on the screen.") - raise + else: + print("Image Not Found : Open Operations label (for activating mscolab operation) not found, previously!") + +def _work_asynchronously(wp1_x, wp1_y): + find_and_click_picture('msuimainwindow-work-asynchronously.png', + 'Work Asynchronously (in mscolab) ' + 'checkbox not found ', bounding_box=(0, 0, 149, 23)) + work_async_x, work_async_y = pag.position() + pag.sleep(3) + # Opening Topview again to move waypoints during working locally! + find_and_click_picture("msuimainwindow-menubar.png", + 'Views menu not found', + bounding_box=(40, 0, 80, 22)) + select_listelement(1, sleep=1) + find_and_click_picture('msuimainwindow-server-options.png', + 'Server options button not found.') + select_listelement(1) + create_tutorial_images() + find_and_click_picture('mergewaypointsdialog-keep-server-waypoints.png', + 'Merge waypoints keepe server waypoints not found') + pag.press(ENTER) + pag.keyDown('altleft') + # this selects the next window in the window manager on budgie and kde + pag.press('tab') + pag.keyUp('tab') + pag.keyUp('altleft') + # Moving waypoints. + create_tutorial_images() + find_and_click_picture('topviewwindow-mv-wp.png', + 'Move waypoints not found') + wp2_x, wp2_y = find_and_click_picture('topviewwindow-top-view.png', + 'Topviews Point 2 not found on the screen.', + bounding_box=(322, 112, 346, 135)) + pag.click(wp2_x, wp2_y, interval=2) + pag.moveTo(wp2_x, wp2_y, duration=1) + pag.dragTo(wp1_x, wp1_y + 20, duration=1, button='left') + pag.click(interval=2) + find_and_click_picture('topviewwindow-ins-wp.png', + 'Topview Window not found') + pag.move(-50, 150, duration=1) + pag.click(interval=2) + # Closing topview after displacing waypoints + pag.hotkey(ALT, 'f4') + pag.press('left') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) + create_tutorial_images() + find_and_click_picture('msuimainwindow-server-options.png', + 'Overwrite with local waypoints (during saving to server) button not found.') + select_listelement(2) + create_tutorial_images() + find_and_click_picture('mergewaypointsdialog-overwrite-with-local-waypoints.png', + 'Merge waypoints overwrite with local waypoints not found.') + pag.press(ENTER) + create_tutorial_images() + # Unchecking work asynchronously + pag.moveTo(work_async_x, work_async_y, duration=2) + pag.click(work_async_x, work_async_y, duration=2) + + +def _adminwindow(): + # open admin window + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22), duration=1) + select_listelement(4, key=None, sleep=1) + pag.press('right') + select_listelement(2, sleep=1) + pag.sleep(1) + create_tutorial_images() + # positions of buttons in the view mscolab admin windo + pic = picture("mscolabadminwindow-all-users-without-permission.png") + pos = pag.locateOnScreen(pic) + left_side = (pos.left, pos.top, 500, 800) + pic = picture("mscolabadminwindow-all-users-with-permission.png") + pos = pag.locateOnScreen(pic) + right_side = (pos.left, pos.top, 500, 1000) + selectall_left_x, selectall_left_y = find_and_click_picture('mscolabadminwindow-select-all.png', + 'Select All leftside button not found', + region=left_side) + pag.moveTo(selectall_left_x, selectall_left_y, duration=2) + pag.click(selectall_left_x, selectall_left_y, duration=1) + pag.sleep(2) + pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) + pag.click(selectall_left_x + 90, selectall_left_y, duration=1) + pag.sleep(2) + pag.click(selectall_left_x - 61, selectall_left_y, duration=1) + pag.typewrite('test', interval=1) + pag.moveTo(selectall_left_x, selectall_left_y, duration=2) + pag.click(duration=2) + pag.sleep(1) + pag.moveTo(selectall_left_x + 90, selectall_left_y, duration=2) + pag.click(duration=2) + pag.sleep(2) + # Deleting search item from the search box + pag.click(selectall_left_x - 61, selectall_left_y, duration=2) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.press('backspace') + pag.sleep(2) # Selecting and adding users for collaborating in the operation. if selectall_left_x is not None and selectall_left_y is not None: for count in range(4): pag.moveTo(selectall_left_x, selectall_left_y + 57 * count, duration=1) pag.click(selectall_left_x, selectall_left_y + 57 * count, duration=1) + x, y = find_and_click_picture('mscolabadminwindow-add.png', + 'Add (all the users) button not found on the screen.', + region=left_side) - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'manageusers_add.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Add (all the users)\' button not found on the screen.") - raise + pag.moveTo(x, y, duration=2) + pag.click(x, y, duration=2) + pag.sleep(1) else: print('Not able to select users for adding') - # Searching and changing user permissions and deleting users - try: - selectall_right_x, selectall_right_y = pag.locateCenterOnScreen(picture('mscolab', - 'manageusers_right_selectall.png'), - region=(600, 0, 1200, sc_height)) - pag.moveTo(selectall_right_x - 170, selectall_right_y, duration=2) - pag.click(selectall_right_x - 170, selectall_right_y, duration=2) - pag.typewrite('t', interval=0.3) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.press('backspace') - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select All (modifying permissions)\' button not found on the screen.") - raise - + selectall_right_x, selectall_right_y = find_and_click_picture('mscolabadminwindow-select-all.png', + 'Select All (modifying permissions) ' + 'button not found on the screen.', + region=right_side) + find_and_click_picture('mscolabadminwindow-deselect-all.png', + 'Select All (modifying permissions) ' + 'button not found on the screen.', + region=right_side) + pag.moveTo(selectall_right_x - 170, selectall_right_y, duration=2) + pag.click(selectall_right_x - 170, selectall_right_y, duration=2) + pag.typewrite('t', interval=0.3) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.press('backspace') + pag.sleep(1) # Selecting and modifying user roles if selectall_right_x is not None and selectall_right_y is not None: for i in range(3): @@ -255,21 +297,20 @@ def automate_mscolab(): # pag.move(selectall_right_x, row_gap * (i + 1), duration=1) pag.click(duration=1) pag.sleep(2) - try: - modify_x, modify_y = pag.locateCenterOnScreen(picture('mscolab', 'manageusers_modify.png')) - pag.click(modify_x - 141, modify_y, duration=2) - if i == 0: - pag.press('up', presses=2) - else: - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(1) - pag.click(modify_x, modify_y, duration=2) + modify_x, modify_y = find_and_click_picture('mscolabadminwindow-modify.png', + 'Modify (access permissions) ' + 'button not found on the screen.)', + region=right_side) + pag.click(modify_x - 141, modify_y, duration=2) + if i == 0: + pag.press('up', presses=2) + else: + pag.press('down') pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Modify (access permissions)\' button not found on the screen.") - raise + pag.press(ENTER) + pag.sleep(1) + pag.click(modify_x, modify_y, duration=2) + pag.sleep(1) # Deleting the first user in the list pag.moveTo(selectall_right_x, selectall_right_y + 56, duration=1) @@ -289,367 +330,224 @@ def automate_mscolab(): pag.click(selectall_right_x - 82, selectall_right_y, duration=2) pag.press('down') pag.sleep(1) - pag.press(enter) + pag.press(ENTER) pag.sleep(1) pag.sleep(1) else: print('Image Not Found: Select All button has previously not found on the screen') - # Closing user permission window - pag.hotkey('command', 'w') if platform == 'dawrin' else pag.hotkey(alt, 'f4') + pag.hotkey(ALT, 'f4') pag.sleep(2) - # Demonstrating Chat feature of mscolab to the user - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right', presses=2, interval=2) - pag.press(enter) - pag.sleep(3) - else: - print('Image not Found : File menu not found (while opening Chat window)') - # Sending messages to collaboraters or other users - pag.typewrite(chat_message1, interval=0.05) +def _versionhistory(): + # Opening version history window. + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22)) + select_listelement(2, sleep=1) + create_tutorial_images() + # Operations performed in version history window. + x, y = find_and_click_picture('mscolabversionhistory-refresh-window.png', + 'Refresh Window (in version history window) button not found.') + pag.moveTo(x, y, duration=2) + pag.click(x, y, duration=2) pag.sleep(2) - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'chat_send.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(2) - - pag.typewrite(chat_message2, interval=0.05) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - # Uploading an example image of msui logo. - pag.moveTo(x, y + 40, duration=2) - pag.click(x, y + 40, duration=2) - pag.sleep(1) - pag.typewrite(example_image_path, interval=0.2) - pag.sleep(1) - pag.press(enter) - pag.sleep(1) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Send (while in chat window)\' button not found on the screen.") - raise - # Searching messages in the chatbox using the search bar - try: - previous_x, previous_y = pag.locateCenterOnScreen(picture('mscolab', 'chat_previous.png')) - pag.moveTo(previous_x - 70, previous_y, duration=2) - pag.click(previous_x - 70, previous_y, duration=2) - pag.sleep(1) - pag.typewrite(search_message, interval=0.3) - pag.sleep(1) - pag.moveTo(previous_x + 82, previous_y, duration=2) - pag.click(previous_x + 82, previous_y, duration=2) - pag.sleep(2) - pag.moveTo(previous_x, previous_y, duration=2) - pag.click(previous_x, previous_y, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Previous (while in chat window searching operation)\' button not found on the screen.") - raise - # Closing the Chat Window - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') + pag.click(x, y + 32, duration=2) + pag.sleep(1) + pag.press('down') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) + pag.moveTo(x, y + 164, duration=1) + pag.click(x, y + 164, duration=1) + pag.sleep(4) + # Changing this change to a named version + # Giving name to a change version. + x1, y1 = pag.locateCenterOnScreen(picture('mscolabversionhistory-name-version.png')) + pag.sleep(1) + pag.moveTo(x1, y1, duration=2) + pag.click(x1, y1, duration=2) + pag.sleep(1) + pag.typewrite('Initial waypoint', interval=0.3) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + pag.moveTo(x, y + 93, duration=1) + pag.click(x, y + 93, duration=1) pag.sleep(2) + pag.moveTo(x, y + 125, duration=1) + pag.click(x, y + 125, duration=1) + pag.sleep(1) + x2, y2 = pag.locateCenterOnScreen(picture('mscolabversionhistory-checkout.png')) + pag.sleep(1) + pag.moveTo(x2, y2, duration=2) + pag.click(x2, y2, duration=2) + pag.sleep(1) + pag.press(ENTER) + # Filtering changes to display only named changes. + pag.moveTo(x1 + 29, y1, duration=1) + pag.click(x1 + 29, y1, duration=1) + pag.sleep(1) + pag.press('up') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(3) + # Closing the Version History Window + pag.hotkey(ALT, 'f4') + pag.sleep(4) - # Opening Topview - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.sleep(1) - pag.press('right') - pag.sleep(1) - pag.press(enter) - pag.sleep(4) +def _topview_wp(): + # Opening Topview + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(40, 0, 80, 22)) + select_listelement(1, sleep=1) + create_tutorial_images() # Adding some waypoints to topview - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - wp1_x, wp1_y = pag.position() - pag.sleep(1) - pag.move(65, 65, duration=1) - pag.click(duration=2) - wp2_x, wp2_y = pag.position() - pag.sleep(1) - - pag.move(-150, 30, duration=1) - pag.click(duration=2) - pag.sleep(1) - pag.move(180, 100, duration=1) - pag.click(duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint (in topview) button not found on the screen.") - raise - + find_and_click_picture('topviewwindow-ins-wp.png', + 'Topview insert wp button not found') + pag.move(-50, 150, duration=1) + pag.click(interval=2) + wp1_x, wp1_y = pag.position() + pag.sleep(1) + pag.move(65, 65, duration=1) + pag.click(duration=2) + # wp2_x, wp2_y = pag.position() + pag.sleep(1) + pag.move(-150, 30, duration=1) + pag.click(duration=2) + pag.sleep(1) + pag.move(180, 100, duration=1) + pag.click(duration=2) + pag.sleep(3) # Closing the topview - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') + pag.hotkey(ALT, 'f4') pag.press('left') pag.sleep(1) - pag.press(enter) + pag.press(ENTER) pag.sleep(1) + return wp1_x, wp1_y - # Opening version history window. - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right', presses=2, interval=1) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(1) - - # Operations performed in version history window. - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'refresh_window.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.click(x, y + 32, duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - pag.moveTo(x, y + 164, duration=1) - pag.click(x, y + 164, duration=1) - pag.sleep(4) - # Changing this change to a named version - try: - # Giving name to a change version. - x1, y1 = pag.locateCenterOnScreen(picture('mscolab', 'name_version.png')) - pag.sleep(1) - pag.moveTo(x1, y1, duration=2) - pag.click(x1, y1, duration=2) - pag.sleep(1) - pag.typewrite('Initial waypoint', interval=0.3) - pag.sleep(1) - pag.press(enter) - pag.sleep(1) - pag.moveTo(x, y + 93, duration=1) - pag.click(x, y + 93, duration=1) - pag.sleep(2) - - pag.moveTo(x, y + 125, duration=1) - pag.click(x, y + 125, duration=1) - pag.sleep(1) - - # Checking out to a particular version - pag.moveTo(x1 + 95, y1, duration=2) - pag.click(x1 + 95, y1, duration=1) - pag.sleep(1) - pag.press('left') - pag.sleep(2) - pag.press(enter) - pag.sleep(2) - - # Filtering changes to display only named changes. - pag.moveTo(x1 + 29, y1, duration=1) - pag.click(x1 + 29, y1, duration=1) - pag.sleep(1) - pag.press('up') - pag.sleep(1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Name Version (in topview) button not found on the screen.") - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException : Refresh Window (in version history window) button not found on the screen.") - raise - # Closing the Version History Window - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') - pag.sleep(4) - - # Activate Work Asynchronously with the mscolab server. - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'work_asynchronously.png')) - pag.sleep(1) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - work_async_x, work_async_y = pag.position() - pag.sleep(3) - # Opening Topview again to move waypoints during working locally! - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.press('right') - pag.sleep(1) - pag.press(enter) - pag.sleep(4) - - # Moving waypoints. - try: - if wp1_x is not None and wp2_x is not None: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png')) - pag.click(x, y, interval=2) - try: - wp2_x, wp2_y = pag.locateCenterOnScreen(picture('mscolab', 'topview_point2.png')) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Point 2\' not found on the screen.") - raise - pag.click(wp2_x, wp2_y, interval=2) - pag.moveTo(wp2_x, wp2_y, duration=1) - pag.dragTo(wp1_x, wp1_y, duration=1, button='left') - pag.click(interval=2) - - except (ImageNotFoundException, OSError, Exception): - print("\n Exception : Move Waypoint button could not be located on the screen") - raise - # Closing topview after displacing waypoints - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - - # Saving to Server the Work that has been done asynchronously. - if work_async_x is not None and work_async_y is not None: - pag.moveTo(work_async_x + 600, work_async_y, duration=2) - pag.click(work_async_x + 600, work_async_y, duration=2) - pag.press('down', presses=2, interval=1) - pag.press(enter) - pag.sleep(3) - - # Overwriting Server waypoints with Local Waypoints. - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'overwrite_waypoints.png')) - pag.sleep(1) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.sleep(2) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Overwrite with local waypoints (during saving to server) button" - " not found on the screen.") - raise - - # Unchecking work asynchronously - pag.moveTo(work_async_x, work_async_y, duration=2) - pag.click(work_async_x, work_async_y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Work Asynchronously (in mscolab) checkbox not found on the screen.") - raise - - # Activating a local flight track - if open_operations_x is not None and open_operations_y is not None: - pag.moveTo(open_operations_x - 900, open_operations_y, duration=2) - pag.sleep(1) - pag.doubleClick(open_operations_x - 900, open_operations_y, duration=2) - pag.sleep(2) - else: - print("Image Not Found : Open Operations label (for activating local flighttrack) not found, previously!") - - # Opening Topview again and making some changes in it - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=2) - pag.sleep(1) - pag.press('right') - pag.sleep(1) - pag.press(enter) - pag.sleep(4) - # Adding waypoints in a different fashion than the pevious one (for local flighttrack) - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, duration=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(65, 10, duration=1) - pag.click(duration=2) - pag.sleep(1) - - pag.move(-100, 10, duration=1) - pag.click(duration=2) - pag.sleep(1) - pag.move(90, 10, duration=1) - pag.click(duration=2) - pag.sleep(3) +def _chatting(): + # Demonstrating Chat feature of mscolab to the user + find_and_click_picture("msuimainwindow-menubar.png", + 'Operation menu not found', + bounding_box=(89, 0, 150, 22)) + select_listelement(1, sleep=1) + pag.sleep(3) + create_tutorial_images() + chat_message1 = 'Hi buddy! What\'s the next plan? I have marked the points in topview for the dummy operation.' + chat_message2 = 'Hey there user! This is the chat feature of MSCOLAB. You can have a conversation with your ' + # Sending messages to collaboraters or other users + pag.typewrite(chat_message1, interval=0.05) + pag.sleep(2) + x, y = find_and_click_picture('mscolaboperation-send.png', + 'Send (while in chat window) button not found on the screen.') + pag.typewrite(chat_message2, interval=0.05) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) + # Uploading an example image of msui logo. + pag.moveTo(x, y + 40, duration=2) + pag.click(x, y + 40, duration=2) + pag.sleep(1) + pag.typewrite(EXMPLE_IMAGE_PATH, interval=0.2) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + pag.moveTo(x, y, duration=2) + pag.click(x, y, duration=2) + pag.sleep(2) + # Searching messages in the chatbox using the search bar + previous_x, previous_y = find_and_click_picture('mscolaboperation-previous.png', + 'Previous (while in chat window searching' + ' operation) button not found.') + pag.moveTo(previous_x - 70, previous_y, duration=2) + pag.click(previous_x - 70, previous_y, duration=2) + pag.sleep(1) + search_message = 'chat feature of MSCOLAB' + pag.typewrite(search_message, interval=0.3) + pag.sleep(1) + pag.moveTo(previous_x + 82, previous_y, duration=2) + pag.click(previous_x + 82, previous_y, duration=2) + pag.sleep(2) + pag.moveTo(previous_x, previous_y, duration=2) + pag.click(previous_x, previous_y, duration=2) + pag.sleep(2) + # Closing the Chat Window + pag.hotkey(ALT, 'f4') + pag.sleep(2) - # Sending topview to the background - pag.hotkey('ctrl', 'up') - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint (in topview again) button not found on the screen.") - raise - # Activating the opened mscolab operation - if open_operations_x is not None and open_operations_y is not None: - pag.moveTo(open_operations_x, open_operations_y + 20, duration=2) - pag.sleep(1) - pag.doubleClick(open_operations_x, open_operations_y + 20, duration=2) - pag.sleep(3) +def _activate_operation(): + find_and_click_picture('msuimainwindow-operations.png', + 'Operations label not found on screen', + bounding_box=(0, 0, 72, 17)) + open_operations_x, open_operations_y = pag.position() + pag.moveTo(open_operations_x, open_operations_y + 20, duration=2) + pag.sleep(1) + pag.doubleClick(open_operations_x, open_operations_y + 20, duration=2) + pag.sleep(2) + return open_operations_x, open_operations_y - # Opening the topview again by double clicking on open views - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'openviews.png')) - pag.moveTo(x, y + 22, duration=2) - pag.doubleClick(x, y + 22, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Open Views label not found on the screen.") - # Closing the topview - pag.hotkey('command', 'w') if platform == 'darwin' else pag.hotkey(alt, 'f4') - pag.press('left') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - else: - print("Image Not Found : Open Operations label (for activating mscolab operation) not found, previously!") +def _create_operation(): + find_and_click_picture("msuimainwindow-menubar.png", + 'File menu not found', + bounding_box=(0, 0, 38, 22)) + select_listelement(1, key=None, sleep=1) + pag.press('right') + select_listelement(1, sleep=1) + pag.sleep(1) + pag.press('tab') + for value in [OPERATION_NAME, OPERATION_DESCRIPTION]: + type_and_key(value, key='tab', interval=0.05) + pag.sleep(1) + create_tutorial_images() + find_and_click_picture('addoperationdialog-ok.png', + 'OK button when adding a new operation not found on the screen.') + pag.press(ENTER) + + +def _login_user_after_creation(): + # Login new user + pag.press('tab', presses=2) + type_and_key(ENTER, key='tab') + type_and_key(PASSWORD, key='tab') + pag.press(ENTER) + # store userdata + pag.press('left') + pag.press(ENTER) - # Deleting the operation - if file_x is not None and file_y is not None: - pag.moveTo(file_x, file_y, duration=2) - pag.click(file_x, file_y, duration=1) - pag.sleep(1) - pag.press('right', presses=2, interval=1) - pag.sleep(1) - pag.press('down', presses=3, interval=1) - pag.press(enter, presses=2, interval=2) - pag.sleep(2) - pag.typewrite(p_name, interval=0.3) - pag.press(enter, presses=2, interval=2) - pag.sleep(3) - # Opening user profile - try: - x, y = pag.locateCenterOnScreen(picture('mscolab', 'johndoe_profile.png')) - pag.moveTo(x + 32, y, duration=2) - pag.click(x + 32, y, duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter, presses=2, interval=2) - pag.sleep(2) +def _create_user(): + find_and_click_picture('mscolabconnectdialog-add-user.png', 'Add User Button not found') + pag.sleep(4) + # Entering details of new user + new_user_input = [USERNAME, EMAIL, PASSWORD, PASSWORD] + for value in new_user_input: + type_and_key(value, key='tab') + pag.press('tab') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(2) - pag.click(x + 32, y, duration=2) - pag.sleep(1) - pag.press('down', presses=2, interval=2) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : John Doe (in mscolab window) Profile/Logo button not found on the screen.") - raise - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() +def _connect_to_mscolab_url(): + # connect + find_and_click_picture('msuimainwindow-connect.png', + "Connect to Mscolab button not found on the screen.") + create_tutorial_images() + # create user on server + find_and_click_picture('mscolabconnectdialog-http-localhost-8083.png', 'Url not found') + type_and_key(MSCOLAB_URL) + pag.press(ENTER) + # update server data + pag.press('left') + pag.press(ENTER) if __name__ == '__main__': diff --git a/tutorials/tutorial_performancesettings.py b/tutorials/tutorial_performancesettings.py index 6000b8ead..ec87619a6 100644 --- a/tutorials/tutorial_performancesettings.py +++ b/tutorials/tutorial_performancesettings.py @@ -22,16 +22,16 @@ See the License for the specific language governing permissions and limitations under the License. """ - -import pyautogui as pag import os.path -import tempfile +import pyautogui as pag import shutil +import tempfile -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, + create_tutorial_images, select_listelement, find_and_click_picture, type_and_key) +from tutorials.utils.platform_keys import platform_keys + +CTRL, ENTER, WIN, ALT = platform_keys() def automate_performance(): @@ -42,132 +42,73 @@ def automate_performance(): # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() - - # Satellite Predictor file path + # Performance file path path = os.path.normpath(os.getcwd() + os.sep + os.pardir) ps_file_path = os.path.join(path, 'docs/samples/config/msui/performance_simple.json.sample') dirpath = tempfile.mkdtemp() sample = os.path.join(dirpath, 'example.json') shutil.copy(ps_file_path, sample) - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) - pag.hotkey('ctrl', 't') - pag.sleep(3) - + msui_full_screen_and_open_first_view(view_cmd='t') # Opening Performance Settings dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'selecttoopencontrol.png')) - pag.moveTo(x + 250, y - 462, duration=1) - if platform == 'linux' or platform == 'linux2': - # the window need to be moved a bit below the topview window - pag.dragRel(400, 387, duration=2) - elif platform == 'win32' or platform == 'darwin': - pag.dragRel(200, 487, duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") - raise - - tv_x, tv_y = pag.position() - # Opening Hexagon Control dockwidget - if tv_x is not None and tv_y is not None: - pag.moveTo(tv_x - 250, tv_y + 462, duration=2) - pag.click(duration=2) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) + find_and_click_picture('tableviewwindow-select-to-open-control.png', + 'Select to open control not found') + select_listelement(2) + pag.press(ENTER) + x, y = pag.position() + + pag.moveTo(x + 250, y - 462, duration=1) + pag.dragRel(400, 387, duration=2) + pag.sleep(1) + + # updating tutorial images + create_tutorial_images() # Exploring through the file system and loading the performance settings json file for a dummy aircraft. - try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'select.png')) - pag.click(x, y, duration=2) - pag.sleep(1) - pag.typewrite(sample, interval=0.1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Select\' button (for loading performance_settings.json file) not found on the screen.") - raise + find_and_click_picture('tableviewwindow-select.png', 'Select button not found') + type_and_key(sample) + # Checking the Show Performance checkbox to display the settings file in the table view - try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'show_performance.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Show Performance\' checkbox not found on the screen.") - raise + find_and_click_picture('tableviewwindow-show-performance.png', + 'Show performance button not found', + bounding_box=(0, 0, 140, 23)) # Changing the maximum take off weight - try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'maximum_takeoff_weight.png')) - pag.click(x + 318, y, duration=2) - pag.sleep(4) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('87000', interval=0.3) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Maximum Takeoff Weight\' fill box not found on the screen.") - raise + find_and_click_picture('tableviewwindow-maximum-take-off-weight-lb.png', + 'Max take off weight lb not found') + x, y = pag.position() + pag.click(x + 318, y, duration=2) + type_and_key('87000') + pag.sleep(2) + # Changing the aircraft weight of the dummy aircraft - try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'aircraft_weight.png')) - pag.click(x + 300, y, duration=2) - pag.sleep(4) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('48000', interval=0.3) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Aircraft weight\' fill box not found on the screen.") - raise + find_and_click_picture('tableviewwindow-aircraft-weight-no-fuel-lb.png', + 'Aircraft weight no fuel not found') + x, y = pag.position() + pag.click(x + 300, y, duration=2) + type_and_key('48000') # Changing the take off time of the dummy aircraft - try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'take_off_time.png')) - pag.click(x + 410, y, duration=2) - pag.sleep(4) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - for _ in range(5): - pag.press('up') - pag.sleep(2) - pag.typewrite('04', interval=0.5) - pag.press(enter) + find_and_click_picture('tableviewwindow-take-off-time.png', + 'take off time not found') + x, y = pag.position() + pag.click(x + 410, y, duration=2) + type_and_key('') + for _ in range(5): + pag.press('up') pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Take off time\' fill box not found on the screen.") - raise + type_and_key('04', interval=0.5) + + # update tutorial images + create_tutorial_images() # Showing and hiding the performance settings - try: - x, y = pag.locateCenterOnScreen(picture('performancesettings', 'show_performance.png')) - pag.click(x, y, duration=2) - pag.sleep(3) - - pag.click(x, y, duration=2) - pag.sleep(3) - - pag.click(x, y, duration=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Show Performance\' checkbox not found on the screen.") - raise + for _ in range(3): + find_and_click_picture('tableviewwindow-show-performance.png', + 'show performance button not found', + bounding_box=(0, 0, 140, 23)) + # update tutorial images + create_tutorial_images() print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish() diff --git a/tutorials/tutorial_remotesensing.py b/tutorials/tutorial_remotesensing.py index 4faae905d..e964bb528 100644 --- a/tutorials/tutorial_remotesensing.py +++ b/tutorials/tutorial_remotesensing.py @@ -21,13 +21,15 @@ See the License for the specific language governing permissions and limitations under the License. """ - import pyautogui as pag -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, + create_tutorial_images, select_listelement, find_and_click_picture, zoom_in, + add_waypoints_to_topview) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings + +CTRL, ENTER, WIN, ALT = platform_keys() def automate_rs(): @@ -36,176 +38,148 @@ def automate_rs(): to a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. - pag.sleep(10) + pag.sleep(5) - ctrl, enter, win, alt = platform_keys() + msui_full_screen_and_open_first_view() - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(3) + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + os_screen_region = topview["os_screen_region"] - # Opening Remote Sensing dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.press('down', presses=3, interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") - raise - - # Adding waypoints for demonstrating remote sensing - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) - pag.click(x, y, interval=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(65, 65, duration=1) - pag.click(interval=2) - pag.sleep(1) + _open_remote_sensing_widget(os_screen_region) + add_waypoints_to_topview(os_screen_region) + _show_solar_angle(os_screen_region) - pag.move(-150, 30, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(200, 150, duration=1) - pag.click(interval=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint button in topview not found on the screen.") - raise + azimuth_x, azimuth_y = _change_azimuth_angle(os_screen_region) + _change_elevation_angle(os_screen_region) - # Showing Solar Angle Colors - try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'showangle.png')) - pag.sleep(1) - pag.click(x, y, duration=2) - pag.sleep(1) + x, y = _draw_tangents_to_the_waypoints(os_screen_region) - for _ in range(2): - pag.click(x + 100, y, duration=1) - pag.press('down', interval=1) - pag.sleep(1) - pag.press(enter, interval=1) - pag.sleep(2) + _change_tangent_distance(x, y) + _rotate_the_tangent_by_different_angels(azimuth_x, azimuth_y, y, os_screen_region) - for _ in range(3): - pag.click(x + 200, y, duration=1) - pag.press('down', interval=1) - pag.sleep(1) - pag.press(enter, interval=1) - pag.sleep(2) + print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - pag.click(x + 200, y, duration=1) - pag.press('up', presses=3, interval=1) - pag.sleep(1) - pag.press(enter, interval=1) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Show angle\' checkbox not found on the screen.") - raise + finish() - # Changing azimuth angles - try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'azimuth.png')) - pag.click(x + 70, y, duration=1) - azimuth_x, azimuth_y = pag.position() - pag.sleep(2) - pag.hotkey(ctrl, 'a') - pag.sleep(2) - pag.typewrite('45', interval=1) - pag.press(enter) - pag.sleep(3) - pag.click(duration=1) - pag.hotkey(ctrl, 'a') - pag.typewrite('90', interval=1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Azimuth\' spinbox not found on the screen.") - raise - # Changing elevation angles - try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'elevation.png')) - pag.click(x + 70, y, duration=1) - pag.sleep(2) - pag.hotkey(ctrl, 'a') +def _open_remote_sensing_widget(os_screen_region): + # Opening Remote Sensing dockwidget + find_and_click_picture('topviewwindow-select-to-open-control.png', + 'topview window selection of docking widgets not found', + region=os_screen_region) + select_listelement(3) + pag.press(ENTER) + create_tutorial_images() + + +def _rotate_the_tangent_by_different_angels(azimuth_x, azimuth_y, y, os_screen_region): + zoom_in('topviewwindow-zoom.png', "Zoom Button not found", + move=(0, 150), dragRel=(230, 150), region=os_screen_region) + # Rotating the tangent through various angles + pag.click(azimuth_x, azimuth_y, duration=1) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.typewrite('120', interval=0.5) + pag.sleep(2) + for _ in range(10): + pag.press('down') pag.sleep(2) - pag.typewrite('-1', interval=1) - pag.press(enter) - pag.sleep(3) - pag.click(duration=1) - pag.hotkey(ctrl, 'a') - pag.typewrite('-3', interval=1) - pag.press(enter) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Elevation\' spinbox not found on the screen.") - raise + pag.sleep(1) + pag.click(azimuth_x + 500, y, duration=1) + pag.sleep(1) + +def _change_tangent_distance(x, y): + # Changing Kilometers of the tangent distance + pag.click(x + 250, y, duration=1) + pag.sleep(1) + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.typewrite('20', interval=1) + pag.press(ENTER) + pag.sleep(1) + + +def _draw_tangents_to_the_waypoints(os_screen_region): # Drawing tangents to the waypoints and path - try: - x, y = pag.locateCenterOnScreen(picture('remotesensing', 'drawtangent.png')) - pag.click(x, y, duration=1) - pag.sleep(2) - # Changing color of tangents - pag.click(x + 160, y, duration=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(1) + find_and_click_picture('topviewwindow-draw-tangent-points.png', + 'Draw tangent points not found', + region=os_screen_region) + x, y = pag.position() + # Changing color of tangents + pag.click(x + 160, y, duration=1) + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + return x, y + + +def _change_elevation_angle(os_screen_region): + # Changing elevation angles + find_and_click_picture('topviewwindow-elevation.png', + 'elevation not found', + region=os_screen_region) + x, y = pag.position() + pag.click(x + 70, y, duration=1) + pag.sleep(2) + pag.hotkey(CTRL, 'a') + pag.sleep(2) + pag.typewrite('-1', interval=1) + pag.press(ENTER) + pag.sleep(1) + pag.click(duration=1) + pag.hotkey(CTRL, 'a') + pag.typewrite('-3', interval=1) + pag.press(ENTER) + pag.sleep(1) + - # Changing Kilometers of the tangent distance - pag.click(x + 250, y, duration=1) +def _change_azimuth_angle(os_screen_region): + # Changing azimuth angles + find_and_click_picture('topviewwindow-viewing-direction-azimuth.png', + 'Viewing direction azimuth not found', + region=os_screen_region) + x, y = pag.position() + pag.click(x + 90, y, duration=1) + pag.move(100, 100) + azimuth_x, azimuth_y = pag.position() + pag.sleep(2) + pag.hotkey(CTRL, 'a') + pag.sleep(2) + pag.typewrite('45', interval=1) + pag.press(ENTER) + pag.sleep(1) + pag.click(duration=1) + pag.hotkey(CTRL, 'a') + pag.typewrite('90', interval=1) + pag.press(ENTER) + pag.sleep(1) + return azimuth_x, azimuth_y + + +def _show_solar_angle(os_screen_region): + # Showing Solar Angle Colors + x, y = find_and_click_picture('topviewwindow-show-angle-degree.png', + 'Show angle in degrees not found', + region=os_screen_region) + for _ in range(2): + pag.click(x + 100, y, duration=1) + pag.press('down', interval=1) pag.sleep(1) - pag.hotkey(ctrl, 'a') + pag.press(ENTER, interval=1) + pag.sleep(2) + for _ in range(3): + pag.click(x + 200, y, duration=1) + pag.press('down', interval=1) pag.sleep(1) - pag.typewrite('20', interval=1) - pag.press(enter) - pag.sleep(3) - - # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) - pag.click(x, y, interval=2) - pag.move(0, 150, duration=1) - pag.dragRel(230, 150, duration=2) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise - - # Rotating the tangent through various angles - try: - pag.click(azimuth_x, azimuth_y, duration=1) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite('120', interval=0.5) - pag.sleep(2) - for _ in range(10): - pag.press('down') - pag.sleep(2) - pag.sleep(1) - pag.click(azimuth_x + 500, y, duration=1) - pag.sleep(1) - except UnboundLocalError: - print('Azimuth spinbox coordinates are not stored. Hence cannot change values.') - raise - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Tangent\' checkbox not found on the screen.") - raise - - print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + pag.press(ENTER, interval=1) + pag.sleep(2) + pag.click(x + 200, y, duration=1) + pag.press('up', presses=3, interval=1) + pag.sleep(1) + pag.press(ENTER, interval=1) + pag.sleep(2) if __name__ == '__main__': diff --git a/tutorials/tutorial_satellitetrack.py b/tutorials/tutorial_satellitetrack.py index 4b6b72991..4aa965e2d 100644 --- a/tutorials/tutorial_satellitetrack.py +++ b/tutorials/tutorial_satellitetrack.py @@ -21,14 +21,16 @@ See the License for the specific language governing permissions and limitations under the License. """ - -import pyautogui as pag import os.path +import pyautogui as pag +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, + create_tutorial_images, select_listelement, find_and_click_picture, type_and_key, zoom_in) +from tutorials.utils.platform_keys import platform_keys + -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +CTRL, ENTER, WIN, ALT = platform_keys() +PATH = os.path.normpath(os.getcwd() + os.sep + os.pardir) +SATELLITE_PATH = os.path.join(PATH, 'docs/samples/satellite_tracks/satellite_predictor.txt') def automate_rs(): @@ -38,106 +40,71 @@ def automate_rs(): """ # Giving time for loading of the MSS GUI. pag.sleep(5) - - ctrl, enter, win, alt = platform_keys() - # Satellite Predictor file path - path = os.path.normpath(os.getcwd() + os.sep + os.pardir) - satellite_path = os.path.join(path, 'docs/samples/satellite_tracks/satellite_predictor.txt') - - # Maximizing the window - try: - pag.hotkey('ctrl', 'command', 'f') if platform == 'darwin' else pag.hotkey(win, 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") + msui_full_screen_and_open_first_view() pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(3) + create_tutorial_images() - # Opening Satellite Track dockwidget - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.press('down', presses=2, interval=1) - pag.sleep(1) - pag.press(enter) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'select to open control\' button/option not found on the screen.") - raise - - # Loading the file: - try: - x, y = pag.locateCenterOnScreen(picture('satellitetrack', 'load.png')) - pag.sleep(1) - pag.click(x - 150, y, duration=2) - pag.sleep(1) - pag.hotkey(ctrl, 'a') - pag.sleep(1) - pag.typewrite(satellite_path, interval=0.1) - pag.sleep(1) - pag.click(x, y, duration=1) - pag.press(enter) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Load\' button not found on the screen.") - raise + find_and_click_picture('topviewwindow-select-to-open-control.png', + 'topview window selection of docking widgets not found') + select_listelement(2) + pag.press(ENTER) + pag.sleep(2) + + # Changing map to Global + find_and_click_picture('topviewwindow-01-europe-cyl.png', + "Map change dropdown could not be located on the screen") + select_listelement(2) + + # update images + create_tutorial_images() + + # Todo find and use QLineEdit leFile instead of Load button + # Loading the file + find_and_click_picture('topviewwindow-load.png', 'Load button not found', xoffset=-150) + type_and_key(SATELLITE_PATH, interval=0.1) + find_and_click_picture('topviewwindow-load.png', 'Load button not found') # Switching between different date and time of satellite overpass. - try: - x, y = pag.locateCenterOnScreen(picture('satellitetrack', 'predicted_satellite_overpasses.png')) - pag.click(x + 200, y, duration=1) - for _ in range(10): - pag.click(x + 200, y, duration=1) - pag.sleep(1) - pag.press('down') - pag.sleep(1) - pag.press(enter) - pag.sleep(2) + find_and_click_picture('topviewwindow-predicted-satellite-overpasses.png', + 'Predicted satellite button not found', xoffset=200) + x, y = pag.position() + + pag.click(x + 200, y, duration=1) + for _ in range(10): pag.click(x + 200, y, duration=1) - pag.press('up', presses=3, interval=1) - pag.press(enter) pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Predicted Satellite Overpass\' dropdown menu not found on the screen.") - raise - - # Changing map to global - try: - if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png')) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - pag.press(enter, interval=1) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Map change dropdown could not be located on the screen") - raise - - # Adding waypoints for demonstrating remote sensing - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) - pag.click(x, y, interval=2) - pag.move(111, 153, duration=2) - pag.click(duration=2) - pag.move(36, 82, duration=2) - pag.click(duration=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Add waypoint button in topview not found on the screen.") - raise + pag.press('down') + pag.sleep(1) + pag.press(ENTER) + pag.sleep(1) + pag.click(x + 200, y, duration=1) + pag.press('up', presses=3, interval=1) + pag.press(ENTER) + pag.sleep(1) - # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) - pag.click(x, y, interval=2) - pag.move(260, 130, duration=1) - pag.dragRel(184, 135, duration=2) - pag.sleep(5) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise + # update images + create_tutorial_images() + + # enable adding waypoints + find_and_click_picture('topviewwindow-ins-wp.png', + 'Clickable button/option not found.') + + # set waypoints + pag.move(111, 153, duration=2) + pag.click(interval=2) pag.sleep(1) + pag.move(36, 82, duration=2) + pag.click(interval=2) + pag.sleep(1) + + # update images + create_tutorial_images() + pag.sleep(1) + + # Zooming into the map + zoom_in('topviewwindow-zoom.png', 'Zoom button could not be located.', + move=(260, 130), dragRel=(184, 135)) print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish() diff --git a/tutorials/tutorial_views.py b/tutorials/tutorial_views.py index 39fafc831..9c2723685 100644 --- a/tutorials/tutorial_views.py +++ b/tutorials/tutorial_views.py @@ -2,7 +2,7 @@ msui.tutorials.tutorial_views ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - This python script generates an automatic demonstration of how to use the top view, side view, table view and + This python script generates an automatic demonstration of how to use the top view, side view, table view andq linear view section of Mission Support System in creating a operation and planning the flightrack. This file is part of MSS. @@ -23,812 +23,287 @@ See the License for the specific language governing permissions and limitations under the License. """ - import pyautogui as pag -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, + select_listelement, find_and_click_picture, zoom_in, type_and_key, move_window, + move_and_setup_layerchooser, show_other_widgets, add_waypoints_to_topview) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings +CTRL, ENTER, WIN, ALT = platform_keys() -# ToDo in sideview and topview waypoint movement needs adjustment def automate_views(): """ This is the main automating script of the MSS views tutorial which will cover all the views(topview, sideview, - tableview, linear view) in demonstrating how to create a operation. This will be recorded and savedto a file having + tableview, linear view) in demonstrating how to create an operation. This will be recorded and savedto a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() - - # Screen Resolutions - sc_width, sc_height = pag.size()[0] - 1, pag.size()[1] - 1 - - # Maximizing the window - try: - if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'up') - elif platform == 'darwin': - pag.hotkey('ctrl', 'command', 'f') - elif platform == 'win32': - pag.hotkey('win', 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(2) - # Shifting topview window to upper right corner - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) - pag.click(x, y - 56, interval=2) - if platform == 'win32' or platform == 'darwin': - pag.dragRel(525, -110, duration=2) - elif platform == 'linux' or platform == 'linux2': - pag.dragRel(910, -25, duration=2) - pag.move(0, 56) - add_tv_x, add_tv_y = pag.position() - pag.move(-486, -56, duration=1) - pag.click(interval=1) - if platform == 'win32' or platform == 'linux' or platform == 'linux2': - pag.hotkey('ctrl', 'v') - elif platform == 'darwin': - pag.hotkey('command', 'v') - pag.sleep(4) - # Shifting Sideview window to upper left corner. - try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) - if platform == 'win32' or platform == 'darwin': - pag.moveTo(x1, y1 - 56, duration=1) - pag.dragRel(-494, -177, duration=2) - elif platform == 'linux' or platform == 'linux2': - pag.moveTo(x1, y1 - 56, duration=1) - pag.dragRel(-50, -30, duration=2) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2': - pag.keyDown('altleft') - # ToDo selection of views have to be done with ctrl f - # this selects the next window in the window manager on budgie - pag.press('tab') - pag.keyUp('tab') - pag.press('tab') - pag.keyUp('tab') - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - elif platform == 'darwin': - pag.press('command', 'tab', 'right') - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("Exception: \'Side View Window Header\' was not found on the screen") - raise - except (ImageNotFoundException, OSError, Exception): - print("Exception: \'Topview Window Header\' was not found on the screen") - raise + msui_full_screen_and_open_first_view() - # Adding waypoints - if add_tv_x is not None and add_tv_y is not None: - pag.sleep(1) - pag.click(add_tv_x, add_tv_y, interval=2) - pag.move(-50, 150, duration=1) - pag.click(interval=2) - pag.sleep(1) - pag.move(65, 65, duration=1) - pag.click(interval=2) - pag.sleep(1) + pag.sleep(1) + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + # move topview on screen + x_drag_rel = 910 + y_drag_rel = -10 + move_window(topview["os_screen_region"], x_drag_rel, y_drag_rel) + create_tutorial_images() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + add_waypoints_to_topview(topview['os_screen_region']) + # memorize last added point + x1, y1 = pag.position() + + # click on msui main + pag.move(150, -150, duration=1) + pag.click(interval=2) + pag.sleep(1) - pag.move(-150, 30, duration=1) - x1, y1 = pag.position() - pag.click(interval=2) - pag.sleep(1) - pag.move(200, 150, duration=1) - pag.click(interval=2) - x2, y2 = pag.position() - pag.sleep(1) - pag.move(100, -80, duration=1) - pag.click(interval=2) - pag.move(56, -63, duration=1) - pag.click(interval=2) - pag.sleep(3) - else: - print("Screen coordinates not available for add waypoints for topview") - raise + hotkey = CTRL, 'up' + pag.hotkey(*hotkey) + + # open sideview + pag.hotkey(CTRL, 'v') + pag.sleep(1) + create_tutorial_images() + sideview = load_settings_qsettings('sideview', {"os_screen_region": (0, 0, 0, 0)}) + + # move sideview on screen + x_drag_rel = -50 + y_drag_rel = -30 + move_window(sideview["os_screen_region"], x_drag_rel, y_drag_rel) + + pag.keyDown('altleft') + # this selects the next window in the window manager on budgie and kde + pag.press('tab') + pag.keyUp('tab') + pag.press('tab') + pag.keyUp('tab') + pag.keyUp('altleft') + pag.sleep(1) + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layers.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'wms_url.png'), - region=(int(sc_width / 2), 0, sc_width, sc_height)) - pag.click(x + 220, y, interval=2) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topviews' \'WMS URL\' editbox button/option not found on the screen.") - raise - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'get_capabilities.png'), - region=(int(sc_width / 2), 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topviews' \'Get capabilities\' button/option not found on the screen.") - raise - - # Relocating Layerlist of topview - if platform == 'win32': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 627, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 675, duration=2) # To be decided - pag.sleep(1) - # Storing screen coordinates for List layer of top view - ll_tov_x, ll_tov_y = pag.position() - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topviews WMS' \'Server\\Layers\' button/option not found on the screen.") - raise + find_and_click_picture('topviewwindow-server-layer.png', + 'Topview Server Layer not found', + region=topview["os_screen_region"]) + create_tutorial_images() + move_and_setup_layerchooser(topview["os_screen_region"], -171, -390, 10, 675) + + tvll_region = list(topview["os_screen_region"]) + tvll_region[3] = tvll_region[3] + 675 # Selecting some layers in topview layerlist - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 16 - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'equivalent_layer.png'), - region=(int(sc_width / 2), 0, sc_width, sc_height)) - temp1, temp2 = x, y - pag.click(x, y, interval=2) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap * 2, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, -gap * 4, duration=1) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Divergence Layer\' option not found on the screen.") - raise - # Setting different levels and valid time - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 3), interval=2) - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'level.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x + 200, y, interval=2) - pag.move(0, 140, duration=1) - pag.click(interval=1) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Pressure level\' button/option not found on the screen.") - raise - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'valid.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x + 200, y, interval=1) - pag.move(0, 80, duration=1) - pag.click(interval=1) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Valid till\' button/option not found on the screen.") - raise + # lookup layer entry from the multilayering checkbox + find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering selection not found', + region=tuple(tvll_region)) + + x, y = pag.position() + # disable multilayer + pag.click(x, y) + # Divergence and Geopotential + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + # Relative Huminidity + pag.click(x + 50, y + 110, interval=2) + pag.sleep(1) + + create_tutorial_images() + ll_tov_x, ll_tov_y = pag.position() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + # Moving waypoints in Topview - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - try: - px, py = pag.locateCenterOnScreen(picture('views', 'topview_point2.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Topview's \'Point 2\' not found on the screen.") - raise - pag.click(px, py, interval=2) - pag.moveTo(px, py, duration=1) - pag.dragTo(px + 46, py - 67, duration=1, button='left') - pag.click(interval=2) - x3, y3 = pag.position() - pag.sleep(1) - except ImageNotFoundException: - print("\n Exception : Move Waypoint button could not be located on the screen") - raise + _tv_move_waypoints(topview["os_screen_region"], x1, y1) + x3, y3 = pag.position() + pag.sleep(1) + # Deleting waypoints - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'remove_waypoint.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - pag.moveTo(x3, y3, duration=1) - pag.click(duration=1) - if platform == 'win32': - pag.press('left') - pag.sleep(2) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('right') - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - pag.sleep(2) - except ImageNotFoundException: - print("\n Exception : Remove Waypoint button could not be located on the screen") - raise - - # Changing map to Global - try: - if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - x, y = pag.locateCenterOnScreen(picture('wms', 'europe_cyl.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - pag.press('down', presses=2, interval=0.5) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - pag.sleep(6) - except (ImageNotFoundException, TypeError, OSError, Exception): - print("\n Exception : Topview's Map change dropdown could not be located on the screen") - raise + find_and_click_picture('topviewwindow-del-wp.png', + 'Delete waypoints not found', + region=topview["os_screen_region"]) + pag.moveTo(x3, y3, duration=1) + pag.click(duration=1) + # Yes is default + pag.sleep(3) + pag.press(ENTER) + pag.sleep(2) + create_tutorial_images() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + + find_and_click_picture('topviewwindow-01-europe-cyl.png', + 'Projection 01-europe-cyl not found', + region=topview["os_screen_region"]) + select_listelement(2) # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png'), - region=(int(sc_width / 2) - 100, 0, sc_width, sc_height)) - pag.click(x, y, interval=2) - pag.move(155, 121, duration=1) - pag.click(duration=1) - pag.dragRel(260, 110, duration=2) - pag.sleep(4) - except ImageNotFoundException: - print("\n Exception : Topview's Zoom button could not be located on the screen") - raise + zoom_in('topviewwindow-zoom.png', 'Zoom button not found', + move=(155, 121), dragRel=(260, 110), + region=topview["os_screen_region"]) + pag.sleep(2) + create_tutorial_images() + sideview = load_settings_qsettings('sideview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + # SideView Operations - # Opening web map service - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x, y, interval=2) - pag.press('down', interval=1) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'SideView's select to open control\' button/option not found on the screen.") - raise # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layers.png'), region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x, y, interval=2) - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'wms_url.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x + 220, y, interval=2) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideviews' \'WMS URL\' editbox button/option not found on the screen.") - raise - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'get_capabilities.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x, y, interval=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : SideView's \'Get capabilities\' button/option not found on the screen.") - raise - if platform == 'win32': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 570, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(-171, -390, duration=1) - pag.dragRel(10, 600, duration=2) - # Storing screen coordinates for List layer of side view - ll_sv_x, ll_sv_y = pag.position() - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideviews WMS' \'Server\\Layers\' button/option not found on the screen.") - raise - # Selecting some layers in Sideview WMS - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 16 - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'cloudcover.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) - temp1, temp2 = x, y - pag.click(x, y, interval=2) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap * 2, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(3) - pag.move(0, -gap * 4, duration=1) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideview's \'Cloud Cover Layer\' option not found on the screen.") - raise - # Setting different levels and valid time - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 4), interval=2) - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'valid.png'), region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x + 200, y, interval=1) - pag.move(0, 80, duration=1) - pag.click(interval=1) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Sideview's \'Valid till\' button/option not found on the screen.") - raise - - # Move waypoints in SideView - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x, y, interval=2) - try: - px, py = pag.locateCenterOnScreen(picture('views', 'sideview_point1.png')) - # point1: 127, 394 - except (ImageNotFoundException, OSError, Exception): - print(f"\nException : Sideview's \'Point 1\' not found on the screen.") - raise - offsets = [0, 114, 161, 200, ] - for offset in offsets: - pag.click(px + offset, py, interval=2) - pag.moveTo(px + offset, py, duration=1) - pag.dragTo(px + offset, py - offset, duration=5, button='left') - pag.click(interval=2) - - except ImageNotFoundException: - print("\n Exception :Sideview's Move Waypoint button could not be located on the screen") - raise - # Adding waypoints in SideView - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png'), - region=(0, 0, int(sc_width / 2) - 100, sc_height)) - pag.click(x, y, duration=1) - pag.click(x + 239, y + 186, duration=1) - pag.sleep(3) - pag.click(x + 383, y + 93, duration=1) - pag.sleep(3) - pag.click(x + 450, y + 140, duration=1) - pag.sleep(4) - pag.click(x, y, duration=1) - pag.sleep(1) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Sideview's add waypoint button not found on the screen.") - raise + find_and_click_picture('sideviewwindow-server-layer.png', + 'Sideview server layer not found', + region=sideview["os_screen_region"]) + + create_tutorial_images() + move_and_setup_layerchooser(sideview["os_screen_region"], -171, -390, 10, 600) + + ll_sv_x, ll_sv_y = pag.position() + + _sv_layers(sideview["os_screen_region"], tvll_region) + + find_and_click_picture('sideviewwindow-valid.png', + 'Sideview Window not found', + region=sideview["os_screen_region"]) + x, y = pag.position() + pag.click(x + 200, y, interval=1) + pag.move(0, 80, duration=1) + pag.press(ENTER) + + create_tutorial_images() + sideview = load_settings_qsettings('sideview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + + pag.sleep(2) + _sv_adjust_altitude(sideview["os_screen_region"]) + + create_tutorial_images() + _sv_add_waypoints(sideview["os_screen_region"]) + # Closing list layer of sideview and topview to make screen a little less congested. pag.click(ll_sv_x, ll_sv_y, duration=2) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - elif platform == 'darwin': - pag.hotkey('command', 'w') + pag.hotkey('altleft', 'f4') pag.sleep(1) + pag.press('left') + pag.press(ENTER) + pag.click(ll_tov_x, ll_tov_y, duration=2) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - elif platform == 'darwin': - pag.hotkey('command', 'w') + pag.hotkey('altleft', 'f4') + pag.sleep(1) + pag.press('left') + pag.press(ENTER) # Table View # Opening Table View pag.move(-80, 120, duration=1) - # pag.moveTo(1800, 1000, duration=1) - pag.click(duration=1) - # ANY now selected - # ToDo ANY should be inactive whithout an OP pag.click(duration=1) pag.sleep(1) pag.hotkey('ctrl', 't') pag.sleep(2) - # Relocating Tableview and performing operations on table view - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png')) - pag.moveTo(x, y - 462, duration=1) - if platform == 'linux' or platform == 'linux2': - pag.dragRel(250, 887, duration=3) - elif platform == 'win32' or platform == 'darwin': - pag.dragRel(None, 487, duration=2) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2': - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab') - pag.keyUp('altleft') - pag.sleep(1) - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab', presses=2) # This needs to be checked in Linux - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - pag.sleep(1) - pag.keyDown('alt') - pag.press('tab') - pag.press('right', presses=2) - pag.keyUp('alt') - elif platform == 'darwin': - pag.keyDown('command') - pag.press('tab') - pag.press('right') - pag.keyUp('command') - pag.sleep(1) - pag.keyDown('command') - pag.press('tab') - pag.press('right', presses=2) - pag.keyUp('command') - pag.sleep(1) - if platform == 'win32' or platform == 'darwin': - pag.dragRel(None, -300, duration=2) - tv_x, tv_y = pag.position() - elif platform == 'linux' or platform == 'linux2': - pag.dragRel(None, -450, duration=2) - tv_x, tv_y = pag.position() - - # Locating the selecttoopencontrol for tableview to perform operations - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'selecttoopencontrol.png'), - region=(0, int(sc_height * 0.75), sc_width, int(sc_height * 0.25))) - - # Changing names of certain waypoints to predefined names - pag.click(x, y - 190, duration=1) if platform == 'win32' else pag.click(x, y - 325, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(2) - pag.move(88, 0, duration=1) if platform == 'win32' else pag.move(78, 0, duration=1) - pag.sleep(1) - pag.click(duration=1) - pag.press('down', presses=5, interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(1) - - # Giving user defined names to waypoints - pag.click(x, y - 160, duration=1) if platform == 'win32' else pag.click(x, y - 294, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1.5) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.hotkey('ctrl', 'a') - elif platform == 'darwin': - pag.hotkey('command', 'a') - pag.sleep(1) - pag.write('Location A', interval=0.1) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - pag.click(x, y - 127, duration=1) if platform == 'win32' else pag.click(x, y - 263, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(2) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.hotkey('ctrl', 'a') - elif platform == 'darwin': - pag.hotkey('command', 'a') - pag.sleep(1) - pag.write('Stop Point', interval=0.1) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - # Changing Length of Flight Level - pag.click(x + 266, y - 95, duration=1) if platform == 'win32' else pag.click(x + 236, y - 263, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('319', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - # Changing hPa level of waypoints - pag.click(x + 344, y - 65, duration=1) if platform == 'win32' else pag.click(x + 367, y - 232, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('250', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - # Changing longitude of 'Location A' waypoint - pag.click(x + 194, y - 160, duration=1) if platform == 'win32' else pag.click(x + 165, y - 294, duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('12.36', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - - # Cloning the row of waypoint - try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'clone.png')) - pag.click(x + 15, y - 130, duration=1) if platform == 'win32' else pag.click(x + 15, y - 263, - duration=1) - pag.sleep(1) - pag.click(x1, y1, duration=1) - pag.sleep(2) - pag.click(x + 15, y - 100, duration=1) if platform == 'win32' else pag.click(x + 15, y - 232, - duration=1) - pag.sleep(1) - pag.doubleClick(x + 130, y - 100, duration=1) if platform == 'win32' else pag.click(x + 117, y - 232, - duration=1) - pag.sleep(1) - pag.write('65.26', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - pag.move(580, 0, duration=1) if platform == 'win32' else pag.move(459, 0, duration=1) - pag.doubleClick(duration=1) - pag.sleep(2) - pag.write('This is a reference comment', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's CLONE button not found on the screen.") - raise - # Inserting a new row of waypoints - try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'insert.png')) - pag.click(x + 130, y - 160, duration=1) if platform == 'win32' else pag.click(x + 117, y - 294, - duration=1) - pag.sleep(2) - pag.click(x1, y1, duration=1) - pag.sleep(2) - pag.click(x + 130, y - 125, duration=1) if platform == 'win32' else pag.click(x + 117, y - 263, - duration=1) - pag.sleep(1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('58', interval=0.2) - pag.sleep(0.5) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - pag.move(63, 0, duration=1) if platform == 'win32' else pag.move(48, 0, duration=1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('-1.64', interval=0.2) - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - pag.move(108, 0, duration=1) if platform == 'win32' else pag.move(71, 0, duration=1) - pag.doubleClick(duration=1) - pag.sleep(1) - pag.write('360', interval=0.2) - pag.sleep(0.5) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's INSERT button not found on the screen.") - raise - # Delete Selected waypoints row - try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'deleteselected.png')) - pag.click(x + 150, y - 70, duration=1) if platform == 'win32' else pag.click(x + 150, y - 201, - duration=1) - pag.sleep(2) - pag.click(x1, y1, duration=1) - pag.press('left') - pag.sleep(1) - pag.press('return') if platform == 'darwin' else pag.press('enter') - pag.sleep(2) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's DELETE SELECTED button not found on the screen.") - raise - # Reverse waypoints' order - try: - x1, y1 = pag.locateCenterOnScreen(picture('wms', 'reverse.png')) - for _ in range(3): - pag.click(x1, y1, duration=1) - pag.sleep(1.5) - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's REVERSE button not found on the screen.") - raise - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Tableview's selecttoopencontrol button (bottom part) not found on the screen.") - raise - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : TableView's Select to open Control option (at the top) not found on the screen.") - raise + create_tutorial_images() + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + # move tableview on screen + x_drag_rel = 250 + y_drag_rel = 687 + move_window(tableview["os_screen_region"], x_drag_rel, y_drag_rel) + + show_other_widgets() + + # pag.dragRel(None, -450, duration=2) + tv_x, tv_y = pag.position() + pag.click(tv_x, tv_y) + pag.sleep(1) + tableview = load_settings_qsettings('tableview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) + create_tutorial_images() + # Locating the selecttoopencontrol for tableview to perform operations + find_and_click_picture('tableviewwindow-select-to-open-control.png', + 'Tableview select to open control not found', + region=tableview["os_screen_region"]) + # explaining the tableview + x, xoffset, y = _tab_add_data() + _tab_clone(tableview["os_screen_region"], x, y, xoffset) + _tab_insert(tableview["os_screen_region"], x, y, xoffset) + _tab_delete(tableview["os_screen_region"], x, y) + _tab_reverse(tableview["os_screen_region"]) + # Closing Table View to make space on screen - if tv_x is not None and tv_y is not None: - pag.click(tv_x, tv_y, duration=1) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - pag.press('left') - pag.sleep(1) - pag.press('enter') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - pag.press('left') - pag.sleep(1) - pag.press('enter') - elif platform == 'darwin': - pag.hotkey('command', 'w') - pag.press('left') - pag.sleep(1) - pag.press('return') + pag.click(tv_x, tv_y, duration=1) + pag.hotkey('altleft', 'f4') + pag.sleep(1) + pag.press('left') + pag.sleep(1) + pag.press('enter') # Opening Linear View pag.sleep(1) pag.move(0, 400, duration=1) pag.click(interval=1) - pag.hotkey('ctrl', 'l') - pag.sleep(4) - pag.hotkey(win, 'up') - pag.click(10, 10, interval=2) - pag.dragRel(853, 360, duration=3) - pag.sleep(2) + pag.hotkey(CTRL, 'l') + pag.sleep(1) - # Relocating Linear View - try: - pag.locateCenterOnScreen(picture('views', 'selecttoopencontrol.png')) - - if platform == 'linux' or platform == 'linux2': - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab') - pag.keyUp('altleft') - pag.sleep(1) - pag.keyDown('altleft') - pag.press('tab') - pag.press('tab') - pag.press('tab') - pag.keyUp('altleft') - elif platform == 'win32': - pag.keyDown('alt') - pag.press('tab') - pag.press('right') - pag.keyUp('alt') - pag.sleep(1) - pag.keyDown('alt') - pag.press('tab') - pag.press('right', presses=2, interval=1) - pag.keyUp('alt') - elif platform == 'darwin': - pag.keyDown('command') - pag.press('tab') - pag.press('right') - pag.keyUp('command') - pag.sleep(1) - pag.keyDown('command') - pag.press('tab') - pag.press('right', presses=2, interval=1) - pag.keyUp('command') - pag.sleep(1) - pag.dragRel(-102, -470, duration=2) if platform == 'win32' else pag.dragRel(-90, -500, duration=2) - lv_x, lv_y = pag.position() - except (ImageNotFoundException, OSError, TypeError, Exception): - print("\nException : Linearview's window header not found on the screen.") - raise + create_tutorial_images() + linearview = load_settings_qsettings('linearview', {"os_screen_region": (0, 0, 0, 0)}) + + # move linearview on screen + x_drag_rel = 0 + y_drag_rel = 630 + + move_window(linearview["os_screen_region"], x_drag_rel, y_drag_rel) + + show_other_widgets() + + lv_x, lv_y = pag.position() + create_tutorial_images() + linearview = load_settings_qsettings('linearview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('views', 'layers.png'), - region=(900, 830, sc_width, sc_height)) - pag.click(x, y, interval=2) - - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('views', 'wms_url.png'), - region=(0, int(sc_height * 0.65), sc_width, int(sc_height * 0.35))) - pag.click(x + 220, y, interval=2) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Linearviews' \'WMS URL\' editbox button/option not found on the screen.") - raise - try: - x, y = pag.locateCenterOnScreen(picture('views', 'get_capabilities.png'), - region=(0, int(sc_height * 0.65), sc_width, int(sc_height * 0.35))) - pag.click(x, y, interval=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : LinearView's \'Get capabilities\' button/option not found on the screen.") - raise - if platform == 'win32': - pag.move(-171, -390, duration=1) - pag.dragRel(-867, 135, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(-171, -390, duration=1) - pag.dragRel(-900, 245, duration=2) - # Storing screen coordinates for List layer of side view - pag.position() - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Linearview's WMS \'Server\\Layers\' button/option not found on the screen.") - raise + find_and_click_picture('linearwindow-server-layer.png', + "Server layer button not found", + region=linearview["os_screen_region"]) + + create_tutorial_images() + move_and_setup_layerchooser(linearview["os_screen_region"], -171, -390, 900, 100) + + create_tutorial_images() + linearview = load_settings_qsettings('linearview', {"os_screen_region": (0, 0, 0, 0)}) # Selecting Some Layers in Linear wms section - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 16 - try: - x, y = pag.locateCenterOnScreen(picture('views', 'vertical_velocity.png'), - region=(0, int(sc_height / 2), sc_width, int(sc_height / 2))) - pag.click(x, y, interval=2) - x, y = pag.locateCenterOnScreen(picture('views', 'horizontal_wind.png'), - region=(0, int(sc_height / 2), sc_width, int(sc_height / 2))) - temp1, temp2 = x, y - pag.click(x, y, interval=2) - pag.sleep(1) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(1) - pag.move(0, gap * 2, duration=1) - pag.click(interval=1) - pag.sleep(1) - pag.move(0, gap, duration=1) - pag.click(interval=1) - pag.sleep(1) - pag.move(0, -gap * 4, duration=1) - pag.click(interval=1) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : Linearview's \'Horizontal Wind Layer\' option not found on the screen.") - raise - # Add waypoints after anaylzing the linear section wms - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png'), region=(0, 0, int(sc_width / 2), sc_height)) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.click(x + 30, y + 50, duration=1) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\n Exception :Sideview's Add Waypoint button could not be located on the screen") - raise + gap = 32 + lvll_region = list(linearview["os_screen_region"]) + lvll_region[0] = lvll_region[0] - 900 - 171 + find_and_click_picture('multilayersdialog-multilayering.png', + ' Multilayer not found', + region=tuple(lvll_region)) + x, y = pag.position() + # unselect multilayer + pag.click(x, y) + pag.sleep(1) + + # Cloudcover + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap * 2, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) # CLosing Linear View Layer List - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 4), duration=2) - pag.sleep(1) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'f4') - elif platform == 'win32': - pag.hotkey('alt', 'f4') - elif platform == 'darwin': - pag.hotkey('command', 'w') - pag.sleep(1) + pag.click(x, y, duration=2) + pag.sleep(1) + pag.hotkey('altleft', 'f4') # Clicking on Linear View Window Head - if lv_x is not None and lv_y is not None: - pag.click(lv_x, lv_y, duration=1) + pag.click(lv_x, lv_y, duration=1) print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") @@ -836,5 +311,277 @@ def automate_views(): finish() +def _sv_layers(os_screen_region, tvll_region): + """ + + Selects in the sideview layer chooser some layers + + :param os_screen_region: a list representing the region of the screen where the actions will be performed. + :param tvll_region: a list representing the region of the screen that will be used for calculations. + + Return type: + None + + Example usage: + os_screen_region = [0, 0, 1920, 1080] + tvll_region = [100, 100, 500, 500] + _sv_layers(os_screen_region, tvll_region) + + """ + gap = 16 + svll_region = list(os_screen_region) + svll_region[3] = tvll_region[3] + 600 + find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering not found', + region=tuple(svll_region)) + x, y = pag.position() + # Cloudcover + pag.click(x + 50, y + 70, interval=2) + pag.sleep(1) + temp1, temp2 = x, y + pag.click(x, y, interval=2) + pag.sleep(3) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap * 2, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, gap, duration=1) + pag.click(interval=1) + pag.sleep(3) + pag.move(0, -gap * 4, duration=1) + pag.click(interval=1) + pag.sleep(3) + # Setting different levels and valid time + pag.click(temp1, temp2 + (gap * 4), interval=2) + + +def _tab_reverse(os_screen_region): + """ + Reverses the order of a table view displayed on the screen. + + :param os_screen_region (tuple): The region of the screen where the table view is located. + + Returns: + None + """ + find_and_click_picture('tableviewwindow-reverse.png', 'Reverse Button not found', + region=os_screen_region) + x1, y1 = pag.position() + for _ in range(3): + pag.click(x1, y1, duration=1) + pag.sleep(1.5) + + +def _tab_delete(os_screen_region, x, y): + """ + Delete a selected tab in a table view. + + :param os_screen_region (tuple): The region of the screen where the table view is located. + :param x (int): The x-coordinate of the tab to delete relative to the table view. + :param y (int): The y-coordinate of the tab to delete relative to the table view. + + Returns: + None + """ + find_and_click_picture('tableviewwindow-delete-selected.png', 'Delete button not', + region=os_screen_region) + x1, y1 = pag.position() + pag.click(x + 150, y - 201, duration=1) + pag.sleep(2) + pag.click(x1, y1, duration=1) + pag.press(ENTER) + pag.sleep(2) + + +def _tab_insert(os_screen_region, x, y, xoffset): + """ + Inserts multiple new row of waypoints into the table view. + + :param os_screen_region (tuple): The region of the screen where the table view is located. + :param x (int): The x-coordinate of the starting position. + :param y (int): The y-coordinate of the starting position. + :param xoffset (int): The x-offset for clicking on the table view. + + Returns: + None + """ + # Inserting a new row of waypoints + find_and_click_picture('tableviewwindow-insert.png', 'Insert button not found', + region=os_screen_region) + x1, y1 = pag.position() + pag.click(x + 117, y - 294, duration=1) + pag.sleep(2) + pag.click(x1, y1, duration=1) + pag.sleep(2) + pag.click(x + xoffset + 85, y - 263, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('58') + pag.sleep(1) + pag.click(x + xoffset + 170, y - 232, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('360') + + +def _tab_clone(os_screen_region, x, y, xoffset): + """ + Clone a table line in the specified screen region. + + :param os_screen_region: The region of the screen where the table view window is located. + :param x: The x-coordinate of a line in the table view window. + :param y: The y-coordinate of a line in the table view window. + :param xoffset: The offset to be added to the x-coordinate when performing clicks. + + :return: None + + :raises: Exception - If the clone button is not found. + + Example usage: + _tab_clone(os_screen_region, x, xoffset, y) + """ + find_and_click_picture('tableviewwindow-clone.png', 'Clone button not found', + region=os_screen_region) + x1, y1 = pag.position() + pag.click(x + xoffset, y - 263, duration=1) + pag.sleep(1) + pag.click(x1, y1, duration=1) + pag.sleep(2) + pag.click(x + xoffset, y - 232, duration=1) + pag.sleep(1) + pag.click(x + xoffset + 85, y - 232, duration=1) + pag.sleep(1) + type_and_key('65.26') + pag.click(x + xoffset + 550, y - 232, duration=1) + pag.doubleClick(duration=1) + type_and_key('Comment1') + + +def _tab_add_data(): + x, y = pag.position() + xoffset = -100 + # Changing names of certain waypoints to predefined names + pag.click(x + xoffset, y - 360, duration=1) + pag.sleep(1) + pag.doubleClick(duration=1) + pag.sleep(2) + pag.move(78, 0, duration=1) + pag.sleep(1) + pag.click(duration=1) + pag.press('down', presses=5, interval=0.2) + pag.sleep(1) + pag.press('enter') + pag.sleep(1) + # Giving user defined names to waypoints + pag.click(x + xoffset, y - 294, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + # marks word + pag.doubleClick() + type_and_key('Location') + # annother waypoint name + pag.click(x + xoffset, y - 263, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + # no blank in values + type_and_key('StopPoint', interval=0.1) + # Changing hPa level of waypoints + pag.click(x + xoffset + 170, y - 232, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('250') + # xoffset + # Changing longitude of 'Location A' waypoint + pag.click(x + xoffset + 125, y - 294, duration=1) + pag.sleep(1) + pag.doubleClick() + pag.sleep(1) + type_and_key('12.36') + return x, xoffset, y + + +def _tv_move_waypoints(os_screen_region, x, y): + find_and_click_picture('topviewwindow-mv-wp.png', + 'Move waypoints not found', + region=os_screen_region) + pag.click(x, y, interval=2) + pag.moveTo(x, y, duration=1) + pag.dragTo(x + 46, y - 67, duration=1, button='left') + pag.click(interval=2) + + +def _sv_add_waypoints(os_screen_region): + # Adding waypoints in SideView + find_and_click_picture('sideviewwindow-ins-wp.png', + 'sideview ins waypoint not found', + region=os_screen_region) + x, y = pag.position() + pag.click(x + 239, y + 186, duration=1) + pag.sleep(3) + pag.click(x + 383, y + 93, duration=1) + pag.sleep(3) + pag.click(x + 450, y + 140, duration=1) + pag.sleep(4) + pag.click(x, y, duration=1) + pag.sleep(1) + + +def _sv_adjust_altitude(os_screen_region): + """ + Adjusts the altitude of sideview waypoints. + + Parameters: + - os_screen_region: The screen region where sideview is located + + Returns: None + """ + # smaller region, seems the widget covers a bit the content + pic_name = ('sideviewwindow-cloud-cover-0-1-vertical-section-valid-' + '2012-10-17t12-00-00z-initialisation-2012-10-17t12-00-00z.png') + # pic = picture(pic_name, bounding_box=(20, 20, 60, 300)) + find_and_click_picture('sideviewwindow-mv-wp.png', + 'Sideview move wp not found', + region=os_screen_region) + find_and_click_picture(pic_name, bounding_box=(187, 300, 206, 312)) + # adjust altitude of sideview waypoints + px, py = pag.position() + offsets = [0, 60, 93] + + for offset in offsets: + pag.click(px + offset, py, interval=2) + pag.moveTo(px + offset, py, duration=1) + pag.dragTo(px + offset, py - offset - 50, duration=5, button='left') + pag.click(interval=2) + + +def _tv_add_waypoints(os_screen_region): + + find_and_click_picture('topviewwindow-ins-wp.png', + 'Topview Window not found', + region=os_screen_region) + # Adding waypoints + pag.sleep(1) + pag.move(-50, 150, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(65, 65, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(-150, 30, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(200, 150, duration=1) + pag.click(interval=2) + + if __name__ == '__main__': start(target=automate_views, duration=567) diff --git a/tutorials/tutorial_waypoints.py b/tutorials/tutorial_waypoints.py index 7ca6186b1..705add3eb 100644 --- a/tutorials/tutorial_waypoints.py +++ b/tutorials/tutorial_waypoints.py @@ -27,10 +27,11 @@ import pyautogui as pag import datetime -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, select_listelement, + find_and_click_picture, zoom_in, panning) +from tutorials.utils.platform_keys import platform_keys + +CTRL, ENTER, WIN, ALT = platform_keys() def automate_waypoints(): @@ -39,30 +40,15 @@ def automate_waypoints(): to a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. - pag.sleep(15) - ctrl, enter, win, alt = platform_keys() - - # Maximizing the window - try: - if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'up') - elif platform == 'darwin': - pag.hotkey('ctrl', 'command', 'f') - elif platform == 'win32': - pag.hotkey('win', 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - pag.sleep(2) - pag.hotkey('ctrl', 'h') pag.sleep(5) - # Adding waypoints - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'add_waypoint.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\nException : Clickable button/option not found on the screen.") - raise + msui_full_screen_and_open_first_view() + + # enable adding waypoints + find_and_click_picture('topviewwindow-ins-wp.png', + 'Clickable button/option not found.') + + # set waypoints pag.move(-50, 150, duration=1) pag.click(interval=2) pag.sleep(1) @@ -79,14 +65,11 @@ def automate_waypoints(): x2, y2 = pag.position() pag.sleep(3) - # Moving waypoints - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'move_waypoint.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Move Waypoint button could not be located on the screen") - raise + # enable moving waypoints + find_and_click_picture('topviewwindow-mv-wp.png', + ' Move Waypoint button could not be located.') + # moving waypoints pag.moveTo(x2, y2, duration=1) pag.click(interval=2) pag.dragRel(100, 150, duration=1) @@ -94,113 +77,56 @@ def automate_waypoints(): pag.dragRel(35, -50, duration=1) x1, y1 = pag.position() - # Deleting waypoints - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'remove_waypoint.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Remove Waypoint button could not be located on the screen") - raise + # enable deleting waypoints + find_and_click_picture('topviewwindow-del-wp.png', + 'Remove Waypoint button could not be located.') + + # delete waypoints pag.moveTo(x1, y1, duration=1) pag.click(duration=1) - pag.press('left') + # Yes is default pag.sleep(3) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) + pag.press(ENTER) pag.sleep(2) # Changing map to Global - try: - if platform == 'linux' or platform == 'linux2' or platform == 'darwin': - print(pag.position()) - x, y = pag.locateCenterOnScreen(picture('waypoints', 'europe_cyl.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Map change dropdown could not be located on the screen") - raise - pag.press('down', presses=2, interval=0.5) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) + find_and_click_picture('topviewwindow-01-europe-cyl.png', + "Map change dropdown could not be located.") + select_listelement(2) pag.sleep(5) # Zooming into the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'zoom.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Zoom button could not be located on the screen") - raise - pag.move(150, 200, duration=1) - pag.dragRel(400, 250, duration=2) - pag.sleep(5) + zoom_in('topviewwindow-zoom.png', 'Zoom button could not be located.', + move=(150, 200), dragRel=(400, 250)) # Panning into the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'pan.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Pan button could not be located on the screen") - raise - pag.moveRel(400, 400, duration=1) - pag.dragRel(-100, -50, duration=2) - pag.sleep(5) + panning('topviewwindow-pan.png', 'Pan button could not be located.', + moveRel=(400, 400), dragRel=(-100, -50)) + # another panning, button is still active pag.move(-20, -25, duration=1) pag.dragRel(90, 50, duration=2) pag.sleep(5) # Switching to the previous appearance of the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'previous.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Previous button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-back.png', 'back button could not be located.') pag.sleep(5) # Switching to the next appearance of the map - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'next.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Next button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-forward.png', 'forward button could not be located.') pag.sleep(5) # Resetting the map to the original size - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'home.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Home button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-home.png', 'home button could not be located.') pag.sleep(5) # Saving the figure - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'save.png')) - pag.click(x, y, interval=2) - except ImageNotFoundException: - print("\n Exception : Save button could not be located on the screen") - raise + find_and_click_picture('topviewwindow-save.png', 'save button could not be located.') current_time = datetime.datetime.now().strftime('%d-%m-%Y %H-%M-%S') - fig_filename = f'Fig_{current_time}.png' - pag.sleep(3) - if platform == 'win32': - pag.write(fig_filename, interval=0.25) - pag.press('enter', interval=1) - if platform == 'linux' or platform == 'linux2': - pag.hotkey('altleft', 'tab') # if the save file system window is not in the forefront, use this statement. - # This can happen sometimes. At that time, you just need to uncomment it. - pag.write(fig_filename, interval=0.25) - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.write(fig_filename, interval=0.25) - pag.press('return', interval=1) + pag.hotkey('altleft', 'tab') # if the save file system window is not in the forefront, use this statement. + # This can happen sometimes. At that time, you just need to uncomment it. + pag.write(f'Fig_{current_time}.png', interval=0.25) + pag.press(ENTER) print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") finish() diff --git a/tutorials/tutorial_wms.py b/tutorials/tutorial_wms.py index 4b8794195..a21720b30 100644 --- a/tutorials/tutorial_wms.py +++ b/tutorials/tutorial_wms.py @@ -23,346 +23,207 @@ See the License for the specific language governing permissions and limitations under the License. """ - import pyautogui as pag +from tutorials.utils import (start, finish, msui_full_screen_and_open_first_view, create_tutorial_images, + find_and_click_picture, move_and_setup_layerchooser, get_region, + select_listelement) +from tutorials.utils.platform_keys import platform_keys +from mslib.utils.config import load_settings_qsettings -from sys import platform -from pyscreeze import ImageNotFoundException -from tutorials.utils import platform_keys, start, finish -from tutorials.pictures import picture +CTRL, ENTER, WIN, ALT = platform_keys() -def automate_waypoints(): +def automate_wms(): """ This is the main automating script of the MSS web map service tutorial which will be recorded and saved to a file having dateframe nomenclature with a .mp4 extension(codec). """ # Giving time for loading of the MSS GUI. pag.sleep(5) - ctrl, enter, win, alt = platform_keys() - - # Maximizing the window - try: - if platform == 'linux' or platform == 'linux2': - pag.hotkey('winleft', 'up') - elif platform == 'darwin': - pag.hotkey('ctrl', 'command', 'f') - elif platform == 'win32': - pag.hotkey('win', 'up') - except Exception: - print("\nException : Enable Shortcuts for your system or try again!") - raise - pag.sleep(2) - pag.hotkey('ctrl', 'h') - pag.sleep(1) + msui_full_screen_and_open_first_view() + topview = load_settings_qsettings('topview', {"os_screen_region": (0, 0, 0, 0)}) + pag.sleep(1) # Locating Server Layer - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layers.png')) - pag.click(x, y, interval=2) - if platform == 'win32': - pag.move(35, -485, duration=1) - pag.dragRel(-800, -60, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.move(35, -522, duration=1) - pag.dragRel(950, -30, duration=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Server\\Layers\' button/option not found on the screen.") - raise - - # Entering wms URL - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'wms_url.png')) - pag.click(x + 220, y, interval=2) - pag.hotkey('ctrl', 'a', interval=1) - pag.write('http://open-mss.org/', interval=0.25) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'WMS URL\' editbox button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'get_capabilities.png')) - pag.click(x, y, interval=2) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Get capabilities\' button/option not found on the screen.") - raise - - # Selecting some layers - if platform == 'win32': - gap = 22 - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - gap = 18 - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'equivalent_layer.png')) - pag.click(x, y, interval=2) - x, y = pag.locateCenterOnScreen(picture('wms', 'divergence_layer.png')) - temp1, temp2 = x, y - pag.click(x, y, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Divergence Layer\' option not found on the screen.") - raise + find_and_click_picture('topviewwindow-server-layer.png', + 'Topview Server Layer not found', + region=topview["os_screen_region"]) + create_tutorial_images() + move_and_setup_layerchooser(topview["os_screen_region"], -171, -390, 10, 675) + tvll_region = list(topview["os_screen_region"]) + tvll_region[3] = tvll_region[3] + 675 + + # Selecting some layers in topview layerlist + # lookup layer entry from the multilayering checkbox + x, y = find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering selection not found', + region=tuple(tvll_region)) + pag.click() + # Divergence and Geopotential + pag.click(x, y + 70, interval=2) + pag.sleep(1) + # Relative Huminidity + pag.click(x, y + 110, interval=2) + pag.sleep(1) + + # let's create our helper images + create_tutorial_images() # Filter layer - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'layer_filter.png')) - pag.click(x + 150, y, interval=2) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Layer filter editbox\' button/option not found on the screen.") - raise - - if x is not None and y is not None: - pag.write('temperature', interval=0.25) - pag.moveTo(temp1, temp2, duration=1) - pag.click(interval=2) - pag.sleep(1) - - # Clearing filter - pag.moveTo(x + 150, y, duration=1) - pag.click(interval=1) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('backspace', presses=11, interval=0.25) - elif platform == 'darwin': - pag.press('delete', presses=11, interval=0.25) - pag.sleep(1) - - # Multilayering - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'multilayering.png')) - pag.moveTo(x, y, duration=2) - # pag.move(-48, None) - pag.click() - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering Checkbox\' button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'checkbox_unselected_divergence.png')) - if platform == 'win32': - pag.moveTo(x - 268, y, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.moveTo(x - 55, y, duration=2) - pag.click(interval=1) - pag.moveTo(x - 55, y + 30, duration=2) - pag.click(interval=1) - pag.sleep(2) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Divergence layer multilayering checkbox\' option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'multilayering.png')) - pag.moveTo(x, y, duration=2) - # pag.move(-48, None) - pag.click() - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Multilayering Checkbox\' button/option not found on the screen.") - raise - - # Starring the layers - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'equivalent_layer.png')) - pag.moveTo(x, y, duration=2) - pag.click(interval=1) - x, y = pag.locateCenterOnScreen(picture('wms', 'divergence_layer.png')) - if platform == 'win32': - pag.moveTo(x - 255, y, duration=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.moveTo(x - 100, y, duration=2) - pag.click(interval=1) - pag.sleep(1) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Divergence layer star\' button/option not found on the screen.") - raise + find_and_click_picture('multilayersdialog-layer-filter.png', + 'multilayers layer filter not found', + region=tuple(tvll_region), xoffset=150) + pag.write('temperature', interval=0.25) + pag.click(interval=2) + pag.sleep(1) + + # let's create our helper images + create_tutorial_images() + # clear by clicking on the red X + find_and_click_picture('multilayersdialog-temperature.png', + 'multilayersdialog temperature not found', + bounding_box=(627, 0, 657, 20), region=tuple(tvll_region)) + + # let's create our helper images + create_tutorial_images() + # star two layers + xm, ym = find_and_click_picture('multilayersdialog-multilayering.png', + 'Multilayering selection not found', + region=tuple(tvll_region)) + + pag.click() + + # unstar Relative Huminidity + pag.click(xm, ym + 110, interval=2) + pag.sleep(1) # Filtering starred layers. - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'star_filter.png')) - pag.click(x, y, interval=2) - pag.click(temp1, temp2, duration=1) - pag.sleep(1) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Starred filter\' button/option not found on the screen.") - raise - - # removind Filtering starred layers - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'unstar_filter.png')) - pag.moveTo(x, y, duration=2) - pag.click(x, y, interval=1) - - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Unstarred filter\' button/option not found on the screen.") - raise + x, y = find_and_click_picture('multilayersdialog-temperature.png', + 'multilayersdialog temperature not found', + bounding_box=(658, 2, 677, 18), region=tuple(tvll_region)) + pag.sleep(2) + # removing starred selection showing full list + pag.click(x, y, interval=2) + pag.sleep(1) + + # Load some data + pag.click(xm + 200, ym + 70, interval=2) + create_tutorial_images() + pag.sleep(2) # Setting different levels and valid time - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2 + (gap * 4), interval=2) - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'level.png')) - pag.click(x + 200, y, interval=2) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Pressure level\' button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'initialization.png')) - initx, inity = x, y - pag.click(x + 200, y, interval=1) - pag.sleep(1) - pag.click(x + 200, y, interval=1) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Initialization\' button/option not found on the screen.") - raise - - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'valid.png')) - validx, validy = x, y - pag.click(x + 200, y, interval=2) - pag.click(interval=1) - pag.sleep(3) - except (ImageNotFoundException, OSError, Exception): - print("\nException : \'Valid till\' button/option not found on the screen.") - raise - - # Time gap for initialization and valid - if initx is not None and inity is not None and validx is not None and validy is not None: - pag.click(initx + 818, inity, interval=2) - pag.press('up', presses=5, interval=0.25) - pag.press('down', presses=3, interval=0.25) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter') - elif platform == 'darwin': - pag.press('return') - - pag.click(validx + 833, validy, interval=2) - pag.press('up', presses=5, interval=0.20) - pag.press('down', presses=6, interval=0.20) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter') - elif platform == 'darwin': - pag.press('return') - - # Previous and Next of Initial(Initialization) values - pag.click(initx + 733, inity, clicks=2, interval=2) - pag.click(initx + 892, inity, clicks=2, interval=2) - - # Previous and Next of Valid values - pag.click(validx + 743, validy, clicks=4, interval=4) - pag.click(validx + 902, validy, clicks=4, interval=4) + region = get_region('topviewwindow-30-0-hpa.png', region=topview["os_screen_region"]) + find_and_click_picture('topviewwindow-30-0-hpa.png', + '30 hPa not found', + region=topview["os_screen_region"]) + for _ in range(5): + select_listelement(1) + pag.click() + + # changing level using the > and < right side + a = region.left + region.width + 45 + b = region.top + region.height / 2 + + for _ in range(3): + pag.click(a, b) + + a = region.left + region.width + 20 + b = region.top + region.height / 2 + + for _ in range(5): + pag.click(a, b) + + region = get_region('topviewwindow-2012-10-17t12-00-00z.png', + region=topview["os_screen_region"]) + + find_and_click_picture('topviewwindow-2012-10-17t12-00-00z.png', + '2012-10-17t12-00-00z not found', + region=topview["os_screen_region"], yoffset=30) + + for _ in range(2): + select_listelement(1) + pag.click() + + # changing valid time using the > and < right side + a = region.left + region.width + 45 + b = region.top + region.height / 2 + + for _ in range(3): + pag.click(a, b) + + a = region.left + region.width + 20 + b = region.top + region.height / 2 + + for _ in range(5): + pag.click(a, b) # Auto-update feature of wms - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'auto_update.png')) - pag.click(x - 53, y, interval=2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\' auto update checkbox\' button/option not found on the screen.") - raise - - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2, interval=1) - try: - retx, rety = pag.locateCenterOnScreen(picture('wms', 'retrieve.png')) - pag.click(retx, rety, interval=2) - pag.sleep(3) - pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.click(retx, rety, interval=2) - pag.sleep(3) - pag.click(x - 53, y, interval=2) - pag.click(temp1, temp2, interval=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\' retrieve\' button/option not found on the screen.") - raise + x, y = find_and_click_picture('topviewwindow-auto-update.png', + 'autoupdate not found', + region=topview["os_screen_region"] + ) + + retx, rety = find_and_click_picture('topviewwindow-retrieve.png', + 'retrieve not found', + region=topview["os_screen_region"]) + pag.click(retx, rety, interval=2) + pag.sleep(3) + pag.click(x, y, interval=2) + pag.sleep(2) # Using and not using Cache - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'use_cache.png')) - pag.click(x - 46, y, interval=2) - pag.click(temp1, temp2, interval=2) - pag.sleep(4) - pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.sleep(4) - pag.click(x - 46, y, interval=2) - pag.click(temp1, temp2 + (gap * 2), interval=2) - pag.sleep(2) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Use Cache checkbox\' button/option not found on the screen.") - raise + find_and_click_picture('topviewwindow-use-cache.png', + 'use cache not found', + region=topview["os_screen_region"]) + + # select a layer + pag.click(xm + 200, ym + 140, interval=2) + pag.sleep(1) + pag.click() # Clearing cache. The layers load slower - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'clear_cache.png')) - pag.click(x, y, interval=2) - if platform == 'linux' or platform == 'linux2' or platform == 'win32': - pag.press('enter', interval=1) - elif platform == 'darwin': - pag.press('return', interval=1) - pag.click(temp1, temp2, interval=2) - pag.sleep(4) - pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.sleep(4) - pag.click(temp1, temp2 + (gap * 2), interval=2) - pag.sleep(4) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Clear cache\' button/option not found on the screen.") - raise - - # rent layer - if temp1 is not None and temp2 is not None: - pag.click(temp1, temp2, interval=2) - pag.sleep(1) - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'transparent.png')) - pag.click(x - 53, y, interval=2) - if retx is not None and rety is not None: - pag.click(retx, rety, interval=2) - pag.sleep(1) - pag.click(x - 53, y, interval=2) - pag.click(temp1, temp2, interval=2) - pag.click(retx, rety, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Transparent Checkbox\' button/option not found on the screen.") - raise + find_and_click_picture('topviewwindow-clear-cache.png', + 'Clear cache not found', + region=topview["os_screen_region"]) + pag.press(ENTER) + # select a layer + pag.click(xm + 200, ym + 110, interval=2) + pag.sleep(1) + pag.click() + + # transparent layer + x, y = find_and_click_picture('topviewwindow-transparent.png', + 'Transparent not found', + region=topview["os_screen_region"], + ) + pag.click(retx, rety, interval=2) + pag.sleep(1) + pag.click(x, y, interval=2) + pag.click(retx, rety, interval=2) + pag.sleep(1) # Removing a Layer from the map - if temp1 is not None and temp2 is not None: - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'remove.png')) - pag.click(x, y, interval=2) - pag.sleep(1) - pag.click(temp1, temp2 + (gap * 4), interval=2) - pag.click(x, y, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Transparent Checkbox\' button/option not found on the screen.") - raise + x, y = find_and_click_picture('topviewwindow-remove.png', + 'remove not found', + region=topview["os_screen_region"]) + + pag.sleep(1) + pag.click(x, y, interval=2) + # Deleting All layers - try: - x, y = pag.locateCenterOnScreen(picture('wms', 'delete_layers.png')) - if platform == 'win32': - pag.click(x - 74, y, interval=2) - elif platform == 'linux' or platform == 'linux2' or platform == 'darwin': - pag.click(x - 70, y, interval=2) - pag.sleep(1) - except (ImageNotFoundException, OSError, Exception): - print("\nException :\'Deleting all layers bin\' button/option not found on the screen.") - raise + find_and_click_picture('topviewwindow-server-layer.png', + 'Server layer not found', + region=topview["os_screen_region"]) + + find_and_click_picture('multilayersdialog-multilayering.png', + 'multilayering not found', + xoffset=-16, yoffset=50) print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") + + # Close Everything! finish() if __name__ == '__main__': - start(target=automate_waypoints, duration=280) + start(target=automate_wms, duration=280) diff --git a/tutorials/tutorials.batch b/tutorials/tutorials.batch index 4042269fd..e5660b102 100755 --- a/tutorials/tutorials.batch +++ b/tutorials/tutorials.batch @@ -10,7 +10,7 @@ export MSUI_CONFIG_PATH=/tmp/msui_tutorials ########################################################################## # wms tutorial -$HOME/miniconda3/envs/mssdev/bin/python tutorial_wms.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_wms.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -34,7 +34,7 @@ cd .. # kml tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_kml.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_kml.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -58,7 +58,7 @@ cd .. # hexagoncontrol tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_hexagoncontrol.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_hexagoncontrol.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -82,7 +82,7 @@ cd .. # performancesettings tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_performancesettings.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_performancesettings.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -108,7 +108,7 @@ cd .. ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_remotesensing.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_remotesensing.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -132,7 +132,7 @@ cd .. # satellitetrack tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_satellitetrack.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_satellitetrack.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -156,7 +156,7 @@ cd .. ################################################## # tutorial waypoints ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_waypoints.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_waypoints.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -180,7 +180,7 @@ cd .. ################################################################ # views tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_views.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_views.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -206,12 +206,12 @@ cd .. # start a mscolab server on standard port after you have it seeded # we should have a seed for tutorials -$HOME/miniconda3/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py db --seed +$HOME/mambaforge/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py db --seed -$HOME/miniconda3/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py start & +$HOME/mambaforge/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py start & ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_mscolab.py +$HOME/mambaforge/envs/mssdev/bin/python tutorial_mscolab.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* diff --git a/tutorials/utils/__init__.py b/tutorials/utils/__init__.py index 5fe0ba087..e1e436312 100644 --- a/tutorials/utils/__init__.py +++ b/tutorials/utils/__init__.py @@ -10,7 +10,7 @@ :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft und Raumfahrt e.V. :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) - :copyright: Copyright 2016-2022 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); @@ -25,11 +25,20 @@ See the License for the specific language governing permissions and limitations under the License. """ +import os +import platform import sys import multiprocessing import pyautogui as pag +from pyscreeze import ImageNotFoundException + from mslib.msui import msui from tutorials.utils import screenrecorder as sr +from tutorials.utils.picture import picture +from tutorials.utils.platform_keys import platform_keys +from mslib.msui.constants import MSUI_CONFIG_PATH + +CTRL, ENTER, WIN, ALT = platform_keys() def initial_ops(): @@ -52,7 +61,13 @@ def initial_ops(): def call_recorder(x_start=0, y_start=0, x_width=int(pag.size()[0]), y_width=int(pag.size()[1]), duration=120): """ - Calls the screen recorder class to start the recording of the automation. + Starts a call recording of the specified area on the screen. + + :param x_start: (optional) The x-coordinate of the starting point for the recording area. Defaults to 0. + :param y_start: (optional) The y-coordinate of the starting point for the recording area. Defaults to 0. + :param x_width: (optional) The width of the recording area. Defaults to the width of the screen. + :param y_width: (optional) The height of the recording area. Defaults to the height of the screen. + :param duration: (optional) The duration of the recording in seconds. Defaults to 120 seconds. """ sr.ScreenRecorder() rec = sr.ScreenRecorder(x_start, y_start, x_width, y_width) @@ -64,28 +79,16 @@ def call_msui(): """ Calls the main MSS GUI window since operations are to be performed on it only. """ - msui.main() + msui.main(tutorial_mode=True) -def platform_keys(): - # sys.platform specific keyse - if sys.platform == 'linux' or sys.platform == 'linux2': - enter = 'enter' - win = 'winleft' - ctrl = 'ctrl' - alt = 'altleft' - elif sys.platform == 'win32': - enter = 'enter' - win = 'win' - ctrl = 'ctrl' - alt = 'alt' - elif sys.platform == 'darwin': - enter = 'return' - ctrl = 'command' - return ctrl, enter, win, alt +def finish(): + """ + Closes all open windows and exits the application. + This method is used to automate the process of closing all open windows and exiting the application. -def finish(): + """ # clean up and close all try: if sys.platform == 'linux' or sys.platform == 'linux2': @@ -126,19 +129,30 @@ def finish(): raise -def start(target=None, duration=120): +def start(target=None, duration=120, dry_run=False): """ - This function runs the above functions as different processes at the same time and can be - controlled from here. (This is the main process.) + Starts the automation process. + + :param target: A function representing the target task to be automated. Default is None. + :param duration: An integer representing the duration of the recording in seconds. Default is 120. + :param dry_run: A boolean indicating whether to run in dry-run mode or not. Default is False. + :return: None + + Note: Uncomment the line pag.press('q') if recording windows do not close in some cases. """ + if platform.system() == 'Linux': + # makes shure the keyboard is set to US + os.system("setxkbmap -layout us") if target is None: return p1 = multiprocessing.Process(target=call_msui) p2 = multiprocessing.Process(target=target) - p3 = multiprocessing.Process(target=call_recorder, kwargs={"duration": duration}) + if not dry_run: + p3 = multiprocessing.Process(target=call_recorder, kwargs={"duration": duration}) + p3.start() print("\nINFO : Starting Automation.....\n") - p3.start() + pag.sleep(5) initial_ops() p1.start() @@ -146,7 +160,349 @@ def start(target=None, duration=120): p2.join() p1.join() - p3.join() + if not dry_run: + p3.join() print("\n\nINFO : Automation Completes Successfully!") # pag.press('q') # In some cases, recording windows does not closes. So it needs to ne there. sys.exit() + + +def create_tutorial_images(): + """ + + This method `create_tutorial_images` is used to simulate the keyboard key + combination 'Ctrl + F' and then puts the program to sleep for 1 second. + + """ + pag.hotkey('ctrl', 'f') + pag.sleep(1) + + +def get_region(image, region=None): + """ + Find the region of the given image on the screen. + + :param image: The image to locate on the screen. + :return: The region of the image found on the screen. + :rtype: tuple(int, int, int, int) + """ + if region is not None: + image_region = pag.locateOnScreen(picture(image), region=region) + else: + image_region = pag.locateOnScreen(picture(image)) + return image_region + + +def click_center_on_screen(pic, duration=2, xoffset=0, yoffset=0, region=None, click=True): + """ + Clicks the center of an image on the screen. + + :param pic: The image file or partial image file to locate on the screen. + :param duration: The duration (in seconds) for the click action. Default is 2 seconds. + :param xoffset: The horizontal offset from the center of the image. Default is 0. + :param yoffset: The vertical offset from the center of the image. Default is 0. + :param region: The region on the screen to search for the image. Default is None, which searches the entire screen. + :param click: Indicates whether to perform the click action. Default is True. + + :return: None + """ + if region is None: + x, y = pag.locateCenterOnScreen(pic) + else: + x, y = pag.locateCenterOnScreen(pic, region=region) + if click: + pag.click(x + xoffset, y + yoffset, duration=duration) + + +def select_listelement(steps, sleep=5, key=ENTER): + """ + Selects an element from a list by moving the cursor downward and pressing a key. + + :param steps: Number of times to move the cursor downward. + :param sleep: Time to sleep after pressing the key (default is 5 seconds). + :param key: Key to press after moving the cursor (default is 'ENTER'). + :return: None + """ + pag.press('down', presses=steps, interval=0.5) + if key is not None: + pag.press(key, interval=1) + pag.sleep(sleep) + + +def find_and_click_picture(pic_name, exception_message=None, duration=2, xoffset=0, yoffset=0, + bounding_box=None, region=None, click=True): + """ + + Finds a specified picture and clicks on it. + When the image can't be found, an exception is raised and a failure.png image is created + + :param pic_name: The name of the picture to find. This can be a file name or a string pattern. + :param exception_message: Optional. Custom exception message to be displayed if the picture is not found. + Defaults to None. + :param duration: Optional. The duration of the click in seconds. Defaults to 2. + :param xoffset: Optional. The x-axis offset for the click position. Defaults to 0. + :param yoffset: Optional. The y-axis offset for the click position. Defaults to 0. + :param bounding_box: Optional. The bounding box for the search area. Defaults to None. + :param region: Optional. The region in which to search for the picture. Defaults to None. + :param click: Optional. Indicates whether to perform the click action. Defaults to True. + + :raises ImageNotFoundException: If the picture is not found. + :raises OSError: If there is an error while processing the picture. + :raises Exception: If any other exception occurs. + + :returns: A tuple containing the x and y coordinates of the clicked position. + """ + x, y = (0, 0) + message = exception_message if exception_message is not None else f"{pic_name} not found" + try: + click_center_on_screen(picture(pic_name, bounding_box=bounding_box), + duration, xoffset=xoffset, yoffset=yoffset, region=region, click=click) + x, y = pag.position() + # ToDo verify + # pag.moveTo(x, y, duration=duration) + pag.sleep(1) + except (ImageNotFoundException, OSError, Exception): + filename = os.path.join(MSUI_CONFIG_PATH, "failure.png") + print(f"\nException: {message} see {filename} for details") + im = pag.screenshot(region=region) + im.save(filename) + raise + + return (x, y) + + +def load_kml_file(pic_name, file_path, exception_message): + """ + Loads a KML file using the given picture name and file path. + + :param pic_name: The name of the picture to be found and clicked. + :param file_path: The path to the KML file. + :param exception_message: The exception message to be printed and raised if an error occurs. + :raises ImageNotFoundException: If the specified picture cannot be found. + :raises OSError: If an error occurs while typing the file path or pressing the ENTER key. + :raises Exception: If an unknown error occurs. + + """ + try: + find_and_click_picture(pic_name, exception_message) + pag.typewrite(file_path, interval=0.1) + pag.sleep(1) + pag.press(ENTER) + except (ImageNotFoundException, OSError, Exception): + print(exception_message) + raise + + +def change_color(pic_name, exception_message, actions, interval=2, sleep_time=2): + """ + Changes the color of the specified picture and performs the given actions. + """ + try: + click_center_on_screen(picture(pic_name), interval) + pag.sleep(sleep_time) + actions() + except (ImageNotFoundException, OSError, Exception): + print(f"\nException: {exception_message}") + raise + + +def zoom_in(pic_name, exception_message, move=(379, 205), dragRel=(70, 75), region=None): + """ + This method locates a given picture on the screen, clicks on it, moves the mouse cursor, + performs a drag motion, waits for 5 seconds, and raises an exception if the picture is not found + + :param pic_name: The name of the picture to locate on the screen. + :param exception_message: The message to be displayed in case the picture is not found. + :param move: The amount to move the mouse cursor horizontally and vertically after clicking on the picture. + Defaults to (379, 205). + :param dragRel: The amount to drag the mouse cursor horizontally and vertically after moving. + Defaults to (70, 75). + :param region: The specific region of the screen to search for the picture. + Defaults to None, which means the entire screen will be searched. + """ + try: + x, y = pag.locateCenterOnScreen(picture(pic_name), region=region) + pag.click(x, y, interval=2) + pag.move(move[0], move[1], duration=1) + pag.dragRel(dragRel[0], dragRel[1], duration=2) + pag.sleep(5) + except ImageNotFoundException: + print(f"\nException: {exception_message}") + raise + + +def panning(pic_name, exception_message, moveRel=(400, 400), dragRel=(-100, -50), region=None): + """ + Executes panning action on the screen. + + :param pic_name: The name of the picture file to locate on the screen. + :param exception_message: The message to display in case of exceptions. + :param moveRel: The relative movements to be made after clicking on the picture. Defaults to (400, 400). + :param dragRel: The relative movements to be made during the dragging action. Defaults to (-100, -50). + :param region: The region of the screen to search for the picture. Defaults to None. + """ + try: + x, y = pag.locateCenterOnScreen(picture(pic_name), region=region) + pag.click(x, y, interval=2) + pag.moveRel(moveRel[0], moveRel[1], duration=1) + pag.dragRel(dragRel[0], dragRel[1], duration=2) + except (ImageNotFoundException, OSError, Exception): + print(f"\nException: {exception_message}") + raise + + +def type_and_key(value, interval=0.1, key=ENTER): + """ + Type and Enter method + + This method types the given value and then presses the Enter key on the keyboard. + + :param value (str): The value to be typed. + :param interval (float, optional): The interval between typing each character. Defaults to 0.3 seconds. + """ + pag.hotkey(CTRL, 'a') + pag.sleep(1) + pag.typewrite(value, interval=interval) + pag.sleep(1) + pag.press(key) + + +def move_window(os_screen_region, x_drag_rel, y_drag_rel, x_mouse_down_offset=100): + """ + + Move the window to a new position. + + :param os_screen_region: A tuple containing the screen region of the window to be moved. + It should have the format (x, y, w, h), where x and y are the coordinates of the top-left + corner of the window, and w and h are the width and height of the window, respectively. + :param x_drag_rel: The amount to drag the window horizontally relative to its current position. + Positive values will move the window to the right, while negative values will move it + to the left. + :param y_drag_rel: The amount to drag the window vertically relative to its current position. + Positive values will move the window down, while negative values will move it up. + :param x_mouse_down_offset: The offset from the left corner of the window where the mouse button + will be pressed. + This is useful to avoid clicking on any buttons or icons within the window. The default value is 100. + + Example usage: + os_screen_region = (100, 200, 800, 600) + x_drag_rel = 100 + y_drag_rel = 50 + move_window(os_screen_region, x_drag_rel, y_drag_rel) + + This will move the window located at (100, 200) to a new position that is 100 pixels to the right and 50 pixels + down from its current position. + + """ + x, y = os_screen_region[0:2] + # x, y is left corner where the msui logo is + pag.mouseDown(x + x_mouse_down_offset, y - 10, duration=10) + pag.sleep(1) + pag.dragRel(x_drag_rel, y_drag_rel, duration=2) + pag.mouseUp() + + +def move_and_setup_layerchooser(os_screen_region, x_move, y_move, x_drag_rel, y_drag_rel, x_mouse_down_offset=220): + """ + + Move and set up the layer chooser in a given screen region. + + :param os_screen_region: The screen region where the actions will be performed. + :param x_move: The horizontal distance to move the mouse cursor. + :param y_move: The vertical distance to move the mouse cursor. + :param x_drag_rel: The horizontal distance to drag the mouse cursor relative to its current position. + :param y_drag_rel: The vertical distance to drag the mouse cursor relative to its current position. + :param x_mouse_down_offset (optional): The offset from the left corner of the window where the mouse button + will be pressed. This is useful to avoid clicking on any buttons or icons within the window. Defaults to 220. + + + Example Usage: + move_and_setup_layerchooser((0, 0, 1920, 1080), 100, -50, 200, 100, x_mouse_down_offset=300) + move_and_setup_layerchooser((0, 0, 1920, 1080), -50, 0, 100, 200) + + """ + find_and_click_picture('multilayersdialog-http-localhost-8081.png', + 'Url not found', region=os_screen_region) + x, y = pag.position() + pag.click(x + x_mouse_down_offset, y, interval=2) + type_and_key('http://open-mss.org/', interval=0.1) + try: + find_and_click_picture('multilayersdialog-get-capabilities.png', + 'Get capabilities not found', region=os_screen_region) + except TypeError: + pag.press(ENTER) + pag.move(x_move, y_move, duration=1) + pag.dragRel(x_drag_rel, y_drag_rel, duration=2) + + +def show_other_widgets(): + """ + Displays other widgets in the application. + + This method shows the sideview, linearview, and topview of the application. + It uses the `pag` module from the PyAutoGUI library to simulate key presses. + + Note: + - The 'altleft' key is pressed and released in the following sections to navigate through the application. + - The 'tab' key is pressed multiple times to switch between different views. + + Example usage: + show_other_widgets() + + """ + # show sideview + pag.keyDown('altleft') + pag.press('tab') + pag.press('tab') + pag.keyUp('altleft') + pag.sleep(1) + # show linearview also + pag.keyDown('altleft') + pag.press('tab') + pag.keyUp('altleft') + # show topview also + pag.keyDown('altleft') + pag.press('tab') + pag.press('tab') + pag.press('tab') + pag.keyUp('altleft') + pag.sleep(1) + + +def msui_full_screen_and_open_first_view(view_cmd='h'): + """ + Open the first view and go full screen in MSUI. + + :param view_cmd: The command to open the view (default is 'h' for Home). + :type view_cmd: str + + :return: None + """ + hotkey = WIN, 'pageup' + pag.hotkey(*hotkey) + pag.sleep(1) + if view_cmd is not None: + pag.hotkey(CTRL, view_cmd) + pag.sleep(1) + create_tutorial_images() + pag.sleep(2) + + +def add_waypoints_to_topview(os_screen_region): + # enable adding waypoints + find_and_click_picture('topviewwindow-ins-wp.png', + 'Clickable button/option not found.', + region=os_screen_region) + # Adding waypoints for demonstrating remote sensing + pag.move(-50, 150, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(65, 65, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(-150, 30, duration=1) + pag.click(interval=2) + pag.sleep(1) + pag.move(200, 150, duration=1) + pag.click(interval=2) + pag.sleep(2) diff --git a/tutorials/utils/constants.py b/tutorials/utils/constants.py index 1e375604e..d6402d85b 100644 --- a/tutorials/utils/constants.py +++ b/tutorials/utils/constants.py @@ -10,7 +10,7 @@ :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V. :copyright: Copyright 2011-2014 Marc Rautenhaus (mr) - :copyright: Copyright 2016-2022 by the MSS team, see AUTHORS. + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tutorials/utils/picture.py b/tutorials/utils/picture.py new file mode 100644 index 000000000..34e1891d8 --- /dev/null +++ b/tutorials/utils/picture.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" + + mslib.tutorials.utils.picture + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module provides functions to read images for the different tutorials for comparison + + This file is part of MSS. + + :copyright: Copyright 2016-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import os +import time +from pathlib import Path +from slugify import slugify +from PIL import Image +from mslib.msui.constants import MSUI_CONFIG_PATH + + +def picture(name, bounding_box=None): + filename = os.path.join(MSUI_CONFIG_PATH, "tutorial_images", name) + if bounding_box is not None: + with Image.open(filename) as img: + cropped_img = img.crop(bounding_box) + part = '-'.join([str(val) for val in bounding_box]) + new_name = slugify(f'{Path(name).stem}-{part}') + filename = os.path.join(MSUI_CONFIG_PATH, "tutorial_images", f'{new_name}.png') + cropped_img.save(filename) + time.sleep(1) + return filename diff --git a/tutorials/utils/platform_keys.py b/tutorials/utils/platform_keys.py new file mode 100644 index 000000000..e8e956038 --- /dev/null +++ b/tutorials/utils/platform_keys.py @@ -0,0 +1,58 @@ +""" + msui.tutorials.utils.platform_keys + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Includes platform-specific modules + + This file is part of MSS. + + :copyright: Copyright 2021 Hrithik Kumar Verma + :copyright: Copyright 2021-2023 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import sys + + +def platform_keys(): + """ + Returns platform specific key mappings. + + Returns: + A tuple containing the key mappings for the current platform. + + Note: + The key mappings returned depend on the value of `sys.platform`. + For Linux, the return values are ('ctrl', 'enter', 'winleft', 'altleft'). + For Windows, the return values are ('ctrl', 'enter', 'win', 'alt'). + For macOS, the return values are ('command', 'return'). + + Example: + ctrl, enter, win, alt = platform_keys() + """ + # sys.platform specific keyse + if sys.platform == 'linux' or sys.platform == 'linux2': + enter = 'enter' + win = 'winleft' + ctrl = 'ctrl' + alt = 'altleft' + elif sys.platform == 'win32': + enter = 'enter' + win = 'win' + ctrl = 'ctrl' + alt = 'alt' + elif sys.platform == 'darwin': + enter = 'return' + ctrl = 'command' + return ctrl, enter, win, alt