diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..9570efb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: stable 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 b5732cc43..2ca8f13a6 100644 --- a/.github/workflows/build_docs_gallery.yml +++ b/.github/workflows/build_docs_gallery.yml @@ -2,36 +2,19 @@ name: Build Gallery on: pull_request: - inputs: - branch_name: - required: true - type: string - secrets: - PAT: - required: true - -env: - PAT: ${{ secrets.PAT }} jobs: Test-MSS-docs: runs-on: ubuntu-latest - defaults: - run: - shell: bash - container: image: openmss/testing-develop steps: - - name: Trust My Directory - run: git config --global --add safe.directory /__w/MSS/MSS - uses: actions/checkout@v4 - name: Create gallery - timeout-minutes: 25 + timeout-minutes: 5 run: | - source /opt/conda/bin/activate mssenv \ - && cd $GITHUB_WORKSPACE/docs \ - && python conf.py + cd docs + mamba run --no-capture-output -n mssenv python conf.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 638be9c89..6c8e77ee5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,10 +5,12 @@ on: branches: - develop - stable + - 'GSOC**' pull_request: branches: - develop - stable + - 'GSOC**' jobs: flake8: @@ -16,14 +18,14 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.8 + - 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 no-crlf-in-git: diff --git a/.github/workflows/testing-all-oses.yml b/.github/workflows/testing-all-oses.yml new file mode 100644 index 000000000..92f38804c --- /dev/null +++ b/.github/workflows/testing-all-oses.yml @@ -0,0 +1,53 @@ +name: Test MSS + +on: + push: + branches: + - develop + - stable + - 'GSOC**' + pull_request: + branches: + - develop + - stable + - 'GSOC**' + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["macos-13", "macos-14", "ubuntu-latest"] + steps: + - uses: actions/checkout@v4 + - name: Build requirements.txt file + run: | + sed -n '/^requirements:/,/^test:/p' localbuild/meta.yaml | + sed -e "s/.*- //" | + sed -e "s/menuinst.*//" | + sed -e "s/.*://" > requirements.tmp.txt + cat requirements.d/development.txt >> requirements.tmp.txt + echo "pytest-randomly" >> requirements.tmp.txt + sed -e '/^$/d' -e '/^#.*$/d' requirements.tmp.txt > requirements.txt + rm requirements.tmp.txt + cat requirements.txt + - name: Get current year and calendar week + id: year-and-week + run: echo "year-and-week=$(date +%Y-%V)" >> "$GITHUB_OUTPUT" + - uses: mamba-org/setup-micromamba@v1 + with: + environment-file: requirements.txt + environment-name: ci + cache-environment: true + # Set the cache key in a way that the cache is invalidated every week on monday + cache-environment-key: environment-${{ steps.year-and-week.outputs.year-and-week }} + - name: Run tests + timeout-minutes: 20 + # The ignored files can somehow cause the test suite to timeout. + # I have no idea yet on why this happens and how to fix it. + # Even a module level skip is not enough, they need to be completely ignored. + # TODO: fix those tests and drop the ignores + run: micromamba run -n ci env QT_QPA_PLATFORM=offscreen pytest -v -n logical --durations=20 --cov=mslib + --ignore=tests/_test_msui/test_sideview.py --ignore=tests/_test_msui/test_topview.py --ignore=tests/_test_msui/test_wms_control.py + tests diff --git a/.github/workflows/testing-develop.yml b/.github/workflows/testing-develop.yml index 79fe6ff5b..b3ee2c3c5 100644 --- a/.github/workflows/testing-develop.yml +++ b/.github/workflows/testing-develop.yml @@ -4,28 +4,16 @@ on: push: branches: - develop - pull_request: branches: - develop + workflow_dispatch: jobs: test-develop: 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 }} + image_suffix: develop secrets: PAT: ${{ secrets.PAT }} diff --git a/.github/workflows/testing-gsoc.yml b/.github/workflows/testing-gsoc.yml new file mode 100644 index 000000000..872f77d9c --- /dev/null +++ b/.github/workflows/testing-gsoc.yml @@ -0,0 +1,16 @@ +name: test GSoC + +on: + push: + branches: + - 'GSOC**' + pull_request: + branches: + - 'GSOC**' + +jobs: + test-gsoc: + uses: + ./.github/workflows/testing.yml + with: + image_suffix: develop diff --git a/.github/workflows/testing-scheduled.yml b/.github/workflows/testing-scheduled.yml index 2704fcfff..a4bab8ae0 100644 --- a/.github/workflows/testing-scheduled.yml +++ b/.github/workflows/testing-scheduled.yml @@ -4,24 +4,23 @@ on: schedule: - 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 }} + trigger-testing-stable: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - uses: benc-uk/workflow-dispatch@v1.2.3 + with: + workflow: testing-stable.yml + ref: stable - test-develop-scheduled: - uses: - ./.github/workflows/testing.yml - with: - xdist: no - branch_name: develop - event_name: ${{ github.event_name }} - secrets: - PAT: ${{ secrets.PAT }} + trigger-testing-develop: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - uses: benc-uk/workflow-dispatch@v1.2.3 + with: + workflow: testing-develop.yml + ref: develop diff --git a/.github/workflows/testing-stable.yml b/.github/workflows/testing-stable.yml index c52b6acd1..851dce7e2 100644 --- a/.github/workflows/testing-stable.yml +++ b/.github/workflows/testing-stable.yml @@ -14,18 +14,6 @@ jobs: 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 }} + image_suffix: stable secrets: PAT: ${{ secrets.PAT }} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c94b1a44a..0e1f739f6 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,131 +3,77 @@ name: Pytest MSS on: workflow_call: inputs: - xdist: - required: true - type: string - branch_name: - required: true - type: string - event_name: + image_suffix: required: true type: string secrets: PAT: - required: true env: - PAT: ${{ secrets.PAT }} - EVENT: ${{ inputs.event_name }} + mamba-env: mss-${{ inputs.image_suffix }}-env jobs: Test-MSS: runs-on: ubuntu-latest - defaults: - run: - shell: bash - container: - image: openmss/testing-${{ inputs.branch_name }} + image: openmss/testing-${{ inputs.image_suffix }} - steps: - - name: Trust My Directory - run: git config --global --add safe.directory /__w/MSS/MSS + strategy: + fail-fast: false + matrix: + order: ["normal", "reverse"] + steps: - uses: actions/checkout@v4 - name: Check for changed dependencies - run: | - cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt \ - || (echo Dependencies differ \ - && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) - - - name: Install pyvirtualdisplay if on stable - if: ${{ inputs.branch_name == 'stable' }} - run: | - source /opt/conda/etc/profile.d/conda.sh - source /opt/conda/etc/profile.d/mamba.sh - mamba install -n mss-${{ inputs.branch_name }}-env pyvirtualdisplay + run: cmp -s /meta.yaml localbuild/meta.yaml && cmp -s /development.txt requirements.d/development.txt || + (echo Dependencies differ && echo "triggerdockerbuild=yes" >> $GITHUB_ENV ) - name: Always rebuild dependencies for scheduled builds (started from testing-scheduled.yml) - if: ${{ inputs.event_name == 'workflow_dispatch' }} + if: ${{ github.event_name == 'workflow_dispatch' }} run: echo "triggerdockerbuild=yes" >> $GITHUB_ENV - name: Invoke dockertesting image creation # The image creation is intentionally only triggered for push events because # scheduled tests should just check that new dependency versions do not break the # tests, but should not update the image. - if: ${{ inputs.event_name == 'push' && env.triggerdockerbuild == 'yes' && inputs.xdist == 'no' }} + if: ${{ (github.ref_name == 'stable' || github.ref_name == 'develop') && github.event_name == 'push' && env.triggerdockerbuild == 'yes' && matrix.order == 'normal' }} uses: benc-uk/workflow-dispatch@v1.2.3 with: - workflow: Update Image testing-${{ inputs.branch_name }} + workflow: Update Image testing-${{ inputs.image_suffix }} repo: Open-MSS/dockertesting ref: main token: ${{ secrets.PAT }} - name: Reinstall dependencies if changed - if: ${{ success() && env.triggerdockerbuild == 'yes' }} + if: ${{ env.triggerdockerbuild == 'yes' }} run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && mamba deactivate \ - && cat localbuild/meta.yaml \ - | sed -n '/^requirements:/,/^test:/p' \ - | sed -e "s/.*- //" \ - | sed -e "s/menuinst.*//" \ - | sed -e "s/.*://" > reqs.txt \ - && cat requirements.d/development.txt >> reqs.txt \ - && 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 + cat localbuild/meta.yaml | + sed -n '/^requirements:/,/^test:/p' | + sed -e "s/.*- //" | + sed -e "s/menuinst.*//" | + sed -e "s/.*://" > reqs.txt + cat requirements.d/development.txt >> reqs.txt + cat reqs.txt + mamba env remove -n ${{ env.mamba-env }} + mamba create -y -n ${{ env.mamba-env }} --file reqs.txt - name: Print conda list - run: | - source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && mamba list + run: mamba run --no-capture-output -n ${{ env.mamba-env }} mamba list - name: Run tests - 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 - 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) + timeout-minutes: 10 + run: mamba run --no-capture-output -n ${{ env.mamba-env }} xvfb-run pytest + -v -n 6 --dist loadfile --max-worker-restart 4 --durations=20 --cov=mslib + ${{ (matrix.order == 'normal' && ' ') || (matrix.order == 'reverse' && '--reverse') }} tests - name: Collect coverage - if: ${{ success() && inputs.event_name == 'push' && inputs.branch_name == 'develop' && inputs.xdist == 'no'}} + if: ${{ (github.event_name == 'push' || github.event_name == 'pull_request') && matrix.order == 'normal' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - cd $GITHUB_WORKSPACE \ - && source /opt/conda/etc/profile.d/conda.sh \ - && source /opt/conda/etc/profile.d/mamba.sh \ - && mamba activate mss-${{ inputs.branch_name }}-env \ - && mamba install coveralls \ - && coveralls --service=github + git config --global --add safe.directory /__w/MSS/MSS + mamba install -n ${{ env.mamba-env }} coveralls + mamba run --no-capture-output -n ${{ env.mamba-env }} coveralls --service=github diff --git a/.gitignore b/.gitignore index 793657576..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 diff --git a/CHANGES.rst b/CHANGES.rst index edca88e5e..a4d234058 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,36 @@ Changelog ========= +Version 9.0.0 +~~~~~~~~~~~~~ + +Nilupul Manodya implemented SAML 2.0 (Security Assertion Markup Language) Authentication. +MSColab can now be configured with an existing IdP or multiple IdPs. +In this way, a user can authenticate themselves in one system and gain access to another system +by providing proof of authentication. In our documentation in the Components section you find a detailed description. + +Matthias Riße refactored the test suite and optimized and accelerated our CI test runs. +All tests run in parallel now and are not retried upon failure, and most tests are also executed in a randomized order, instilling more confidence in the results. +Quite a bit of duplicated code was unified, mostly using pytest fixtures. +Additionally, CI test runs now also happen on x86_64- and ARM-based macOS. + +Jörn Ungermann refactored various parts of MSColab for faster processing with fewer requests. + +Reimar Bauer refactored the tutorials and the documentation shows now mp4 files embedded in html5. + + +HINT: +~~~~~ +The syntax of the server configuration of MSColab and MSWMS has changed. We removed the class definition. +For MSColab we have added new configuration options related to SAML2. +The change on the MSColab server also required changes on the MSUI handling the MSColab login. These changes are +not backwards compatible. Server and Client must use versions >=9.0.0. +We introduced a MSCOLAB_auth_user_name in the users configuration which simplifies the login process. + +All changes: +https://github.com/Open-MSS/MSS/milestone/93?closed=1 + + Version 8.3.5 ~~~~~~~~~~~~~ @@ -123,8 +153,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/Menu/mss.ico b/Menu/mss.ico deleted file mode 100644 index 1fbd60b78..000000000 Binary files a/Menu/mss.ico and /dev/null differ diff --git a/Menu/msui_shortcut.json b/Menu/msui_shortcut.json deleted file mode 100644 index 7330988ff..000000000 --- a/Menu/msui_shortcut.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "menu_name": "Anaconda${PY_VER} ${PLATFORM}", - "menu_items": [ - { - "name": "MSUI", - "pywscript": "${PYTHON_SCRIPTS}/msui-script.py", - "icon": "${MENU_DIR}/mss.ico" - } - ] -} diff --git a/NOTICE b/NOTICE index 830742c76..f94f145cc 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 39e3d243d..ed3e5cb59 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 9ef2251b6..e596983d7 100644 --- a/conftest.py +++ b/conftest.py @@ -25,12 +25,11 @@ limitations under the License. """ -import importlib.machinery +import importlib.util import os import sys -import mock -import warnings -from PyQt5 import QtWidgets +import logging +import matplotlib # Disable pyc files sys.dont_write_bytecode = True @@ -40,10 +39,12 @@ import keyring from mslib.mswms.demodata import DataFiles import tests.constants as constants +from mslib.utils.loggerdef import configure_mpl_logger -# 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) +matplotlib_logger = configure_mpl_logger() + +# 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 +54,189 @@ 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') + +# enable SQLALCHEMY_ECHO +SQLALCHEMY_ECHO = True + +# mscolab file upload settings +UPLOAD_FOLDER = fs.path.join(DATA_DIR, 'uploads') +MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB + +# 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-2024 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 93f8fe102..d0aeca2d9 100644 --- a/docs/components.rst +++ b/docs/components.rst @@ -10,3 +10,5 @@ Components mscolab gentutorials mssautoplot + conf_sso_test_msscolab + sso_via_saml_mscolab diff --git a/docs/conf.py b/docs/conf.py index 26df56259..1b9fec3f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,11 +23,11 @@ from string import Template def get_tutorial_images(): - TUTORIAL_URL = "https://fz-juelich.sciebo.de/s/7DUjGMgP1HFvakG/download" - TUTORIAL_DIR = 'videos/gif' + TUTORIAL_URL = "https://fz-juelich.sciebo.de/s/KcF29hPNRzkxN6q/download" + TUTORIAL_DIR = 'videos/mp4' if not os.path.exists(TUTORIAL_DIR): os.makedirs(TUTORIAL_DIR) - TUTORIAL_ARCHIVE = 'videos/gif/tutorials.zip' + TUTORIAL_ARCHIVE = 'videos/mp4/tutorials.zip' if not os.path.exists(TUTORIAL_ARCHIVE): response = requests.get(TUTORIAL_URL) open(TUTORIAL_ARCHIVE, "wb").write(response.content) @@ -119,7 +119,10 @@ def get_tutorial_images(): # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx_rtd_theme'] +extensions = ['sphinx_rtd_theme', 'sphinxcontrib.video'] + +# raise a warning when a secondary source is missing. +video_enforce_extra_source = True # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -259,6 +262,8 @@ def get_tutorial_images(): # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". + +# videos is secondary source for sphinxcontrib-videos, everything below gets into _build/html/_static html_static_path = ['mss_theme', 'gallery/plots', 'videos'] # Add any extra paths that contain custom files (such as robots.txt or diff --git a/docs/conf_sso_test_msscolab.rst b/docs/conf_sso_test_msscolab.rst new file mode 100644 index 000000000..ad9ecaf19 --- /dev/null +++ b/docs/conf_sso_test_msscolab.rst @@ -0,0 +1,125 @@ +Configuration MSS Colab Server with Testing IdP for SSO +======================================================= +Testing IDP (`mslib/msidp`) is specifically designed for testing the Single Sign-On (SSO) process with the mscolab server using PySAML2. + +.. note:: + The important module `xmlsec1 `_ is not provided for windows operations systems. Therefore this feature is not available on windows. + +Here is documentation that explains the configuration of the MSS Colab Server with the testing IdP. + +.. warning:: + When running publicly rather than in development, you should not use the built-in development server ( msidp / idp.py ). + + The development server is provided by MSS for convenience, but is not designed to be particularly efficient, stable, or secure. + +Getting started +--------------- + +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 ``USERS`` and ``PASSWD`` section of the ``MSS/mslib/msidp/idp_user.py`` file. Look for the relevant section in the file to find the necessary login credentials. diff --git a/docs/development.rst b/docs/development.rst index 78bc54ca6..26f4dca81 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,25 +220,15 @@ 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. -Setup msui_settings.json for special tests -.......................................... - -On default all tests use default configuration defined in mslib.msui.MissionSupportSystemDefaultConfig. -If you want to overwrite this setup and try out a special configuration add an msui_settings.json -file to the testings base dir in your tmp directory. You call it by the custom `--msui_settings` option - - - Setup MSWMS server ------------------ @@ -343,9 +333,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 +382,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 +454,27 @@ As developer you should copy this directory and adjust the source path, build nu using a local meta.yaml recipe:: $ cd yourlocalbuild - $ conda build . - $ conda create -n mssbuildtest mamba - $ conda activate mssbuildtest - $ mamba install --use-local mss + $ mamba build . + $ mamba create -n mssbuildtest + $ mamba activate mssbuildtest + $ mamba install -c local mss Take care on removing alpha builds, or increase the build number for a new version. +Alternative local build by boa +------------------------------ + +`boa `_ is a new faster option to build conda packages. +We need first to convert the existing description to a recipe.yaml:: + + $ cd yourlocalbuild + $ boa convert meta.yaml > recipe.yaml + $ boa build . + $ mamba install -c local mss + + Creating a new release ---------------------- diff --git a/docs/environment.yml b/docs/environment.yml index 0764e97e4..8e703ec7b 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -15,12 +15,13 @@ dependencies: - xstatic - defusedxml - sphinx_rtd_theme + - sphinxcontrib-video - sphinx - fs - netCDF4 - - future - PyQt5 - owslib + - future - basemap >=1.3.3 - pint - python <3.12 diff --git a/docs/gentutorials.rst b/docs/gentutorials.rst index d1f0e59ff..6f40b8d9c 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 -is used to create all tutorials compressed to gifcycles. +`start_tutorial.sh` is to create one tutorial and `tutorials.batch` +is used to create all tutorials. 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 b4b347f6a..539a0222b 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -65,17 +65,6 @@ versions for dependencies. :: (mssenv) $ msui -.. hint:: - For installing MSS on the ARM-MAC Apple devices because not all dependencies - are crosscompiled you need to set an additional subdir. :: - - $ mamba create -n mssenv - $ mamba activate mssenv - (mssenv) $ mamba config --env --set subdir osx-64 - (mssenv) $ mamba install mss=$mss_version python - (mssenv) $ msui - - Mamba Server based installation example ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/mscolab.rst b/docs/mscolab.rst index 23cea506b..983cf2921 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 e383537c6..79b3a1eff 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 5529230f6..7249ee5fd 100644 --- a/docs/samples/config/mscolab/mscolab_settings.py.sample +++ b/docs/samples/config/mscolab/mscolab_settings.py.sample @@ -25,55 +25,83 @@ 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') + +# Set to True for testing and False for production +SQLALCHEMY_ECHO = False + +enable_basic_http_authentication = False + +# text to be written in new mscolab based ftml files. +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 e58cd32aa..a60bdc137 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..95ba29fc3 --- /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 (msidp is solely for development and testing purposes, do not use in production environments). + +.. note:: + You can find instructions to set up msidp by `conf_sso_test_msscolab.rst`. + + +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/tutorial.rst b/docs/tutorial.rst index ae693e88a..a188127e3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -6,7 +6,32 @@ During GSOC 2021 a bunch of new tutorials were programmed by Hrithik Kumar Verma a `public share `_ -Some videos about the Mission Support System (MSS) +Get familiar by some videos about the Mission Support System (MSS). + + + .. video:: _static/mp4/tutorial_waypoints.mp4 + :autoplay: + :loop: + :alt: This is the waypoints tutorial,i.e., whenever we are going to plan + a flight track, we have to place the waypoints in some places and, + form a flight path. + The origin is set at Nagpur, New Delhi being the first way point. + Clicking ADD WAY POINT BUTTON, we can add new way points. + The WAY POINTS 2,3,4 and 5 are added at required locations. + We are moving the WAY POINT NO.5 by clicking the MOVE WAY POINT button. + We can see that blue dot is demonstrating the click and we hit a WAY POINT. + Now, we are going to delete a WAY POINT, it is asking for the confirmation to delete it or not. + For placing the WAY POINTS and other operations, we have options to select Europe, Germany, and so on. + Selecting GLOBAL displays the world map. + For a customised map, we can click on zoom button, and drag the cursor over map, clicking left. + We can also pan the map. + Clicking Left and Right arrow buttons changes the map region to previous and next respectively. + Clicking Home button on the top left shows the global view of the map. + Finally we can save the map by clicking SAVE button. This saves the image in .png format. + + + + .. toctree:: :maxdepth: 4 diff --git a/docs/tutorials/tutorial_hexagoncontrol.rst b/docs/tutorials/tutorial_hexagoncontrol.rst index 8ca9faa2b..0c3dae428 100644 --- a/docs/tutorials/tutorial_hexagoncontrol.rst +++ b/docs/tutorials/tutorial_hexagoncontrol.rst @@ -1,7 +1,16 @@ -Tableview and hexagon flight patterns -------------------------------------- +Table View and Hexagon Flight Patterns +-------------------------------------- -For tomographic imaging, a hexagonal flight pattern with tablevin the flight path can be integrated. +For tomographic imaging, a hexagonal flight pattern can be integrated by a docking widget of Table View - - .. image:: /videos/gif/tutorial_hexagoncontrol.gif + .. video:: ../_static/mp4/tutorial_hexagoncontrol.mp4 + :alt: Top View windows is opened (CTRL+H). + We select the "global (cyl)" for the world map. + Zooming in for the required region. + Assuming Delhi as the center we are to place waypoints around it in a hexagonal fashion. + Opening Table View (CTRL+T). It contains the latitudinal and longitudinal locations of waypoints. + Doing this manually will not be perfect and hence, hexagon control! + Clicking on "(select to open control)", and selecting "(hexagonal control)" + This screen can be used to place the latitude and longitude of center, and radius of hexagon. + We can see the changes in the Top View. + Angle of first point can also be changed, which is by default set at 0. diff --git a/docs/tutorials/tutorial_introduction_topview.rst b/docs/tutorials/tutorial_introduction_topview.rst index 1c44c5022..cda45e8a7 100644 --- a/docs/tutorials/tutorial_introduction_topview.rst +++ b/docs/tutorials/tutorial_introduction_topview.rst @@ -1,6 +1,43 @@ -Topview and selecting of layers -------------------------------- +Top View and Selecting of Layers +-------------------------------- -Selection and display of different data in the topview with the help of the layer chooser. +Selection and display of different data in the Top View with the help of the layer chooser. - .. image:: /videos/gif/tutorial_wms.gif + .. video:: ../_static/mp4/tutorial_wms.mp4 + :alt: When we open the Top View (CTRL+H) of the map, the Web Map Service is already opened by default. + It collects its data from the server: "open-mss dot org" that provides demodata for the meteorological or + atmospheric informations as layer lists. + As we click on the "server layer" option, the layer list window opens that lists out various layers. + We just have to enter the WMS url and get capabilities for those layers. + So, here we find various layers: Divergence and Geopotential Height, + Equivalent potential temperature and geo potential height, etc. + When we click on the "Divergence" layer, some divergence in height that are marked by blue and + red lines are displayed. + Similarly, when we move on to the "Equivalent potential temperature" layer, + we find various temperature potentials at various places on the map. + We can also customize the levels, that is, the pressure levels after selecting a particular layer. + As we enter "temperature" in layer filter, all the relevant layers related to temperature are displayed. + In multilayering, we can see more than one layers such as temperature and pressure together + at the same time displayed onto the map. + Suppose we are using any layer frequently, we can just star it and find it easily using layer filter. + Now coming to "Divergence and Geopotential Height", we can check the map at various atmospheric pressures such + as at 150 hPa or 200 hPa or 500 hPa. + Initialisation shows the date and time from which the data is available. Here it is 17/10/12. + Valid shows the date and time till the data available is valid. + Here it is 17/10/12 and time is 12:00 UTC. + We can customize these points as per our requirement. + We can select the various initialization and valid interval slots as per availability. + For example, when we select 3 hours initialization time, we can see the date and time marked crossed + as there is no such initialization time ahead or back. + Similarly, when we select 6 hours valid time we can see the time changes to 18:00 UTC. + Coming to the auto-update, if it is checked then whatever is selected on the list gets updated on + the map automatically. + If the "auto update" option is not checked then we have to press retrieve after + each selection on the layer list. + If we select "use cache" as checked, then it takes less time to load the data from layer list + on the map but if its unchecked it takes more time to load. + We can also click on "clear cache" to clear the cache that is stored locally for the layer lists data. + There is a "delete trashcan button" on the top left corner of the layer list to clear all the + layers loaded from the server. + Clicking on remove deletes the layer data on the map. + Now we close the WMS layer list window. diff --git a/docs/tutorials/tutorial_kml.rst b/docs/tutorials/tutorial_kml.rst index 91d00bb4e..5d8c3d951 100644 --- a/docs/tutorials/tutorial_kml.rst +++ b/docs/tutorials/tutorial_kml.rst @@ -1,6 +1,26 @@ -Topview and KML data --------------------- +Top View and KML Data +--------------------- -es can be displayed in the Topview. Color and line width can be adjusted. +es can be displayed in the Top View. Color and line width can be adjusted. - .. image:: /videos/gif/tutorial_kml.gif + .. video:: ../_static/mp4/tutorial_kml.mp4 + :alt: Open the TopView (CTRL+H) + After clicking on "(select to open control)", click on KML OVERLAY. The UI will look as shown. + KML files can be used to show the geographical boundary which helps in planning the WAY POINTS. + We can put the WAY POINTS along this line. We will now see how to add, move, select KML files. + We can see two KML files here, one is COLOR.KML ; and the another one is FOLDER.KML. + COLOR.KML is in black colour, FOLDER.KML is denoted by black colour. + The box is checked that shows that these KML files are shown in the map, if unchecked, it will not show. + Now, we will add KML files by clicking on ADD KML FILES. + The dialogue will be opened and it will add the path where the KML file is present. + You can also select it through the file dialogue. + When we add a KML file, the default colour is Black and the default linewidth is 2 units. + Now, let's change colour and line width of the KML files. Before that, we add another KML file. + Both the KML files will be opened when we will open the GLOBAL map. + Both the files are in black colour. + Now, we will change colour of FOLDER.KML file by selecting it and clicking on CHANGE COLOUR. + We change it from Black to blue followed by blue to green. + Changing it for some time... + Now, we change line width by changing its numerical value whose range is 0 to 10. + We change it to different values to obtain different linewidths. + The top view is closed and the tutorial ends. \ No newline at end of file diff --git a/docs/tutorials/tutorial_mscolab.rst b/docs/tutorials/tutorial_mscolab.rst index 783235984..3aa014f12 100644 --- a/docs/tutorials/tutorial_mscolab.rst +++ b/docs/tutorials/tutorial_mscolab.rst @@ -1,7 +1,93 @@ Introduction to MSUI and MSColab -------------------------------- + Using the different views of the MSUI with a fictitious flight path and demo data. In comparison to the standalone mode of the MSUI an example setup of users is shown on a MSColab server and the possibilities of interactions. - .. image:: /videos/gif/tutorial_mscolab.gif + .. video:: ../_static/mp4/tutorial_mscolab.mp4 + :alt: MSColab stores data in an online server, and can be used to access the data remotely as also working in a + team where everyone contributes his part. It is used for collaborating with the users as a team together + and working on a shared MSColab operation. + In the window opened, enter MSColab url and click on connect. + "successfully connected to the MSColab server" + we can enter Email id and Password for an existing user, else we can click on Add user to register a new user. + Filling new user details, and clicking OK. + As, the user exists, we are logged in. + Now the user John Doe is logged in to MSColab. + He can now start working on shared operation. + Go to File and then New and then MSColab Operation option. + Add MSColab operation + MSS operation window opens + name the path, give the Description of the operation. + we can also choose a flight track file or dot FTML file, which is stored on our computer. + Add operation by clicking OK. + "your operation was successfully created" + Double click on the shared operation name to activate it. + While working on a shared file, we can control who can access this operation. + Go to Operation and then below Maintenance, "Manage Users." + Admin Window-MSS pops up. + The left side has list of users without permission, while the right side has a list of those users who have been granted an access to this operation. + In All Users without permission section, we can select all users at a time or deselect all of them, or we can search for a user. + We search for the user "test" in the server. + Now we empty the search bar. + After selecting users, we click on Add button, which moves these users to All users with permission section to the right side of the window. + For the users who have access permission to the operation, we can also change the access roles. + Here for b, we have changed the permission from collaborator to admin. + We can alo delete a user with a permission by selecting user and clicking on delete button. + Changing their access roles.... + We can filter users based on their roles being Collaborator, admin or viewer. + Filtering for some time.... + We can also communicate with fellow users in our operation. + Go to Operation and then Chat option. + Click on it. + MSColab chat window pops up. + The left pan shows the list of people who have access to this operation. + Enter the message in the space provided. + Typing the texts..... + click on send button, or press Enter. + Typing another message.... + we can upload files by clicking on upload and browsing for the file. + Here, we send the mss logo to all the users to see. + We can also search for a message by entering a phrase or complete text and then pressing previous or next as we need to find the message. + Next, we close the chat window. + Now, we will see how to manage version history. + We will go to operations and then click version history.. + Version history is basically a record in the changes performed in the waypoints of the flight track. + We can switch to a particular version history and name that version according to our choice or just checkout to that version. + In that case, the current waypoints will be changed to that version of waypoints and their locations. + Now we will just surf through all the version history. + We change the name of one version history to "Initial Waypoints" by clicking on "Name Version" + Now, we select another waypoint version and then checkout to that version by clicking on checkout button. + It asks for a confirmation. + Now we close the version history window... + We then select work asynchronously checkbox. It means that we are offline at present. + Later, we can save our changes to server or fetch new changes from the server changed by other people or the changes present on the server the last time. + We then open Top View and move the waypoints for making changes... + We then open drop down menu of "save to server." + It will open a window. + We can either click overwrite server waypoints with local waypoints or local waypoints with server waypoints. + We click overwrite with local waypoints. + The server waypoint changes with the work we have done locally. + Now it gives a confirmation message. "Added local waypoints to server" + Now we will close the window. + We will again start work synchronously with other users by unchecking "Work Unsynchronously." + We can switch between local flight tracks and shared operation by double clicking any one of them. + Remember, at any point only one flight track can be active, either local or shared. + Now we make changes in the Top View of local flight track. + Opening Top View and making the changes.... + Adding waypoints.... + We can see new waypoints are added here in a different fashion. + It is different from the first set of waypoints we have selected and added. + We now close the Top View. + The local flight track got changed. + Now for confirming, we open the shared flightrack by activating it. + We open Top View and can see it is different form local flightrack. + We now close the Top View and answer the confirmation. + We now delete the operation, see Maintenance in the Operation menu. + We have to retype its name for the confirmation for deleting it. + Now, we click on John Doe's User profile and we select the Profile of the user. + We can see his email and server its connected to! + Now, we log the user out. + We close the MSColab window and it asks for confirmation. + This is all about MSColab. diff --git a/docs/tutorials/tutorial_msui_views.rst b/docs/tutorials/tutorial_msui_views.rst index 9ac7e0418..7e1ba7dcd 100644 --- a/docs/tutorials/tutorial_msui_views.rst +++ b/docs/tutorials/tutorial_msui_views.rst @@ -1,6 +1,56 @@ Introduction to MSUI -------------------- + Using the different views of the MSUI with a fictitious flight path and demo data. - .. image:: /videos/gif/tutorial_views.gif + .. video:: ../_static/mp4//tutorial_views.mp4 + :alt: Lets look at the tutorial of the various views required for flight planning: + Top View (CTRL+H), Side View (CTRL+V), Linear View (CTRL+L) and Table View (CTRL+T). + At first, lets open Top View and Side View. + Now we will take some time to relocate it properly for a better visibility. + Now we will add some way points to plan the flight track. + As we make changes in the Top View, simultaneously changes are also made + in the Side View, Table View and Linear View. + Basically, the different views are just different ways to showcase the same data from different perspectives. + Flight moves from Nagpur to Delhi to waypoint number 2 to waypoint number 3 and so on. + Then look at the web map service to check whether the way points chosen are correct. + We are going to improvise the flight track with wms. + In the WMS, we will open the server layer and click "select layer" list. + Then through open-mss dot org we will derive the layer list and load the capabilities. + Now the layer list is loaded and we drag it down just to create some space. + Now we will select different layers : divergence, equivalent potential temperature, relative humidity, etcetera. + Now while changing the levels, we can see the map at 250 hPa relative humidity, + initialized from 17/10/2012 valid till 18/10/2012. + Based upon the analyzation on the map, we can change the waypoints like we can drag waypoints. + Thus wms helps us in analyzing and planning the way points on the map and plan the flight tracks flawlessly. + The wms layer list is different for top view and side view. + In the top view we can see various layers of pressure. + Similarly, now we click on the side view layer list and derive data from open-mss dot org. + Click on cloud cover vertical section and we can see blue clouds on the side view map. + Similarly, we can see horizontal wind, specific humidity, temperature and so on. + We can move the way points to higher pressure add new wazpoints and so on. + Now lets close the layer list and open table view and enter the latitudinal and longitudinal data + and simultaneously see changes in the top and side view. + We can click on way point 1 and it has list of options to select in the naming of the waypoints. + Suppose we select Brindisi, the latitude and longitude gets changed accordingly. + For the way point 4 we set the user defined name Stop Point. In this case the latitude and longitude + remains the same, only the way point gets named. + In this table we can directly enter the values. + To insert a new way point, we can just click on sixth row and then clone + and a completely new way point is created below way point 6 with same values. + Simultaneously, we can see the Top View and Side View also gets changed. + Click on the delete option to delete way point 8. + Clicking on reverse, the entire direction of flight track gets reversed. + Way point 0 now appears on the place of way point 11 and way point 11 appears on the place of way point 0. + Now lets close the table view and open Linear View and see simultaneous changes in Linear and Side View. + In linear view, we see linearly how the flight profile gets changed with the change in latitude and longitude. + Linear View also has its own wms and layer list and we derive the layer list using open-mss dot org and + retrieve the capabilities present in this layer. + Select horizontal wind and we can see the difference of horizontal wind at various way points denoted by lines. + Similarly we will select other layers and see its graphical presentation on the linear view. + In linear view we can only see the graphical representations of layer list data. + To add or delete way points we have to use the Top or Side Views. + Finally we close the linear view. + Now closing the side view.... + Now closing the top view.... diff --git a/docs/tutorials/tutorial_performance_settings.rst b/docs/tutorials/tutorial_performance_settings.rst index b9c5c4a84..82954355a 100644 --- a/docs/tutorials/tutorial_performance_settings.rst +++ b/docs/tutorials/tutorial_performance_settings.rst @@ -1,6 +1,33 @@ -Tableview and aircraft performance data ---------------------------------------- +Table View and Aircraft Performance Data +---------------------------------------- The range-specific data of an aircraft can be taken into account in Tableview for flight planning. - .. image:: /videos/gif/tutorial_performancesettings.gif + .. video:: ../_static/mp4/tutorial_performancesettings.mp4 + :alt: This is the Performance Settings dockwidget opened in the Table View (CTRL+T) of the MSS software + where by putting and changing some parameters, we can evaluate the performance of the aircraft. + Parameters like Flight Altitude, Aviation fuel, Aircraft weight, Maximum take off weight + from one point to other in flight planning, etcetera, can be evaluated. + Click on "(select to open control)" to open the "Performance Settings" dockwidget. + There will be different attributes in the table view which will be used to evaluate the + performance data of the dummy aircraft from this performance settings. + As we open the performance settings dockwidget, the panel for the settings opens up. + As we don’t have a real aircraft, we’ll take a dummy aircraft. + Now we’ll have to select a file called dot json file stored in the MSS database in the syntax of dot json. + The information related to the performance data of the aircraft are stored here. + Click on the "Select" option to select the Json file from the MSS docs database as an example. + Click on show performance for the related data to be shown. + You can see the changes in the columns of the table view regarding the performance parameters of the aircraft. + Giving some examples, things like the change in flight time, fuel, aircraft weight, ceiling altitude, + etc. as the flight takes off from Nagpur and reaches Delhi, can be analysed. + There are options in the panel like take off time, maximum take off weight and aircraft weight without fuel. + We can change these settings to see the how the performance data changes. + Like, we now changed the maximum take off weight from ninety one thousands to eighty seven thousand kilograms. + And the aircraft weight without fuel from fifty six thousands to forty eight thousands kilograms. + Here you can see we changed the take off time from 19th May 2024 to 19th October 2024. + Then we can uncheck the box of show Performance and then click it again and there you can see the changes + in the performance settings. + It just shows and hides the performance data. + Now we are going to close the Top View. + It asks for confirmation whether we want to close it or not. + We select yes in the confirmation dialogue and it closes. diff --git a/docs/tutorials/tutorial_remotesensing.rst b/docs/tutorials/tutorial_remotesensing.rst index b765f7bec..e1a1d1253 100644 --- a/docs/tutorials/tutorial_remotesensing.rst +++ b/docs/tutorials/tutorial_remotesensing.rst @@ -1,7 +1,36 @@ -Topview and remotesensing tools -------------------------------- +Top View and Remotesensing Tools +-------------------------------- In order to be able to take into account the viewing angle and solar level for measuring instruments, the remotesensing tools are used - .. image:: /videos/gif/tutorial_remotesensing.gif + .. video:: ../_static/mp4/tutorial_remotesensing.mp4 + :alt: This is the Remote Sensing Section of the Top View. + It shows the position and angle of the flight from any particular object in the sky. + Azimuth is the forward direction line of the flight. If we go above tHE AZIMUTH, angle is in positive, + and if we descend down, angle is in negative. + The Top View (CTRL+H) will be used to see what "Remote Sensing Tools" is. + From "(select to open control)", we select- "Remote Sensing Tools". + Our main objective here is to find out the disturbance in visibility due to light source + from any illuminating object like sun. + The solar angle colour in dark to light is 0 to 15 for RED and 15 to 45 for violet and 45 to 180 for green. + We can show angles relative to "sun", "venus" and"moon". + "total (horizon)" is the light of the surroundings of the flight. + We will add some WAY POINTS numbered as 2,3,4,5. + After clicking on "show agle (degree)", the flight track's colour has changed to green or light green. + The flight is at 90° from azimuth and -1 shows that flight is descended down from azimuth by one degree. + Showing the colours in the flight path connecting waypoints, green colour here shows that our solar angle + is in range from 45 to 180 degree. + We will now look for "moon". There is no significant change. + And for "venus", the colour of way point changed to light green. + Changing "total (horizon)" to "total" changes the colour of way point from green to violet. + Changing "total (horizon)" to "elevation" changes the colour of way point from green to red. + Now for "venus", after changing the angle from 90 to 45 degree, the color changed to light green. + Changing "elevation" from -1 to -2 or -3 degree doesn't bring any significant change in colour of the way point. + We will now look for "draw tangent points". We can choose colors for the tangent points. + We keep it red for clear vision. + kilometers can also be changed to know the distance of tangent point. + We zoom it for for more understanding. The flight path is able to view a distance of 20 km from + the help of tangent point. + The tangent lines changes its direction on changing angle to 120° as it changes clockwise on increasing + the angles or 75° as it changes anticlockwise on decreasing the angles. diff --git a/docs/tutorials/tutorial_satellitetrack.rst b/docs/tutorials/tutorial_satellitetrack.rst index 34ec96c16..f36dcb2ce 100644 --- a/docs/tutorials/tutorial_satellitetrack.rst +++ b/docs/tutorials/tutorial_satellitetrack.rst @@ -1,7 +1,25 @@ -Topview and satellite overflight ---------------------------------- +Top View and Satellite Overflight +---------------------------------- To combine a flight path with a satellite overflight, the remotesensing widget is used. - .. image:: /videos/gif/tutorial_satellitetrack.gif + .. video:: ../_static/mp4/tutorial_satellitetrack.mp4 + :alt: This is Satellite Tracking Prediction System that can be used to check the accuracy of the path + travelled by a Satellite by the help of data collected from different space agencies and planning + a flight accordingly. + These flights which can be refered to as the Testing flights are made to travel in + the direction of these Satellites by collaborating with these space agencies. + Open the Top View (CTRL+H) of the MSS software. + Click on “(select to open control)“ and then click “Satellite Tracks“. + You will see the option :“File with predicted satellite track” where you can + load the file retrieved from the space agencies. + After opening it, the “Predicted Satellite Overpasses” gets activated where you can put the time + and date frames for the overpassing satellite to be checked. + Click on the Link given below in blue color to add new files for predicting Satellite track. + Select the date and time frame from the drop down menu for which you want to check + the overpassing of the Satellite. + We traverse through the various options of date and time inside the overpass selector for a while. + After selecting the Time Frame, Zoom in the map to see the Satellite Tracks. + Select the Starting way point and the ending way point in parallel to the flight as shown by + the satellite overpasses path. diff --git a/docs/tutorials/tutorial_waypoints.rst b/docs/tutorials/tutorial_waypoints.rst index a316a5bec..e43d21f3d 100644 --- a/docs/tutorials/tutorial_waypoints.rst +++ b/docs/tutorials/tutorial_waypoints.rst @@ -1,6 +1,22 @@ -Topview drawing waypoints +Top View Drawing Waypoints -------------------------- Waypoints for a flight path are defined, shifted and deleted. - .. image:: /videos/gif/tutorial_waypoints.gif + .. video:: ../_static/mp4/tutorial_waypoints.mp4 + :alt: This is the waypoints tutorial,i.e., whenever we are going to plan + a flight track, we have to place the waypoints in some places and, + form a flight path. + The origin is set at Nagpur, New Delhi being the first way point. + Clicking ADD WAY POINT BUTTON, we can add new way points. + The WAY POINTS 2,3,4 and 5 are added at required locations. + We are moving the WAY POINT NO.5 by clicking the MOVE WAY POINT button. + We can see that blue dot is demonstrating the click and we hit a WAY POINT. + Now, we are going to delete a WAY POINT, it is asking for the confirmation to delete it or not. + For placing the WAY POINTS and other operations, we have options to select Europe, Germany, and so on. + Selecting GLOBAL displays the world map. + For a customised map, we can click on zoom button, and drag the cursor over map, clicking left. + We can also pan the map. + Clicking Left and Right arrow buttons changes the map region to previous and next respectively. + Clicking Home button on the top left shows the global view of the map. + Finally we can save the map by clicking SAVE button. This saves the image in .png format. diff --git a/docs/usage.rst b/docs/usage.rst index 59682233f..7dae56fba 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/bld.bat b/localbuild/bld.bat index 9522d2ca4..3dec9e8b9 100644 --- a/localbuild/bld.bat +++ b/localbuild/bld.bat @@ -1,9 +1,7 @@ -set MENU_DIR=%PREFIX%\Menu -if not exist %MENU_DIR% mkdir %MENU_DIR% -if errorlevel 1 exit 1 -copy %SRC_DIR%\Menu\msui_shortcut.json %MENU_DIR%\msui_shortcut.json -if errorlevel 1 exit 1 -copy %SRC_DIR%\Menu\mss.ico %MENU_DIR%\mss.ico -if errorlevel 1 exit 1 +mkdir "%PREFIX%\Menu" +copy /Y "%RECIPE_DIR%\menu.json" "%PREFIX%\Menu\%PKG_NAME%_menu.json" +copy /Y "%RECIPE_DIR%\msui.ico" "%PREFIX%\Menu\msui.ico" + +; conda-build issue 5311, currently we can't build for windows with pip install %PYTHON% setup.py install --single-version-externally-managed --record record.txt if errorlevel 1 exit 1 diff --git a/localbuild/build.sh b/localbuild/build.sh new file mode 100644 index 000000000..ab507fa63 --- /dev/null +++ b/localbuild/build.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +mkdir -p "${PREFIX}/Menu" +cp "${RECIPE_DIR}/menu.json" "${PREFIX}/Menu/${PKG_NAME}_menu.json" +cp "${RECIPE_DIR}/msui.png" "${PREFIX}/Menu/msui.png" + +"${PYTHON}" -m pip install . --no-deps -vv diff --git a/localbuild/menu.json b/localbuild/menu.json new file mode 100644 index 000000000..a39a7cdf6 --- /dev/null +++ b/localbuild/menu.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "menu_name": "Mission Support System", + "menu_items": [ + { + "name": "MSUI ({{ ENV_NAME }})", + "description": "Mission Support System MSUI", + "activate": true, + "icon": "{{ MENU_DIR }}/msui.{{ ICON_EXT }}", + "command": [ + "{{ PYTHON }}", + "{{ PREFIX }}/bin/msui" + ], + "platforms": { + "win": { + "command": [ + "{{ PYTHON }}", + "{{ SCRIPTS_DIR }}/msui-script.py" + ], + "file_extensions": [".menuinst"] + }, + "linux": { + "Categories": ["Internet", "Science"], + "Keywords": ["documentation", "information"], + "StartupNotify": true, + "MimeType": ["application/x-menuinst"], + "glob_patterns": { + "application/x-menuinst": "*.menuinst" + } + }, + "osx": { + "CFBundleDocumentTypes": [ + { + "CFBundleTypeName": "org.conda.menuinst.msui", + "CFBundleTypeRole": "Viewer", + "LSItemContentTypes": ["org.conda.menuinst.main-file-uti"], + "LSHandlerRank": "Default" + } + ], + "UTExportedTypeDeclarations": [ + { + "UTTypeConformsTo": ["public.data", "public.content"], + "UTTypeIdentifier": "org.conda.menuinst.main-file-uti", + "UTTypeTagSpecification": { + "public.filename-extension": ["menuinst"] + } + } + ] + } + } + } + ] +} diff --git a/localbuild/meta.yaml b/localbuild/meta.yaml index 289a056e0..9e200326f 100644 --- a/localbuild/meta.yaml +++ b/localbuild/meta.yaml @@ -1,6 +1,5 @@ {% set version = "alpha" %} - package: name: mss version: {{ version }} @@ -9,39 +8,37 @@ source: path: ../ build: - skip: true # [py<38] number: 1000 - script: "{{ PYTHON }} -m pip install . --no-deps -vv" # [not win] requirements: build: - python - pip - setuptools + - future + - menuinst >=2.0.2 host: - python - setuptools - pip - future - - menuinst # [win] run: - python + - future - defusedxml - - menuinst # [win] - 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 - netcdf4 - 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 @@ -51,15 +48,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 @@ -69,7 +66,7 @@ requirements: - PyMySQL >=0.9.3 - validate_email - multidict - - pint <=0.22 + - pint - python-socketio >=5 - python-engineio >=4 - markdown @@ -84,7 +81,13 @@ requirements: - flask-wtf - email_validator - keyring - - dbus-python + - dbus-python # [not win] + - python-slugify + - flask-login + - pysaml2 + - libxmlsec1 # [not win] + run_constrained: + - menuinst >=2.0.2 test: imports: @@ -94,9 +97,11 @@ test: - mswms_demodata -h - msui -h - mscolab -h + - mssautoplot --help + - msidp -h # [not win] about: - summary: 'A web service based tool to plan atmospheric research flights.' + summary: 'A client/server application developed in the community to collaboratively create flight plans based on model data.' home: https://github.com/Open-MSS/MSS license: "Apache-2.0" license_family: "APACHE" @@ -104,7 +109,10 @@ about: description: | MSS - Mission Support System - http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012.pdf + Documentation: + * https://mss.rtfd.io + * https://gmd.copernicus.org/articles/15/8983/2022/gmd-15-8983-2022.pdf + * http://www.geosci-model-dev.net/5/55/2012/gmd-5-55-2012.pdf Software for planning research Aircraft Missions. For discussion of the possibilites of the research flights, diff --git a/localbuild/msui.ico b/localbuild/msui.ico new file mode 100644 index 000000000..5479fdbb9 Binary files /dev/null and b/localbuild/msui.ico differ diff --git a/localbuild/msui.png b/localbuild/msui.png new file mode 100644 index 000000000..0b6f25cda Binary files /dev/null and b/localbuild/msui.png differ diff --git a/localbuild/post-link.sh b/localbuild/post-link.sh deleted file mode 100644 index 7390bf171..000000000 --- a/localbuild/post-link.sh +++ /dev/null @@ -1,9 +0,0 @@ -unamestr=`uname` -# only if we don't execute in a docker environment -if [[ "$(cat /proc/1/sched | head -n 1)" = "systemd (1, #threads: 1)" ]] && [[ "$unamestr" == 'Linux' ]]; then - msuicmd="$CONDA_PREFIX/bin/msui" - $msuicmd -m - echo "menue entry ($msuicmd -m): done" -else - echo "called in a container" -fi diff --git a/localbuild/pre-unlink.sh b/localbuild/pre-unlink.sh deleted file mode 100644 index f04892e9b..000000000 --- a/localbuild/pre-unlink.sh +++ /dev/null @@ -1,7 +0,0 @@ -unamestr=`uname` -# only if we don't execute in a docker environment -if [[ "$(cat /proc/1/sched | head -n 1)" = "systemd (1, #threads: 1)" ]] && [[ "$unamestr" == 'Linux' ]]; then - msui -d - else - echo "called in a container" -fi diff --git a/mslib/index.py b/mslib/index.py index 25cf38f23..36b110e05 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 df6d21901..9cf33a150 100644 --- a/mslib/mscolab/app/__init__.py +++ b/mslib/mscolab/app/__init__.py @@ -25,11 +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 mslib.mswms.gallery_builder import STATIC_LOCATION +from flask_sqlalchemy import SQLAlchemy from mslib.utils import prefix_route @@ -39,14 +42,14 @@ # in memory database for testing # app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' -APP = Flask(__name__, template_folder=os.path.join(DOCS_SERVER_PATH, 'static', 'templates'), static_url_path="/static", - static_folder=STATIC_LOCATION) +APP = Flask(__name__, template_folder=os.path.join(DOCS_SERVER_PATH, 'static', 'templates')) APP.config.from_object(__name__) APP.route = prefix_route(APP.route, SCRIPT_NAME) APP.config['MSCOLAB_DATA_DIR'] = mscolab_settings.MSCOLAB_DATA_DIR APP.config['SQLALCHEMY_DATABASE_URI'] = mscolab_settings.SQLALCHEMY_DB_URI APP.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +APP.config['SQLALCHEMY_ECHO'] = mscolab_settings.SQLALCHEMY_ECHO APP.config['UPLOAD_FOLDER'] = mscolab_settings.UPLOAD_FOLDER APP.config['MAX_CONTENT_LENGTH'] = mscolab_settings.MAX_UPLOAD_SIZE APP.config['SECRET_KEY'] = mscolab_settings.SECRET_KEY @@ -58,3 +61,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 7de109ba9..475ed5200 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): @@ -59,9 +62,9 @@ def get_messages(self, op_id, timestamp=None): timestamp: if provided, messages only after this time stamp is provided """ if timestamp is None: - timestamp = datetime.datetime(1970, 1, 1) + timestamp = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) else: - timestamp = datetime.datetime.strptime(timestamp, "%Y-%m-%d, %H:%M:%S") + timestamp = datetime.datetime.fromisoformat(timestamp) messages = Message.query \ .filter(Message.op_id == op_id) \ .filter(Message.reply_id.is_(None)) \ @@ -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 05be35784..1c6b02f5c 100644 --- a/mslib/mscolab/conf.py +++ b/mslib/mscolab/conf.py @@ -24,78 +24,187 @@ 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 = ["*"] + # Set to True for testing and False for production + SQLALCHEMY_ECHO = False - # dir where msui output files are stored - BASE_DIR = os.path.expanduser("~") + # mscolab file upload settings + UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') + MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB - DATA_DIR = os.path.join(BASE_DIR, "colabdata") + # used to generate and parse tokens + SECRET_KEY = secrets.token_urlsafe(16) - # mscolab data directory - MSCOLAB_DATA_DIR = os.path.join(DATA_DIR, 'filedata') + # used to generate the password token + SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) - # MYSQL CONNECTION STRING: "mysql+pymysql://:@:/?charset=utf8mb4" - SQLALCHEMY_DB_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'mscolab.db') + STUB_CODE = """ + + + + + + + + + + + """ - # mscolab file upload settings - UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads') - MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB + # 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" - # used to generate and parse tokens - SECRET_KEY = secrets.token_urlsafe(16) + enable_basic_http_authentication = 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 verification by Mail + MAIL_ENABLED = False - # used to generate the password token - SECURITY_PASSWORD_SALT = secrets.token_urlsafe(16) + # mail settings + # MAIL_SERVER = 'localhost' + # MAIL_PORT = 25 + # MAIL_USE_TLS = False + # MAIL_USE_SSL = True - STUB_CODE = """ - - - - - - - - - - - """ - enable_basic_http_authentication = False + # mail authentication + # MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') + # MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') - # enable verification by Mail - MAIL_ENABLED = False + # mail accounts + # MAIL_DEFAULT_SENDER = 'MSS@localhost' + # filepath to md file with imprint + IMPRINT = None + # filepath to md file with gdpr + GDPR = None - # mail settings - # MAIL_SERVER = 'localhost' - # MAIL_PORT = 25 - # MAIL_USE_TLS = False - # MAIL_USE_SSL = True + # enable login by identity provider + USE_SAML2 = False - # mail authentication - # MAIL_USERNAME = os.environ.get('APP_MAIL_USERNAME') - # MAIL_PASSWORD = os.environ.get('APP_MAIL_PASSWORD') + # accounts on a database on the server + DIRECT_LOGIN = True - # mail accounts - # MAIL_DEFAULT_SENDER = 'MSS@localhost' + # Enable SSL certificate verification during SSO between MSColab and IdP + ENABLE_SSO_SSL_CERT_VERIFICATION = True + + # dir where mscolab single sign process files are stored + MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') + + +mscolab_settings = default_mscolab_settings() + +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 dd7664326..e1a61f979 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,36 +380,40 @@ 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, 'comment': change.comment, 'version_name': change.version_name, 'username': change.user.username, - 'created_at': change.created_at.strftime("%Y-%m-%d, %H:%M:%S") + 'created_at': change.created_at.isoformat() }, 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..49da165b5 --- /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-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 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 5a13aa38b..877041fc0 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -26,36 +26,52 @@ """ 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 + cache_ok = True + + 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) + password = db.Column(db.String(255)) + registered_on = db.Column(AwareDateTime, nullable=False) confirmed = db.Column(db.Boolean, nullable=False, default=False) - confirmed_on = db.Column(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 +123,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 +145,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 +163,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,23 +173,16 @@ 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) message_type = db.Column(db.Enum(MessageType), default=MessageType.TEXT) reply_id = db.Column(db.Integer, db.ForeignKey('messages.id')) - created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + created_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) user = db.relationship('User') replies = db.relationship('Message', cascade='all,delete,delete-orphan', single_parent=True) @@ -191,13 +200,13 @@ 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) version_name = db.Column(db.String(255), default=None) comment = db.Column(db.String(255), default=None) - created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow) + created_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) user = db.relationship('User') def __init__(self, op_id, u_id, commit_hash, version_name=None, comment=None): diff --git a/mslib/mscolab/mscolab.py b/mslib/mscolab/mscolab.py index d672ebae7..a04afe940 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 7ef141d65..366e221c5 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(days=mscolab_settings.ARCHIVE_THRESHOLD) + ) db.session.commit() @@ -236,52 +234,52 @@ def seed_data(): 'username': 'a', 'id': 8, 'password': 'a', - 'emailid': 'a' + 'emailid': 'a@notexisting.org' }, { 'username': 'b', 'id': 9, 'password': 'b', - 'emailid': 'b' + 'emailid': 'b@notexisting.org' }, { 'username': 'c', 'id': 10, 'password': 'c', - 'emailid': 'c' + 'emailid': 'c@notexisting.org' }, { 'username': 'd', 'id': 11, 'password': 'd', - 'emailid': 'd' + 'emailid': 'd@notexisting.org' }, { 'username': 'test1', 'id': 12, 'password': 'test1', - 'emailid': 'test1' + 'emailid': 'test1@notexisting.org' }, { 'username': 'test2', 'id': 13, 'password': 'test2', - 'emailid': 'test2' + 'emailid': 'test2@notexisting.org' }, { 'username': 'test3', 'id': 14, 'password': 'test3', - 'emailid': 'test3' + 'emailid': 'test3@notexisting.org' }, { 'username': 'test4', 'id': 15, 'password': 'test4', - 'emailid': 'test4' + 'emailid': 'test4@notexisting.org' }, { 'username': 'mscolab_user', 'id': 16, 'password': 'password', - 'emailid': 'mscolab_user' + 'emailid': 'mscolab_user@notexisting.org' }, { 'username': 'merge_waypoints_user', 'id': 17, 'password': 'password', - 'emailid': 'merge_waypoints_user' + 'emailid': 'merge_waypoints_user@notexisting.org' }] for user in users: db_user = User(user['emailid'], user['username'], user['password']) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index f9deec08b..568e78af4 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,24 @@ 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) + if len(str(email.strip())) == 0 or len(str(username.strip())) == 0: + return {"success": False, "message": "Your username or email cannot be empty"} is_valid_username = True if username.find("@") == -1 else False is_valid_email = validate_email(email) if not is_valid_email: - 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"} + user = User(email, username, password) + result = fm.modify_user(user, action="create") + return {"success": result} def verify_user(func): @@ -199,21 +197,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 +269,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 +305,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 +340,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,28 +351,25 @@ 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): - timestamp = request.args.get("timestamp", request.form.get("timestamp", "1970-01-01, 00:00:00")) + timestamp = request.args.get("timestamp", request.form.get("timestamp", "1970-01-01T00:00:00+00:00")) chat_messages = cm.get_messages(op_id, timestamp) return jsonify({"messages": chat_messages}) return "False" @@ -342,32 +385,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 +427,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 +454,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 +465,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 +497,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 +507,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 +544,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"]) -@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"]) +@APP.route('/undo_changes', methods=["POST"]) @verify_user -def undo_ftml(): - # ToDo rename to undo_changes +def undo_changes(): ch_id = request.form.get('ch_id', -1) ch_id = int(ch_id) user = g.user - result = fm.undo(ch_id, user) + result = fm.undo_changes(ch_id, user) # get op_id from change ch = Change.query.filter_by(id=ch_id).first() if result is True: @@ -685,8 +702,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 +740,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 8cbcb0d6c..85bf6b569 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 4b00e806c..865297939 100644 --- a/mslib/mscolab/utils.py +++ b/mslib/mscolab/utils.py @@ -55,20 +55,20 @@ def get_message_dict(message): "message_type": message.message_type, "reply_id": message.reply_id, "replies": [], - "time": message.created_at.strftime("%Y-%m-%d, %H:%M:%S") + "time": message.created_at.isoformat() } -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..6ffa3316e --- /dev/null +++ b/mslib/msidp/idp.py @@ -0,0 +1,1159 @@ +# 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 +import warnings + +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, PASSWD +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) + + +# ----------------------------------------------------------------------------- + +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(): + warnings.warn( + '\033[91mWARNING: msidp is solely for development and ' + 'testing purposes; do not use in production environments.\033[0m' + ) + parser = argparse.ArgumentParser() + parser.add_argument("-p", dest="path", help="Path to configuration file.", + default="./idp_conf.py") + 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..21d726a4e --- /dev/null +++ b/mslib/msidp/idp_conf.py @@ -0,0 +1,211 @@ +# -*- 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 logging +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") +if not os.path.exists(XMLSEC_PATH): + logging.warning("%s not found", XMLSEC_PATH) + +# CRTs and metadata files can be generated through the mscolab server. +# if configured that way CRTs DIRs should be same in both IDP and mscolab server. +BASE_DIR = os.path.expanduser("~") +DATA_DIR = os.path.join(BASE_DIR, "colabdata") +MSCOLAB_SSO_DIR = os.path.join(DATA_DIR, 'datasso') + +BASEDIR = os.path.abspath(os.path.dirname(__file__)) + + +def full_path(local_file): + """Return the full path by joining the BASEDIR and local_file.""" + return os.path.join(BASEDIR, local_file) + + +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..b6d983ba2 --- /dev/null +++ b/mslib/msidp/idp_user.py @@ -0,0 +1,64 @@ +# -*- 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", + } +} + +EXTRA = { + "roland": { + "eduPersonEntitlement": "urn:mace:swamid.se:foo:bar", + "schacGender": "male", + "schacUserPresenceID": "sky:pepe.perez", + } +} + +PASSWD = { + "testuser": "qwerty", +} diff --git a/mslib/msidp/idp_uwsgi.py b/mslib/msidp/idp_uwsgi.py new file mode 100644 index 000000000..7d86b6bbf --- /dev/null +++ b/mslib/msidp/idp_uwsgi.py @@ -0,0 +1,1106 @@ +# pylint: skip-file +# -*- coding: utf-8 -*- +""" + mslib.msidp.idp_uwsgi.py + ~~~~~~~~~~~~~~~~~~~~~~~~ + + WSGI application for IDP + + This file is part of MSS. + + :copyright: Copyright 2023 Nilupul Manodya + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +# Additional Info: +# This file is imported from +# https://github.com/IdentityPython/pysaml2/blob/master/example/idp2/idp_uwsgi.py +# and customized as MSS requirements. Pylint has been disabled for this imported file. + +# Parts of the code + +import argparse +import base64 +from hashlib import sha1 +import importlib +import logging +import os +import re +import time +import socket + +from Cookie import SimpleCookie +from urlparse import parse_qs +from saml2 import ( + BINDING_HTTP_ARTIFACT, + BINDING_HTTP_POST, + BINDING_HTTP_REDIRECT, + BINDING_PAOS, + BINDING_SOAP, + BINDING_URI, + server, + time_util +) +from saml2.authn import is_equal +from saml2.authn_context import PASSWORD, UNSPECIFIED, AuthnBroker, authn_context_class_ref +from saml2.httputil import ( + BadRequest, + NotFound, + Redirect, + Response, + ServiceError, + Unauthorized, + get_post, + geturl +) +from saml2.ident import Unknown +from saml2.metadata import create_metadata_string +from saml2.profile import ecp +from saml2.s_utils import PolicyError, UnknownPrincipal, exception_trace, UnsupportedBinding, rndstr +from saml2.sigver import encrypt_cert_from_item, verify_redirect_signature + +from mslib.msidp.idp_user import EXTRA, USERS, PASSWD +from mako.lookup import TemplateLookup + + +logger = logging.getLogger("saml2.idp") + + +class Cache: + """ + A cache class for mapping users to UIDs and vice versa. + """ + def __init__(self): + self.user2uid = {} + self.uid2user = {} + + +def _expiration(timeout, tformat="%a, %d-%b-%Y %H:%M:%S GMT"): + """ + :param timeout: + :param tformat: + :return: + """ + if timeout == "now": + return time_util.instant(tformat) + elif timeout == "dawn": + return time.strftime(tformat, time.gmtime(0)) + else: + # validity time should match lifetime of assertions + return time_util.in_a_while(minutes=timeout, format=tformat) + + +def get_eptid(idp, req_info, session): + """ + Get the EPTID (Entity-Participant Target ID) based on the provided parameters. + """ + return idp.eptid.get(idp.config.entityid, req_info.sender(), + session["permanent_id"], session["authn_auth"]) + + +def dict2list_of_tuples(dictionary): + """ + Convert a dictionary to a list of tuples. + """ + return [(k, v) for k, v in dictionary.items()] + + +class Service: + """ + Service class for handling SAML operations + """ + def __init__(self, environ, start_response, user=None): + self.environ = environ + logger.debug("ENVIRON: %s", environ) + self.start_response = start_response + self.user = user + + def unpack_redirect(self): + """ + Unpacks and parses a HTTP-redirect request + """ + if "QUERY_STRING" in self.environ: + _qs = self.environ["QUERY_STRING"] + return {k: v[0] for k, v in parse_qs(_qs).items()} + return None + + def unpack_post(self): + """ + Unpacks and parses a HTTP-POST request. + """ + _dict = parse_qs(get_post(self.environ)) + logger.debug("unpack_post:: %s", _dict) + try: + return {k: v[0] for k, v in _dict.items()} + except Exception: + return None + + def unpack_soap(self): + """ + Unpacks and parses a SOAP request. + """ + try: + query = get_post(self.environ) + return {"SAMLRequest": query, "RelayState": ""} + except Exception: + return None + + def unpack_either(self): + """ + Unpacks and retrieves data from either a GET or POST request. + """ + if self.environ["REQUEST_METHOD"] == "GET": + _dict = self.unpack_redirect() + elif self.environ["REQUEST_METHOD"] == "POST": + _dict = self.unpack_post() + else: + _dict = None + logger.debug("_dict: %s", _dict) + return _dict + + def operation(self, saml_msg, binding): + """ + Performs the SAML operation based on the provided SAML message and binding. + """ + logger.debug("_operation: %s", saml_msg) + if not saml_msg or "SAMLRequest" not in saml_msg: + resp = BadRequest("Error parsing request or no request") + return resp(self.environ, self.start_response) + else: + try: + _encrypt_cert = encrypt_cert_from_item(saml_msg["req_info"].message) + return self.do(saml_msg["SAMLRequest"], binding, + saml_msg["RelayState"], encrypt_cert=_encrypt_cert) + except KeyError: + # Can live with no relay state + return self.do(saml_msg["SAMLRequest"], binding) + + def artifact_operation(self, saml_msg): + """ + Handles artifact-based operations. + """ + if not saml_msg: + resp = BadRequest("Missing query") + return resp(self.environ, self.start_response) + else: + # exchange artifact for request + request = IDP.artifact2message(saml_msg["SAMLart"], "spsso") + try: + return self.do(request, BINDING_HTTP_ARTIFACT, saml_msg["RelayState"]) + except KeyError: + return self.do(request, BINDING_HTTP_ARTIFACT) + + def response(self, binding, http_args): + """ + Generates the response based on the specified binding and HTTP arguments. + """ + if binding == BINDING_HTTP_ARTIFACT: + resp = Redirect() + else: + resp = Response(http_args["data"], headers=http_args["headers"]) + return resp(self.environ, self.start_response) + + def do(self, query, binding, relay_state="", encrypt_cert=None): + """ + Performs the SAML operation based on the provided query + """ + pass + + def redirect(self): + """Expects a HTTP-redirect request""" + + _dict = self.unpack_redirect() + return self.operation(_dict, BINDING_HTTP_REDIRECT) + + def post(self): + """Expects a HTTP-POST request""" + + _dict = self.unpack_post() + return self.operation(_dict, BINDING_HTTP_POST) + + def artifact(self): + """ + Handles the artifact operation, which can be either through HTTP_Redirect or HTTP_POST. + """ + # Can be either by HTTP_Redirect or HTTP_POST + _dict = self.unpack_either() + return self.artifact_operation(_dict) + + def soap(self): + """ + Single log out using HTTP_SOAP binding + """ + logger.debug("- SOAP -") + _dict = self.unpack_soap() + logger.debug("_dict: %s", _dict) + return self.operation(_dict, BINDING_SOAP) + + def uri(self): + """ + Handles the URI operation. + """ + _dict = self.unpack_either() + return self.operation(_dict, BINDING_SOAP) + + def not_authn(self, key, requested_authn_context): + """ + Handles the case when the user is not authenticated. + """ + ruri = geturl(self.environ, query=False) + return do_authentication( + self.environ, self.start_response, authn_context=requested_authn_context, + key=key, redirect_uri=ruri + ) + + +# ----------------------------------------------------------------------------- + +REPOZE_ID_EQUIVALENT = "uid" +FORM_SPEC = """
+ + +
""" + +# ----------------------------------------------------------------------------- +# === Single log in ==== +# ----------------------------------------------------------------------------- + + +class AuthenticationNeeded(Exception): + """ + Exception raised when authentication is required. + """ + def __init__(self, authn_context=None, *args, **kwargs): + Exception.__init__(*args, **kwargs) + self.authn_context = authn_context + + +class SSO(Service): + """ + Single Sign-On (SSO) service. + """ + def __init__(self, environ, start_response, user=None): + Service.__init__(self, environ, start_response, user) + self.binding = "" + self.response_bindings = None + self.resp_args = {} + self.binding_out = None + self.destination = None + self.req_info = None + self.op_type = "" + + def verify_request(self, query, binding): + """ + :param query: The SAML query, transport encoded + :param binding: Which binding the query came in over + """ + resp_args = {} + if not query: + logger.info("Missing QUERY") + resp = Unauthorized("Unknown user") + return resp_args, resp(self.environ, self.start_response) + + if not self.req_info: + self.req_info = IDP.parse_authn_request(query, binding) + + logger.info("parsed OK") + _authn_req = self.req_info.message + logger.debug("%s", _authn_req) + + try: + self.binding_out, self.destination = IDP.pick_binding( + "assertion_consumer_service", bindings=self.response_bindings, + entity_id=_authn_req.issuer.text + ) + except Exception as err: + logger.error("Couldn't find receiver endpoint: %s", err) + raise + + logger.debug("Binding: %s, destination: %s", self.binding_out, self.destination) + + resp_args = {} + try: + resp_args = IDP.response_args(_authn_req) + _resp = None + except UnknownPrincipal as excp: + _resp = IDP.create_error_response(_authn_req.id, self.destination, excp) + except UnsupportedBinding as excp: + _resp = IDP.create_error_response(_authn_req.id, self.destination, excp) + + return resp_args, _resp + + def do(self, query, binding_in, relay_state="", encrypt_cert=None): + """ + :param query: The request + :param binding_in: Which binding was used when receiving the query + :param relay_state: The relay state provided by the SP + :param encrypt_cert: Cert to use for encryption + :return: A response + """ + try: + resp_args, _resp = self.verify_request(query, binding_in) + except UnknownPrincipal as excp: + logger.error("UnknownPrincipal: %s", excp) + resp = ServiceError(f"UnknownPrincipal: {excp}") + return resp(self.environ, self.start_response) + except UnsupportedBinding as excp: + logger.error("UnsupportedBinding: %s", excp) + resp = ServiceError(f"UnsupportedBinding: {excp}") + return resp(self.environ, self.start_response) + + if not _resp: + identity = USERS[self.user].copy() + # identity["eduPersonTargetedID"] = get_eptid(IDP, query, session) + logger.info("Identity: %s", identity) + + if REPOZE_ID_EQUIVALENT: + identity[REPOZE_ID_EQUIVALENT] = self.user + try: + try: + 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) + + +# ----------------------------------------------------------------------------- + +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 268472fe5..fbd2df2fc 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 cb3a6bc05..66898d82f 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/constants.py b/mslib/msui/constants.py index 9e3555d16..b6eccdf9d 100644 --- a/mslib/msui/constants.py +++ b/mslib/msui/constants.py @@ -30,12 +30,17 @@ import fs import os import logging +import platformdirs # ToDo refactor to generic functions, keep only constants HOME = os.path.expanduser(f"~{os.path.sep}") MSUI_CONFIG_PATH = os.getenv("MSUI_CONFIG_PATH", os.path.join(HOME, ".config", "msui")) # Make sure that MSUI_CONFIG_PATH exists -_ = fs.open_fs(MSUI_CONFIG_PATH, create=True) +_fs = fs.open_fs(MSUI_CONFIG_PATH, create=True) +# MSUI does not actually support any PyFilesystem2 fs that is not available as a local path +MSUI_CONFIG_SYSPATH = _fs.getsyspath("") + +MSUI_CACHE_PATH = platformdirs.user_cache_path("msui", "mss") GRAVATAR_DIR_PATH = fs.path.join(MSUI_CONFIG_PATH, "gravatars") @@ -83,18 +88,3 @@ logging.error('"%s" can''t be created', MSS_AUTOPLOT) AUTH_LOGIN_CACHE = {} - -POSIX = {"application_destination": os.path.join(HOME, ".local/share/applications/msui{}.desktop"), - "icon_destination": os.path.join(HOME, ".local/share/icons/hicolor/{}/apps/mss-logo{}.png"), - "desktop": """[Desktop Entry] -Name=msui {} -Comment=A web service based tool to plan atmospheric research flights (mission support system). -Keywords=documentation;information; -Exec={} -Icon={} -Type=Application -Categories=Science;Education; -StartupNotify=true -X-GNOME-SingleWindow=false -X-Ubuntu-Gettext-Domain=msui -"""} diff --git a/mslib/msui/editor.py b/mslib/msui/editor.py index fedc19d8e..a28b83208 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 a0f01bef7..1d3ad2b15 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 159f10482..493148ac7 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 043553ad1..660fd0f7d 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 26f5c1a40..4ec52b828 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 c305f263e..ff5d12be1 100644 --- a/mslib/msui/mpl_map.py +++ b/mslib/msui/mpl_map.py @@ -50,11 +50,13 @@ from mslib.msui import mpl_pathinteractor as mpl_pi from mslib.utils.airdata import get_airports, get_airspaces +from mslib.utils.loggerdef import configure_mpl_logger OPENAIP_NOTICE = "Airspace data used comes from openAIP.\n" \ "Visit openAIP.net and contribute to better aviation data, free for everyone to use and share." OURAIRPORTS_NOTICE = "Airports provided by OurAirports." +mpl_logger = configure_mpl_logger() class MapCanvas(basemap.Basemap): @@ -198,7 +200,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 +683,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 +754,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 b394349e2..59fb8702a 100644 --- a/mslib/msui/mpl_pathinteractor.py +++ b/mslib/msui/mpl_pathinteractor.py @@ -55,6 +55,10 @@ from mslib.utils.units import units from mslib.utils.thermolib import pressure2flightlevel from mslib.msui import flighttrack as ft +from mslib.utils.loggerdef import configure_mpl_logger + + +mpl_logger = configure_mpl_logger() def distance_point_linesegment(p, l1, l2): @@ -910,7 +914,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 +1058,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 +1154,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 b96694c4f..2544c154e 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -50,6 +50,7 @@ from mslib.msui import mpl_pathinteractor as mpl_pi from mslib.msui import mpl_map from mslib.msui.icons import icons +from mslib.utils.loggerdef import configure_mpl_logger PIL_IMAGE_ORIGIN = "upper" LAST_SAVE_DIRECTORY = config_loader(dataset="data_dir") @@ -94,6 +95,8 @@ "plot_title_size": "default", "axes_label_size": "default"} +mpl_logger = configure_mpl_logger() + class ViewPlotter: def __init__(self, fig=None, ax=None, settings_tag=None, settings=None): @@ -475,6 +478,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 +759,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 +837,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 +922,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 +940,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 +966,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 +979,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 +1033,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 +1097,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 +1111,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 +1146,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 +1361,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 +1386,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 +1467,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 +1490,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 +1506,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 +1680,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 fdf7fb30d..2c59ace2d 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' + url = urljoin(self.mscolab_server_url, "token") + url_recover_password = urljoin(self.mscolab_server_url, "reset_request") try: - r = s.post(url, data=data, timeout=(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 = urljoin(self.mscolab_server_url, "available_idps") + webbrowser.open(url_idp_login, new=2) + self.stackedWidget.setCurrentWidget(self.idpAuthPage) + + def idp_auth_token_submit_handler(self): + """Handle IDP authentication token submission""" + url_idp_login_auth = urljoin(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 = urljoin(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' + url = urljoin(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,9 @@ def delete_account(self): } try: - r = requests.post(self.mscolab_server_url + '/delete_user', data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "delete_own_account") + r = requests.post(url, data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") @@ -823,10 +849,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 +862,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 +924,20 @@ 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)) + url = urljoin(self.mscolab_server_url, "create_operation") + r = requests.post(url, data=data, + timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) except requests.exceptions.RequestException as e: logging.error(e) show_popup(self.ui, "Error", "Some error occurred! Please reconnect.") self.logout() 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,14 +947,18 @@ 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) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1077,9 +1112,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 +1146,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 +1169,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 +1240,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 +1254,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 +1276,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 +1285,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 +1298,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 +1366,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 +1440,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)) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] @@ -1366,22 +1460,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 +1486,8 @@ def render_new_permission(self, op_id, u_id): data = { 'token': self.token } - r = requests.get(self.mscolab_server_url + '/user', data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "user") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) if _json['user']['id'] == u_id: @@ -1403,7 +1498,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 +1507,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 +1573,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 +1589,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 +1599,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 + } + url = urljoin(self.mscolab_server_url, "operations") + try: + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) + except requests.exceptions.MissingSchema: + show_popup(self.ui, "Error", "Session expired, new login required") if r is not None and r.text != "False": _json = json.loads(r.text) operations = _json["operations"] + self.ui.filterCategoryCb.currentIndexChanged.disconnect(self.operation_category_handler) self.ui.filterCategoryCb.clear() - categories = set(["ANY"]) + 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)) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=tuple(config_loader(dataset="MSCOLAB_timeout"))) if r.text != "False": _json = json.loads(r.text) self.operations = _json["operations"] logging.debug("adding operations to ui") operations = sorted(self.operations, key=lambda k: k["path"].lower()) self.ui.listOperationsMSC.clear() - self.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 +1676,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 +1684,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 +1691,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 +1748,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 +1765,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 +1786,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 +1822,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 +1852,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 +1863,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 +1871,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")) @@ -1782,7 +1890,8 @@ def request_wps_from_server(self): "token": self.token, "op_id": self.active_op_id } - r = requests.get(self.mscolab_server_url + '/get_operation_by_id', data=data) + url = urljoin(self.mscolab_server_url, "get_operation_by_id") + r = requests.get(url, data=data) if r.text != "False": xml_content = json.loads(r.text)["content"] return xml_content @@ -1802,9 +1911,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 +1925,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 +1953,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 +1976,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 +1987,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 +2023,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 +2045,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 +2053,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 +2062,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 +2078,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 +2164,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 225aa1bc6..3d3776118 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 05726cf7f..bb74e314a 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"] @@ -349,12 +349,13 @@ def load_all_messages(self): data = { "token": self.token, "op_id": self.op_id, - "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + "timestamp": datetime.datetime(1970, 1, 1, + tzinfo=datetime.timezone.utc).isoformat() } # 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 +376,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 +411,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 +425,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 +443,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 +471,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 +506,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 +654,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 a27adcbdf..f2b4b9cd9 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,18 +133,18 @@ 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() for change in changes: - created_at = datetime.strptime(change["created_at"], "%Y-%m-%d, %H:%M:%S") + created_at = datetime.fromisoformat(change["created_at"]) local_time = utc_to_local_datetime(created_at) date = local_time.strftime('%d/%m/%Y') time = local_time.strftime('%I:%M %p') @@ -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 dec88b615..9e75b77f6 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 75c12431e..0a4c4108e 100644 --- a/mslib/msui/msui.py +++ b/mslib/msui/msui.py @@ -30,1072 +30,38 @@ """ 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 main(tutorial_mode=False): """ + Entry point of the msui program. - 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(): - try: - prefix = os.environ["CONDA_DEFAULT_ENV"] - except KeyError: - prefix = "" - app_prefix = prefix - if prefix: - app_prefix = f"-{prefix}" - icon_hash = hashlib.md5('.'.join([__version__, app_prefix]).encode('utf-8')).hexdigest() + @param tutorial_mode: Specifies whether the program should run in tutorial mode or not. + """ parser = argparse.ArgumentParser() parser.add_argument("-v", "--version", help="show version", action="store_true", default=False) parser.add_argument("--debug", help="show debugging log messages on console", action="store_true", default=False) parser.add_argument("--logfile", help="Specify logfile location. Set to empty string to disable.", action="store", default=os.path.join(constants.MSUI_CONFIG_PATH, "msui.log")) - parser.add_argument("-m", "--menu", help="adds msui to menu", action="store_true", default=False) - parser.add_argument("-d", "--deinstall", help="removes msui from menu", action="store_true", default=False) parser.add_argument("--update", help="Updates MSS to the newest version", action="store_true", default=False) args = parser.parse_args() @@ -1120,41 +86,6 @@ def main(): setup_logging(args) - if args.menu: - # Experimental feature to get msui into application menu - if platform.system() == "Linux": - icon_size = '48x48' - src_icon_path = icons(icon_size) - icon_destination = constants.POSIX["icon_destination"].format(icon_size, icon_hash) - dirname = os.path.dirname(icon_destination) - if not os.path.exists(dirname): - os.makedirs(dirname) - shutil.copyfile(src_icon_path, icon_destination) - desktop = constants.POSIX["desktop"] - application_destination = constants.POSIX["application_destination"].format(app_prefix) - dirname = os.path.dirname(application_destination) - if not os.path.exists(dirname): - os.makedirs(dirname) - if prefix: - prefix = f"({prefix})" - desktop = desktop.format(prefix, - os.path.join(sys.prefix, "bin", "msui"), - icon_destination) - with open(application_destination, 'w') as f: - f.write(desktop) - logging.info("menu entry created") - sys.exit() - if args.deinstall: - application_destination = constants.POSIX["application_destination"].format(app_prefix) - if os.path.exists(application_destination): - os.remove(application_destination) - icon_size = '48x48' - icon_destination = constants.POSIX["icon_destination"].format(icon_size, icon_hash) - if os.path.exists(icon_destination): - os.remove(icon_destination) - logging.info("menu entry removed") - sys.exit() - logging.info("MSS Version: %s", __version__) logging.info("Python Version: %s", sys.version) logging.info("Platform: %s (%s)", platform.platform(), platform.architecture()) @@ -1185,19 +116,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 +135,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..f1ec9768b --- /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-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 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 ad8144355..e6537e5c9 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 42c0de1dc..d80f10615 100644 --- a/mslib/msui/multiple_flightpath_dockwidget.py +++ b/mslib/msui/multiple_flightpath_dockwidget.py @@ -30,26 +30,28 @@ 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 +from urllib.parse import urljoin 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 +112,12 @@ 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, active_op_id=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 +125,8 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.flight_path = None # flightpath object self.dict_flighttrack = {} # Dictionary of flighttrack data: patch,color,wp_model self.active_flight_track = activeFlightTrack + self.active_op_id = active_op_id + self.msc_category = category # object of active category self.listOperationsMSC = listOperationsMSC self.listFlightTracks = listFlightTracks self.mscolab_server_url = mscolab_server_url @@ -173,27 +178,31 @@ def __init__(self, parent=None, view=None, listFlightTracks=None, self.activate_flighttrack() self.multipleflightrack_worker = Worker(None) - @QtCore.Slot() + @QtCore.pyqtSlot() def logout(self): - self.operations.logout_mscolab() - self.ui.signal_listFlighttrack_doubleClicked.disconnect() - self.ui.signal_permission_revoked.disconnect() - self.ui.signal_render_new_permission.disconnect() - self.operations = None - self.flighttrack_list = True - self.operation_list = False - for idx in range(len(self.obb)): - del self.obb[idx] - - @QtCore.Slot(str, str) + if self.operations is not None: + self.operations.logout_mscolab() + self.ui.signal_listFlighttrack_doubleClicked.disconnect() + self.ui.signal_permission_revoked.disconnect() + self.ui.signal_render_new_permission.disconnect() + self.operations = None + self.flighttrack_list = True + self.operation_list = False + for idx in range(len(self.obb)): + del self.obb[idx] + + @QtCore.pyqtSlot(str, str) def login(self, url, token): self.mscolab_server_url = url self.token = token self.connect_mscolab_server() def connect_mscolab_server(self): + if self.active_op_id is not None: + self.deactivate_all_flighttracks() self.operations = MultipleFlightpathOperations(self, self.mscolab_server_url, self.token, self.list_operation_track, + self.active_op_id, self.listOperationsMSC, self.view) self.obb.append(self.operations) @@ -250,7 +259,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 +274,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 +312,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 @@ -506,10 +515,10 @@ class MultipleFlightpathOperations: on the TopView canvas. """ - def __init__(self, parent, mscolab_server_url, token, list_operation_track, listOperationsMSC, view): + def __init__(self, parent, mscolab_server_url, token, list_operation_track, active_op_id, listOperationsMSC, view): # Variables related to Mscolab Operations self.parent = parent - self.active_op_id = None + self.active_op_id = active_op_id self.mscolab_server_url = mscolab_server_url self.token = token self.view = view @@ -522,9 +531,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 +542,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 +558,19 @@ 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)) + url = urljoin(self.mscolab_server_url, "operations") + r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": _json = json.loads(r.text) operations = _json["operations"] + 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): @@ -563,7 +579,8 @@ def request_wps_from_server(self, op_id): "token": self.token, "op_id": op_id } - r = requests.get(self.mscolab_server_url + '/get_operation_by_id', data=data, timeout=(2, 10)) + url = urljoin(self.mscolab_server_url, "get_operation_by_id") + r = requests.get(url, data=data, timeout=(2, 10)) if r.text != "False": xml_content = json.loads(r.text)["content"] return xml_content @@ -609,6 +626,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 +646,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 +694,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 +705,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 +791,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 5691327b8..6e1c0d725 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 6979f5579..d1e37a610 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 096933b46..e709f7ce8 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 f3d7546b1..53ecfc3ba 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 5611e990b..7f76c0b0d 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 c068a784b..d31ed846d 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 87ef9870d..62ced2e3a 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,36 @@ 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_listFlightTracks = mainwindow.listFlightTracks + self.mainwindow_filterCategoryCb = mainwindow.filterCategoryCb + self.mainwindow_listOperationsMSC = mainwindow.listOperationsMSC + self.setupUi(self) self.setWindowIcon(QtGui.QIcon(icons('64x64'))) @@ -208,7 +221,7 @@ def __init__(self, parent=None, model=None, _id=None, active_flighttrack=None, m self.active_flighttrack = active_flighttrack # Stores active mscolab operation id - self.active_op_id = None + self.active_op_id = mainwindow.mscolab.active_op_id # Mscolab Server Url and token self.mscolab_server_url = mscolab_server_url @@ -230,20 +243,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 +264,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,40 +352,34 @@ 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, + active_op_id=self.active_op_id, 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(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) - widget.signal_parent_closes.connect(self.closed) else: raise IndexError("invalid control index") # Create the actual dock widget containing . self.createDockWidget(index, title, widget) - def closed(self): - self.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() + @QtCore.pyqtSlot() def disable_cbs(self): self.wms_connected = True - @QtCore.Slot() + @QtCore.pyqtSlot() def enable_cbs(self): self.wms_connected = False @@ -404,7 +411,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 +427,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 4f96517a4..b33822e94 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 b58d59258..888766675 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. @@ -139,6 +140,7 @@ def createDockWidget(self, index, title, widget): # setWidget transfers the widget's ownership to Qt -- no setParent() # call is necessary: self.docks[index].setWidget(widget) + self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.docks[index]) # Check if another dock widget occupies the dock area. If yes, @@ -212,6 +214,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 +239,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 de363ec59..243c250c0 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 fd3119a42..bf4827ad3 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 @@ -473,6 +474,7 @@ def __init__(self, parent=None, default_WMS=None, wms_cache=None, view=None): self.wms_cache = None # Initialise date/time fields with current day, 00 UTC. + # Todo Before refactoring to an aware datetime object add a test to verify the WMS part. self.dteInitTime.setDateTime(QtCore.QDateTime( datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0))) self.dteValidTime.setDateTime(QtCore.QDateTime( @@ -1241,7 +1243,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 +1498,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 +1506,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 +1523,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 +1577,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 +1584,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 +1646,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 +1663,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 ab706e5a9..dd7885619 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 622e9d0fb..3c0758eac 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 03ab4c3b5..6780bd47d 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 a44e399de..af2f399a3 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 e2b4b5613..0b5a69c57 100644 --- a/mslib/mswms/generics.py +++ b/mslib/mswms/generics.py @@ -29,12 +29,14 @@ import matplotlib from mslib.utils.units import convert_to +from mslib.utils.loggerdef import configure_mpl_logger """ Number of levels in discrete colourmaps """ N_LEVELS = 16 +DEFAULT_CMAP = matplotlib.pyplot.cm.turbo """ List of supported targets using the CF standard_name as unique identifier. @@ -43,11 +45,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 +65,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 +104,7 @@ """ _UNITS = { "air_temperature": "K", + "air_potential_temperature": "K", "eastward_wind": "m/s", "equivalent_latitude": "degree N", "ertel_potential_vorticity": "PVU", @@ -161,6 +170,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 +181,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", @@ -199,6 +213,8 @@ elif standard_name not in _TITLES: _TITLES[standard_name] = standard_name.replace("_", " ") +mpl_logger = configure_mpl_logger() + def get_standard_names(): return _TARGETS @@ -302,6 +318,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 +598,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.py b/mslib/mswms/mpl_hsec.py index 6ceae6281..a5ac68b4d 100644 --- a/mslib/mswms/mpl_hsec.py +++ b/mslib/mswms/mpl_hsec.py @@ -45,10 +45,12 @@ from mslib.utils.coordinate import get_projection_params from mslib.utils.units import convert_to from mslib.mswms.utils import make_cbar_labels_readable +from mslib.utils.loggerdef import configure_mpl_logger BASEMAP_CACHE = {} BASEMAP_REQUESTS = [] +mpl_logger = configure_mpl_logger() class AbstractHorizontalSectionStyle(mss_2D_sections.Abstract2DSectionStyle): diff --git a/mslib/mswms/mpl_hsec_styles.py b/mslib/mswms/mpl_hsec_styles.py index 0e6c25e0f..4d90d1288 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 02807c37d..d7235ff40 100644 --- a/mslib/mswms/mpl_lsec.py +++ b/mslib/mswms/mpl_lsec.py @@ -33,9 +33,11 @@ from mslib.mswms import mss_2D_sections from mslib.utils.units import convert_to +from mslib.utils.loggerdef import configure_mpl_logger mpl.rcParams['xtick.direction'] = 'out' mpl.rcParams['ytick.direction'] = 'out' +mpl_logger = configure_mpl_logger() class AbstractLinearSectionStyle(mss_2D_sections.Abstract2DSectionStyle): @@ -48,7 +50,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 253f0a1bf..3d07a6388 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 fb2a31729..842e311fc 100644 --- a/mslib/mswms/mpl_vsec.py +++ b/mslib/mswms/mpl_vsec.py @@ -41,10 +41,12 @@ from mslib.mswms import mss_2D_sections from mslib.utils.units import convert_to, units from mslib.mswms.utils import make_cbar_labels_readable +from mslib.utils.loggerdef import configure_mpl_logger mpl.rcParams['xtick.direction'] = 'out' mpl.rcParams['ytick.direction'] = 'out' +mpl_logger = configure_mpl_logger() class AbstractVerticalSectionStyle(mss_2D_sections.Abstract2DSectionStyle): @@ -60,7 +62,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 +134,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 df6edb17c..dc11b9617 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 9a0f32759..7e669246e 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 22110ca74..2b218282c 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 90c9ada10..e1fa81412 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/docs/help.md b/mslib/static/docs/help.md index 263092515..5ea50caa7 100644 --- a/mslib/static/docs/help.md +++ b/mslib/static/docs/help.md @@ -8,7 +8,7 @@ software that simplifies the process for planning a scientific flight. # Drawing waypoints in the MSUI Topview The example shows defining of waypoints for a flight path, moved and deleted. -![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_images/tutorial_waypoints.gif) +![Waypoint Tutorial](https://mss.readthedocs.io/en/stable/_static/mp4/tutorial_waypoints.mp4) Further tutorials about the Mission Support System Software on: diff --git a/mslib/static/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 efb3d7fb3..b80c0fd98 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..e6ad7eab4 --- /dev/null +++ b/mslib/static/templates/idp/available_idps.html @@ -0,0 +1,71 @@ +{% 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..af46f2d04 --- /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 77e3dd182..b7fbcd507 100644 --- a/mslib/utils/__init__.py +++ b/mslib/utils/__init__.py @@ -65,8 +65,8 @@ def setup_logging(args): ch.setLevel(logging.DEBUG) ch.setFormatter(debug_formatter) else: - logger.setLevel(logging.INFO) - ch.setLevel(logging.INFO) + logger.setLevel(logging.DEBUG) + ch.setLevel(logging.DEBUG) ch.setFormatter(default_formatter) logger.addHandler(ch) # File handler (always on DEBUG level) @@ -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 40aa84dc2..f4bc87906 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 7cac43e22..0c27879a9 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 322feb2ed..7faf3bd18 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -25,8 +25,6 @@ See the License for the specific language governing permissions and limitations under the License. """ - -import sys from PyQt5 import QtCore import copy @@ -34,14 +32,13 @@ import logging import fs import os -import tempfile from mslib.utils import FatalUserError from mslib.msui import constants from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType -class MSUIDefaultConfig(object): +class MSUIDefaultConfig: """Central configuration for the Mission Support System User Interface Application (msui). @@ -129,12 +126,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 = {} @@ -145,8 +148,7 @@ class MSUIDefaultConfig(object): 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") + wms_cache = str(constants.MSUI_CACHE_PATH / "wms_cache") # Maximum size of the cache in bytes. wms_cache_max_size_bytes = 20 * 1024 * 1024 @@ -239,9 +241,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 +288,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 +306,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", @@ -448,7 +453,7 @@ def config_loader(dataset=None, default=False): return user_options -def save_settings_qsettings(tag, settings, ignore_test=False): +def save_settings_qsettings(tag, settings): """ Saves a dictionary settings to disk. @@ -458,10 +463,10 @@ 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): - 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_SYSPATH, "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: @@ -471,7 +476,7 @@ def save_settings_qsettings(tag, settings, ignore_test=False): return settings -def load_settings_qsettings(tag, default_settings=None, ignore_test=False): +def load_settings_qsettings(tag, default_settings=None): """ 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 @@ -485,11 +490,11 @@ 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): - return default_settings settings = {} - q_settings = QtCore.QSettings("msui", "msui-core") + + q_settings = QtCore.QSettings(os.path.join(constants.MSUI_CONFIG_SYSPATH, "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 4888b7cb2..63138d74a 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/loggerdef.py b/mslib/utils/loggerdef.py new file mode 100644 index 000000000..573704dd7 --- /dev/null +++ b/mslib/utils/loggerdef.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" + + mslib.utils.loggerdef + ~~~~~~~~~~~~~~~~ + + This module sets the logging level and prevent code repetition. + + This file is part of MSS. + + :copyright: Copyright 2024 Preetam Sundar Das + :copyright: Copyright 2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import logging + + +def configure_mpl_logger(): + mpl_logger = logging.getLogger('matplotlib') + mpl_logger.setLevel(logging.INFO) + return mpl_logger diff --git a/mslib/utils/migration/config_before_eight.py b/mslib/utils/migration/config_before_eight.py index 00002cbb1..72e60e090 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..bee4bc1f7 --- /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-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 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..71b609d69 --- /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-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 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/mssautoplot.py b/mslib/utils/mssautoplot.py index 2f99480f8..efbed08bc 100644 --- a/mslib/utils/mssautoplot.py +++ b/mslib/utils/mssautoplot.py @@ -52,12 +52,15 @@ from mslib.msui import flighttrack as ft from mslib.utils import config as conf from mslib.utils.auth import get_auth_from_url_and_name +from mslib.utils.loggerdef import configure_mpl_logger TEXT_CONFIG = { "bbox": dict(boxstyle="round", facecolor="white", alpha=0.5, edgecolor="none"), "fontweight": "bold", "zorder": 4, "fontsize": 6, "clip_on": True} +mpl_logger = configure_mpl_logger() + def load_from_ftml(filename): """Load a flight track from an XML file at . 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 e179dea9b..8a3357121 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 88cae00e1..fcd65c9bf 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 2fe68e579..b4dc4c647 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/mslib/utils/verify_user_token.py b/mslib/utils/verify_user_token.py index 38e3c1c83..a1adbafc6 100644 --- a/mslib/utils/verify_user_token.py +++ b/mslib/utils/verify_user_token.py @@ -28,6 +28,7 @@ import logging import requests from mslib.utils.config import config_loader +from urllib.parse import urljoin def verify_user_token(mscolab_server_url, token): @@ -39,7 +40,8 @@ def verify_user_token(mscolab_server_url, token): "token": token } try: - r = requests.get(f'{mscolab_server_url}/test_authorized', data=data, timeout=(2, 10)) + url = urljoin(mscolab_server_url, "test_authorized") + r = requests.get(url, data=data, timeout=(2, 10)) except requests.exceptions.SSLError: logging.debug("Certificate Verification Failed") return False diff --git a/mslib/version.py b/mslib/version.py index 8276f96e8..934844e40 100644 --- a/mslib/version.py +++ b/mslib/version.py @@ -24,4 +24,4 @@ See the License for the specific language governing permissions and limitations under the License. """ -__version__ = u'8.3.5' +__version__ = u'9.0.0' diff --git a/pytest.ini b/pytest.ini index 994165b57..3f0e6d3f9 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 +log_cli = 1 +log_cli_level = ERROR +log_cli_format = %(message)s +log_file = pytest.log +log_file_level = DEBUG +log_file_format = %(asctime)s %(levelname)s %(message)s +log_file_date_format = %Y-%m-%d %H:%M:%S timeout = 60 +filterwarnings = + # These namespaces are declared in a way not conformant with PEP420. Not much we can do about that here, we should keep an eye on when this is fixed in our dependencies though. + 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 976717655..fd3c44abc 100644 --- a/requirements.d/development.txt +++ b/requirements.d/development.txt @@ -1,26 +1,24 @@ # 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 +sphinxcontrib-video 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..cca7ea43a 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.7.0 +# playsound=1.3.0=pypi_0 # not yet on conda-forge +pyautogui=0.9.54 +pyscreeze=0.1.29 +python-mss=9.0.1 diff --git a/setup.cfg b/setup.cfg index be50ea6db..147b23cfb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,32 +34,9 @@ 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 +ignore = E124,E125,E402,W504,A005 max-line-length = 120 exclude = mslib/msui/qt5/*.py, mslib/mscolab/migrations/*.py diff --git a/setup.py b/setup.py index ff8671e57..c0f80484b 100644 --- a/setup.py +++ b/setup.py @@ -27,11 +27,22 @@ # The README.txt file should be written in reST so that PyPI can use # it to generate your project's PyPI page. +import os from past.builtins import execfile -from setuptools import setup, find_packages +from setuptools import setup, find_namespace_packages long_description = open('README.md').read() execfile('mslib/version.py') +console_scripts = [ + "mscolab = mslib.mscolab.mscolab:main", + "mss = mslib.msui.mss:main", + "mssautoplot = mslib.utils.mssautoplot:main", + "msui = mslib.msui.msui:main", + "mswms = mslib.mswms.mswms:main", + "mswms_demodata = mslib.mswms.demodata:main"] +if os.name != 'nt': + console_scripts.append('msidp = mslib.msidp.idp:main') + setup( name="mss", version=__version__, # noqa @@ -46,19 +57,12 @@ license="Apache-2.0", url="https://github.com/Open-MSS/MSS", platforms="any", - packages=find_packages(exclude=['tests*', 'tutorials*']), + packages=find_namespace_packages(include=["mslib", "mslib.*"]), namespace_packages=[], include_package_data=True, zip_safe=False, install_requires=[], # we use conda build recipe entry_points=dict( - console_scripts=[ - "mscolab = mslib.mscolab.mscolab:main", - "mss = mslib.msui.mss:main", - "mssautoplot = mslib.utils.mssautoplot:main", - "msui = mslib.msui.msui:main", - "mswms = mslib.mswms.mswms:main", - "mswms_demodata = mslib.mswms.demodata:main", - ], + console_scripts=console_scripts, ), ) diff --git a/tests/_test_mscolab/test_chat_manager.py b/tests/_test_mscolab/test_chat_manager.py index 2f23ab935..5a720f433 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 a11a21ef6..1f93cd64f 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 647f90e6a..1710ec9d5 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 4b12f5ba0..3631f27b4 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 @@ -197,7 +173,11 @@ def test_get_all_changes(self): assert self.fm.save_file(operation.id, "content1", self.user) assert self.fm.save_file(operation.id, "content2", self.user) all_changes = self.fm.get_all_changes(operation.id, self.user) + # the newest change is on index 0, because it has a recent created_at time assert len(all_changes) == 2 + assert all_changes[0]["id"] == 2 + assert all_changes[0]["id"] > all_changes[1]["id"] + assert all_changes[0]["created_at"] > all_changes[1]["created_at"] def test_get_change_content(self): with self.app.test_client(): @@ -206,9 +186,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 525b8bcd4..8b0a2140f 100644 --- a/tests/_test_mscolab/test_models.py +++ b/tests/_test_mscolab/test_models.py @@ -24,33 +24,99 @@ See the License for the specific language governing permissions and limitations under the License. """ +import pytest +import datetime +import textwrap +from zoneinfo import ZoneInfo -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.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() +from mslib.mscolab.server import register_user +from mslib.mscolab.models import AwareDateTime, User, Permission, Operation, Message, Change + + +def test_aware_datetime_conversion(): + aware_datetime_type = AwareDateTime() + curr_time = datetime.datetime.now(tz=datetime.timezone.utc) + + result_bind = aware_datetime_type.process_bind_param(curr_time, None) + assert result_bind is not None + assert result_bind == curr_time + + result_result = aware_datetime_type.process_result_value(curr_time, None) + assert result_result is not None + assert result_result == curr_time + + result_none = aware_datetime_type.process_bind_param(None, None) + assert result_none is None + + cet_time = datetime.datetime.now(tz=ZoneInfo("CET")) + result_cet = aware_datetime_type.process_bind_param(cet_time, None) + assert result_cet == cet_time + assert result_cet is not None + + +def test_permission_creation(): + permission = Permission(1, 1, "admin") + + assert permission.u_id == 1 + assert permission.op_id == 1 + assert permission.access_level == "admin" + + +@pytest.mark.parametrize("access_level", ["collaborator", "viewer", "creator"]) +def test_permission_repr_values(access_level): + permission = Permission(1, 1, access_level) + expected_repr = textwrap.dedent(f'''\ + ''').strip() + + assert repr(permission).strip() == expected_repr + + +def test_operation_creation(): + operation = Operation("/path/to/operation", "Description of the operation", category="test_category") + + assert operation.path == "/path/to/operation" + assert operation.description == "Description of the operation" + assert operation.category == "test_category" + assert operation.active is True + + +def test_operation_repr(): + operation = Operation("/path/to/operation", "Description of the operation", category="test_category") + expected_repr = f' ' + + assert repr(operation) == expected_repr + + +def test_message_creation(): + message = Message(1, 1, "Hello, this is a test message", "TEXT", None) + + assert message.op_id == 1 + assert message.u_id == 1 + assert message.text == "Hello, this is a test message" + assert message.message_type == "TEXT" + assert message.reply_id is None + + +def test_change_creation(): + change = Change(1, 1, "#abcdef123456", "v1.0", "Initial commit") + + assert change.op_id == 1 + assert change.u_id == 1 + assert change.commit_hash == "#abcdef123456" + assert change.version_name == "v1.0" + assert change.comment == "Initial commit" + + +class Test_User: + @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 +127,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 5671504d8..6e6882d39 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 12b54698a..d3ca67dce 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 1c1a0210b..680364a4b 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,17 +215,35 @@ 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: operation, token = self._create_operation(test_client, self.userdata) fm, user = self._save_content(operation, self.userdata) fm.save_file(operation.id, "content2", user) + # the newest change is on index 0, because it has a recent created_at time response = test_client.get('/get_all_changes', data={"token": token, "op_id": operation.id}) assert response.status_code == 200 data = json.loads(response.data.decode('utf-8')) - assert len(data["changes"]) == 2 + all_changes = data["changes"] + assert len(all_changes) == 2 + assert all_changes[0]["id"] == 2 + assert all_changes[0]["id"] > all_changes[1]["id"] + assert all_changes[0]["created_at"] > all_changes[1]["created_at"] def test_get_change_content(self): assert add_user(self.userdata[0], self.userdata[1], self.userdata[2]) @@ -329,15 +335,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 +380,7 @@ def test_import_permissions(self): # creator is not listed assert data["success"] is True - def _create_operation(self, test_client, userdata=None, path="firstflight", description="simple test"): + 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 +388,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..5280792a3 --- /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-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 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 a21ed37f3..cdffb9303 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,17 +185,18 @@ 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" assert len(messages) == 2 assert messages[0]["u_id"] == self.user.id - timestamp = datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + timestamp = datetime.datetime(1970, 1, 1, + tzinfo=datetime.timezone.utc).isoformat() 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).isoformat() messages = self.cm.get_messages(1, timestamp) assert len(messages) == 0 @@ -230,10 +222,10 @@ def test_get_messages_api(self): data = { "token": token, "op_id": self.operation.id, - "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).isoformat() } # 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 @@ -266,10 +258,10 @@ def test_edit_message(self): data = { "token": token, "op_id": self.operation.id, - "timestamp": datetime.datetime(1970, 1, 1).strftime("%Y-%m-%d, %H:%M:%S") + "timestamp": datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc).isoformat() } # 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 +299,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 5ee37324b..72696596b 100644 --- a/tests/_test_mscolab/test_utils.py +++ b/tests/_test_mscolab/test_utils.py @@ -23,64 +23,30 @@ See the License for the specific language governing permissions and limitations under the License. """ -from flask_testing import TestCase +import datetime import os import pytest import json 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.models import Operation, Message, MessageType, User 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 - u_id = 2 - - class user(): - username = "name" - text = "Moin" - message_type = MessageType.TEXT - reply_id = 0 - replies = [] - - 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() +from mslib.mscolab.utils import (get_recent_op_id, get_session_id, + get_message_dict, create_files, + os_fs_create_dir) + + +class Test_Utils: + @pytest.fixture(autouse=True) + def setup(self, mscolab_app, mscolab_managers): + self.app = mscolab_app + _, _, self.fm = mscolab_managers self.userdata = 'UV10@uv10', 'UV10', 'uv10' self.anotheruserdata = 'UV20@uv20', 'UV20', 'uv20' - socketio, cm, self.fm = setup_managers(self.app) - - def tearDown(self): - handle_db_reset() + with self.app.app_context(): + yield - @pytest.mark.skipif(os.name == "nt", - reason="multiprocessing needs currently start_method fork") 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]) @@ -98,7 +64,10 @@ def test_get_session_id(self): assert get_session_id(sockets, 5) == 100 def test_get_message_dict(self): - result = get_message_dict(Message()) + message = Message(0, 0, "Moin") + message.user = User(*self.userdata) + message.created_at = datetime.datetime.now(tz=datetime.timezone.utc) + result = get_message_dict(message) assert result["message_type"] == MessageType.TEXT def test_os_fs_create_dir(self): diff --git a/tests/_test_msui/test_aircrafts.py b/tests/_test_msui/test_aircrafts.py index 2ad905ce5..c83208560 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 c7e0b455f..f3dc3cfc6 100644 --- a/tests/_test_msui/test_editor.py +++ b/tests/_test_msui/test_editor.py @@ -28,34 +28,28 @@ 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, qtbot): + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window = editor.EditorMainWindow() + self.save_file_name = self.save_file_name + self.window.show() + yield + if os.path.exists(self.save_file_name): + os.remove(self.save_file_name) + self.window.hide() @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 8aabe8d79..b669cff13 100644 --- a/tests/_test_msui/test_kmloverlay_dockwidget.py +++ b/tests/_test_msui/test_kmloverlay_dockwidget.py @@ -27,9 +27,9 @@ import os import fs -import sys import mock -from PyQt5 import QtWidgets, QtCore, QtTest, QtGui +import pytest +from PyQt5 import 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, qtbot): self.view = mock.Mock() self.view.map = mock.Mock(side_effect=lambda x, y: (x, y)) self.view.map.plot = mock.Mock(return_value=[mock.Mock()]) @@ -50,17 +50,11 @@ def setup_method(self): self.window = kd.KMLOverlayControlWidget(view=self.view) self.window.show() - QtWidgets.QApplication.processEvents() QtTest.QTest.qWaitForWindowExposed(self.window) # start load test self.window.select_all() self.window.remove_file() - QtWidgets.QApplication.processEvents() - - def teardown_method(self): - QtWidgets.QApplication.processEvents() - self.application.quit() - QtWidgets.QApplication.processEvents() + yield self.window.close() if os.path.exists(save_kml): os.remove(save_kml) @@ -73,7 +67,6 @@ def select_file(self, file): # Utility function for single file filename = (path,) # converted to tuple self.window.select_file(filename) self.window.load_file() - QtWidgets.QApplication.processEvents() return path def select_files(self): # Utility function for multiple files @@ -81,17 +74,14 @@ def select_files(self): # Utility function for multiple files path = fs.path.join(sample_path, sample) filename = (path,) # converted to tuple self.window.select_file(filename) - QtWidgets.QApplication.processEvents() @mock.patch("mslib.msui.kmloverlay_dockwidget.get_open_filenames", return_value=[fs.path.join(sample_path, "line.kml")]) def test_get_file(self, mockopen): # Tests opening of QFileDialog QtTest.QTest.mouseClick(self.window.btSelectFile, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 - @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 """ @@ -99,31 +89,28 @@ def test_select_file(self, mockbox): assert self.window.listWidget.count() == 0 for sample in ["folder.kml", "line.kml", "color.kml", "style.kml"]: path = self.select_file(sample) - QtTest.QTest.qWait(250) assert self.window.listWidget.item(index).checkState() == QtCore.Qt.Checked index = index + 1 assert self.window.directory_location == path - 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() + critbox.assert_called_once() self.window.listWidget.clear() self.window.dict_files = {} @@ -132,7 +119,6 @@ def test_remove_file(self): Test removing all files except one """ self.select_files() - QtWidgets.QApplication.processEvents() self.window.listWidget.item(0).setCheckState(QtCore.Qt.Unchecked) QtTest.QTest.mouseClick(self.window.pushButton_remove, QtCore.Qt.LeftButton) assert self.window.listWidget.count() == 1 @@ -145,24 +131,19 @@ def test_remove_all_files(self): Test removing all files """ self.select_files() - QtWidgets.QApplication.processEvents() assert self.window.listWidget.count() == 5 QtTest.QTest.mouseClick(self.window.pushButton_remove, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.window.listWidget.count() == 0 # No items in list assert self.window.dict_files == {} # Dictionary should be empty assert self.count_patches() == 0 - @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 """ self.select_files() - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.pushButton_merge, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mocksave.call_count == 1 assert os.path.exists(save_kml) @@ -180,11 +161,9 @@ def test_customize_kml(self, mock_colour_button): QtTest.QTest.mouseClick(self.window.listWidget.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) - QtWidgets.QApplication.processEvents() # Clicking on Push Button Colour QtTest.QTest.mouseClick(self.window.pushButton_color, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mock_colour_button.call_count == 1 # Testing the Double Spin Box for linewidth diff --git a/tests/_test_msui/test_linearview.py b/tests/_test_msui/test_linearview.py index 8743f298c..2a43fdd13 100644 --- a/tests/_test_msui/test_linearview.py +++ b/tests/_test_msui/test_linearview.py @@ -29,47 +29,32 @@ 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 PyQt5 import QtTest, QtCore from mslib.msui import flighttrack as ft import mslib.msui.linearview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_LINEARVIEW -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, qtbot): 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, qtbot): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -78,55 +63,34 @@ def setup_method(self): self.window = tv.MSUILinearViewWindow(model=waypoints_model) 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_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, qtbot, 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("") @@ -134,38 +98,23 @@ def setup_method(self): 0, rows=len(initial_waypoints), waypoints=initial_waypoints) self.window = tv.MSUILinearViewWindow(model=waypoints_model) self.window.show() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - 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() + def query_server(self, qtbot, url): QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.wms_control.cpdlg.canceled) + with qtbot.wait_signal(self.wms_control.cpdlg.canceled): + QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - @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(qtbot, self.url) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) diff --git a/tests/_test_msui/test_mpl_map.py b/tests/_test_msui/test_mpl_map.py index 46b41e677..dbe6b0651 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, qtbot): kwargs = {'resolution': 'l', 'area_thresh': 1000.0, 'ax': plt.gca(), 'llcrnrlon': -15.0, 'llcrnrlat': 35.0, 'urcrnrlon': 30.0, 'urcrnrlat': 65.0, 'epsg': '4326'} self.map = MapCanvas(**kwargs) diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index c9778fca4..f24344187 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -24,8 +24,8 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys import os +import sys import fs import fs.errors import fs.opener.errors @@ -38,22 +38,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, qtbot, 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]) @@ -61,9 +56,8 @@ 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.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;")] - def test_disconnect(self): - self._connect_to_mscolab() + @mock.patch("PyQt5.QtWidgets.QWidget.setStyleSheet") + @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) + def test_connect_success(self, mockbox, mockset, qtbot): + assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") != "fnord" + self._connect_to_mscolab(qtbot, password="fnord") + + assert mslib.utils.auth.get_password_from_keyring("MSCOLAB_AUTH_" + self.url, "mscolab") == "fnord" + assert mockset.call_args_list == [mock.call("color: green;")] + + def test_disconnect(self, qtbot): + self._connect_to_mscolab(qtbot) 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() - self._login(self.userdata[0], self.userdata[2]) - QtWidgets.QApplication.processEvents() + def test_login(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, self.userdata[0], self.userdata[2]) + + def assert_(): + # show logged in widgets + assert self.main_window.usernameLabel.text() == self.userdata[1] + assert self.main_window.connectBtn.isVisible() is False + assert self.main_window.mscolab.connect_window is None + assert self.main_window.local_active is True + # test operation listing visibility + assert self.main_window.listOperationsMSC.model().rowCount() == 1 + qtbot.wait_until(assert_) + + @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) + def test_login_with_different_account_shows_update_credentials_popup(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) + connect_window = self.main_window.mscolab.connect_window + self._login(qtbot, self.userdata[0], self.userdata[2]) + 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 @@ -116,25 +144,24 @@ def test_login(self): # test operation listing visibility assert self.main_window.listOperationsMSC.model().rowCount() == 1 - def test_logout_action_trigger(self): + def test_logout_action_trigger(self, qtbot): # Login - self._connect_to_mscolab() - self._login(self.userdata[0], self.userdata[2]) - QtWidgets.QApplication.processEvents() + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, self.userdata[0], self.userdata[2]) assert self.main_window.usernameLabel.text() == self.userdata[1] # Logout self.main_window.mscolab.logout_action.trigger() - QtWidgets.QApplication.processEvents() assert self.main_window.listOperationsMSC.model().rowCount() == 0 assert self.main_window.mscolab.conn is None assert self.main_window.local_active is True assert self.main_window.usernameLabel.text() == "User" - def test_logout(self): + def test_logout(self, qtbot): # Login - self._connect_to_mscolab() - self._login(self.userdata[0], self.userdata[2]) - QtWidgets.QApplication.processEvents() + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, self.userdata[0], self.userdata[2]) assert self.main_window.usernameLabel.text() == self.userdata[1] # Logout self.main_window.mscolab.logout() @@ -145,10 +172,11 @@ def test_logout(self): assert self.main_window.local_active is True @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_add_user(self, mockmessage): - self._connect_to_mscolab() + def test_add_user(self, mockmessage, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) self._create_user("something", "something@something.org", "something") - assert config_loader(dataset="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 @@ -156,122 +184,83 @@ def test_add_user(self, mockmessage): assert self.main_window.mscolab.connect_window is None @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.No) - def test_add_users_without_updating_credentials_in_config_file(self, mockmessage): - create_msui_settings_file('{"MSCOLAB_mailid": "something@something.org"}') + def test_add_users_without_updating_credentials_in_config_file(self, mockmessage, qtbot): + create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') read_config_file() # check current settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" - self._connect_to_mscolab() + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" + self._connect_to_mscolab(qtbot) 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", + def test_add_users_with_updating_credentials_in_config_file(self, mockmessage, qtbot): + create_msui_settings_file('{"MSS_auth": {"' + self.url + '": "something@something.org"}}') + mslib.utils.auth.save_password_to_keyring(service_name=self.url, username="something@something.org", password="something") read_config_file() # check current settings - assert config_loader(dataset="MSCOLAB_mailid") == "something@something.org" - assert mslib.utils.auth.get_password_from_keyring("MSCOLAB", - "something@something.org") == "password from TestKeyring" - self._connect_to_mscolab() + assert config_loader(dataset="MSS_auth").get(self.url) == "something@something.org" + self._connect_to_mscolab(qtbot) 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, qtbot, password=""): self.window.urlCb.setEditText(self.url) + self.window.httpPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) - def _login(self, emailid="a", password="a"): + def assert_(): + assert not self.window.connectBtn.isVisible() + assert self.window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) + + def _login(self, qtbot, emailid="a", password="a"): self.window.loginEmailLe.setText(emailid) self.window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) + + def assert_(): + assert self.main_window.mscolab.connect_window is None + qtbot.wait_until(assert_) def _create_user(self, username, email, password): QtTest.QTest.mouseClick(self.window.addUserBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() self.window.newUsernameLe.setText(str(username)) - QtWidgets.QApplication.processEvents() self.window.newEmailLe.setText(str(email)) - QtWidgets.QApplication.processEvents() self.window.newPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() self.window.newConfirmPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() okWidget = self.window.newUserBb.button(self.window.newUserBb.Ok) QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - - 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, qtbot, 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]) @@ -289,12 +278,12 @@ def setup_method(self): assert add_user(self.userdata3[0], self.userdata3[1], self.userdata3[2]) assert add_user_to_operation(path=self.operation_name3, access_level="collaborator", emailid=self.userdata3[0]) - QtTest.QTest.qWait(500) - self.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,35 +294,29 @@ 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() - self._login(emailid=self.userdata[0], password=self.userdata[2]) + def test_activate_operation(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) # 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): - self._connect_to_mscolab() - self._login(emailid=self.userdata[0], password=self.userdata[2]) + def test_view_open(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) # test after activating operation self._activate_operation_at_index(0) self.window.actionTableView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 1 self.window.actionTopView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 2 self.window.actionSideView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 3 self.window.actionLinearView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 4 operation = self.window.mscolab.active_op_id @@ -341,22 +324,241 @@ 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()) + def test_multiple_views_and_multiple_flightpath(self, qtbot): + """ + checks that we can have multiple topviews with the multiple flightpath dockingwidget + and we are able to cycle a login/logout + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + # test after activating operation + self._activate_operation_at_index(0) + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + topview_0 = self.window.listViews.item(0) + + # next topview + self.window.actionTopView.trigger() + topview_1 = self.window.listViews.item(1) + + def assert_active_views(): + # check 2 view opened + assert len(self.window.get_active_views()) == 2 + qtbot.wait_until(assert_active_views) + + # open multiple flightpath first window + topview_0.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_0.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + + # activate all operation, this enables them in the docking widget too + self._activate_operation_at_index(1) + self._activate_operation_at_index(2) + self._activate_operation_at_index(0) + # ToDo refactor to be able to activate/deactivate by the docking widget and that it can be checked + + # open multiple flightpath second window + topview_1.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_1.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + + # activate all operation, this enables them in the docking widget too + self._activate_operation_at_index(1) + self._activate_operation_at_index(2) + self._activate_operation_at_index(0) + # ToDo refactor to be able to activate/deactivate by the docking widget and that it can be checked + + def assert_label_text(): + # verify logged in + assert self.window.usernameLabel.text() == self.userdata[1] + qtbot.wait_until(assert_label_text) + + self.window.mscolab.logout() + + def assert_logout_text(): + assert self.window.usernameLabel.text() == "User" + qtbot.wait_until(assert_logout_text) + + self._connect_to_mscolab(qtbot) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + # verify logged in again + qtbot.wait_until(assert_label_text) + # ToDo verify all operations disabled again without a visual check + + def test_marked_bold_only_in_multiple_flight_path_operations_for_active_operation(self, qtbot): + """ + checks that when we use operations only the operations is bold marked not the flighttrack too + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + # test after activating operation + self._activate_operation_at_index(0) + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + + topview_0 = self.window.listViews.item(0) + assert topview_0.window.tv_window_exists is True + # open multiple flightpath first window + topview_0.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_0.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + assert topview_0.window.active_op_id is not None + + list_flighttrack = topview_0.window.docks[5].widget().list_flighttrack + list_operation_track = topview_0.window.docks[5].widget().list_operation_track + + for i in range(list_operation_track.count()): + listItem = list_operation_track.item(i) + if self.window.mscolab.active_op_id == listItem.op_id: + assert listItem.font().bold() is True + for i in range(list_flighttrack.count()): + listItem = list_flighttrack.item(i) + assert listItem.font().bold() is False + + def test_correct_active_op_id_in_topview(self, qtbot): + """ + checks that active_op_id is set + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + assert self.window.mscolab.active_op_id is None + # test after activating operation + self._activate_operation_at_index(0) + assert self.window.mscolab.active_op_id is not None + selected_op_id = self.window.mscolab.active_op_id + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + topview_0 = self.window.listViews.item(0) + assert topview_0.window.active_op_id is not None + assert topview_0.window.active_op_id == selected_op_id + + def test_multiple_flightpath_switching_to_flighttrack_and_logout(self, qtbot): + """ + checks that we can switch in topviews with the multiple flightpath dockingwidget + between local flight track and operations, and we are able to cycle a login/logout + """ + # more operations for the user + for op_name in ["second", "third"]: + assert add_operation(op_name, "description") + assert add_user_to_operation(path=op_name, emailid=self.userdata[0]) + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + + # test after activating operation + self._activate_operation_at_index(0) + self.window.actionTopView.trigger() + + def assert_active_views(): + # check 1 view opened + assert len(self.window.get_active_views()) == 1 + qtbot.wait_until(assert_active_views) + topview_0 = self.window.listViews.item(0) + assert topview_0.window.tv_window_exists is True + topview_0.window.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + def assert_attribute(): + assert topview_0.window.testAttribute(QtCore.Qt.WA_DeleteOnClose) + qtbot.wait_until(assert_attribute) + + # open multiple flightpath first window + topview_0.window.cbTools.currentIndexChanged.emit(6) + + def assert_dock_loaded(): + assert topview_0.window.docks[5] is not None + qtbot.wait_until(assert_dock_loaded) + + # activate all operation, this enables them in the docking widget too + self._activate_operation_at_index(1) + self._activate_operation_at_index(2) + self._activate_operation_at_index(0) + # ToDo refactor to be able to activate/deactivate by the docking widget and that it can be checked + + self._activate_flight_track_at_index(0) + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + topview_0.window.close() + + def assert_window_closed(): + assert topview_0.window.tv_window_exists is False + qtbot.wait_until(assert_window_closed) + + def assert_label_text(): + # verify logged in + assert self.window.usernameLabel.text() == self.userdata[1] + qtbot.wait_until(assert_label_text) + + self.window.mscolab.logout() + + def assert_logout_text(): + assert self.window.usernameLabel.text() == "User" + qtbot.wait_until(assert_logout_text) + + self._connect_to_mscolab(qtbot) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + # verify logged in again + qtbot.wait_until(assert_label_text) + # ToDo verify all operations disabled again without a visual check + @mock.patch("PyQt5.QtWidgets.QFileDialog.getSaveFileName", return_value=(fs.path.join(mscolab_settings.MSCOLAB_DATA_DIR, 'test_export.ftml'), "Flight track (*.ftml)")) - def test_handle_export(self, mockbox): - self._connect_to_mscolab() - self._login(emailid=self.userdata[0], password=self.userdata[2]) + def test_handle_export(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.actionExportFlightTrackFTML.trigger() - QtWidgets.QApplication.processEvents() exported_waypoints = WaypointsTableModel(filename=fs.path.join(self.window.mscolab.data_dir, 'test_export.ftml')) wp_count = len(self.window.mscolab.waypoints_model.waypoints) @@ -364,148 +566,129 @@ 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.skipif( + sys.platform == "darwin", + reason="This test is flaky on macOS because of some cleanup error in temporary files.", + ) + @pytest.mark.parametrize("name", [("example.ftml", "actionImportFlightTrackFTML", 5), + ("example.csv", "actionImportFlightTrackCSV", 5), + ("example.txt", "actionImportFlightTrackTXT", 5), + ("flitestar.txt", "actionImportFlightTrackFliteStar", 10)]) + def test_import_file(self, name, qtbot): 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: + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) + self._activate_operation_at_index(0) + 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") - def test_work_locally_toggle(self): - self._connect_to_mscolab() - self._login(emailid=self.userdata[0], password=self.userdata[2]) + 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, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) + self._login(qtbot, emailid=self.userdata[0], password=self.userdata[2]) self._activate_operation_at_index(0) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) wpdata_local = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) wpdata_server = self.window.mscolab.waypoints_model.waypoint_data(0) assert wpdata_local.lat != wpdata_server.lat - @pytest.mark.skip("fails often on github on a timeout >60s") - @mock.patch("mslib.msui.mscolab.QtWidgets.QErrorMessage.showMessage") @mock.patch("mslib.msui.mscolab.get_open_filename", return_value=os.path.join(sample_path, u"example.ftml")) - def test_browse_add_operation(self, mockopen, mockmessage): - self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") + def test_browse_add_operation(self, mockopen, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") assert self.window.listOperationsMSC.model().rowCount() == 0 self.window.actionAddOperation.trigger() - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.path.setText(str("example")) - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.description.setText(str("example")) - QtWidgets.QApplication.processEvents() self.window.mscolab.add_proj_dialog.category.setText(str("example")) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mscolab.add_proj_dialog.browse, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() okWidget = self.window.mscolab.add_proj_dialog.buttonBox.button( self.window.mscolab.add_proj_dialog.buttonBox.Ok) - QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - # we need to wait for the update of the operation list - QtTest.QTest.qWait(200) - QtWidgets.QApplication.processEvents() - assert self.window.listOperationsMSC.model().rowCount() == 1 - item = self.window.listOperationsMSC.item(0) - assert item.operation_path == "example" - assert item.access_level == "creator" - - @mock.patch("PyQt5.QtWidgets.QErrorMessage") - def test_add_operation(self, mockbox): - self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - 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 + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) + + def assert_(): + m.assert_called_once() + qtbot.wait_until(assert_) + + def assert_(): + assert self.window.listOperationsMSC.model().rowCount() == 1 + item = self.window.listOperationsMSC.item(0) + assert item.operation_path == "example" + assert item.access_level == "creator" + qtbot.wait_until(assert_) + + def test_add_operation(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "Alpha", "Description Alpha") + 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') - self._connect_to_mscolab() - self._create_user("berta", "berta@something.org", "something") + def test_handle_delete_operation(self, mocktext, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) + self._create_user(qtbot, "berta", "berta@something.org", "something") 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() + 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): - self._connect_to_mscolab() + def test_handle_leave_operation(self, mockmessage, qtbot): + self._connect_to_mscolab(qtbot) - self._login(self.userdata3[0], self.userdata3[2]) - QtWidgets.QApplication.processEvents() + modify_config_file({"MSS_auth": {self.url: self.userdata3[0]}}) + self._login(qtbot, self.userdata3[0], self.userdata3[2]) assert self.window.usernameLabel.text() == self.userdata3[1] assert self.window.connectBtn.isVisible() is False @@ -516,120 +699,157 @@ def test_handle_leave_operation(self, mockmessage): assert op_id is not None self.window.actionTopView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 1 self.window.actionSideView.trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.get_active_views()) == 2 self.window.actionLeaveOperation.trigger() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(0) - 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): - self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + def test_handle_rename_operation(self, mocktext, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None - self.window.actionRenameOperation.trigger() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(0) + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionRenameOperation.trigger() + m.assert_called_once_with(self.window, "Rename successful", "Operation is renamed successfully.") 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): - self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=("new_description", True)) + def test_update_description(self, mocktext, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None - self.window.actionUpdateOperationDesc.trigger() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(0) + with mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) as m: + self.window.actionChangeDescription.trigger() + m.assert_called_once_with(self.window, "Update successful", "Description is updated successfully.") assert self.window.mscolab.active_op_id is not None - assert self.window.mscolab.active_operation_desc == "new_desciption" - - def test_get_recent_op_id(self): - self._connect_to_mscolab() - self._create_user("anton", "anton@something.org", "something") - QtTest.QTest.qWait(100) + 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(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") + assert self.window.listOperationsMSC.model().rowCount() == 1 + 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() + m.assert_called_once_with(self.window, "Update successful", "Category is updated successfully.") + assert self.window.mscolab.active_op_id is not None + assert self.window.mscolab.active_operation_category == "new_category" + + def test_any_special_category(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") + 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) + # 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(qtbot) + modify_config_file({"MSS_auth": {self.url: "anton@something.org"}}) + self._create_user(qtbot, "anton", "anton@something.org", "something") 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): - self._connect_to_mscolab() - self._create_user("berta", "berta@something.org", "something") - QtTest.QTest.qWait(100) + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_get_recent_operation(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "berta@something.org"}}) + self._create_user(qtbot, "berta", "berta@something.org", "something") 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): - self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") - self._create_operation("flight1234", "Description flight1234") + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_open_chat_window(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") assert self.window.listOperationsMSC.model().rowCount() == 1 self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None self.window.actionChat.trigger() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(0) assert self.window.mscolab.chat_window is not None - def test_close_chat_window(self): - 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 + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_close_chat_window(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") + self._create_operation(qtbot, "flight1234", "Description flight1234") self._activate_operation_at_index(0) assert self.window.mscolab.active_op_id is not None self.window.actionChat.trigger() - QtWidgets.QApplication.processEvents() self.window.mscolab.close_chat_window() assert self.window.mscolab.chat_window is None - def test_delete_operation_from_list(self): - self._connect_to_mscolab() - self._create_user("other", "other@something.org", "something") + @mock.patch("PyQt5.QtWidgets.QMessageBox.information", return_value=QtWidgets.QMessageBox.Ok) + def test_delete_operation_from_list(self, mockbox, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "other@something.org"}}) + self._create_user(qtbot, "other", "other@something.org", "something") assert self.window.usernameLabel.text() == 'other' 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): - self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") + def test_user_delete(self, mockmessage, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") u_id = self.window.mscolab.user['id'] self.window.mscolab.open_profile_window() QtTest.QTest.mouseClick(self.window.mscolab.profile_dialog.deleteAccountBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.window.listOperationsMSC.model().rowCount() == 0 assert self.window.usernameLabel.isVisible() is False assert self.window.connectBtn.isVisible() is True @@ -639,104 +859,120 @@ def test_user_delete(self, mockmessage): def test_open_help_dialog(self): self.window.actionMSColabHelp.trigger() - QtWidgets.QApplication.processEvents() assert self.window.mscolab.help_dialog is not None - def test_close_help_dialog(self): + def test_close_help_dialog(self, qtbot): self.window.actionMSColabHelp.trigger() - QtWidgets.QApplication.processEvents() assert self.window.mscolab.help_dialog is not None QtTest.QTest.mouseClick( self.window.mscolab.help_dialog.okayBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(50) - QtWidgets.QApplication.processEvents() - assert self.window.mscolab.help_dialog is None - - @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 assert_(): + assert self.window.mscolab.help_dialog is None + qtbot.wait_until(assert_) + + def test_create_dir_exceptions(self): + with mock.patch("fs.open_fs", new=ExceptionMock(fs.errors.CreateFailed).raise_exc), \ + 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): - self._connect_to_mscolab() - self._create_user("something", "something@something.org", "something") + def test_profile_dialog(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something") self.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): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) - def _login(self, emailid, password): + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) + + def _login(self, qtbot, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) - def _create_user(self, username, email, password): + def assert_(): + assert self.window.mscolab.connect_window is None + qtbot.wait_until(assert_) + + def _create_user(self, qtbot, username, email, password): QtTest.QTest.mouseClick(self.connect_window.addUserBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() self.connect_window.newUsernameLe.setText(str(username)) - QtWidgets.QApplication.processEvents() self.connect_window.newEmailLe.setText(str(email)) - QtWidgets.QApplication.processEvents() self.connect_window.newPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() self.connect_window.newConfirmPasswordLe.setText(str(password)) - QtWidgets.QApplication.processEvents() okWidget = self.connect_window.newUserBb.button(self.connect_window.newUserBb.Ok) QtTest.QTest.mouseClick(okWidget, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - def _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") - QtWidgets.QApplication.processEvents() + self.window.mscolab.add_proj_dialog.category.setText(category) 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): + # The main window must be on top + self.window.activateWindow() + # get the item by its index item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() + + def _activate_flight_track_at_index(self, index): + # The main window must be on top + self.window.activateWindow() + # get the item by its index + item = self.window.listFlightTracks.item(index) + point = self.window.listFlightTracks.visualItemRect(item).center() + QtTest.QTest.mouseClick(self.window.listFlightTracks.viewport(), QtCore.Qt.LeftButton, pos=point) + QtTest.QTest.mouseDClick(self.window.listFlightTracks.viewport(), QtCore.Qt.LeftButton, pos=point) diff --git a/tests/_test_msui/test_mscolab_admin_window.py b/tests/_test_msui/test_mscolab_admin_window.py index 0b6c3bb76..515868e3e 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, qtbot, 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]) @@ -65,42 +58,36 @@ def setup_method(self): assert add_operation("tokyo", "test tokyo") assert add_user_to_operation(path="tokyo", emailid=self.userdata[0], access_level="creator") - QtTest.QTest.qWait(500) - self.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() + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(emailid=self.userdata[0], password=self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) self.window.actionManageUsers.trigger() - QtWidgets.QApplication.processEvents() self.admin_window = self.window.mscolab.admin_window QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() - - 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() - QtWidgets.QApplication.processEvents() - self.process.terminate() + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window.close() def test_permission_filter(self): len_added_users = self.admin_window.modifyUsersTable.rowCount() # Change filter to viewer self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("viewer") - QtWidgets.QApplication.processEvents() # Check how many users are visible visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 1 # Change it back to all self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("all") - QtWidgets.QApplication.processEvents() # Check how many rows are visible visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == len_added_users @@ -110,33 +97,27 @@ def test_text_search_filter(self): len_added_users = self.admin_window.modifyUsersTable.rowCount() # Text Search in add users Table QtTest.QTest.keyClicks(self.admin_window.addUsersSearch, "name1") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.addUsersTable) assert visible_row_count == 1 self.admin_window.addUsersSearch.setText("") QtTest.QTest.keyClicks(self.admin_window.addUsersSearch, "") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.addUsersTable) assert visible_row_count == len_unadded_users # Text Search in modify users Table QtTest.QTest.keyClicks(self.admin_window.modifyUsersSearch, "example") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 1 self.admin_window.modifyUsersSearch.setText("") QtTest.QTest.keyClicks(self.admin_window.modifyUsersSearch, "") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == len_added_users def test_permission_and_text_together(self): QtTest.QTest.keyClicks(self.admin_window.modifyUsersSearch, "viewer") self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("viewer") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 1 self.admin_window.modifyUsersPermissionFilter.currentTextChanged.emit("admin") - QtWidgets.QApplication.processEvents() visible_row_count = self._get_visible_row_count(self.admin_window.modifyUsersTable) assert visible_row_count == 0 @@ -150,7 +131,6 @@ def test_add_permissions(self): if index >= 0: self.admin_window.addUsersPermission.setCurrentIndex(index) QtTest.QTest.mouseClick(self.admin_window.addUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Check if they have been added in the modify users table self._check_users_present(self.admin_window.modifyUsersTable, users, "admin") assert len_unadded_users - 2 == self.admin_window.addUsersTable.rowCount() @@ -161,14 +141,12 @@ def test_modify_permissions(self): # Select users in the add users table self._select_users(self.admin_window.addUsersTable, users) QtTest.QTest.mouseClick(self.admin_window.addUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Select users in the modify users table self._select_users(self.admin_window.modifyUsersTable, users) # Update their permission to viewer index = self.admin_window.modifyUsersPermission.findText("viewer", QtCore.Qt.MatchFixedString) self.admin_window.modifyUsersPermission.setCurrentIndex(index) QtTest.QTest.mouseClick(self.admin_window.modifyUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Check if the permission has been updated self._check_users_present(self.admin_window.modifyUsersTable, users, "viewer") @@ -177,7 +155,6 @@ def test_delete_permissions(self): users = ["name1", "name2"] self._select_users(self.admin_window.addUsersTable, users) QtTest.QTest.mouseClick(self.admin_window.addUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() len_unadded_users = self.admin_window.addUsersTable.rowCount() len_added_users = self.admin_window.modifyUsersTable.rowCount() @@ -185,7 +162,6 @@ def test_delete_permissions(self): self._select_users(self.admin_window.modifyUsersTable, users) # Click on delete permissions QtTest.QTest.mouseClick(self.admin_window.deleteUsersBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() # Check if the deleted users can be found in the add users table self._check_users_present(self.admin_window.addUsersTable, users) assert len_unadded_users + 2 == self.admin_window.addUsersTable.rowCount() @@ -195,34 +171,30 @@ def test_import_permissions(self): index = self.admin_window.importPermissionsCB.findText("paris", QtCore.Qt.MatchFixedString) self.admin_window.importPermissionsCB.setCurrentIndex(index) QtTest.QTest.mouseClick(self.admin_window.importPermissionsBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) assert self.admin_window.modifyUsersTable.rowCount() == 1 - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) def _select_users(self, table, users): for row_num in range(table.rowCount()): @@ -231,7 +203,6 @@ def _select_users(self, table, users): if username in users: point = table.visualItemRect(item).center() QtTest.QTest.mouseClick(table.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() assert len(table.selectionModel().selectedRows()) == 2 def _get_visible_row_count(self, table): diff --git a/tests/_test_msui/test_mscolab_merge_waypoints.py b/tests/_test_msui/test_mscolab_merge_waypoints.py index 96c6d6655..1d0fb9340 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 @@ -33,29 +31,23 @@ import mslib.utils.auth from mslib.msui import flighttrack as ft from mslib.mscolab.conf import mscolab_settings -from PyQt5 import QtCore, QtTest, QtWidgets -from tests.utils import (mscolab_start_server, mscolab_register_and_login, mscolab_create_operation, +from PyQt5 import QtCore, QtTest +from tests.utils import (mscolab_register_and_login, mscolab_create_operation, mscolab_delete_all_operations, mscolab_delete_user) from mslib.msui import mscolab 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) - QtTest.QTest.qWait(500) - self.application = QtWidgets.QApplication(sys.argv) +class Test_Mscolab_Merge_Waypoints: + @pytest.fixture(autouse=True) + def setup(self, qtbot, mscolab_app, mscolab_server): + self.app = mscolab_app + self.url = mscolab_server self.window = msui.MSUIMainWindow(mscolab_data_dir=mscolab_settings.MSCOLAB_DATA_DIR) + self.window.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,13 +61,10 @@ 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'): + def _create_user_data(self, qtbot, emailid='merge@alpha.org'): with self.app.app_context(): - self._connect_to_mscolab() + self._connect_to_mscolab(qtbot) response = mscolab_register_and_login(self.app, self.url, emailid, 'abcdef', 'alpha') assert response.status == '200 OK' @@ -85,144 +74,151 @@ def _create_user_data(self, emailid='merge@alpha.org'): self._login(emailid, 'abcdef') self._activate_operation_at_index(0) - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid="merge_waypoints_user", password="password"): + modify_config_file({"MSS_auth": {self.url: self.emailid}}) self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() def _select_waypoints(self, table): for row in range(table.model().rowCount()): table.selectRow(row) - QtWidgets.QApplication.processEvents() -@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) + self._create_user_data(qtbot, 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) + self._create_user_data(qtbot, 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, qtbot): self.emailid = "fetch_from_server@alpha.org" - self._create_user_data(emailid=self.emailid) + self._create_user_data(qtbot, emailid=self.emailid) wp_server_before = self.window.mscolab.waypoints_model.waypoint_data(0) self.window.workLocallyCheckbox.setChecked(True) - 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 self.window.mscolab.waypoints_model.invert_direction() 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) - QtWidgets.QApplication.processEvents() + 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() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working server_xml = self.window.mscolab.request_wps_from_server() @@ -231,6 +227,4 @@ def handle_merge_dialog(): assert len(new_local_wp.waypoints) == 2 assert new_local_wp.waypoint_data(0).lat == wp_server_before.lat self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) assert self.window.mscolab.waypoints_model.waypoint_data(0).lat == wp_server_before.lat diff --git a/tests/_test_msui/test_mscolab_operation.py b/tests/_test_msui/test_mscolab_operation.py index 8f874fae8..76e5ae334 100644 --- a/tests/_test_msui/test_mscolab_operation.py +++ b/tests/_test_msui/test_mscolab_operation.py @@ -24,23 +24,20 @@ See the License for the specific language governing permissions and limitations under the License. """ -import os -import sys import pytest +import datetime from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import Message +from mslib.mscolab.models import Message, MessageType 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 +from mslib.mscolab.utils import get_message_dict -PORTS = list(range(22000, 22500)) - -class Actions(object): +class Actions: DOWNLOAD = 0 COPY = 1 REPLY = 2 @@ -48,139 +45,136 @@ 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, qtbot, 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]) assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - QtTest.QTest.qWait(500) - self.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() + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) self.window.actionChat.trigger() - QtWidgets.QApplication.processEvents() self.chat_window = self.window.mscolab.chat_window QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() - - def teardown_method(self): + yield self.window.mscolab.logout() if self.window.mscolab.chat_window: self.window.mscolab.chat_window.hide() if self.window.mscolab.conn: 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**") + message = Message.query.filter_by(text='**test message**').first() + result = get_message_dict(message) + assert result["message_type"] == MessageType.TEXT + assert datetime.datetime.fromisoformat(result["time"]) == message.created_at + + 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") - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.chat_window.searchPrevBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index).isSelected() is True QtTest.QTest.mouseClick(self.chat_window.searchPrevBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index - 1).isSelected() is True QtTest.QTest.mouseClick(self.chat_window.searchNextBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.chat_window.messageList.item(message_index).isSelected() is True - def test_copy_message(self): - 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(): assert Message.query.filter_by(text='test edit').count() == 0 - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid, password): self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() def _activate_context_menu_action(self, action_index): item = self.chat_window.messageList.item(self.chat_window.messageList.count() - 1) 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 d772a8c3f..b725640a1 100644 --- a/tests/_test_msui/test_mscolab_save_merge_points.py +++ b/tests/_test_msui/test_mscolab_save_merge_points.py @@ -24,28 +24,17 @@ 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 +from PyQt5 import QtCore, QtTest -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, qtbot): self.emailid = "mergepoints@alpha.org" - self._create_user_data(emailid=self.emailid) + self._create_user_data(qtbot, emailid=self.emailid) self.window.workLocallyCheckbox.setChecked(True) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() merge_waypoints_model = None @@ -55,16 +44,13 @@ def handle_merge_dialog(): self._select_waypoints(self.window.mscolab.merge_dialog.serverWaypointsTable) merge_waypoints_model = self.window.mscolab.merge_dialog.merge_waypoints_model QtTest.QTest.mouseClick(self.window.mscolab.merge_dialog.saveBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - 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) - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QMessageBox.information") as m: + self.window.serverOptionsCb.setCurrentIndex(2) + m.assert_called_once() # get the updated waypoints model from the server # ToDo understand why requesting in follow up test of self.window.waypoints_model not working server_xml = self.window.mscolab.request_wps_from_server() @@ -76,8 +62,6 @@ def handle_merge_dialog(): for wp_index in range(new_wp_count): assert new_local_wp.waypoint_data(wp_index).lat == merge_waypoints_model.waypoint_data(wp_index).lat self.window.workLocallyCheckbox.setChecked(False) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) new_server_wp = self.window.mscolab.waypoints_model assert len(new_server_wp.waypoints) == new_wp_count for wp_index in range(new_wp_count): diff --git a/tests/_test_msui/test_mscolab_version_history.py b/tests/_test_msui/test_mscolab_version_history.py index a8d7b3842..72f218c58 100644 --- a/tests/_test_msui/test_mscolab_version_history.py +++ b/tests/_test_msui/test_mscolab_version_history.py @@ -24,109 +24,87 @@ 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, qtbot, 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]) assert add_operation(self.operation_name, "test europe") assert add_user_to_operation(path=self.operation_name, emailid=self.userdata[0]) self.user = get_user(self.userdata[0]) - QtTest.QTest.qWait(500) - self.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() + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: self.userdata[0]}}) self._login(self.userdata[0], self.userdata[2]) # activate operation and open chat window self._activate_operation_at_index(0) self.window.actionVersionHistory.trigger() - QtWidgets.QApplication.processEvents() self.version_window = self.window.mscolab.version_window assert self.version_window is not None QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() - - 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) - - @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) - def test_set_version_name(self, mockbox): - self._set_version_name() - QtTest.QTest.qWait(100) - assert self.version_window.changes.currentItem().version_name == "MyVersionName" - assert self.version_window.changes.count() == 1 - - @mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]) - def test_version_name_delete(self, mockbox): - self._set_version_name() - QtTest.QTest.qWait(100) - assert self.version_window.changes.currentItem().version_name == "MyVersionName" + + 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_) + + def test_set_version_name(self, qtbot): + self._set_version_name(qtbot) + + def test_version_name_delete(self, qtbot): + self._set_version_name(qtbot) QtTest.QTest.mouseClick(self.version_window.deleteVersionNameBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) - assert self.version_window.changes.count() == 1 - assert self.version_window.changes.currentItem().version_name is None + + def assert_(): + assert self.version_window.changes.count() == 1 + assert self.version_window.changes.currentItem().version_name is None + qtbot.wait_until(assert_) @mock.patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QtWidgets.QMessageBox.Yes) - def test_undo(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() - QtWidgets.QApplication.processEvents() + + def assert_(): + self.version_window.load_all_changes() + assert self.version_window.changes.count() == 2 + qtbot.wait_until(assert_) changes_count = self.version_window.changes.count() self._activate_change_at_index(1) QtTest.QTest.mouseClick(self.version_window.checkoutBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(200) new_changes_count = self.version_window.changes.count() assert changes_count + 1 == new_changes_count @@ -134,43 +112,36 @@ def test_refresh(self): self._change_version_filter(1) changes_count = self.version_window.changes.count() self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) QtTest.QTest.mouseClick(self.version_window.refreshBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) new_changes_count = self.version_window.changes.count() assert new_changes_count == changes_count + 2 - def _connect_to_mscolab(self): + def _connect_to_mscolab(self, qtbot): self.connect_window = mscolab.MSColab_ConnectDialog(parent=self.window, mscolab=self.window.mscolab) self.window.mscolab.connect_window = self.connect_window assert self.connect_window is not None self.connect_window.urlCb.setEditText(self.url) self.connect_window.show() QtTest.QTest.mouseClick(self.connect_window.connectBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) + + def assert_(): + assert not self.connect_window.connectBtn.isVisible() + assert self.connect_window.disconnectBtn.isVisible() + qtbot.wait_until(assert_) def _login(self, emailid, password): assert self.connect_window is not None self.connect_window.loginEmailLe.setText(emailid) self.connect_window.loginPasswordLe.setText(password) QtTest.QTest.mouseClick(self.connect_window.loginBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(500) def _activate_operation_at_index(self, index): assert index < self.window.listOperationsMSC.count() item = self.window.listOperationsMSC.item(index) point = self.window.listOperationsMSC.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseDClick(self.window.listOperationsMSC.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() def _activate_change_at_index(self, index): assert self.version_window is not None @@ -178,28 +149,31 @@ def _activate_change_at_index(self, index): item = self.version_window.changes.item(index) point = self.version_window.changes.visualItemRect(item).center() QtTest.QTest.mouseClick(self.version_window.changes.viewport(), QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.keyClick(self.version_window.changes.viewport(), QtCore.Qt.Key_Return) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) def _change_version_filter(self, index): assert self.version_window is not None assert index < self.version_window.versionFilterCB.count() self.version_window.versionFilterCB.setCurrentIndex(index) self.version_window.versionFilterCB.currentIndexChanged.emit(index) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - def _set_version_name(self): + def _set_version_name(self, qtbot): self._change_version_filter(1) + num_changes_before = self.version_window.changes.count() # make a changes self.window.mscolab.waypoints_model.invert_direction() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - self.version_window.load_all_changes() - QtWidgets.QApplication.processEvents() + + # Ensure that the change is visible + def assert_(): + self.version_window.load_all_changes() + assert self.version_window.changes.count() == num_changes_before + 1 + qtbot.wait_until(assert_) + self._activate_change_at_index(0) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.version_window.nameVersionBtn, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() + with mock.patch("PyQt5.QtWidgets.QInputDialog.getText", return_value=["MyVersionName", True]): + QtTest.QTest.mouseClick(self.version_window.nameVersionBtn, QtCore.Qt.LeftButton) + + # Ensure that the name change is fully processed + def assert_(): + assert self.version_window.changes.currentItem().version_name == "MyVersionName" + qtbot.wait_until(assert_) diff --git a/tests/_test_msui/test_mss.py b/tests/_test_msui/test_mss.py index 1b041c45a..17e5ce903 100644 --- a/tests/_test_msui/test_mss.py +++ b/tests/_test_msui/test_mss.py @@ -24,18 +24,11 @@ See the License for the specific language governing permissions and limitations under the License. """ - - -import sys -from PyQt5 import QtWidgets, QtTest, QtCore +from PyQt5 import QtTest, QtCore from mslib.msui import mss -def test_mss_rename_message(): - application = QtWidgets.QApplication(sys.argv) +def test_mss_rename_message(qtbot): 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 2cd740eda..3b0bf9faa 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -26,22 +26,21 @@ """ -import sys import mock import os -import platform +import fs 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, 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 -@mock.patch("mslib.msui.msui.constants.POSIX", POSIX) def test_main(): with pytest.raises(SystemExit) as pytest_wrapped_e: with mock.patch("mslib.msui.msui.argparse.ArgumentParser.parse_args", @@ -49,25 +48,41 @@ def test_main(): msui.main() assert pytest_wrapped_e.typename == "SystemExit" - if platform.system() == "Linux": - with pytest.raises(SystemExit) as pytest_wrapped_e: - with mock.patch("mslib.msui.msui.argparse.ArgumentParser.parse_args", - return_value=argparse.Namespace(version=False, update=False, menu=True, - deinstall=False, debug=False, logfile="log.log")): - msui.main() - assert pytest_wrapped_e.typename == "SystemExit" - with pytest.raises(SystemExit) as pytest_wrapped_e: - with mock.patch("mslib.msui.msui.argparse.ArgumentParser.parse_args", - return_value=argparse.Namespace(version=False, update=False, menu=False, - deinstall=True, debug=False, logfile="log.log")): - msui.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, qtbot, 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() + + 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, qtbot): + self.window = msui_mw.MSUI_AboutDialog() + yield + self.window.hide() def test_milestone_url(self): with urlopen(self.window.milestone_url) as f: @@ -75,26 +90,16 @@ 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, qtbot): + 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 +129,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 +143,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,21 +152,18 @@ class Test_MSSSideViewWindow(object): # "GPX": ["gpx", "mslib.plugins.io.gpx", "save_to_gpx"] } - def setup_method(self): + @pytest.fixture(autouse=True) + def setup(self, qtbot): 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() self.window.show() - 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', @@ -170,117 +172,88 @@ def teardown_method(self): for i in range(self.window.listViews.count()): 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() - QtWidgets.QApplication.processEvents() - self.window.config_editor.close() - assert mockbox.critical.call_count == 0 - - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_open_shortcut(self, mockbox): + + def test_open_config(self): + self.window.actionConfiguration.trigger() + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + self.window.config_editor.close() + + def test_open_shortcut(self): self.window.actionShortcuts.trigger() - QtWidgets.QApplication.processEvents() - 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 self.window.actionSaveActiveFlightTrackAs.trigger() - QtWidgets.QApplication.processEvents() assert mocksave.call_count == 1 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 @@ -289,56 +262,53 @@ def test_plugin_export(self, save_file): if obj_name == action.objectName(): action.trigger() break - QtWidgets.QApplication.processEvents() assert mocksave.call_count == 1 assert os.path.exists(save_file[0]) os.remove(save_file[0]) - @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 9c2780501..d25ffc3cf 100644 --- a/tests/_test_msui/test_multiple_flightpath_dockwidget.py +++ b/tests/_test_msui/test_multiple_flightpath_dockwidget.py @@ -24,19 +24,17 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys -from PyQt5 import QtWidgets, QtTest +import pytest +from PyQt5 import QtTest from mslib.msui import msui from mslib.msui.multiple_flightpath_dockwidget import MultipleFlightpathControlWidget from mslib.msui import flighttrack as ft 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, qtbot): self.window = msui.MSUIMainWindow() self.window.create_new_flight_track() @@ -46,18 +44,14 @@ 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 b9902f193..4981a3439 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, qtbot): + 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 ff39acf46..92a661a29 100644 --- a/tests/_test_msui/test_satellite_dockwidget.py +++ b/tests/_test_msui/test_satellite_dockwidget.py @@ -26,42 +26,39 @@ """ import os -import sys import mock -from PyQt5 import QtWidgets, QtCore, QtTest +import pytest +from PyQt5 import 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, qtbot): 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") self.window.leFile.setText(path) assert self.window.cbSatelliteOverpasses.count() == 0 QtTest.QTest.mouseClick(self.window.btLoadFile, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert self.window.cbSatelliteOverpasses.count() == 11 assert self.view.plot_satellite_overpass.call_count == 1 self.window.cbSatelliteOverpasses.currentIndexChanged.emit(2) - QtWidgets.QApplication.processEvents() assert self.view.plot_satellite_overpass.call_count == 2 self.view.reset_mock() - 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 26403e1e4..22b3302c7 100644 --- a/tests/_test_msui/test_sideview.py +++ b/tests/_test_msui/test_sideview.py @@ -30,73 +30,50 @@ 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 PyQt5 import QtTest, QtCore, QtGui from mslib.msui import flighttrack as ft import mslib.msui.sideview as tv from mslib.msui.mpl_qtwidget import _DEFAULT_SETTINGS_SIDEVIEW -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, qtbot): 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() assert all(x == y for x, y in zip(levels, [300, 320, 340])) QtTest.QTest.mouseClick(self.window.btAdd, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() levels = self.window.get_flight_levels() assert all(x == y for x, y in zip(levels, [0, 300, 320, 340])) @mock.patch("PyQt5.QtWidgets.QColorDialog.getColor", return_value=QtGui.QColor()) def test_setColour(self, mockdlg): QtTest.QTest.mouseClick(self.window.btFillColour, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockdlg.call_count == 1 -class Test_MSSSideViewWindow(object): - def setup_method(self): - self.application = QtWidgets.QApplication(sys.argv) +class Test_MSSSideViewWindow: + @pytest.fixture(autouse=True) + def setup(self, qtbot): initial_waypoints = [ft.Waypoint(40., 25., 300), ft.Waypoint(60., -10., 400), ft.Waypoint(40., 10, 300)] waypoints_model = ft.WaypointsTableModel("") @@ -105,85 +82,55 @@ def setup_method(self): self.window = tv.MSUISideViewWindow(model=waypoints_model) 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_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 """ self.window.mpl.navbar._actions['insert_wp'].trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 point = self.window.mpl.canvas.rect().center() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=QtCore.QPoint(1, 1)) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) # click again on same position - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 - 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, qtbot, 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("") @@ -191,41 +138,27 @@ def setup_method(self): 0, rows=len(initial_waypoints), waypoints=initial_waypoints) self.window = tv.MSUISideViewWindow(model=waypoints_model) self.window.show() - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - 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() + def query_server(self, qtbot, url): QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.wms_control.cpdlg.canceled) + with qtbot.wait_signal(self.wms_control.cpdlg.canceled): + QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - @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(qtbot, self.url) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) assert self.window.getView().plotter.image is not None + 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 bb6c9aa47..4f4fc580f 100644 --- a/tests/_test_msui/test_suffix.py +++ b/tests/_test_msui/test_suffix.py @@ -26,31 +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 +from PyQt5 import 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, qtbot): 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'] for i in range(len(suffix)): self.window.cbVerticalAxis.setCurrentIndex(i) - QtWidgets.QApplication.processEvents() assert self.window.sbPtop.suffix() == suffix[i] diff --git a/tests/_test_msui/test_tableview.py b/tests/_test_msui/test_tableview.py index 6ea4d9c7a..6f14668e8 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, qtbot): # Create an initital flight track. initial_waypoints = [ft.Waypoint(flightlevel=0, location="EDMO", comments="take off OP"), ft.Waypoint(48.10, 10.27, 200), @@ -54,22 +52,15 @@ def setup_method(self): self.window = tv.MSUITableViewWindow(model=waypoints_model) 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_open_hex(self): """ Tests opening the hexagon dock widget. """ self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() assert len(self.window.docks) == 2 assert self.window.docks[0] is not None assert self.window.docks[1] is None @@ -79,7 +70,6 @@ def test_open_perf_settings(self): Tests opening the performance settings dock widget. """ self.window.cbTools.currentIndexChanged.emit(2) - QtWidgets.QApplication.processEvents() assert len(self.window.docks) == 2 assert self.window.docks[0] is None assert self.window.docks[1] is not None @@ -91,27 +81,22 @@ def test_insertremove_hexagon(self, mockbox): Test inserting and removing hexagons in TableView using the Hexagon dockwidget """ self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 QtTest.QTest.mouseClick(self.window.docks[0].widget().pbAddHexagon, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 12 assert mockbox.call_count == 0 QtTest.QTest.mouseClick(self.window.docks[0].widget().pbRemoveHexagon, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 5 - @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 """ self.window.cbTools.currentIndexChanged.emit(2) - QtWidgets.QApplication.processEvents() self.window.waypoints_model.performance_settings = DEFAULT_PERFORMANCE self.window.waypoints_model.update_distances(0) @@ -129,9 +114,7 @@ def test_performance(self, mockopen, mockcrit): assert self.window.waypoints_model.columnCount() == 15 # todo this does not check that actually something happens QtTest.QTest.mouseClick(self.window.docks[1].widget().pbLoadPerformance, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert mockopen.call_count == 1 - assert mockcrit.call_count == 0 def test_insert_point(self): """ @@ -145,7 +128,6 @@ def test_insert_point(self): assert len(self.window.waypoints_model.waypoints) == 5 wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btAddWayPointToFlightTrack, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert len(self.window.waypoints_model.waypoints) == 6 assert all(_x == _y for _x, _y in zip(wps[:3], wps2[:3])), (wps, wps2) @@ -163,7 +145,6 @@ def test_clone_point(self): assert len(self.window.waypoints_model.waypoints) == 5 wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btCloneWaypoint, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert len(self.window.waypoints_model.waypoints) == 6 assert all(_x == _y for _x, _y in zip(wps[:3], wps2[:3])), (wps, wps2) @@ -183,7 +164,6 @@ def test_remove_point(self, mockbox): assert len(self.window.waypoints_model.waypoints) == 5 wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btDeleteWayPoint, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 4 @@ -196,7 +176,6 @@ def test_reverse_points(self): """ wps = list(self.window.waypoints_model.waypoints) QtTest.QTest.mouseClick(self.window.btInvertDirection, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() wps2 = self.window.waypoints_model.waypoints assert all([_x == _y for _x, _y in zip(wps[::-1], wps2)]) @@ -216,21 +195,17 @@ def test_drag_point(self): QtTest.QTest.mousePress( self.window.tableWayPoints.viewport(), QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, item1.center()) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseMove( self.window.tableWayPoints.viewport(), item2.center()) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseRelease( self.window.tableWayPoints.viewport(), QtCore.Qt.LeftButton, QtCore.Qt.NoModifier, item2.center()) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 wps_after = list(self.window.waypoints_model.waypoints) assert wps_before != wps_after, (wps_before, wps_after) - @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 +231,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 12d89589b..88aaf5e40 100644 --- a/tests/_test_msui/test_topview.py +++ b/tests/_test_msui/test_topview.py @@ -29,185 +29,116 @@ 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, qtbot): 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, qtbot): + 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() - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(rsdock.cbDrawTangents, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() rsdock.dsbTangentHeight.setValue(6) - QtWidgets.QApplication.processEvents() rsdock.dsbObsAngleAzimuth.setValue(70) QtTest.QTest.mouseClick(rsdock.cbDrawTangents, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() rsdock.cbShowSolarAngle.setChecked(True) - 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 """ self.window.mpl.navbar._actions['insert_wp'].trigger() - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 3 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=QtCore.QPoint(1, 1)) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) # click again on same position - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 5 - 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 - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 self.window.mpl.navbar._actions['delete_wp'].trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - assert 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 - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 self.window.mpl.navbar._actions['delete_wp'].trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.mousePress(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseRelease(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtWidgets.QApplication.processEvents() - QtWidgets.QApplication.processEvents() assert mockbox.call_count == 1 assert len(self.window.waypoints_model.waypoints) == 4 - 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 - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseClick(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 self.window.mpl.navbar._actions['move_wp'].trigger() - QtWidgets.QApplication.processEvents() QtTest.QTest.mousePress(self.window.mpl.canvas, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() point = QtCore.QPoint((self.window.width() // 3), self.window.height() // 2) QtTest.QTest.mouseMove( self.window.mpl.canvas, pos=point) - QtWidgets.QApplication.processEvents() QtTest.QTest.mouseRelease( self.window.mpl.canvas, QtCore.Qt.LeftButton, pos=point) - QtWidgets.QApplication.processEvents() assert len(self.window.waypoints_model.waypoints) == 4 - 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,138 +164,93 @@ 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, qtbot, 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) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() self.window.cbTools.currentIndexChanged.emit(1) - QtWidgets.QApplication.processEvents() self.wms_control = self.window.docks[0].widget() self.wms_control.multilayers.cbWMS_URL.setEditText("") - - 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() + def query_server(self, qtbot, url): QtTest.QTest.keyClicks(self.wms_control.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.wms_control.cpdlg.canceled) + with qtbot.wait_signal(self.wms_control.cpdlg.canceled): + QtTest.QTest.mouseClick(self.wms_control.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - @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(qtbot, self.url) + with qtbot.wait_signal(self.wms_control.image_displayed): + QtTest.QTest.mouseClick(self.wms_control.btGetMap, QtCore.Qt.LeftButton) assert self.window.getView().map.image is not None self.window.getView().set_settings({}) self.window.getView().clear_figure() 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, qtbot): + 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 df1bd9311..452ee88d7 100644 --- a/tests/_test_msui/test_updater.py +++ b/tests/_test_msui/test_updater.py @@ -24,9 +24,9 @@ See the License for the specific language governing permissions and limitations under the License. """ -import sys import mock -from PyQt5 import QtWidgets, QtTest +import pytest +from PyQt5 import QtWidgets from mslib.msui.updater import UpdaterUI, Updater from mslib.utils.qt import Worker @@ -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, qtbot): self.updater = Updater() self.status = "" self.update_available = False @@ -83,15 +74,10 @@ 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() - QtWidgets.QApplication.processEvents() + yield @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 +91,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 +99,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 +110,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 +118,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,10 +128,8 @@ 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("", "") - QtTest.QTest.qWait(100) assert ui.statusLabel.text() == "Update successful. Please restart MSS." assert ui.btRestart.isEnabled() diff --git a/tests/_test_msui/test_wms_capabilities.py b/tests/_test_msui/test_wms_capabilities.py index 862a6ef10..a512a0b79 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 +from PyQt5 import 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, qtbot): self.capabilities = mock.Mock() self.capabilities.capabilities_document = u"Hölla die Waldfee".encode("utf-8") self.capabilities.provider = mock.Mock() @@ -47,37 +47,22 @@ def setup_method(self): self.capabilities.provider.contact.address = None self.capabilities.provider.contact.postcode = None self.capabilities.provider.contact.city = None + yield def start_window(self): self.window = wc.WMSCapabilitiesBrowser( url="http://example.com", capabilities=self.capabilities) QtTest.QTest.qWaitForWindowExposed(self.window) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(100) - def 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 7f7921335..86b4c18c1 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -26,22 +26,15 @@ """ import os -import sys import mock import shutil import tempfile import pytest import hashlib -import multiprocessing -from mslib.mswms.mswms import application -from PyQt5 import QtWidgets, QtCore, QtTest +import urllib +from PyQt5 import 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): @@ -56,11 +49,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: @@ -68,11 +65,6 @@ def _setup(self, widget_type): self.tempdir = tempfile.mkdtemp() if not os.path.exists(self.tempdir): os.mkdir(self.tempdir) - QtTest.QTest.qWait(3000) - 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: @@ -88,83 +80,71 @@ def _setup(self, widget_type): server = self.window.multilayers.listLayers.findItems(url, QtCore.Qt.MatchFixedString)[0] self.window.multilayers.delete_server(server) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(2000) QtTest.QTest.qWaitForWindowExposed(self.window) QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - def teardown_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): + def query_server(self, qtbot, url): while len(self.window.multilayers.cbWMS_URL.currentText()) > 0: QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Backspace) - QtWidgets.QApplication.processEvents() QtTest.QTest.keyClicks(self.window.multilayers.cbWMS_URL, url) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(2000) # time for the server to start up - QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.cpdlg.canceled) + with qtbot.wait_signal(self.window.cpdlg.canceled): + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) -@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, qtbot): self._setup("hsec") + yield + self._teardown() - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_no_server(self, mockbox): + def test_no_server(self, qtbot): """ 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(qtbot, 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, qtbot): """ 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(qtbot, 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, qtbot): """ 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(qtbot, 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, qtbot): """ 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(qtbot, 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, qtbot): """ 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(qtbot, 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, qtbot): + self.query_server(qtbot, self.url) self.window.init_time_back_click() self.window.init_time_fwd_click() self.window.valid_time_fwd_click() @@ -179,55 +159,42 @@ 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, qtbot): """ assert that an aborted getmap call does not change the displayed image """ - self.query_server(f"http://127.0.0.1:{self.port}") - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.qWait(20) - QtTest.QTest.keyClick(self.window.pdlg, QtCore.Qt.Key_Enter) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) - + self.query_server(qtbot, self.url) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) + QtTest.QTest.keyClick(self.window.pdlg, QtCore.Qt.Key_Enter) assert self.view.draw_image.call_count == 0 assert self.view.draw_legend.call_count == 0 assert self.view.draw_metadata.call_count == 0 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(qtbot, 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}") - - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + self.query_server(qtbot, self.url) - # 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 @@ -235,66 +202,52 @@ def test_server_getmap_cached(self, mockbox): self.view.reset_mock() QtTest.QTest.mouseClick(self.window.cbCacheEnabled, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) - - assert 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 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(qtbot, 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) - - assert mockbox.critical.call_count == 0 + + with qtbot.wait_signal(self.window.cpdlg.canceled): + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, ord(str(self.port)[-1])) + QtTest.QTest.keyClick(self.window.multilayers.cbWMS_URL, QtCore.Qt.Key_Slash) + QtTest.QTest.mouseClick(self.window.multilayers.btGetCapabilities, QtCore.Qt.LeftButton) + + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) + assert self.view.draw_image.call_count == 1 assert self.view.draw_legend.call_count == 1 assert self.view.draw_metadata.call_count == 1 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_handling(self, mockbox): + def test_multilayer_handling(self, qtbot): """ 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(qtbot, 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()): @@ -318,33 +271,29 @@ def test_multilayer_handling(self, mockbox): assert self.window.multilayers.listLayers.itemWidget(server.child(0), 2).currentText() == "1" # Check drawing not causing errors - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - 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 - @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, qtbot): + self.query_server(qtbot, self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) 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 @@ -356,9 +305,7 @@ def test_filter_handling(self, mockbox): self.window.multilayers.filter_favourite_toggled() assert server.isHidden() self.window.multilayers.filter_favourite_toggled() - QtTest.QTest.qWait(100) QtTest.QTest.mouseMove(self.window.multilayers.listLayers, QtCore.QPoint(icon_start_fav + 3, 0), -1) - QtWidgets.QApplication.processEvents() self.window.multilayers.check_icon_clicked(server.child(0)) self.window.multilayers.filter_favourite_toggled() # ToDo The next assert fails in reverse test order @@ -368,24 +315,21 @@ def test_filter_handling(self, mockbox): # Check deleting server is working QtTest.QTest.mouseMove(self.window.multilayers.listLayers, QtCore.QPoint(icon_start_del + 3, 0), -1) - QtWidgets.QApplication.processEvents() self.window.multilayers.check_icon_clicked(server) - assert len(self.window.multilayers.listLayers.findItems(f"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, qtbot): """ 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(qtbot, 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) @@ -402,22 +346,19 @@ def test_singlelayer_handling(self, mockbox): assert self.window.lLayerName.text().endswith(server.child(1).text(0)) # Check drawing not causing errors - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - 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 - @mock.patch("PyQt5.QtWidgets.QMessageBox") - def test_multilayer_syncing(self, mockbox): + def test_multilayer_syncing(self, qtbot): """ 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(qtbot, 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 +382,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, qtbot): + self.query_server(qtbot, self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] self.window.cbAutoUpdate.setCheckState(False) server.setExpanded(True) @@ -455,66 +394,50 @@ def test_server_no_thread(self, mockbox, mockthread): server.child(0).setCheckState(0, 2) server.child(1).setCheckState(0, 2) - QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - QtWidgets.QApplication.processEvents() - wait_until_signal(self.window.image_displayed) + with qtbot.wait_signal(self.window.image_displayed): + QtTest.QTest.mouseClick(self.window.btGetMap, QtCore.Qt.LeftButton) - 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, qtbot): 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(qtbot, 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, qtbot): """ 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(qtbot, self.url) + server = self.window.multilayers.listLayers.findItems(f"{self.url}/", QtCore.Qt.MatchFixedString)[0] - server.child(0).draw() - wait_until_signal(self.window.image_displayed) - - assert mockbox.critical.call_count == 0 + with qtbot.wait_signal(self.window.image_displayed): + server.child(0).draw() -class TestWMSControlWidgetSetupSimple(object): +class TestWMSControlWidgetSetupSimple: xml = """ @@ -571,8 +494,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, qtbot): self.view = HSecViewMockup() self.window = wc.HSecWMSControlWidget(view=self.view) self.window.show() @@ -582,18 +505,12 @@ def setup_method(self): server = self.window.multilayers.listLayers.findItems(url, QtCore.Qt.MatchFixedString)[0] 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) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -610,7 +527,6 @@ def test_xml_currenttag(self): 2014-10-17T12:00:00Z/current/P1Y """ testxml = self.xml.format("", self.srs_base, dimext_time + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() print([self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())]) assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())][:4] == \ ['2014-10-17T12:00:00Z', '2015-10-17T12:00:00Z', '2016-10-17T12:00:00Z', '2017-10-17T12:00:00Z'] @@ -627,7 +543,6 @@ def test_xml_emptyextent(self): testxml = self.xml.format( "", self.srs_base, dimext_time_empty + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == [] assert [self.window.cbLevel.itemText(i) for i in range(self.window.cbLevel.count())] == [] @@ -640,7 +555,6 @@ def test_xml_onlytimedim(self): testxml = self.xml.format("", self.srs_base, dimext_time_noext + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert not self.window.cbValidTime.isEnabled() assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -655,7 +569,6 @@ def test_xml_separatedim(self): testxml = self.xml.format( dimext_time_dim, self.srs_base, dimext_time_ext + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -667,7 +580,6 @@ def test_xml_separate_leafs(self): testxml = self.xml.format( self.dimext_inittime, self.srs_base, self.dimext_time + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -680,7 +592,6 @@ def test_xml_time_forecast(self): testxml = self.xml.format( "", self.srs_base, dimext_time_forecast + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2013-10-17T12:00:00Z', '2013-10-17T18:00:00Z', '2013-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -695,7 +606,6 @@ def test_xml_inittime_reference(self): testxml = self.xml.format( "", self.srs_base, self.dimext_time + dimext_inittime_reference + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -706,7 +616,6 @@ def test_xml_inittime_reference(self): def test_xml_no_elevation(self): testxml = self.xml.format("", self.srs_base, self.dimext_time + self.dimext_inittime) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -717,7 +626,6 @@ def test_xml_no_elevation(self): def test_xml_no_validtime(self): testxml = self.xml.format("", self.srs_base, self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert not self.window.cbValidTime.isEnabled() assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -729,7 +637,6 @@ def test_xml_no_inittime(self): testxml = self.xml.format( "", self.srs_base, self.dimext_time + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == [] @@ -744,7 +651,6 @@ def test_xml_time_period(self): testxml = self.xml.format( "", self.srs_base, dimext_time_period + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -761,7 +667,6 @@ def test_xml_time_multiperiod(self): testxml = self.xml.format( "", self.srs_base, dimext_time_period + dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() self.window.cbAutoUpdate.setCheckState(False) self.window.cbInitTime.setCurrentIndex(0) assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ @@ -778,7 +683,6 @@ def test_valid_before_init(self): testxml = self.xml.format( "", self.srs_base, dimext_time_period + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -791,7 +695,6 @@ def test_xml_time_init_period(self): testxml = self.xml.format( "", self.srs_base, self.dimext_time + dimext_inittime_period + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ ['2012-10-17T12:00:00Z', '2012-10-17T18:00:00Z', '2012-10-18T00:00:00Z'] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == \ @@ -806,7 +709,6 @@ def test_xml_othertimeformat(self): testxml = self.xml.format( "", self.srs_base, dimext_time_format + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() self.window.cbAutoUpdate.setCheckState(False) self.window.cbInitTime.setCurrentIndex(0) assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == \ @@ -814,14 +716,12 @@ 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 """ testxml = self.xml.format( "", self.srs_base, dimext_time_error + self.dimext_inittime + self.dimext_elevation) self.window.activate_wms(wc.MSUIWebMapService(None, version='1.1.1', xml=testxml)) - QtWidgets.QApplication.processEvents() assert [self.window.cbValidTime.itemText(i) for i in range(self.window.cbValidTime.count())] == [] assert [self.window.cbInitTime.itemText(i) for i in range(self.window.cbInitTime.count())] == [] diff --git a/tests/_test_mswms/test_dataaccess.py b/tests/_test_mswms/test_dataaccess.py index 6d08a1ed7..54dd6e4ec 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 f0550a017..ea45ab37c 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 33d1715d4..b2ca28866 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 fa275acdd..0a93c31ce 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 5ccd5a5d6..971ddc78e 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 c61b7b9e9..c614682a3 100644 --- a/tests/_test_mswms/test_wms.py +++ b/tests/_test_mswms/test_wms.py @@ -25,7 +25,6 @@ See the License for the specific language governing permissions and limitations under the License. """ - import os from shutil import move @@ -35,20 +34,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 +62,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 +104,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 +114,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 +128,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 +153,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 +187,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 +214,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 +245,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 +272,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 +304,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 +318,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 +329,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 +341,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 +358,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 +373,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) @@ -385,6 +387,13 @@ def test_import_error(self): assert mslib.mswms.wms.mswms_settings.__file__ is not None assert mslib.mswms.wms.mswms_auth.__file__ is not None + @pytest.mark.skip("""\ +There is a race condition between modifying with ncap2 and asserting that the file changed where the server might not +see the change before the request is made, which leads to a failure of the following assert. + +This test fails on macOS 14 and can also fail on Linux when the pytest test order is randomized. +""".strip(), + ) def test_files_changed(self): def do_test(): environ = { @@ -397,7 +406,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 @@ -426,6 +435,12 @@ def do_test(): "data_access", new=watch_access): do_test() + @pytest.mark.skip("""\ +This test changes global variables (e.g. DOCS_LOCATION) which can affect other tests depending on test order +(e.g. tests/_test_mswms/test_mss_plot_driver.py::Test_VSec::test_VS_gallery_template fails consistently in reverse order +on macOS 14). +""".strip(), + ) def test_gallery(self, tmpdir): tempdir = tmpdir.mkdir("static") docsdir = tmpdir.mkdir("docs") diff --git a/tests/_test_plugins/test_io_kml.py b/tests/_test_plugins/test_io_kml.py index 9860b2049..15a5e43ee 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 2cd97f99a..60edf1368 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 e392ca68a..b56dbcc40 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 8364cf9d7..8ee2c878c 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. @@ -49,17 +49,17 @@ class TestSettingsSave(object): def test_save_settings(self): settings = {'foo': 'bar'} - config.save_settings_qsettings(self.tag, settings, ignore_test=True) + config.save_settings_qsettings(self.tag, settings) def test_load_settings(self): settings = {'foo': 'bar'} - config.save_settings_qsettings(self.tag, settings, ignore_test=True) - settings = config.load_settings_qsettings(self.tag, ignore_test=True) + config.save_settings_qsettings(self.tag, settings) + settings = config.load_settings_qsettings(self.tag) assert isinstance(settings, dict) 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 68ea662fd..ba5b50456 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 29eaf75a7..76de2fec2 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 640975bef..568783c01 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 a2ef261a9..d239a755d 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 d18a4ca62..fe57af4e3 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 81e049eeb..c116e472a 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 1ea5cf421..cac933a8e 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -27,6 +27,7 @@ import os import fs +import tempfile from fs.tempfs import TempFS try: @@ -44,6 +45,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") @@ -59,6 +61,9 @@ os.environ["MSUI_CONFIG_PATH"] = MSUI_CONFIG_PATH SERVER_CONFIG_FILE_PATH = fs.path.join(SERVER_CONFIG_FS.getsyspath(""), SERVER_CONFIG_FILE) +_xdg_cache_home_temporary_directory = tempfile.TemporaryDirectory() +os.environ["XDG_CACHE_HOME"] = _xdg_cache_home_temporary_directory.name + # we keep DATA_DIR until we move netCDF4 files to pyfilesystem2 DATA_DIR = DATA_FS.getsyspath("") @@ -66,18 +71,3 @@ MSCOLAB_URL = "http://localhost:8083" # mscolab test server's url MSCOLAB_URL_TEST = "http://localhost:8084" - -POSIX = {"application_destination": os.path.join(ROOT_DIR, ".local/share/applications/msui{}.desktop"), - "icon_destination": os.path.join(ROOT_DIR, ".local/share/icons/hicolor/{}/apps/mss-logo{}.png"), - "desktop": """[Desktop Entry] -Name=msui {} -Comment=A web service based tool to plan atmospheric research flights (mission support system). -Keywords=documentation;information; -Exec={} -Icon={} -Type=Application -Categories=Science;Education; -StartupNotify=true -X-GNOME-SingleWindow=false -X-Ubuntu-Gettext-Domain=msui -"""} 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..4005225ec --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +""" + + tests.fixtures + ~~~~~~~~~~~~~~ + + This module provides utils for pytest to test mslib modules + + This file is part of MSS. + + :copyright: Copyright 2023-2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import 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 qtbot(qtbot, fail_if_open_message_boxes_left, close_remaining_widgets): + """Fixture that re-defines the qtbot fixture from pytest-qt with additional checks.""" + yield qtbot + # Wait for a while after the requesting test has finished. At time of writing this + # is required to (mostly) stabilize the coverage reports, because tests don't + # properly close their Qt-related stuff and therefore there is no guarantee about + # what the Qt event loop has or hasn't done yet. Waiting just gives it a bit more + # time to converge on the same result every time the tests are executed. This is a + # band-aid fix, the proper fix is to make sure each test cleans up after itself. + qtbot.wait(5000) + + +@pytest.fixture(scope="session") +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/test_meta.py b/tests/test_meta.py new file mode 100644 index 000000000..7fcc83ab1 --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" + + tests.test_meta + ~~~~~~~~~~~~~~~ + + This module provides tests that are "meta" in some way, i.e. that don't test + application code but test that e.g. other tests follow some convention. + + This file is part of MSS. + + :copyright: Copyright 2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import pathlib + + +def test_processEvents_is_not_used_in_tests(request): + """Check that no test is calling PyQt5.QtWidgets.QApplication.processEvents() explicitly.""" + tests_path = pathlib.Path(request.config.rootdir) / "tests" + for test_file in tests_path.rglob("*.py"): + if str(test_file) == request.fspath: + # Skip the current file + continue + assert ( + "processEvents" not in test_file.read_text() + ), "processEvents is mentioned in {}".format(test_file.relative_to(request.config.rootdir)) + + +def test_qWait_is_not_used_in_tests(request): + """Check that no test is calling PyQt5.QtTest.QTest.qWait explicitly.""" + tests_path = pathlib.Path(request.config.rootdir) / "tests" + for test_file in tests_path.rglob("*.py"): + if str(test_file) == request.fspath: + # Skip the current file + continue + assert ( + "qWait(" not in test_file.read_text() + ), "qWait is mentioned in {}".format(test_file.relative_to(request.config.rootdir)) diff --git a/tests/utils.py b/tests/utils.py index 87ba3804e..7e0083f5d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -26,21 +26,12 @@ limitations under the License. """ 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 +66,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 +77,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 +87,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 +97,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 +123,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 +131,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 +144,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 +152,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,91 +160,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 mscolab_ping_server(port): - url = f"http://127.0.0.1:{port}/status" - try: - r = requests.get(url, timeout=(2, 10)) - if r.text == "Mscolab server": - return True - except requests.exceptions.ConnectionError: - 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): - """ - Blocks the calling thread until the signal emits or the timeout expires. - """ - init_time = time.time() - finished = False - - def done(*args): - nonlocal finished - finished = True - - signal.connect(done) - while not finished and time.time() - init_time < timeout: - QtTest.QTest.qWait(100) - +def is_url_response_ok(url): try: - signal.disconnect(done) - except TypeError: - pass - finally: - return finished + response = requests.get(url) + return response.status_code == 200 + except: # noqa: E722 + return False class ExceptionMock: @@ -268,32 +185,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/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 8dd4a3823..08fd034f4 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 c0f1749c6..dcf4e0d54 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,97 @@ 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') + create_tutorial_images() + 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 288652575..809cc962c 100644 --- a/tutorials/tutorial_kml.py +++ b/tutorials/tutorial_kml.py @@ -24,196 +24,86 @@ 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() + finish(close_widgets=2) + + +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) + # cursor is on center of the button, moving it so it can be found on screen + pag.move(100, 100) + create_tutorial_images() + _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) + start(target=automate_kml, duration=130) diff --git a/tutorials/tutorial_mscolab.py b/tutorials/tutorial_mscolab.py index 8d7270a6e..390d6bcf6 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,246 @@ 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() + # pyatogui click or mousedown does not show on the profile button (in this sequence) the menu, manually it does + # also _create_user(), _login_user_after_creation(), _delete_account() succeeds + # ToDo find a solution for the sequence used here to delete at the end the account + # _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 +300,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,368 +333,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.sleep(1) + pag.press(ENTER) 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__': - start(target=automate_mscolab, duration=638) + start(target=automate_mscolab, duration=640) diff --git a/tutorials/tutorial_performancesettings.py b/tutorials/tutorial_performancesettings.py index 63e4bcd86..66cca1ed2 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 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 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_performance(): @@ -42,135 +42,74 @@ 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() + + # 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) - # 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) + # update tutorial images + create_tutorial_images() - 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 + # Showing and hiding the performance settings + for _ in range(2): + find_and_click_picture('tableviewwindow-show-performance.png', + 'show performance button not found', + bounding_box=(0, 0, 140, 23)) + # move the mouse pointer to a different location, so that the image can be found + pag.move(50, 50) + # update tutorial images + create_tutorial_images() print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + finish(close_widgets=2) if __name__ == '__main__': diff --git a/tutorials/tutorial_remotesensing.py b/tutorials/tutorial_remotesensing.py index 8e3ef1240..2e15045b2 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(close_widgets=2) - # 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 1a1d4c44f..eb4e2ce54 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,110 +40,75 @@ 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() + finish(close_widgets=2) if __name__ == '__main__': - start(target=automate_rs, duration=148) + start(target=automate_rs, duration=170) diff --git a/tutorials/tutorial_views.py b/tutorials/tutorial_views.py index 5cae834ab..a520b1b52 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,817 +23,561 @@ 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() + # 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 + find_and_click_picture('multilayersdialog-multilayering.png', 'Multilayer not found', + bounding_box=(18, 0, 95, 20)) + 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.") # Close Everything! - finish() + finish(close_widgets=4) + + +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-18t06-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__': diff --git a/tutorials/tutorial_waypoints.py b/tutorials/tutorial_waypoints.py index 12d0a78d8..4b1ea63e5 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,116 +77,59 @@ 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 - pag.sleep(5) + find_and_click_picture('topviewwindow-home.png', 'home button could not be located.') + pag.sleep(3) # 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) + pag.sleep(2) print("\nAutomation is over for this tutorial. Watch next tutorial for other functions.") - finish() + finish(close_widgets=2) if __name__ == '__main__': diff --git a/tutorials/tutorial_wms.py b/tutorials/tutorial_wms.py index 54f4c1ebc..8cabe5a6f 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 658b0c8db..e95363dfa 100755 --- a/tutorials/tutorials.batch +++ b/tutorials/tutorials.batch @@ -13,10 +13,11 @@ ~/bin/highlight-pointer -r 10 --key-quit q & mkdir /tmp/msui_tutorials export MSUI_CONFIG_PATH=/tmp/msui_tutorials +export XDG_CACHE_HOME=$(mktemp -d) ########################################################################## # wms tutorial -$HOME/miniconda3/envs/mssdev/bin/python tutorial_wms.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_wms.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -24,23 +25,16 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:04:30 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_wms.gif - +ffmpeg -y -i last_recording.mp4 -ss 00:00:15 -to 00:03:40 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# crop action +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1100:1300:1400:650[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_wms.mp4 # get back to tutorials dir cd .. ################################################################################################ # kml tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_kml.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_kml.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -48,15 +42,9 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:03:30 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_kml.gif +ffmpeg -y -i last_recording.mp4 -ss 00:00:15 -to 00:01:55 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# crop action +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1100:900:1400:650[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_kml.mp4 # get back to tutorials dir cd .. @@ -64,7 +52,7 @@ cd .. # hexagoncontrol tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_hexagoncontrol.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_hexagoncontrol.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -72,15 +60,9 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:02:40 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_hexagoncontrol.gif +ffmpeg -y -i last_recording.mp4 -ss 00:00:15 -to 00:02:25 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# crop action +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1600:1400:1300:650[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_hexagoncontrol.mp4 # get back to tutorials dir cd .. @@ -88,7 +70,7 @@ cd .. # performancesettings tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_performancesettings.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_performancesettings.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -96,15 +78,9 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:01:54 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_performancesettings.gif +ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:01:50 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# crop action +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1500:700:1200:700[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_performancesettings.mp4 # get back to tutorials dir cd .. @@ -114,7 +90,7 @@ cd .. ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_remotesensing.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_remotesensing.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -123,14 +99,8 @@ rm -r /tmp/msui_tutorials/* cd recordings # slice (start and ending) ffmpeg -y -i last_recording.mp4 -ss 00:00:18 -to 00:03:12 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_remotesensing.gif +# crop action +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1100:900:1400:650[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_remotesensing.mp4 # get back to tutorials dir cd .. @@ -138,7 +108,7 @@ cd .. # satellitetrack tutorial ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_satellitetrack.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_satellitetrack.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -146,15 +116,9 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:02:18 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_satellitetrack.gif +ffmpeg -y -i last_recording.mp4 -ss 00:00:15 -to 00:02:36 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# crop action +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1100:900:1400:650[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_satellitetrack.mp4 # get back to tutorials dir cd .. @@ -162,7 +126,7 @@ cd .. ################################################## # tutorial waypoints ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_waypoints.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_waypoints.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -170,23 +134,17 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:24 -to 00:02:31 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_waypoints.gif +ffmpeg -y -i last_recording.mp4 -ss 00:00:24 -to 00:02:35 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# crop action +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1100:900:1400:650[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_waypoints.mp4 # get back to tutorials dir cd .. ################################################################ -# views tutorial +# views tutorial, 4K ~/bin/highlight-pointer -r 10 --key-quit q & -$HOME/miniconda3/envs/mssdev/bin/python tutorial_views.py +$HOME/Miniforge/envs/mssdev/bin/python tutorial_views.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -194,30 +152,26 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:09:14 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 -# crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_views.gif +ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:08:22 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# crop action result = 3K +ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=2560:1440:1200:650[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 +# resize to HD +ffmpeg -y -i tmp2.mp4 -vf scale=1920:1080 -c:v libx264 -crf 30 -acodec mp2 tutorial_views.mp4 # get back to tutorials dir cd .. #################################################### -# tutorial mscolab +# tutorial mscolab, 3K, because we need the menu in full screen # start a mscolab server on standard port after you have it seeded # we should have a seed for tutorials -$HOME/miniconda3/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py db --seed +$HOME/Miniforge/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py db --seed -$HOME/miniconda3/envs/mssdev/bin/python ../mslib/mscolab/mscolab.py start & +$HOME/Miniforge/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/Miniforge/envs/mssdev/bin/python tutorial_mscolab.py # remove config files created for this tutorial rm -r /tmp/msui_tutorials/* @@ -225,15 +179,11 @@ rm -r /tmp/msui_tutorials/* # improve recording file using the copy last_recording.mp4 cd recordings # slice (start and ending) -ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:10:28 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +ffmpeg -y -i last_recording.mp4 -ss 00:00:14 -to 00:10:30 -c:v libx264 -crf 30 -acodec mp2 tmp1.mp4 +# resize to HD +ffmpeg -y -i tmp1.mp4 -vf scale=1920:1080 -c:v libx264 -crf 30 -acodec mp2 tmp1b.mp4 # crop -ffmpeg -y -i tmp1.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tmp2.mp4 -# scale -# ffmpeg -y -i tmp2.mp4 -vf scale=1280:720 -c:v libx264 -crf 30 -acodec mp2 tmp3.mp4 -# set speed (very fast) -# ffmpeg -y -i tmp3.mp4 -filter:v "setpts=0.1*PTS" tmp5.mp4 -# makes a gifcycle, based on tmp2.mp4, using max_colors=16[p] gives 3.1MByte gif file -ffmpeg -y -i tmp2.mp4 -filter_complex "fps=5,scale=854:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer" tutorial_mscolab.gif +ffmpeg -y -i tmp1b.mp4 -filter_complex "[0:v]crop=1920:1044:0:0[cropped]" -map "[cropped]" -crf 30 -acodec mp2 tutorial_mscolab.mp4 # get back to tutorials dir cd .. diff --git a/tutorials/utils/__init__.py b/tutorials/utils/__init__.py index bc8729686..4cf865ce5 100644 --- a/tutorials/utils/__init__.py +++ b/tutorials/utils/__init__.py @@ -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,32 +79,20 @@ 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(close_widgets=3): + """ + 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': - for _ in range(3): + for _ in range(close_widgets): pag.hotkey('altleft', 'f4') pag.sleep(3) pag.press('left') @@ -102,7 +105,7 @@ def finish(): pag.keyUp('altleft') pag.press('q') if sys.platform == 'win32': - for _ in range(3): + for _ in range(close_widgets): pag.hotkey('alt', 'f4') pag.sleep(3) pag.press('left') @@ -112,7 +115,7 @@ def finish(): pag.hotkey('alt', 'tab') pag.press('q') elif sys.platform == 'darwin': - for _ in range(3): + for _ in range(close_widgets): pag.hotkey('command', 'w') pag.sleep(3) pag.press('left') @@ -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 of the image. The image is cropped to. 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/cursor.py b/tutorials/utils/cursor.py index 1f923f743..f486cf870 100644 --- a/tutorials/utils/cursor.py +++ b/tutorials/utils/cursor.py @@ -105,15 +105,15 @@ def __init__(self, display=None): if not self.display: self.display = self.xlib.XOpenDisplay(display) # (display) or (None) - def argbdata_to_pixdata(self, data, len): - if data is None or len < 1: + def argbdata_to_pixdata(self, data, length): + if data is None or length < 1: return None # Create byte array - b = array.array('b', b'\x00' * 4 * len) + b = array.array('b', b'\x00' * 4 * length) offset, i = 0, 0 - while i < len: + while i < length: argb = data[i] & 0xffffffff rgba = (argb << 8) | (argb >> 24) b1 = (rgba >> 24) & 0xff diff --git a/tutorials/utils/picture.py b/tutorials/utils/picture.py new file mode 100644 index 000000000..b9b784f4b --- /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-2024 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +import os +import 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..e7abfbcde --- /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-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 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 diff --git a/tutorials/utils/screenrecorder.py b/tutorials/utils/screenrecorder.py index 2d0584f03..da27145b7 100644 --- a/tutorials/utils/screenrecorder.py +++ b/tutorials/utils/screenrecorder.py @@ -66,9 +66,9 @@ def __init__(self, x_start=None, y_start=None, sc_width=None, sc_height=None): current_time = datetime.datetime.now().strftime('%d-%m-%Y_%H-%M-%S') self.file_name = f'REC_{current_time}.mp4' parent_dir = os.getcwd() - dir = "recordings" + directory = "recordings" try: - path = os.path.join(parent_dir, dir) + path = os.path.join(parent_dir, directory) os.makedirs(path, exist_ok=True) final_path = os.path.join(path, self.file_name) except OSError: