diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index a436084..0000000 --- a/.editorconfig +++ /dev/null @@ -1,19 +0,0 @@ -root = true - -[*] -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.py] -indent_style = space -indent_size = 4 - -[*.ui] -indent_style = space -indent_size = 2 -max_line_length = 100000 - -[*.md] -trim_trailing_whitespace = false diff --git a/.flake8 b/.flake8 deleted file mode 100644 index d8618a9..0000000 --- a/.flake8 +++ /dev/null @@ -1,9 +0,0 @@ -[flake8] -# Black compatible values https://black.readthedocs.io/en/stable/compatible_configs.html#flake8 -max-line-length = 88 -exclude = test_* -extend-ignore = - # E203: whitespace before ':' - E203, - # ANN101: Missing type annotation for self in method - ANN101 diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml new file mode 100644 index 0000000..5f6b86d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -0,0 +1,71 @@ +name: Bug/Crash report. +description: Create a bug report to help us improve our plugin. +labels: + - "Bug" + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report correctly. + + - type: textarea + id: what + attributes: + label: What is the bug or the crash? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce the issue + description: | + Steps, sample datasets and qgis project file to reproduce the behavior. + Screencasts or screenshots are more than welcome, you can drag&drop them in the text box. + + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: about-info + attributes: + label: Versions + description: | + In the QGIS Help menu -> About, click in the table, Ctrl+A and then Ctrl+C. Finally paste here. + Do not make a screenshot. + validations: + required: true + + - type: checkboxes + id: qgis-version + attributes: + label: Supported QGIS version + description: | + Each month, there is a new release of QGIS. According to the release schedule, you should at least be running a supported QGIS version. + You can check the release schedule https://www.qgis.org/en/site/getinvolved/development/roadmap.html#release-schedule + options: + - label: I'm running a supported QGIS version according to the official roadmap. + + - type: checkboxes + id: new-profile + attributes: + label: New profile + description: | + Did you try with a new QGIS profile? Some issues or crashes might be related to other plugins or specific configuration. + You must try with a new profile to check if the issue remains. + Read this link how to create a new profile + https://docs.qgis.org/3.16/en/docs/user_manual/introduction/qgis_configuration.html#working-with-user-profiles + options: + - label: I tried with a new QGIS profile + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: | + Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/15_feature_request.yml b/.github/ISSUE_TEMPLATE/15_feature_request.yml new file mode 100644 index 0000000..b7acee5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/15_feature_request.yml @@ -0,0 +1,24 @@ +name: Feature request +description: Suggest a feature idea. +labels: + - 'Feature Request' +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this feature request correctly. + + - type: textarea + id: what + attributes: + label: Feature description + description: A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + id: Additional + attributes: + label: Additional context + description: | + Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index be6f4e0..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - Plugin: [e.g. 1.0] - - QGIS [e.g. 3.14] - - Python: [e.g. 3.8] - - OS: [e.g. Windows 10, Fedora 32] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..6e787a0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: true + +contact_links: + - name: Documentation + url: https://github.com/lpoaura/PluginQGis-LPOData + about: Please read carefully the documentation before to submit an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 3d2e74c..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: enhancement -assignees: '' - ---- - -**Expected behaviour** -A clear and concise description of what you'd like to happen if you do x. - -**Current behaviour** -A clear and concise description of the current behaviour when you do x. If completely new feature, leave empty. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. If relevant please also provide version of the plugin and information on the system you are running it on. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..86f197b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/requirements" + schedule: + interval: monthly + time: "04:00" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..6567b40 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,22 @@ +ci-cd: + - .github/workflows/* + - .github/labeler.yml + +dependencies: + - requirements.txt + - requirements/*.txt + +documentation: + - docs/**/* + +packaging: + - requirements/packaging.txt + - setup.py + +quality: + - tests/**/* + +tooling: + - .pre-commit-config.yaml + - .vscode/**/* + - setup.cfg diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..64604cf --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,22 @@ +changelog: + exclude: + authors: + - dependabot + - pre-commit-ci + categories: + - title: Bugs fixes 🐛 + labels: + - bug + - title: Features and enhancements 🎉 + labels: + - enhancement + - UI + - title: Tooling 🔧 + labels: + - ci-cd + - title: Documentation 📖 + labels: + - documentation + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml new file mode 100644 index 0000000..c477465 --- /dev/null +++ b/.github/workflows/auto-labeler.yml @@ -0,0 +1,14 @@ +name: "🏷 PR Labeler" +on: + - pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml deleted file mode 100644 index 7da22cd..0000000 --- a/.github/workflows/code-style.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: code-style - -on: - pull_request: - push: - branches: [master, main] - -jobs: - code-style: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.2 diff --git a/.github/workflows/documentation.yml.off b/.github/workflows/documentation.yml.off new file mode 100644 index 0000000..aa58d07 --- /dev/null +++ b/.github/workflows/documentation.yml.off @@ -0,0 +1,87 @@ +name: "📚 Documentation Builder" + +on: + push: + branches: [ master ] + paths: + - '.github/workflows/documentation.yml' + - 'docs/**/*' + - 'requirements/documentation.txt' + tags: + - "*" + + pull_request: + branches: [ master ] + paths: + - ".github/workflows/documentation.yml" + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +env: + PYTHON_VERSION: 3.9 + + +jobs: + build-docs: + + runs-on: ubuntu-latest + + steps: + - name: Get source code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + cache: "pip" + cache-dependency-path: "requirements/documentation.txt" + python-version: ${{ env.PYTHON_VERSION }} + + - name: Cache Sphinx cache + uses: actions/cache@v4 + with: + path: docs/_build/cache + key: ${{ runner.os }}-sphinx-${{ hashFiles('docs/**/*') }} + restore-keys: | + ${{ runner.os }}-sphinx- + + - name: Install project requirements + run: | + python -m pip install -U pip setuptools wheel + python -m pip install -U -r requirements/documentation.txt + + - name: Build doc using Sphinx + run: sphinx-build -b html -j auto -d docs/_build/cache -q docs docs/_build/html + + - name: Save build doc as artifact + uses: actions/upload-artifact@v4 + with: + name: documentation + path: docs/_build/html/* + if-no-files-found: error + retention-days: 30 + + - name: Setup Pages + uses: actions/configure-pages@v4 + if: ${{ github.event_name == 'push' && ( startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/master' ) }} + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + if: ${{ github.event_name == 'push' && ( startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/master' ) }} + with: + # Upload entire repository + path: docs/_build/html/ + + - name: Deploy to GitHub Pages + id: deployment + if: ${{ github.event_name == 'push' && ( startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/master' ) }} + uses: actions/deploy-pages@v1 diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..3ac6735 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,44 @@ +name: "✅ Linter" + +on: + push: + branches: [ master ] + paths: + - '**.py' + + pull_request: + branches: [ master ] + paths: + - '**.py' + +env: + PROJECT_FOLDER: "plugin_qgis_lpo" + PYTHON_VERSION: 3.9 + + +jobs: + lint-py: + name: Python 🐍 + + runs-on: ubuntu-latest + + steps: + - name: Get source code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install project requirements + run: | + python -m pip install -U pip setuptools wheel + python -m pip install -U flake8 + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 ${{ env.PROJECT_FOLDER }} --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. + flake8 ${{ env.PROJECT_FOLDER }} --count --exit-zero --statistics diff --git a/.github/workflows/packager.yml b/.github/workflows/packager.yml new file mode 100644 index 0000000..f6b8da9 --- /dev/null +++ b/.github/workflows/packager.yml @@ -0,0 +1,50 @@ +name: "📦 Packager" + +env: + PROJECT_FOLDER: "plugin_qgis_lpo" + PYTHON_VERSION: 3.9 + +on: + push: + branches: [ master ] + + +jobs: + packaging: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + cache: "pip" + cache-dependency-path: "requirements/packaging.txt" + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install system requirements + run: | + sudo apt update + sudo apt install qt5-qmake qttools5-dev-tools + + - name: Install project requirements + run: | + python -m pip install -U pip setuptools wheel + python -m pip install -U -r requirements/packaging.txt + + - name: Update translations + run: pylupdate5 -noobsolete -verbose ${{ env.PROJECT_FOLDER }}/resources/i18n/plugin_translation.pro + + - name: Compile translations + run: lrelease ${{ env.PROJECT_FOLDER }}/resources/i18n/*.ts + + - name: Package the latest version + run: qgis-plugin-ci package latest --allow-uncommitted-changes + + - uses: actions/upload-artifact@v4 + with: + name: ${{ env.PROJECT_FOLDER }}-latest + path: ${{ env.PROJECT_FOLDER }}.*.zip + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index fe56209..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Release - -on: - release: - types: released - -jobs: - plugin_dst: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - submodules: true - - - name: Set up Python 3.8 - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - # Needed if the plugin is using Transifex, to have the lrelease command - # - name: Install Qt lrelease - # run: sudo apt-get update && sudo apt-get install qt5-default qttools5-dev-tools - - - name: Install qgis-plugin-ci - run: pip3 install qgis-plugin-ci - - # When osgeo upload is wanted: --osgeo-username usrname --osgeo-password ${{ secrets.OSGEO_PASSWORD }} - # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} - - name: Deploy plugin - run: qgis-plugin-ci release ${GITHUB_REF/refs\/tags\//} --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml new file mode 100644 index 0000000..510589f --- /dev/null +++ b/.github/workflows/releaser.yml @@ -0,0 +1,58 @@ +name: "🚀 Releaser" + +on: + push: + tags: + - "*" + +env: + PROJECT_FOLDER: "plugin_qgis_lpo" + PYTHON_VERSION: 3.9 + + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Get source code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + cache: "pip" + cache-dependency-path: "requirements/packaging.txt" + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install system requirements + run: | + sudo apt update + sudo apt install qt5-qmake qttools5-dev-tools + + - name: Install Python requirements + run: | + python -m pip install -U pip setuptools wheel + python -m pip install -U -r requirements/packaging.txt + + - name: Compile translations + run: lrelease ${{ env.PROJECT_FOLDER }}/resources/i18n/*.ts + + - name : Get current changelog for ${GITHUB_REF/refs\/tags\//} + run: qgis-plugin-ci changelog ${GITHUB_REF/refs\/tags\//} >> release.md + + - name: Create release on GitHub + uses: ncipollo/release-action@v1.14.0 + with: + bodyFile: release.md + generateReleaseNotes: true + + - name: Deploy plugin + run: >- + qgis-plugin-ci + release ${GITHUB_REF/refs\/tags\//} + --github-token ${{ secrets.GITHUB_TOKEN }} + --allow-uncommitted-changes + --create-plugin-repo diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml deleted file mode 100644 index e836188..0000000 --- a/.github/workflows/test-and-pre-release.yml +++ /dev/null @@ -1,118 +0,0 @@ -# workflow name -name: Tests - -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the wanted branches -on: - pull_request: - push: - branches: [master, main] - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - linux_tests: - # The type of runner that the job will run on - runs-on: ubuntu-latest - strategy: - matrix: - # Remove unsupported versions and add more versions. Use LTR version in the cov_tests job - docker_tags: [release-3_10, release-3_16, latest] - fail-fast: false - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - with: - submodules: true - - - name: Pull qgis - run: docker pull qgis/qgis:${{ matrix.docker_tags }} - - # Runs all tests - - name: Run tests - run: > - docker run --rm --net=host --volume `pwd`:/app -w=/app -e QGIS_PLUGIN_IN_CI=1 qgis/qgis:${{ matrix.docker_tags }} sh -c - "pip3 install -qr requirements-dev.txt && xvfb-run -s '+extension GLX -screen 0 1024x768x24' - pytest -v --cov=plugin_qgis_lpo --cov-report=xml" - - # Upload coverage report. Will not work if the repo is private - - name: Upload coverage to Codecov - if: ${{ matrix.docker_tags == 'latest' && !github.event.repository.private }} - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml - flags: unittests - fail_ci_if_error: false # set to true when upload is working - verbose: false - - windows_tests: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v2 - with: - submodules: true - - - name: Choco install qgis - uses: crazy-max/ghaction-chocolatey@v1 - with: - args: install qgis-ltr -y - - - name: Run tests - shell: pwsh - run: | - $env:PATH="C:\Program Files\QGIS 3.16\bin;$env:PATH" - $env:QGIS_PLUGIN_IN_CI=1 - python-qgis-ltr.bat -m pip install -qr requirements-dev.txt - python-qgis-ltr.bat -m pytest -v - - pre-release: - name: "Pre Release" - runs-on: "ubuntu-latest" - needs: [linux_tests, windows_tests] - - steps: - - uses: hmarr/debug-action@v2 - - - uses: "marvinpinto/action-automatic-releases@latest" - if: ${{ github.event.pull_request }} - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "dev-pr" - prerelease: true - title: "Development Build made for PR #${{ github.event.number }}" - - - uses: "marvinpinto/action-automatic-releases@latest" - if: ${{ github.event.after != github.event.before }} - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "dev" - prerelease: true - title: "Development Build made for master branch" - - - uses: actions/checkout@v2 - with: - submodules: true - - - name: Set up Python 3.8 - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - # Needed if the plugin is using Transifex, to have the lrelease command - # - name: Install Qt lrelease - # run: sudo apt-get update && sudo apt-get install qt5-default qttools5-dev-tools - - - name: Install qgis-plugin-ci - run: pip3 install qgis-plugin-ci - - # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} - - name: Deploy plugin - if: ${{ github.event.pull_request }} - run: qgis-plugin-ci release dev-pr --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update - - # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} - - name: Deploy plugin - if: ${{ github.event.after != github.event.before }} - run: qgis-plugin-ci release dev --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml new file mode 100644 index 0000000..b36a272 --- /dev/null +++ b/.github/workflows/tester.yml @@ -0,0 +1,81 @@ +name: "🎳 Tester" + +on: + push: + branches: [ master ] + paths: + - '**.py' + + pull_request: + branches: [ master ] + paths: + - '**.py' + +env: + PROJECT_FOLDER: "plugin_qgis_lpo" + PYTHON_VERSION: 3.9 + + +jobs: + tests-unit: + runs-on: ubuntu-latest + + steps: + - name: Get source code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + cache: "pip" + cache-dependency-path: "requirements/testing.txt" + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Python requirements + run: | + python -m pip install -U pip setuptools wheel + python -m pip install -U -r requirements/testing.txt + + - name: Run Unit tests + run: pytest tests/unit/ + + test-qgis: + runs-on: ubuntu-latest + + container: + image: qgis/qgis:release-3_28 + env: + CI: true + DISPLAY: ":99" + MUTE_LOGS: true + NO_MODALS: 1 + PYTHONPATH: "/usr/share/qgis/python/plugins:/usr/share/qgis/python:." + WITH_PYTHON_PEP: false + options: -v ${{ github.workspace }}:/tests_directory + + steps: + - name: Get source code + uses: actions/checkout@v3 + + - name: Print QGIS version + run: qgis --version + + - name: Set up Python + uses: actions/setup-python@v4 + with: + cache: "pip" + cache-dependency-path: "requirements/testing.txt" + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Python requirements + run: | + python3 -m pip install -U pip setuptools wheel + python3 -m pip install -U -r requirements/testing.txt + + - name: Run Unit tests + run: pytest tests/qgis/ + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 6db1fe2..7be9069 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,131 @@ -plugin_qgis_lpo/i18n +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +junit/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +docs/_apidoc/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ venv/ -.venv/ -start_ide.bat -.vscode -*/.pytest_cache -__pycache__ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# -- CUSTOM ---- +*.zip +.vscode/ +*.qm diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 85a19ce..06b10dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,31 +1,46 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks +exclude: ".venv|__pycache__|tests/dev/|tests/fixtures/" + repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.4.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - id: check-added-large-files - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort + args: ["--maxkb=500"] + - id: check-case-conflict + - id: check-xml + - id: check-yaml + - id: detect-private-key + - id: end-of-file-fixer + - id: fix-byte-order-marker + - id: fix-encoding-pragma + args: [--remove] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 23.1.0 hooks: - id: black - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910-1 + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] + + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 hooks: - - id: mypy - additional_dependencies: ['types-requests'] - # - repo: https://github.com/PyCQA/flake8 - # rev: 7.0.0 - # hooks: - # - id: flake8 - # additional_dependencies: - # - flake8-bugbear~=21.9.2 - # - pep8-naming~=0.12.1 - # - flake8-qgis>=1.0.0 + - id: flake8 + files: ^pyqgis_resource_browser/.*\.py$ + additional_dependencies: ["flake8-qgis<2"] + args: + [ + "--config=setup.cfg", + "--select=E9,F63,F7,F82,QGS101,QGS102,QGS103,QGS104,QGS106", + ] + +ci: + autoupdate_schedule: quarterly + skip: [] + submodules: false diff --git a/.qgis-plugin-ci b/.qgis-plugin-ci deleted file mode 100644 index ef5c283..0000000 --- a/.qgis-plugin-ci +++ /dev/null @@ -1,5 +0,0 @@ -plugin_path: plugin_qgis_lpo -github_organization_slug: lpoaura -project_slug: plugin_qgis_lpo -transifex_coordinator: replace-me -transifex_organization: replace-me diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc32d9..3557686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,22 @@ # CHANGELOG -### v3.0.0-dev - New refactored plugin version +The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). -* Processing algorithm scripts are now pooled in a parent class. -* Plugin files are now in a separate subfolder (`plugin_qgis_lpo`) of the repository -* Form texts are simplified to improve readability + + +# >> 0.1.0 - 2024-02-06 + +- First release +- Generated with the [QGIS Plugins templater](https://oslandia.gitlab.io/qgis/template-qgis-plugin/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..93e6296 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Contributing Guidelines + +First off, thanks for considering to contribute to this project! + +These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. + +# >> Git hooks + +We use git hooks through [pre-commit](https://pre-commit.com/) to enforce and automatically check some "rules". Please install them (`pre-commit install`) before to push any commit. + +See the relevant configuration file: `.pre-commit-config.yaml`. + +# >> Code Style + +Make sure your code *roughly* follows [PEP-8](https://www.python.org/dev/peps/pep-0008/) and keeps things consistent with the rest of the code: + +- docstrings: [sphinx-style](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html#the-sphinx-docstring-format) is used to write technical documentation. +- formatting: [black](https://black.readthedocs.io/) is used to automatically format the code without debate. +- sorted imports: [isort](https://pycqa.github.io/isort/) is used to sort imports +- static analisis: [flake8](https://flake8.pycqa.org/en/latest/) and [PyLint](https://pylint.org/) are used to catch some dizziness and keep the source code healthy. diff --git a/LICENSE b/LICENSE index 228f9b3..9a14379 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,9 @@ - GNU GENERAL PUBLIC LICENSE + + + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 + Copyright (c) 2024, Pole VDC (LPOAuRA) / QGIS Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. diff --git a/README.md b/README.md index a114f4e..61226b4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -# Scripts de processing framework de la LPO AuRA - +# Scripts de processing framework de la LPO AuRA - QGIS Plugin ![tests](https://github.com/lpoaura/plugin_qgis_lpo/workflows/Tests/badge.svg) -[![codecov.io](https://codecov.io/github/lpoaura/plugin_qgis_lpo/coverage.svg?branch=main)](https://codecov.io/github/lpoaura/plugin_qgis_lpo?branch=main) +[![codecov.io](https://codecov.io/github/lpoaura/plugin_qgis_lpo/coverage.svg?branch=master)](https://codecov.io/github/lpoaura/plugin_qgis_lpo?branch=master) ![release](https://github.com/lpoaura/plugin_qgis_lpo/workflows/Release/badge.svg) [![GPLv3 license](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.html) @@ -10,6 +9,9 @@ [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) +[![pylint](https://github.com/lpoaura/PluginQGis-LPODatalint/pylint.svg)](https://github.com/lpoaura/PluginQGis-LPODatalint/) +[![flake8](https://img.shields.io/badge/linter-flake8-green)](https://flake8.pycqa.org/) + Ce plugin ajoute à QGIS des scripts d'exploitation des données naturalistes de la [LPO Auvergne-Rhône-Alpes](https://auvergne-rhone-alpes.lpo.fr/). Il s'appuie sur une base de données [Géonature] (https://github.com/pnx-si/). @@ -28,15 +30,107 @@ La base de données sur laquelle les développements ont été faits dispose ég Pour permettre l'export des données formatées, il est nécessaire de disposer de la libraire `openpyxl`. Pour l'installer `py3_env` puis `python3 -m pip install --user openpyxl`. - + -## Development +# >> Development Refer to [development](docs/development.md) for developing this QGIS3 plugin. -## License +# >> License This plugin is licenced with[GNU General Public License, version 3](https://www.gnu.org/licenses/gpl-3.0.html) See [LICENSE](LICENSE) for more information. + +# >> Generated options + +### Plugin + +| Cookiecutter option | Picked value | +| :-- | :--: | +| Plugin name | Traitement des données LPO | +| Plugin name slugified | plugin_qgis_lpo | +| Plugin name class (used in code) | QgisLpo | +| Plugin category | Database | +| Plugin description short | This plugin is a revolution! | +| Plugin description long | Extends QGIS with revolutionary features that every single GIS end-users was expected (or not)! | +| Plugin tags | geonature,visionature,faune-france,postgresql,lpo | +| Plugin icon | default_icon.png | +| Plugin with processing provider | True | +| Author name | Pole VDC (LPOAuRA) | +| Author organization | LPO Auvergne-Rhône-Alpes | +| Author email | webadmin.aura@lpo.fr | +| Minimum QGIS version | 3.16 | +| Maximum QGIS version | 3.99 | +| Git repository URL | https://github.com/lpoaura/PluginQGis-LPOData | +| Git default branch | master | +| License | GPLv3 | +| Python linter | both | +| CI/CD platform | GitHub | +| IDE | VSCode | + +### Tooling + +This project is configured with the following tools: + +- [Black](https://black.readthedocs.io/en/stable/) to format the code without any existential question +- [iSort](https://pycqa.github.io/isort/) to sort the Python imports + +Code rules are enforced with [pre-commit](https://pre-commit.com/) hooks. +Static code analisis is based on: both + +See also: [contribution guidelines](CONTRIBUTING.md). + +# >> CI/CD + +Plugin is linted, tested, packaged and published with GitHub. + +If you mean to deploy it to the [official QGIS plugins repository](https://plugins.qgis.org/), remember to set your OSGeo credentials (`OSGEO_USER_NAME` and `OSGEO_USER_PASSWORD`) as environment variables in your CI/CD tool. + + +### Documentation + +The documentation is generated using Sphinx and is automatically generated through the CI and published on Pages. + +- homepage: +- repository: +- tracker: + +---- + +# >> Next steps + +### Set up development environment + +> Typical commands on Linux (Ubuntu). + +1. If you don't pick the `git init` option, initialize your local repository: + + ```sh + git init + ``` + +1. Follow the [embedded documentation to set up your development environment](./docs/development/environment.md) +1. Add all files to git index to prepare initial commit: + + ```sh + git add -A + ``` + +1. Run the git hooks to ensure that everything runs OK and to start developing on quality standards: + + ```sh + pre-commit run -a + ``` + +### Try to build documentation locally + +1. Have a look to the [plugin's metadata.txt file](plugin_qgis_lpo/metadata.txt): review it, complete it or fix it if needed (URLs, etc.). +1. Follow the [embedded documentation to build plugin documentation locally](./docs/development/environment.md) + +---- + +# >> License + +Distributed under the terms of the [`GPLv3` license](LICENSE). diff --git a/config/mv_c_cor_vn_taxref.sql b/config/mv_c_cor_vn_taxref.sql index b6045a2..c70f0aa 100644 --- a/config/mv_c_cor_vn_taxref.sql +++ b/config/mv_c_cor_vn_taxref.sql @@ -61,4 +61,4 @@ ALTER TABLE taxonomie.mv_c_cor_vn_taxref OWNER TO dbadmin; GRANT ALL ON TABLE taxonomie.mv_c_cor_vn_taxref TO postgres; GRANT ALL ON TABLE taxonomie.mv_c_cor_vn_taxref TO dbadmin; GRANT SELECT ON TABLE taxonomie.mv_c_cor_vn_taxref TO dt; -GRANT ALL ON TABLE taxonomie.mv_c_cor_vn_taxref TO advanced_user; \ No newline at end of file +GRANT ALL ON TABLE taxonomie.mv_c_cor_vn_taxref TO advanced_user; diff --git a/config/mv_source.sql b/config/mv_source.sql index 6118887..c4e3a03 100644 --- a/config/mv_source.sql +++ b/config/mv_source.sql @@ -1,8 +1,8 @@ -CREATE MATERIALIZED VIEW dbadmin.mv_source as -select 'list_source' as rang, array_agg(distinct split_part(t_sources.desc_source, ' '::text, 1)) AS list_source +CREATE MATERIALIZED VIEW dbadmin.mv_source as +select 'list_source' as rang, array_agg(distinct split_part(t_sources.desc_source, ' '::text, 1)) AS list_source from gn_synthese.t_sources where t_sources.desc_source ilike '%[%]%' group by 1 ; -- View indexes: -CREATE INDEX mv_source_source_idx ON dbadmin.mv_source USING btree (list_source); \ No newline at end of file +CREATE INDEX mv_source_source_idx ON dbadmin.mv_source USING btree (list_source); diff --git a/config/mv_statuts.sql b/config/mv_statuts.sql index a89d7e2..b54749a 100644 --- a/config/mv_statuts.sql +++ b/config/mv_statuts.sql @@ -3,37 +3,37 @@ *****************************************************************/ /* - * Objectif de la vue : + * Objectif de la vue : * - centraliser dans une seule VM les statuts de menace et de protection en s'adossant exclusivement sur la BDC statuts - * + * * Contraintes : * - les anciennes listes rouges de Rhône-Alpes ne figurent pas dans la bdc statut * - il n'est pas possible dans la bdc statut de distinguer les périodes biologiques pour une même LR (mail envoyé à l'INPN pour comprendre) * - intégrer les données des statuts isérois - * + * * Solutions : * - pour contourner ces 2 premiers problèmes la VM s'appuie sur les tables t_redlist, ces données sont préparées dans des CTE via des unions * - la VM s'appuie également sur une table dans un schéma perso - * + * * Evolutions : * - quand le pb de la BDC statut sera réglé il faudra corriger la requête * - quand les nouvelles LR d'AuRA seront publiées et prises en compte dans BDC statut, il faudra également corriger * - tester le fonctionnement avec des LR sur la grande région (AuRA), il n'en existe pas actuellement * - voir comment traiter les statuts isérois * - intégrer les déterminances znieff - * + * * Pense-bête : * - requête à personnaliser en fonction de la région concernée - * + * */ drop materialized view taxonomie.mv_c_statut; create materialized view taxonomie.mv_c_statut as ( -with +with prep_lrra as ( - SELECT DISTINCT - tx.classe, + SELECT DISTINCT + tx.classe, sp.id_redlist, sp.status_order, tx.cd_ref, @@ -46,7 +46,7 @@ prep_lrra as ( where (classe='Aves' or (classe ='Mammalia' and ordre <>'Chiroptera')) and art.area_name ='Rhône-Alpes' ) , -prep_statut_lrra as +prep_statut_lrra as (select distinct sp.cd_ref , CASE @@ -71,7 +71,7 @@ CASE from prep_lrra sp), prep_lrra_ok as( select - cd_ref + cd_ref , string_agg(distinct lrra,', ') lrra , string_agg(distinct lrra_nich,', ') lrra_nich , string_agg(distinct lrra_hiv,', ') lrra_hiv @@ -94,7 +94,7 @@ prep_t_redlist_fr AS ( join taxonomie.mv_c_cor_vn_taxref ctx on sp.cd_ref=ctx.cd_ref where groupe_taxo_fr='Oiseaux' ), prep2 AS ( - SELECT DISTINCT + SELECT DISTINCT ptlr.cd_ref, groupe_taxo_fr, vn_nom_fr, @@ -122,7 +122,7 @@ prep_t_redlist_fr AS ( LEFT JOIN taxonomie.taxref tr ON ptlr.cd_ref = tr.cd_nom LEFT JOIN taxonomie.bib_redlist_source art ON ptlr.id_source = art.id_source ) -, prep_lrf_ok as (SELECT +, prep_lrf_ok as (SELECT prep2.cd_ref, prep2.groupe_taxo_fr, prep2.vn_nom_fr, @@ -135,26 +135,26 @@ prep_t_redlist_fr AS ( GROUP BY prep2.groupe_taxo_fr, prep2.vn_nom_fr, prep2.vn_nom_sci, prep2.cd_ref ) ,lr_auv as ( -select - bs.cd_ref - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs +select + bs.cd_ref + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut='LRR' and bs.lb_adm_tr='Auvergne' -), +), lr_ra as ( -select +select -- bs.cd_nom - bs.cd_ref - , bs.code_statut --- , bs.label_statut + bs.cd_ref + , bs.code_statut +-- , bs.label_statut , null::text as lrra_nich - , null::text as lrra_hiv + , null::text as lrra_hiv , null::text as lrra_migr -FROM taxonomie.bdc_statut bs +FROM taxonomie.bdc_statut bs where bs.cd_type_statut='LRR' and bs.lb_adm_tr='Rhône-Alpes' union - select cd_ref + select cd_ref , lrra /* , case when lrra_nich='CR' then 'En danger critique' when lrra_nich='EN' then 'En danger' @@ -163,32 +163,32 @@ union when lrra_nich='DD' then 'Données insuffisantes' when lrra_nich='LC' then 'Préoccupation mineure' else null end label_statut*/ - , lrra_nich - , lrra_hiv + , lrra_nich + , lrra_hiv , lrra_migr from prep_lrra_ok ), lr_aura as ( -select - bs.cd_ref - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs +select + bs.cd_ref + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut='LRR' and bs.lb_adm_tr='Auvergne-Rhône-Alpes' ), lr_fr as ( -select - bs.cd_ref +select + bs.cd_ref , bs.code_statut lr_france , null::text as lr_fr_nich , null::text as lr_fr_hiv , null::text as lr_fr_migr -FROM taxonomie.bdc_statut bs +FROM taxonomie.bdc_statut bs join taxonomie.taxref on bs.cd_nom =taxref.cd_nom where bs.cd_type_statut='LRN' and bs.lb_adm_tr='France métropolitaine' and taxref.classe <>'Aves' union -select - cd_ref, +select + cd_ref, lr_france, lr_fr_nich, lr_fr_hiv, @@ -196,90 +196,90 @@ select from prep_lrf_ok ), lr_euro as ( -select - bs.cd_ref +select + bs.cd_ref ,bs.cd_nom - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut='LRE' ), lr_monde as ( -select +select bs.cd_ref - , bs.cd_nom - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs + , bs.cd_nom + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='LRM' ) , prot_nat as ( -select - bs.cd_ref +select + bs.cd_ref , string_agg(distinct split_part(label_statut,' : ',2),', ') article , string_agg(distinct bs.code_statut,', ') code_statut - , string_agg(distinct bs.label_statut,', ') label_statut -FROM taxonomie.bdc_statut bs + , string_agg(distinct bs.label_statut,', ') label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='PN' and bs.lb_adm_tr='France métropolitaine' -group by cd_ref +group by cd_ref ) , n2k as ( -select - bs.cd_ref +select + bs.cd_ref , string_agg(distinct split_part(label_statut,' : ',2),', ') annexe -/* , bs.code_statut +/* , bs.code_statut , bs.label_statut */ -FROM taxonomie.bdc_statut bs +FROM taxonomie.bdc_statut bs where bs.cd_type_statut in ('DH','DO') and bs.lb_adm_tr='France métropolitaine' group by 1 ) , berne as ( -select - bs.cd_ref +select + bs.cd_ref , split_part(label_statut,' : ',2) annexe - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='BERN' and bs.lb_adm_tr='France métropolitaine' ) -, +, bonn as ( -select - bs.cd_ref +select + bs.cd_ref , string_agg(distinct split_part(label_statut,' : ',2),', ') annexe -/* , bs.code_statut +/* , bs.code_statut , bs.label_statut */ -FROM taxonomie.bdc_statut bs +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='BONN' group by cd_ref ) , pna_en_cours as ( -select - bs.cd_ref +select + bs.cd_ref , case when bs.code_statut ='true' then 'Oui' else null end statut - , bs.label_statut -FROM taxonomie.bdc_statut bs + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='PNA' ) , pna_ex as ( -select - bs.cd_ref +select + bs.cd_ref , case when bs.code_statut ='true' then 'Oui' else null end statut - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='exPNA' ) , -sc38 as -(select tb.cdnom_taxref cd_ref, sc38_2015 -from lpo38_aat.tabesp1806 tb - left join taxonomie.mv_c_cor_vn_taxref ccvt on ccvt.cd_nom =tb.cdnom_taxref +sc38 as +(select tb.cdnom_taxref cd_ref, sc38_2015 +from lpo38_aat.tabesp1806 tb + left join taxonomie.mv_c_cor_vn_taxref ccvt on ccvt.cd_nom =tb.cdnom_taxref where sc38_2015 is not null ) select distinct @@ -306,7 +306,7 @@ select distinct , pna_en_cours.statut pna_en_cours , pna_ex.statut pna_ex ,sc38.sc38_2015 -from taxonomie.taxref t +from taxonomie.taxref t left join (select * from taxonomie.mv_c_cor_vn_taxref mccvtd where vn_utilisation) cor on cor.cd_ref=t.cd_nom left join lr_auv on lr_auv.cd_ref=cor.cd_ref left join lr_ra on lr_ra.cd_ref=cor.cd_ref @@ -321,7 +321,7 @@ from taxonomie.taxref t left join pna_en_cours on pna_en_cours.cd_ref=cor.cd_ref left join pna_ex on pna_ex.cd_ref=cor.cd_ref left join sc38 on sc38.cd_ref=cor.cd_ref -where t.cd_nom =t.cd_ref +where t.cd_nom =t.cd_ref order by groupe_taxo_fr, vn_nom_fr ); @@ -354,12 +354,12 @@ select * from taxonomie.mv_c_cor_vn_taxref_dev mccvtd where vn_utilisation and c select * from taxonomie.mv_statut ms where cd_ref =65076; select * from taxonomie.mv_statut ms where vn_nom_sci ilike 'columbia liv%'; -select -tx_nom_sci -, vn_id +select +tx_nom_sci +, vn_id , count(vcod.*) -from taxonomie.mv_c_cor_vn_taxref_dev mccvtd -join src_lpodatas.v_c_observations_dev vcod on mccvtd.vn_id =vcod.source_id_sp +from taxonomie.mv_c_cor_vn_taxref_dev mccvtd +join src_lpodatas.v_c_observations_dev vcod on mccvtd.vn_id =vcod.source_id_sp where vn_utilisation is false group by 1,2; @@ -370,24 +370,24 @@ select * from taxonomie.mv_c_cor_vn_taxref_dev mccvtd where cd_ref = 61281; -- intégration de l'ensemble des tables sur bdc_statut select distinct * -FROM taxonomie.bdc_statut bs +FROM taxonomie.bdc_statut bs join taxonomie.bdc_statut_text bst on (bst.cd_doc =bs.cd_doc and bst.cd_sig =bs.cd_sig ) - join taxonomie.bdc_statut_type bsty on bsty.cd_type_statut =bst.cd_type_statut + join taxonomie.bdc_statut_type bsty on bsty.cd_type_statut =bst.cd_type_statut join taxonomie.bdc_statut_cor_text_values bsctv2 on bsctv2.id_text =bst.id_text - join taxonomie.bdc_statut_taxons bst2 on bst2.id_value_text =bsctv2.id_value_text and bst2.cd_nom =bs.cd_nom - join taxonomie.bdc_statut_values bsv on bsv.id_value =bsctv2.id_value and bsv.code_statut =bs.code_statut + join taxonomie.bdc_statut_taxons bst2 on bst2.id_value_text =bsctv2.id_value_text and bst2.cd_nom =bs.cd_nom + join taxonomie.bdc_statut_values bsv on bsv.id_value =bsctv2.id_value and bsv.code_statut =bs.code_statut where bst.cd_type_statut='LRN' and bst.lb_adm_tr='France métropolitaine' and bs.cd_ref =54265 /*and bs.cd_type_statut='ZDET' and bs.lb_adm_tr ilike '%rhôn%'*/ - + -- contrôle de coérence -select cd_ref, count(*) -from taxonomie.mv_statut ms +select cd_ref, count(*) +from taxonomie.mv_statut ms where lr_ra is not null or lr_auv is not null and vn_nom_fr is not null group by 1 -having count(*)>1; - +having count(*)>1; + @@ -397,8 +397,8 @@ select * from taxonomie.mv_statut ms where cd_ref=61281 ; select * -from lpo38_aat.tabesp1806 tb - left join taxonomie.mv_c_cor_vn_taxref_dev ccvt on ccvt.cd_nom =tb.cdnom_taxref +from lpo38_aat.tabesp1806 tb + left join taxonomie.mv_c_cor_vn_taxref_dev ccvt on ccvt.cd_nom =tb.cdnom_taxref -- left join taxonomie.taxref t on ccvt.cd_nom =t.cd_nom where ccvt.cd_nom =699157 ; @@ -422,11 +422,11 @@ select * from taxonomie.taxref t where cd_nom =2492; select * from taxonomie.taxref t where nom_vern ilike '%héron g%'; select * from taxonomie.mv_statut ms where vn_nom_sci ilike 'boloria%'; -select - bs.cd_ref - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs +select + bs.cd_ref + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='LRM' and cd_nom =69182 select * from taxonomie.mv_c_cor_vn_taxref_dev mccvtd where vn_utilisation and cd_ref =69182; @@ -434,13 +434,13 @@ select * from taxonomie.mv_statut ms where cd_ref =69182; -- -with +with lr_monde as ( -select - bs.cd_nom - , bs.code_statut - , bs.label_statut -FROM taxonomie.bdc_statut bs +select + bs.cd_nom + , bs.code_statut + , bs.label_statut +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='LRM' ) select distinct @@ -450,8 +450,8 @@ select distinct cor.cd_ref , lr_monde.* */ lr_monde.* -from taxonomie.taxref t - /*left join (SELECT cor.vn_id, t.cd_ref AS cd_ref +from taxonomie.taxref t + /*left join (SELECT cor.vn_id, t.cd_ref AS cd_ref FROM taxonomie.mv_c_cor_vn_taxref_dev cor LEFT JOIN taxonomie.taxref t ON cor.cd_ref = t.cd_nom) cor ON t.cd_nom =cor.cd_ref*/ left join (select * from taxonomie.mv_c_cor_vn_taxref_dev mccvtd where vn_utilisation) cor on cor.cd_nom=t.cd_nom @@ -462,8 +462,7 @@ where t.cd_nom =t.cd_ref and t.cd_nom =69182 -select +select * -FROM taxonomie.bdc_statut bs +FROM taxonomie.bdc_statut bs where bs.cd_type_statut ='LRM' and cd_ref =69182 - diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..9e3a601 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,135 @@ +#!python3 + +""" + Configuration for project documentation using Sphinx. +""" + +# standard +import sys +from datetime import datetime +from os import environ, path + +sys.path.insert(0, path.abspath("..")) # move into project package + +# 3rd party +import sphinx_rtd_theme # noqa: F401 theme of Read the Docs + +# Package +from plugin_qgis_lpo import __about__ + +# -- Build environment ----------------------------------------------------- +on_rtd = environ.get("READTHEDOCS", None) == "True" + +# -- Project information ----------------------------------------------------- +author = __about__.__author__ +copyright = __about__.__copyright__ +description = __about__.__summary__ +project = __about__.__title__ +version = release = __about__.__version__ + +# -- General configuration --------------------------------------------------- + +# 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 included + "sphinx.ext.autosectionlabel", + "sphinx.ext.extlinks", + "sphinx.ext.githubpages", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + # 3rd party + "myst_parser", + "sphinx_copybutton", + "sphinx_rtd_theme", +] + + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +source_suffix = {".md": "markdown", ".rst": "restructuredtext"} +autosectionlabel_prefix_document = True +# The master toctree document. +master_doc = "index" + + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = [ + "_build", + ".venv", + "Thumbs.db", + ".DS_Store", + "_output", + "ext_libs", + "tests", + "demo", +] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + + +# -- Options for HTML output ------------------------------------------------- + +# -- Theme + +html_favicon = str(__about__.__icon_path__) +html_logo = str(__about__.__icon_path__) +# uncomment next line if you store some statics which are not directly linked into the markdown/RST files +# html_static_path = ["static/include_additional"] +html_theme = "sphinx_rtd_theme" +html_theme_options = { + "display_version": True, + "logo_only": False, + "prev_next_buttons_location": "both", + "style_external_links": True, + "style_nav_header_background": "SteelBlue", + # Toc options + "collapse_navigation": True, + "includehidden": False, + "navigation_depth": 4, + "sticky_navigation": False, + "titles_only": False, +} + +# -- EXTENSIONS -------------------------------------------------------- + +# Configuration for intersphinx (refer to others docs). +intersphinx_mapping = { + "PyQt5": ("https://www.riverbankcomputing.com/static/Docs/PyQt5", None), + "python": ("https://docs.python.org/3/", None), + "qgis": ("https://qgis.org/pyqgis/master/", None), +} + +# MyST Parser +myst_enable_extensions = [ + "amsmath", + "colon_fence", + "deflist", + "dollarmath", + "html_image", + "linkify", + "replacements", + "smartquotes", + "substitution", +] + +myst_substitutions = { + "author": author, + "date_update": datetime.now().strftime("%d %B %Y"), + "description": description, + "qgis_version_max": __about__.__plugin_md__.get("general").get( + "qgismaximumversion" + ), + "qgis_version_min": __about__.__plugin_md__.get("general").get( + "qgisminimumversion" + ), + "repo_url": __about__.__uri__, + "title": project, + "version": version, +} + +myst_url_schemes = ("http", "https", "mailto") diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index e954a0c..0000000 --- a/docs/development.md +++ /dev/null @@ -1,173 +0,0 @@ -Development of plugin_qgis_lpo plugin -=========================== - -This project uses [qgis_plugin_tools](https://github.com/lpoaura/qgis_plugin_tools) submodule, -so set git setting value: `git config --global submodule.recurse true`. - -When cloning use `--recurse-submodules` like so: -`git clone --recurse-submodules https://github.com/lpoaura/plugin_qgis_lpo.git` - -When pulling from existing repo: -```sh -git submodule init -git submodule update -``` - - -The code for the plugin is in the [plugin_qgis_lpo](../plugin_qgis_lpo) folder. Make sure you have required tools, such as -Qt with Qt Editor and Qt Linquist installed by following this -[tutorial](https://www.qgistutorials.com/en/docs/3/building_a_python_plugin.html#get-the-tools). - -For building the plugin use platform independent [build.py](../plugin_qgis_lpo/build.py) script. - -## Setting up development environment - -To get started with the development, follow these steps: - -1. Go to the [plugin_qgis_lpo](../plugin_qgis_lpo) directory with a terminal -1. Create a new Python virtual environment with pre-commit using Python aware of QGIS libraries: - ```shell - python build.py venv - ``` - In Windows it would be best to use python-qgis.bat or python-qgis-ltr.bat: - ```shell - C:\OSGeo4W64\bin\python-qgis.bat build.py venv - ``` -1. **Note: This part is only for developers that are using QGIS < 3.16.8.** If you want to use IDE for development, it is best to start it with the - following way on Windows: - ```shell - :: Check out the arguments with python build.py start_ide -h - set QGIS_DEV_IDE= - set QGIS_DEV_OSGEO4W_ROOT=C:\OSGeo4W64 - set QGIS_DEV_PREFIX_PATH=C:\OSGeo4W64\apps\qgis-ltr - C:\OSGeo4W64\bin\python-qgis.bat build.py start_ide - :: If you want to create a bat script for starting the ide, you can do it with: - C:\OSGeo4W64\bin\python-qgis.bat build.py start_ide --save_to_disk - ``` - -Now the development environment should be all-set. - -If you want to edit or disable some quite strict pre-commit scripts, edit .pre-commit-config.yaml. -For example to disable typing, remove mypy hook and flake8-annotations from the file. - -## Keeping dependencies up to date - -1. Activate the virtual environment. -2. `pip install pip-tools` -3. `pip-compile --upgrade requirements-dev.in` -4. `pip install -r requirements-dev.txt` or `pip-sync requirements-dev.txt` - -## Adding or editing source files - -If you create or edit source files make sure that: - -* they contain absolute imports: - ```python - from plugin_qgis_lpo.utils.exceptions import TestException # Good - - from ..utils.exceptions import TestException # Bad - - ``` -* they will be found by [build.py](../plugin_qgis_lpo/build.py) script (`py_files` and `ui_files` values) - -* you consider adding test files for the new functionality -## Deployment - -Edit [build.py](../plugin_qgis_lpo/build.py) to contain working values for *profile*, *lrelease* and *pyrcc*. If you are -running on Windows, make sure the value *QGIS_INSTALLATION_DIR* points to right folder - -Run the deployment with: - -```shell script -python build.py deploy -``` - -After deploying and restarting QGIS you should see the plugin in the QGIS installed plugins where you have to activate -it. - - -## Testing - -Install python packages listed in [requirements-dev.txt](../requirements-dev.txt) to the virtual environment -and run tests with: - -```shell script -pytest -``` - -## Translating - -### Translating with Transifex - -Fill in `transifex_coordinator` (Transifex username) and `transifex_organization` -in [.qgis-plugin-ci](../.qgis-plugin-ci) to use Transifex translation. - -If you want to see the translations during development, add `i18n` to the `extra_dirs` in `build.py`: - -```python -extra_dirs = ["resources", "i18n"] -``` - -#### Pushing / creating new translations - -For step-by-step instructions, read the [translation tutorial](./translation_tutorial.md#Tutorial). - -* First, install [Transifex CLI](https://docs.transifex.com/client/installing-the-client) and - [qgis-plugin-ci](https://github.com/opengisch/qgis-plugin-ci) -* Make sure command `pylupdate5` works. Otherwise install it with `pip install pyqt5` -* Run `qgis-plugin-ci push-translation ` -* Go to your Transifex site, add some languages and start translating -* Copy [push_translations.yml](push_translations.yml) file to [workflows](../.github/workflows) folder to enable - automatic pushing after commits to master -* Add this badge ![](https://github.com/lpoaura/plugin_qgis_lpo/workflows/Translations/badge.svg) to - the [README](../README.md) - -##### Pulling - -There is no need to pull if you configure `--transifex-token` into your -[release](../.github/workflows/release.yml) workflow (remember to use Github Secrets). Remember to uncomment the -lrelease section as well. You can however pull manually to test the process. - -* Run `qgis-plugin-ci pull-translation --compile `#### Translating with QT Linguistic (if Transifex not available) - -The translation files are in [i18n](../plugin_qgis_lpo/resources/i18n) folder. Translatable content in python files is -code such as `tr(u"Hello World")`. - -To update language *.ts* files to contain newest lines to translate, run - -```shell script -python build.py transup -``` - -You can then open the *.ts* files you wish to translate with Qt Linguist and make the changes. - -Compile the translations to *.qm* files with: - -```shell script -python build.py transcompile -``` - - -### Github Release - -Follow these steps to create a release - -* Add changelog information to [CHANGELOG.md](../CHANGELOG.md) using this - [format](https://raw.githubusercontent.com/opengisch/qgis-plugin-ci/master/CHANGELOG.md) -* Make a new commit. (`git add -A && git commit -m "Release 0.1.0"`) -* Create new tag for it (`git tag -a 0.1.0 -m "Version 0.1.0"`) -* Push tag to Github using `git push --follow-tags` -* Create Github release -* [qgis-plugin-ci](https://github.com/opengisch/qgis-plugin-ci) adds release zip automatically as an asset - -Modify [release](../.github/workflows/release.yml) workflow according to its comments if you want to upload the -plugin to QGIS plugin repository. - -### Local release - -For local release install [qgis-plugin-ci](https://github.com/opengisch/qgis-plugin-ci) (possibly to different venv -to avoid Qt related problems on some environments) and follow these steps: -```shell -cd plugin_qgis_lpo -qgis-plugin-ci package --disable-submodule-update 0.1.0 -``` diff --git a/docs/development/contribute.md b/docs/development/contribute.md new file mode 100644 index 0000000..ef6daa8 --- /dev/null +++ b/docs/development/contribute.md @@ -0,0 +1,2 @@ +```{include} ../../CONTRIBUTING.md +``` diff --git a/docs/development/documentation.md b/docs/development/documentation.md new file mode 100644 index 0000000..3a206d6 --- /dev/null +++ b/docs/development/documentation.md @@ -0,0 +1,24 @@ +# Documentation + +Project uses Sphinx to generate documentation from docstrings (documentation in-code) and custom pages written in Markdown (through the [MyST parser](https://myst-parser.readthedocs.io/en/latest/)). + +# >> Build documentation website + +To build it: + +```bash +# install aditionnal dependencies +python -m pip install -U -r requirements/documentation.txt +# build it +sphinx-build -b html -d docs/_build/cache -j auto -q docs docs/_build/html +``` + +Open `docs/_build/index.html` in a web browser. + +# >> Write documentation using live render + +```bash +sphinx-autobuild -b html docs/ docs/_build +``` + +Open in a web browser to see the HTML render updated when a file is saved. diff --git a/docs/development/environment.md b/docs/development/environment.md new file mode 100644 index 0000000..ab27078 --- /dev/null +++ b/docs/development/environment.md @@ -0,0 +1,18 @@ +# Development + +# >> Environment setup + +Typically on Ubuntu: + +```bash +# create virtual environment linking to system packages (for pyqgis) +python3 -m venv .venv --system-site-packages +source .venv/bin/activate + +# bump dependencies inside venv +python -m pip install -U pip +python -m pip install -U -r requirements/development.txt + +# install git hooks (pre-commit) +pre-commit install +``` diff --git a/docs/development/history.md b/docs/development/history.md new file mode 100644 index 0000000..3139cd4 --- /dev/null +++ b/docs/development/history.md @@ -0,0 +1,2 @@ +```{include} ../../CHANGELOG.md +``` diff --git a/docs/development/packaging.md b/docs/development/packaging.md new file mode 100644 index 0000000..958264f --- /dev/null +++ b/docs/development/packaging.md @@ -0,0 +1,30 @@ +# Packaging and deployment + +# >> Packaging + +This plugin is using the [qgis-plugin-ci](https://github.com/opengisch/qgis-plugin-ci/) tool to perform packaging operations. +Under the hood, the package command is performing a `git archive` run based on `CHANGELOG.md`. + +Install additional dependencies: + +```bash +python -m pip install -U -r requirements/packaging.txt +``` + +Then use it: + +```bash +# package a specific version +qgis-plugin-ci package 1.3.1 +# package latest version +qgis-plugin-ci package latest +``` + +# >> Release a version + +Through git workflow: + +1. Add the new version to the `CHANGELOG.md` +1. Optionally change the version number in `metadata.txt` +1. Apply a git tag with the relevant version: `git tag -a X.y.z {git commit hash} -m "This version rocks!"` +1. Push tag to main branch: `git push origin X.y.z` diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 0000000..b1d7114 --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,33 @@ +# Testing the plugin + +Tests are written in 2 separate folders: + +- `tests/unit`: testing code which is independent of QGIS API +- `tests/qgis`: testing code which depends on QGIS API + +# >> Requirements + +- 3.16 < QGIS < 3.99 + +```bash +python -m pip install -U -r requirements/testing.txt +``` + +# >> Run unit tests + +```bash +# run all tests with PyTest and Coverage report +python -m pytest + +# run only unit tests with pytest launcher (disabling pytest-qgis) +python -m pytest -p no:qgis tests/unit + +# run only QGIS tests with pytest launcher +python -m pytest tests/qgis + +# run a specific test module using standard unittest +python -m unittest tests.unit.test_plg_metadata + +# run a specific test function using standard unittest +python -m unittest tests.unit.test_plg_metadata.TestPluginMetadata.test_version_semver +``` diff --git a/docs/development/translation.md b/docs/development/translation.md new file mode 100644 index 0000000..0848a46 --- /dev/null +++ b/docs/development/translation.md @@ -0,0 +1,24 @@ +# Manage translations + +# >> Requirements + +Qt Linguist tools are used to manage translations. Typically on Ubuntu: + +```bash +sudo apt install qttools5-dev-tools +``` + +# >> Workflow + +1. Update `.ts` files: + + ```bash + pylupdate5 -noobsolete -verbose plugin_qgis_lpo/resources/i18n/plugin_translation.pro + ``` + +2. Translate your text using QLinguist or directly into `.ts` files. +3. Compile it: + + ```bash + lrelease plugin_qgis_lpo/resources/i18n/*.ts + ``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9e1818a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +# {{ title }} - Documentation + +> **Description:** {{ description }} +> **Author and contributors:** {{ author }} +> **Plugin version:** {{ version }} +> **QGIS minimum version:** {{ qgis_version_min }} +> **QGIS maximum version:** {{ qgis_version_max }} +> **Source code:** {{ repo_url }} +> **Last documentation update:** {{ date_update }} + +---- + +```{toctree} +--- +caption: Usage +maxdepth: 1 +--- +Installation +``` + +```{toctree} +--- +caption: Contribution guide +maxdepth: 1 +--- +development/contribute +development/environment +development/documentation +development/translation +development/packaging +development/testing +development/history +``` diff --git a/docs/push_translations.yml b/docs/push_translations.yml deleted file mode 100644 index 325c665..0000000 --- a/docs/push_translations.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Translations - -on: - push: - branches: - - master - -jobs: - push_translations: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - with: - submodules: true - - - name: Set up Python 3.8 - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - - name: Install qgis-plugin-ci - run: pip3 install qgis-plugin-ci - - - name: Push translations - run: qgis-plugin-ci push-translation ${{ secrets.TRANSIFEX_TOKEN }} diff --git a/docs/usage/installation.md b/docs/usage/installation.md new file mode 100644 index 0000000..b62e77e --- /dev/null +++ b/docs/usage/installation.md @@ -0,0 +1,19 @@ +# Installation + +# >> Stable version (recomended) + +This plugin is published on the official QGIS plugins repository: . + +# >> Beta versions released + +Enable experimental extensions in the QGIS plugins manager settings panel. + +# >> Earlier development version + +If you define yourself as early adopter or a tester and can't wait for the release, the plugin is automatically packaged for each commit to main, so you can use this address as repository URL in your QGIS extensions manager settings: + +```url +https://github.com/lpoaura/PluginQGis-LPOData/plugins.xml +``` + +Be careful, this version can be unstable. diff --git a/plugin_qgis_lpo.code-workspace b/plugin_qgis_lpo.code-workspace deleted file mode 100644 index 96cd67e..0000000 --- a/plugin_qgis_lpo.code-workspace +++ /dev/null @@ -1,54 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "python.languageServer": "Jedi", - "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": [ - "test" - ], - "python.testing.unittestEnabled": false, - "python.testing.nosetestsEnabled": false, - "python.linting.enabled": true, - "python.linting.flake8Enabled": true, - "python.linting.mypyEnabled": true, - "python.linting.pylintEnabled": false, - "python.defaultInterpreterPath": ".venv\\Scripts\\python.exe", - "editor.formatOnSave": true, - "python.analysis.autoFormatStrings": true, - "isort.check": true, - "editor.defaultFormatter": "ms-python.black-formatter", - }, - "launch": { - "configurations": [ - { - "name": "QGIS debugpy", - "type": "python", - "request": "attach", - "connect": { - "host": "localhost", - "port": 5678 - }, - "pathMappings": [ - { - "localRoot": "${workspaceFolder}/plugin_qgis_lpo", - "remoteRoot": "C:/Users/${env:USERNAME}/AppData/Roaming/QGIS/QGIS3/profiles/default/python/plugins/plugin_qgis_lpo" - } - ] - }, - { - "name": "Debug Tests", - "type": "python", - "request": "test", - "console": "integratedTerminal", - "justMyCode": false, - "env": { - "PYTEST_ADDOPTS": "--no-cov" - } - } - ], - } -} diff --git a/plugin_qgis_lpo/.gitattributes b/plugin_qgis_lpo/.gitattributes deleted file mode 100644 index 9dad0d8..0000000 --- a/plugin_qgis_lpo/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -.gitattributes export-ignore -.editorconfig export-ignore -test export-ignore diff --git a/plugin_qgis_lpo/__about__.py b/plugin_qgis_lpo/__about__.py new file mode 100644 index 0000000..03faf18 --- /dev/null +++ b/plugin_qgis_lpo/__about__.py @@ -0,0 +1,114 @@ +#! python3 # noqa: E265 + +""" + Metadata about the package to easily retrieve informations about it. + See: https://packaging.python.org/guides/single-sourcing-package-version/ +""" + +# ############################################################################ +# ########## Libraries ############# +# ################################## + +# standard library +from configparser import ConfigParser +from datetime import date +from pathlib import Path + +# ############################################################################ +# ########## Globals ############### +# ################################## +__all__: list = [ + "__author__", + "__copyright__", + "__email__", + "__license__", + "__summary__", + "__title__", + "__uri__", + "__version__", +] + + +DIR_PLUGIN_ROOT: Path = Path(__file__).parent +PLG_METADATA_FILE: Path = DIR_PLUGIN_ROOT.resolve() / "metadata.txt" + + +# ############################################################################ +# ########## Functions ############# +# ################################## +def plugin_metadata_as_dict() -> dict: + """Read plugin metadata.txt and returns it as a Python dict. + + Raises: + IOError: if metadata.txt is not found + + Returns: + dict: dict of dicts. + """ + config = ConfigParser() + if PLG_METADATA_FILE.is_file(): + config.read(PLG_METADATA_FILE.resolve(), encoding="UTF-8") + return {s: dict(config.items(s)) for s in config.sections()} + else: + raise IOError("Plugin metadata.txt not found at: %s" % PLG_METADATA_FILE) + + +# ############################################################################ +# ########## Variables ############# +# ################################## + +# store full metadata.txt as dict into a var +__plugin_md__: dict = plugin_metadata_as_dict() + +__author__: str = __plugin_md__.get("general").get("author") +__copyright__: str = "2024 - {0}, {1}".format(date.today().year, __author__) +__email__: str = __plugin_md__.get("general").get("email") +__icon_path__: Path = DIR_PLUGIN_ROOT.resolve() / __plugin_md__.get("general").get( + "icon" +) +__icon_dir_path__: Path = DIR_PLUGIN_ROOT.resolve() / "resources" / "images" +__keywords__: list = [ + t.strip() for t in __plugin_md__.get("general").get("repository").split("tags") +] +__license__: str = "GPLv3" +__summary__: str = "{}\n{}".format( + __plugin_md__.get("general").get("description"), + __plugin_md__.get("general").get("about"), +) + +__title__: str = __plugin_md__.get("general").get("name") +__title_clean__: str = "".join(e for e in __title__ if e.isalnum()) + +__uri_homepage__: str = __plugin_md__.get("general").get("homepage") +__uri_repository__: str = __plugin_md__.get("general").get("repository") +__uri_tracker__: str = __plugin_md__.get("general").get("tracker") +__uri__: str = __uri_repository__ + +__version__: str = __plugin_md__.get("general").get("version") +__version_info__: tuple = tuple( + [ + int(num) if num.isdigit() else num + for num in __version__.replace("-", ".", 1).split(".") + ] +) + +# ############################################################################# +# ##### Main ####################### +# ################################## +if __name__ == "__main__": + plugin_md = plugin_metadata_as_dict() + assert isinstance(plugin_md, dict) + assert plugin_md.get("general").get("name") == __title__ + print(f"Plugin: {__title__}") + print(f"By: {__author__}") + print(f"Version: {__version__}") + print(f"Description: {__summary__}") + print(f"Icon: {__icon_path__}") + print( + "For: %s > QGIS > %s" + % ( + plugin_md.get("general").get("qgisminimumversion"), + plugin_md.get("general").get("qgismaximumversion"), + ) + ) + print(__title_clean__) diff --git a/plugin_qgis_lpo/__init__.py b/plugin_qgis_lpo/__init__.py index a79ddaf..72f1597 100644 --- a/plugin_qgis_lpo/__init__.py +++ b/plugin_qgis_lpo/__init__.py @@ -1,19 +1,23 @@ -import os +#! python3 # noqa: E265 +"""init Qgis LPO Plugin""" +# ---------------------------------------------------------- +# Copyright (C) 2015 Martin Dobias +# ---------------------------------------------------------- +# Licensed under the terms of GNU GPL 2 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# -------------------------------------------------------------------- -from qgis.gui import QgisInterface -# from plugin_qgis_lpo.qgis_plugin_tools.infrastructure.debugging import ( # noqa F401 -# setup_debugpy, -# setup_ptvsd, -# setup_pydevd, -# ) +def classFactory(iface): # noqa N802 + """Load the plugin class. -# debugger = os.environ.get("QGIS_PLUGIN_USE_DEBUGGER", "").lower() -# if debugger in {"debugpy", "ptvsd", "pydevd"}: -# locals()["setup_" + debugger]() + :param iface: A QGIS interface instance. + :type iface: QgsInterface + """ + from .plugin_main import QgisLpoPlugin - -def classFactory(iface: QgisInterface): # noqa N802 - from .plugin import Plugin - - return Plugin(iface) + return QgisLpoPlugin(iface) diff --git a/plugin_qgis_lpo/action_scripts/csv_formatter.py b/plugin_qgis_lpo/action_scripts/csv_formatter.py index eb52c72..04c4e40 100644 --- a/plugin_qgis_lpo/action_scripts/csv_formatter.py +++ b/plugin_qgis_lpo/action_scripts/csv_formatter.py @@ -1,25 +1,51 @@ ##################################################### -##### OBJECTIFS DU SCRIPT : # -##### Créer un fichier excel # -##### Le remplir de valeurs # -##### Ajouter des conditions de mise en forme # +# OBJECTIFS DU SCRIPT : # +# Créer un fichier excel # +# Le remplir de valeurs # +# Ajouter des conditions de mise en forme # ##################################################### ##################################################### -##### 1 - Import des librairies # +# 1 - Import des librairies # ##################################################### import os -import re import webbrowser from typing import Dict from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side # , Color -from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout from qgis.core import QgsProject from qgis.gui import QgsMessageBar from qgis.PyQt.QtCore import NULL +from qgis.PyQt.QtWidgets import ( + QDialog, + QDialogButtonBox, + QLabel, + QSizePolicy, + QVBoxLayout, +) + +LR_COLORS = { + "EX": "000000", + "EW": "3d1851", + "RE": "5b1a62", + "CR": "d20019", + "EN": "fabf00", + "VU": "ffed00", + "NT": "faf2c7", + "LC": "78b747", + "DD": "d4d4d4", +} + + +def wc_hex_is_light(color): + """Set font color depending on fill darkness""" + red = int(color[0:2], 16) + green = int(color[2:4], 16) + blue = int(color[4:6], 16) + brightness = ((red * 299) + (green * 587) + (blue * 114)) / 1000 + return "000000" if brightness > 155 else "ffffff" class SuccessDialog(QDialog): @@ -41,18 +67,19 @@ def __init__(self): ##################################################### -##### 2 - Gestion des données # +# >> 2 - Gestion des données # ##################################################### -## Création du fichier excel +# >> Création du fichier excel wb = Workbook() -## Sélection de "feuille" active 'worksheet' +# >> Sélection de "feuille" active 'worksheet' ws = wb.active -## Alimentation du fichier excel avec les données de la table attributaire +# >> Alimentation du fichier excel avec les données de la table attributaire # Récupération de la couche layer_id = "[%@layer_id%]" layer = QgsProject().instance().mapLayer(layer_id) + # Ajout de l'entête fields = layer.fields() fields_row = [] @@ -71,16 +98,15 @@ def __init__(self): feature_row.append(feature[attribute]) # print(feature_row) ws.append(feature_row) - ##################################################### -##### 3 - Mise en forme du fichier # +# >> 3 - Mise en forme du fichier # ##################################################### -##### 3.1 - Mise en italique des cases 'Nom scientifique' +# >> 3.1 - Mise en italique des cases 'Nom scientifique' -## Définition du style +# >> Définition du style italic_grey_font = Font(color="606060", italic=True) -## Rechercher la colonne "Nom scientifique" +# >> Rechercher la colonne "Nom scientifique" for col in ws["1:1"]: if col.value == "Nom scientifique": # Si on trouve une colonne référente alors : @@ -90,72 +116,38 @@ def __init__(self): cell.font = italic_grey_font cell.alignment = Alignment(horizontal="center") -##### 3.2 - Couleur sur les cases de type statut +# >> 3.2 - Couleur sur les cases de type statut -## Définition du style -blackFill = PatternFill(start_color="000000", end_color="000000", fill_type="solid") -purpleFill = PatternFill(start_color="3d1851", end_color="3d1851", fill_type="solid") -lpurpleFill = PatternFill(start_color="5b1a62", end_color="5b1a62", fill_type="solid") -redFill = PatternFill(start_color="d20019", end_color="d20019", fill_type="solid") -orangeFill = PatternFill(start_color="fabf00", end_color="fabf00", fill_type="solid") -yellowFill = PatternFill(start_color="ffed00", end_color="ffed00", fill_type="solid") -beigeFill = PatternFill(start_color="faf2c7", end_color="faf2c7", fill_type="solid") -greenFill = PatternFill(start_color="78b747", end_color="78b747", fill_type="solid") -grey2Fill = PatternFill(start_color="d4d4d4", end_color="d4d4d4", fill_type="solid") - -## Définition de la fonction d'application des couleurs selon le statut +# >> Définition de la fonction d'application des couleurs selon le statut def color_statut_style(x): - for cell in ws[x]: - if re.match("EX", cell.value): - cell.fill = blackFill - elif re.match("EW", cell.value): - cell.fill = purpleFill - elif re.match("RE", cell.value): - cell.fill = lpurpleFill - elif re.match("CR", cell.value): - cell.fill = redFill - elif re.match("EN", cell.value): - cell.fill = orangeFill - elif re.match("VU", cell.value): - cell.fill = yellowFill - elif re.match("NT", cell.value): - cell.fill = beigeFill - elif re.match("LC", cell.value): - cell.fill = greenFill - elif re.match("DD", cell.value): - cell.fill = grey2Fill - - -## Recherche des colonnes de type statut + """set RedList color style""" + for lr_cell in ws[x]: + if lr_cell.value in LR_COLORS.keys(): + color = LR_COLORS[lr_cell.value] + lr_cell.fill = PatternFill( + start_color=color, end_color=color, fill_type="solid" + ) + lr_cell.font = Font(color=wc_hex_is_light(color)) + + +# >> Recherche des colonnes de type statut # Rechercher la colonne "LR France", "LR Rhône-Alpes", "LR Auvergne" # d'autres colonnes à prévoir for col in ws["1:1"]: - if col.value == "LR France": - # Si on trouve une colonne référente alors : - range_statut = col - ref_statut = range_statut.column_letter + ":" + range_statut.column_letter - color_statut_style(ref_statut) - elif col.value == "LR Rhône-Alpes": - # Si on trouve une colonne référente alors : - range_statut = col - ref_statut = range_statut.column_letter + ":" + range_statut.column_letter - color_statut_style(ref_statut) - elif col.value == "LR Auvergne": + if col.value.startswith("LR "): # Si on trouve une colonne référente alors : range_statut = col ref_statut = range_statut.column_letter + ":" + range_statut.column_letter color_statut_style(ref_statut) -##### 3.3 - Style général des colonnes +# >> 3.3 - Style général des colonnes -## Mise en gras des noms de colonnes -col_name_font = Font(bold=True, italic=False, vertAlign=None, color="ffffff", size=12) -blueLPO = PatternFill(start_color="0076bd", end_color="0076bd", fill_type="solid") +# >> Mise en gras des noms de colonnes for cell in ws["1:1"]: - cell.font = col_name_font - cell.fill = blueLPO + cell.font = Font(bold=True, italic=False, vertAlign=None, color="ffffff", size=12) + cell.fill = PatternFill(start_color="0076bd", end_color="0076bd", fill_type="solid") -## Mise en forme de la largeur des colonnes +# >> Mise en forme de la largeur des colonnes dims: Dict[str, int] = {} for row in ws.rows: for cell in row: @@ -170,13 +162,13 @@ def color_statut_style(x): ws.column_dimensions[col].width = value -## Mise en forme des bordures du tableau +# >> Mise en forme des bordures du tableau # Définition d'une fonction qui parcourt les cellules et applique le style de bordure choisie def set_border(ws, cell_range): border = Border(bottom=Side(border_style="thin", color="0076bd")) - for row in ws[cell_range]: - for cell in row: - cell.border = border + for line in ws[cell_range]: + for rcell in line: + rcell.border = border # Obtenir les dimensions du tableau @@ -185,7 +177,7 @@ def set_border(ws, cell_range): set_border(ws, "A1:Z3275") ##################################################### -##### 4 - Enregistrement du résultat final # +# >> 4 - Enregistrement du résultat final # ##################################################### # Sauvegarde du fichier diff --git a/plugin_qgis_lpo/action_scripts/joke.py b/plugin_qgis_lpo/action_scripts/joke.py index 3b930e9..f1f1576 100644 --- a/plugin_qgis_lpo/action_scripts/joke.py +++ b/plugin_qgis_lpo/action_scripts/joke.py @@ -1,7 +1,13 @@ """Fake news""" -from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout from qgis.gui import QgsMessageBar +from qgis.PyQt.QtWidgets import ( + QDialog, + QDialogButtonBox, + QLabel, + QSizePolicy, + QVBoxLayout, +) class JokeDialog(QDialog): diff --git a/plugin_qgis_lpo/build.py b/plugin_qgis_lpo/build.py deleted file mode 100644 index 803a77e..0000000 --- a/plugin_qgis_lpo/build.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import glob -from typing import List - -from qgis_plugin_tools.infrastructure.plugin_maker import PluginMaker - -""" -################################################# -# Edit the following to match the plugin -################################################# -""" - -py_files = [ - fil - for fil in glob.glob("**/*.py", recursive=True) - if "test/" not in fil and "test\\" not in fil -] -locales = ["fi"] -profile = "default" -ui_files = list(glob.glob("**/*.ui", recursive=True)) -resources = list(glob.glob("**/*.qrc", recursive=True)) -extra_dirs = ["resources"] -compiled_resources: List[str] = [] - -PluginMaker( - py_files=py_files, - ui_files=ui_files, - resources=resources, - extra_dirs=extra_dirs, - compiled_resources=compiled_resources, - locales=locales, - profile=profile, -) diff --git a/plugin_qgis_lpo/commons/helpers.py b/plugin_qgis_lpo/commons/helpers.py index d41abcd..c9895e6 100644 --- a/plugin_qgis_lpo/commons/helpers.py +++ b/plugin_qgis_lpo/commons/helpers.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ /*************************************************************************** ScriptsLPO : common_functions.py @@ -17,16 +15,13 @@ ***************************************************************************/ """ - from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple -import matplotlib.pyplot as plt -import processing +from qgis import processing from qgis.core import ( QgsField, QgsFields, - QgsMessageLog, QgsProcessingAlgorithm, QgsProcessingContext, QgsProcessingException, @@ -55,19 +50,21 @@ def check_layer_is_valid(feedback: QgsProcessingFeedback, layer: QgsVectorLayer) """ if not layer.isValid(): raise QgsProcessingException( - """"La couche PostGIS chargée n'est pas valide ! + """La couche PostGIS chargée n'est pas valide ! Checkez les logs de PostGIS pour visualiser les messages d'erreur. - Pour cela, rendez-vous dans l'onglet "Vue > Panneaux > Journal des messages" de QGis, puis l'onglet "PostGIS".""" + Pour cela, rendez-vous dans l'onglet "Vue > Panneaux > Journal des messages" + de QGis, puis l'onglet "PostGIS".""" ) else: # iface.messageBar().pushMessage("Info", "La couche PostGIS demandée est valide, la requête SQL a été exécutée avec succès !", level=Qgis.Info, duration=10) feedback.pushInfo( - "La couche PostGIS demandée est valide, la requête SQL a été exécutée avec succès !" + "La couche PostGIS demandée est valide, " + "la requête SQL a été exécutée avec succès !" ) return None -def construct_sql_array_polygons(layer: QgsVectorLayer): +def sql_array_polygons_builder(layer: QgsVectorLayer): """ Construct the sql array containing the input vector layer's features geometry. """ @@ -77,9 +74,10 @@ def construct_sql_array_polygons(layer: QgsVectorLayer): crs = layer.sourceCrs().authid() if crs.split(":")[0] != "EPSG": raise QgsProcessingException( - """Le SCR (système de coordonnées de référence) de votre couche zone d'étude n'est pas de type 'EPSG'. - Veuillez choisir un SCR adéquat. - NB : 'EPSG:2154' pour Lambert 93 !""" + """Le SCR (système de coordonnées de référence) de votre couche zone \ +d'étude n'est pas de type 'EPSG'. +Veuillez choisir un SCR adéquat. +NB : 'EPSG:2154' pour Lambert 93 !""" ) else: crs = crs.split(":")[1] @@ -103,7 +101,7 @@ def construct_sql_array_polygons(layer: QgsVectorLayer): return array_polygons -def construct_queries_list( +def sql_queries_list_builder( table_name: str, main_query: str, pk_field: str = "id" ) -> List[str]: """Table create""" @@ -115,21 +113,22 @@ def construct_queries_list( return queries -def construct_sql_taxons_filter(taxons_dict: Dict) -> Optional[str]: +def sql_taxons_filter_builder(taxons_dict: Dict) -> Optional[str]: """ Construct the sql "where" clause with taxons filters. """ rank_filters = [] for key, value in taxons_dict.items(): if value: - rank_filters.append(f"{key} in {str(tuple(value))}") + value_list = ",".join([f"'{v}'" for v in value]) + rank_filters.append(f"{key} in ({value_list})") if len(rank_filters) > 0: taxons_where = f"({' or '.join(rank_filters)})" return taxons_where return None -def construct_sql_source_filter(sources: List[str]) -> Optional[str]: +def sql_source_filter_builder(sources: List[str]) -> Optional[str]: """ Construct the sql "where" clause with source filters. """ @@ -138,7 +137,7 @@ def construct_sql_source_filter(sources: List[str]) -> Optional[str]: return None -def construct_sql_geom_type_filter(geom_types: List[str]) -> Optional[str]: +def sql_geom_type_filter_builder(geom_types: List[str]) -> Optional[str]: """ Construct the sql "where" clause with source filters. """ @@ -156,7 +155,7 @@ def construct_sql_geom_type_filter(geom_types: List[str]) -> Optional[str]: return None -def construct_sql_datetime_filter( +def sql_datetime_filter_builder( self: QgsProcessingAlgorithm, period_type_filter: str, timestamp: datetime, @@ -192,7 +191,7 @@ def construct_sql_datetime_filter( return datetime_where -def construct_sql_select_data_per_time_interval( +def sql_timeinterval_cols_builder( # noqa C901 self: QgsProcessingAlgorithm, time_interval_param, start_year_param: int, @@ -201,34 +200,48 @@ def construct_sql_select_data_per_time_interval( parameters: Dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback, -): +) -> Tuple[str, List[str]]: """ Construct the sql "select" data according to a time interval and a period. """ - select_data = "" + select_data = [] x_var = [] + count_param = ( + "*" if aggregation_type_param == "Nombre de données" else "DISTINCT t.cd_ref" + ) if time_interval_param == "Par année": - add_five_years = self.parameterAsEnums(parameters, self.ADD_FIVE_YEARS, context) + add_five_years = self.parameterAsEnums( + parameters, self.ADD_FIVE_YEARS, context # type: ignore + ) if len(add_five_years) > 0: if (end_year_param - start_year_param + 1) % 5 != 0: raise QgsProcessingException( - "Veuillez renseigner une période en année qui soit divisible par 5 ! Exemple : 2011 - 2020." + "Veuillez renseigner une période en année qui soit " + "divisible par 5 ! Exemple : 2011 - 2020." ) else: counter = start_year_param step_limit = start_year_param while counter <= end_year_param: - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE date_an={counter}) AS \"{counter}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE date_an={counter}) AS \"{counter}\" """ + ) x_var.append(str(counter)) if counter == step_limit + 4: - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE date_an>={counter-4} and date_an<={counter}) AS \"{counter-4} - {counter}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE date_an>={counter-4} and date_an<={counter}) AS \"{counter-4} - {counter}\" """ + ) step_limit += 5 counter += 1 else: for year in range(start_year_param, end_year_param + 1): - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE date_an={year}) AS \"{year}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE date_an={year}) AS \"{year}\"""" + ) x_var.append(str(year)) - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE date_an>={start_year_param} and date_an<={end_year_param}) AS \"TOTAL\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE date_an>={start_year_param} and date_an<={end_year_param}) AS \"TOTAL\"""" + ) else: start_month = self.parameterAsEnum(parameters, self.START_MONTH, context) end_month = self.parameterAsEnum(parameters, self.END_MONTH, context) @@ -253,53 +266,69 @@ def construct_sql_select_data_per_time_interval( ) else: for month in range(start_month, end_month + 1): - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE to_char(date, 'YYYY-MM')='{start_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {start_year_param}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE to_char(date, 'YYYY-MM')='{start_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {start_year_param}\"""" + ) x_var.append( - self._months_names_variables[month] + self._months_names_variables[month] # type: ignore + " " + str(start_year_param) ) elif end_year_param == start_year_param + 1: for month in range(start_month, 12): - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE to_char(date, 'YYYY-MM')='{start_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {start_year_param}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE to_char(date, 'YYYY-MM')='{start_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {start_year_param}\"""" + ) x_var.append( - self._months_names_variables[month] + " " + str(start_year_param) + self._months_names_variables[month] + " " + str(start_year_param) # type: ignore ) for month in range(0, end_month + 1): - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE to_char(date, 'YYYY-MM')='{end_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {end_year_param}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE to_char(date, 'YYYY-MM')='{end_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {end_year_param}\"""" + ) x_var.append( self._months_names_variables[month] + " " + str(end_year_param) ) else: for month in range(start_month, 12): - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE to_char(date, 'YYYY-MM')='{start_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {start_year_param}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE to_char(date, 'YYYY-MM')='{start_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {start_year_param}\"""" + ) x_var.append( self._months_names_variables[month] + " " + str(start_year_param) ) for year in range(start_year_param + 1, end_year_param): for month in range(0, 12): - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE to_char(date, 'YYYY-MM')='{year}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {year}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE to_char(date, 'YYYY-MM')='{year}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {year}\"""" + ) x_var.append(self._months_names_variables[month] + " " + str(year)) for month in range(0, end_month + 1): - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE to_char(date, 'YYYY-MM')='{end_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {end_year_param}\"""" + select_data.append( + f"""COUNT({count_param}) filter (WHERE to_char(date, 'YYYY-MM')='{end_year_param}-{months_numbers_variables[month]}') AS \"{self._months_names_variables[month]} {end_year_param}\"""" + ) x_var.append( self._months_names_variables[month] + " " + str(end_year_param) ) - select_data += f""", COUNT({"*" if aggregation_type_param == 'Nombre de données' else "DISTINCT t.cd_ref"}) filter (WHERE to_char(date, 'YYYY-MM')>='{start_year_param}-{months_numbers_variables[start_month]}' and to_char(date, 'YYYY-MM')<='{end_year_param}-{months_numbers_variables[end_month]}') AS \"TOTAL\"""" - feedback.pushDebugInfo(select_data) - return select_data, x_var + select_data.append( + f"""COUNT({count_param}) filter (WHERE to_char(date, 'YYYY-MM')>='{start_year_param}-{months_numbers_variables[start_month]}' and to_char(date, 'YYYY-MM')<='{end_year_param}-{months_numbers_variables[end_month]}') AS \"TOTAL\"""" + ) + final_select_data = ", ".join(select_data) + feedback.pushDebugInfo(final_select_data) + return final_select_data, x_var def load_layer(context: QgsProcessingContext, layer: QgsVectorLayer): """ Load a layer in the current project. """ - root = context.project().layerTreeRoot() - plugin_lpo_group = root.findGroup("Résultats plugin LPO") - if not plugin_lpo_group: - plugin_lpo_group = root.insertGroup(0, "Résultats plugin LPO") - context.project().addMapLayer(layer, False) - plugin_lpo_group.insertLayer(0, layer) + if context.project() is not None: + root = context.project().layerTreeRoot() + plugin_lpo_group = root.findGroup("Résultats plugin LPO") + if not plugin_lpo_group: + plugin_lpo_group = root.insertGroup(0, "Résultats plugin LPO") + context.project().addMapLayer(layer, False) + plugin_lpo_group.insertLayer(0, layer) def execute_sql_queries( diff --git a/test/__init__.py b/plugin_qgis_lpo/gui/__init__.py similarity index 100% rename from test/__init__.py rename to plugin_qgis_lpo/gui/__init__.py diff --git a/plugin_qgis_lpo/gui/dlg_settings.py b/plugin_qgis_lpo/gui/dlg_settings.py new file mode 100644 index 0000000..7a5a510 --- /dev/null +++ b/plugin_qgis_lpo/gui/dlg_settings.py @@ -0,0 +1,166 @@ +#! python3 # noqa: E265 + +""" + Plugin settings form integrated into QGIS 'Options' menu. +""" + +# standard +import platform +from functools import partial +from pathlib import Path +from urllib.parse import quote + +# PyQGIS +from qgis.core import Qgis, QgsApplication +from qgis.gui import QgsOptionsPageWidget, QgsOptionsWidgetFactory +from qgis.PyQt import uic +from qgis.PyQt.Qt import QUrl +from qgis.PyQt.QtGui import QDesktopServices, QIcon + +# project +from plugin_qgis_lpo.__about__ import ( + __icon_dir_path__, + __title__, + __uri_homepage__, + __uri_tracker__, + __version__, +) +from plugin_qgis_lpo.toolbelt import PlgLogger, PlgOptionsManager +from plugin_qgis_lpo.toolbelt.preferences import PlgSettingsStructure + +# ############################################################################ +# ########## Globals ############### +# ################################## + +FORM_CLASS, _ = uic.loadUiType( + Path(__file__).parent / "{}.ui".format(Path(__file__).stem) +) + + +# ############################################################################ +# ########## Classes ############### +# ################################## + + +class ConfigOptionsPage(FORM_CLASS, QgsOptionsPageWidget): + """Settings form embedded into QGIS 'options' menu.""" + + def __init__(self, parent): + super().__init__(parent) + self.log = PlgLogger().log + self.plg_settings = PlgOptionsManager() + + # load UI and set objectName + self.setupUi(self) + self.setObjectName("mOptionsPage{}".format(__title__)) + + report_context_message = quote( + "> Reported from plugin settings\n\n" + f"- operating system: {platform.system()} " + f"{platform.release()}_{platform.version()}\n" + f"- QGIS: {Qgis.QGIS_VERSION}" + f"- plugin version: {__version__}\n" + ) + + # header + self.lbl_title.setText(f"{__title__} - Version {__version__}") + + # customization + self.btn_help.setIcon(QIcon(QgsApplication.iconPath("mActionHelpContents.svg"))) + self.btn_help.pressed.connect( + partial(QDesktopServices.openUrl, QUrl(__uri_homepage__)) + ) + + self.btn_report.setIcon( + QIcon(QgsApplication.iconPath("console/iconSyntaxErrorConsole.svg")) + ) + + self.btn_report.pressed.connect( + partial(QDesktopServices.openUrl, QUrl(f"{__uri_tracker__}new/choose")) + ) + + self.btn_reset.setIcon(QIcon(QgsApplication.iconPath("mActionUndo.svg"))) + self.btn_reset.pressed.connect(self.reset_settings) + + # load previously saved settings + self.load_settings() + + def apply(self): + """Called to permanently apply the settings shown in the options page (e.g. \ + save them to QgsSettings objects). This is usually called when the options \ + dialog is accepted.""" + settings = self.plg_settings.get_plg_settings() + + # misc + settings.debug_mode = self.opt_debug.isChecked() + settings.version = __version__ + + # dump new settings into QgsSettings + self.plg_settings.save_from_object(settings) + + if __debug__: + self.log( + message="DEBUG - Settings successfully saved.", + log_level=4, + ) + + def load_settings(self): + """Load options from QgsSettings into UI form.""" + settings = self.plg_settings.get_plg_settings() + + # global + self.opt_debug.setChecked(settings.debug_mode) + self.lbl_version_saved_value.setText(settings.version) + + def reset_settings(self): + """Reset settings to default values (set in preferences.py module).""" + default_settings = PlgSettingsStructure() + + # dump default settings into QgsSettings + self.plg_settings.save_from_object(default_settings) + + # update the form + self.load_settings() + + +class PlgOptionsFactory(QgsOptionsWidgetFactory): + """Factory for options widget.""" + + # def __init__(self): + # """Constructor.""" + # super().__init__() + + def icon(self) -> QIcon: + """Returns plugin icon, used to as tab icon in QGIS options tab widget. + + :return: _description_ + :rtype: QIcon + """ + return QIcon(str(__icon_dir_path__ / "logo_lpo_aura_carre.png")) + + def createWidget(self, parent) -> ConfigOptionsPage: # noqa N802 + """Create settings widget. + + :param parent: Qt parent where to include the options page. + :type parent: QObject + + :return: options page for tab widget + :rtype: ConfigOptionsPage + """ + return ConfigOptionsPage(parent) + + def title(self) -> str: + """Returns plugin title, used to name the tab in QGIS options tab widget. + + :return: plugin title from about module + :rtype: str + """ + return __title__ + + def helpId(self) -> str: + """Returns plugin help URL. + + :return: plugin homepage url from about module + :rtype: str + """ + return __uri_homepage__ diff --git a/plugin_qgis_lpo/gui/dlg_settings.ui b/plugin_qgis_lpo/gui/dlg_settings.ui new file mode 100644 index 0000000..b96a023 --- /dev/null +++ b/plugin_qgis_lpo/gui/dlg_settings.ui @@ -0,0 +1,245 @@ + + + wdg_plugin_qgis_lpo_settings + + + + 0 + 0 + 538 + 273 + + + + Traitement des données LPO - Settings + + + + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + + 75 + true + + + + + + + <html><head/><body><p align="center"><span style=" font-weight:600;">PluginTitle - Version X.X.X</span></p></body></html> + + + true + + + Qt::AlignCenter + + + true + + + false + + + Qt::TextSelectableByMouse + + + + + + + + 0 + 100 + + + + + + + Miscellaneous + + + false + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + + + + X.X.X + + + Qt::NoTextInteraction + + + + + + + + 200 + 25 + + + + + 500 + 30 + + + + + + + Report an issue + + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + + + + Version used to save settings: + + + + + + + + 200 + 25 + + + + + 500 + 30 + + + + + + + Help + + + + + + + + 200 + 25 + + + + + 16777215 + 30 + + + + true + + + Reset setttings to factory defaults + + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + Enable debug mode. + + + true + + + + + + Debug mode (degraded performances) + + + false + + + + + + + + + + Qt::Vertical + + + + 20 + 56 + + + + + + + + + diff --git a/plugin_qgis_lpo/metadata.txt b/plugin_qgis_lpo/metadata.txt index 040c432..3f8b5c1 100644 --- a/plugin_qgis_lpo/metadata.txt +++ b/plugin_qgis_lpo/metadata.txt @@ -1,19 +1,25 @@ [general] -name=Traitements de la LPO AuRA -qgisMinimumVersion=3.16 -description=Scripts de la LPO pour l'analyse de données -about=Scripts développés par la LPO pour analyser les données naturalistes contenues dans une base Géonature, alimentée notamment par Visionature. -version=3.0.0-dev -author=collectif (LPO AuRA) +name=Traitement des données LPO +about=This plugin is a revolution! +category=Database +hasProcessingProvider=True +description=Extends QGIS with revolutionary features that every single GIS end-users was expected (or not)! +icon=resources/images/logo_lpo_aura_carre.png +tags=geonature,visionature,faune-france,postgresql,lpo + +# credits and contact +author=Pole VDC (LPOAuRA) email=webadmin.aura@lpo.fr +homepage=https://github.com/lpoaura/PluginQGis-LPOData repository=https://github.com/lpoaura/PluginQGis-LPOData tracker=https://github.com/lpoaura/PluginQGis-LPOData/issues -hasProcessingProvider=yes -category=Analysis -changelog=https://github.com/lpoaura/PluginQGis-LPOData/blob/master/CHANGELOG.md -experimental=True + +# experimental flag deprecated=False -tags=LPO AuRA, données, analyse, traitements, Python, PostGIS -homepage=https://github.com/lpoaura/PluginQGis-LPOData -icon=icons/logo_lpo_aura_carre.png -server=False +experimental=True +qgisMinimumVersion=3.16 +qgisMaximumVersion=3.99 + +# versioning +version=3.0.0+dev +changelog= diff --git a/plugin_qgis_lpo/plugin.py b/plugin_qgis_lpo/plugin.py deleted file mode 100644 index ecbab59..0000000 --- a/plugin_qgis_lpo/plugin.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -/*************************************************************************** - ScriptsLPO : scripts_lpo.py - ------------------- - - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ -""" - - -from qgis.core import QgsApplication -from qgis.PyQt.QtWidgets import QAction, QMenu - -from .processing.provider import Provider -from .processing.qgis_processing_postgis import get_connection_name -from .processing.species_map import CarteParEspece - -# from plugin_qgis_lpo.processing.provider import Provider -# from plugin_qgis_lpo.qgis_plugin_tools.tools.custom_logging import ( -# setup_logger, -# teardown_logger, -# ) -# from plugin_qgis_lpo.qgis_plugin_tools.tools.resources import plugin_name - - -# cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0] - -# if cmd_folder not in sys.path: -# sys.path.insert(0, cmd_folder) - - -class Plugin(object): - """QGIS Plugin Implementation.""" - - # name = plugin_name() - - def __init__(self, iface) -> None: - # setup_logger(Plugin.name) - # self.provider = None - self.provider = Provider() - self.iface = iface - # def initProcessing(self): - """Init Processing provider for QGIS >= 3.8.""" - # self.provider = Provider() - # QgsApplication.processingRegistry().addProvider(self.provider) - - def initGui(self): # noqa N802 - # self.initProcessing() - QgsApplication.processingRegistry().addProvider(self.provider) - - self.especes_action = QAction( - # QIcon(), - "Carte par espèces", - self.iface.mainWindow(), - ) - self.especes_action.triggered.connect(self.runEspeces) - try: - # Try to put the button in the LPO menu bar - lpo_menu = [ - a - for a in self.iface.pluginMenu().parent().findChildren(QMenu) - if a.title() == "Plugin LPO" - ][0] - lpo_menu.addAction(self.especes_action) - except IndexError: - # If not successful put the button in the Plugins toolbar - self.iface.addToolBarIcon(self.especes_action) - self.iface.messageBar().pushWarning( - "Attention", - "La carte par espèces est accessible via la barre d'outils d'Extensions", - ) - - def runEspeces(self): # noqa N802 - connection_name = get_connection_name() - if connection_name is not None: - CarteParEspece(connection_name).exec() - - def unload(self): - QgsApplication.processingRegistry().removeProvider(self.provider) - - try: - lpo_menu = [ - a - for a in self.iface.pluginMenu().parent().findChildren(QMenu) - if a.title() == "Plugin LPO" - ][0] - lpo_menu.removeAction(self.especes_action) - except IndexError: - pass - # teardown_logger(Plugin.name) - self.iface.removeToolBarIcon(self.especes_action) diff --git a/plugin_qgis_lpo/plugin_main.py b/plugin_qgis_lpo/plugin_main.py new file mode 100644 index 0000000..5c3841c --- /dev/null +++ b/plugin_qgis_lpo/plugin_main.py @@ -0,0 +1,209 @@ +#! python3 # noqa: E265 + +""" + Main plugin module. +""" + +# standard +from functools import partial +from pathlib import Path + +# PyQGIS +from qgis.core import QgsApplication, QgsSettings +from qgis.gui import QgisInterface +from qgis.PyQt.QtCore import QCoreApplication, QLocale, QTranslator, QUrl +from qgis.PyQt.QtGui import QDesktopServices, QIcon +from qgis.PyQt.QtWidgets import QAction, QMenu + +# project +from plugin_qgis_lpo.__about__ import ( + DIR_PLUGIN_ROOT, + __icon_path__, + __title__, + __uri_homepage__, +) +from plugin_qgis_lpo.gui.dlg_settings import PlgOptionsFactory +from plugin_qgis_lpo.processing.provider import QgisLpoProvider +from plugin_qgis_lpo.processing.qgis_processing_postgis import get_connection_name +from plugin_qgis_lpo.processing.species_map import CarteParEspece +from plugin_qgis_lpo.toolbelt import PlgLogger + +# ############################################################################ +# ########## Classes ############### +# ################################## + + +class QgisLpoPlugin: + def __init__(self, iface: QgisInterface): + """Constructor. + + :param iface: An interface instance that will be passed to this class which \ + provides the hook by which you can manipulate the QGIS application at run time. + :type iface: QgsInterface + """ + self.options_factory: PlgOptionsFactory + self.action_help: QAction + self.action_settings: QAction + self.action_help_plugin_menu_documentation: QAction + self.especes_action: QAction + + self.provider = QgisLpoProvider() + self.iface = iface + self.log = PlgLogger().log + + # translation + # initialize the locale + self.locale: str = QgsSettings().value("locale/userLocale", QLocale().name())[ + 0:2 + ] + locale_path: Path = ( + DIR_PLUGIN_ROOT + / "resources" + / "i18n" + / f"{__title__.lower()}_{self.locale}.qm" + ) + self.log(message=f"Translation: {self.locale}, {locale_path}", log_level=4) + if locale_path.exists(): + self.translator = QTranslator() + self.translator.load(str(locale_path.resolve())) + QCoreApplication.installTranslator(self.translator) + + def initGui(self): + """Set up plugin UI elements.""" + + # settings page within the QGIS preferences menu + self.options_factory = PlgOptionsFactory() + self.iface.registerOptionsWidgetFactory(self.options_factory) + + # -- Actions + self.action_help = QAction( + QgsApplication.getThemeIcon("mActionHelpContents.svg"), + self.tr("Help"), + self.iface.mainWindow(), + ) + self.action_help.triggered.connect( + partial(QDesktopServices.openUrl, QUrl(__uri_homepage__)) + ) + + self.action_settings = QAction( + QgsApplication.getThemeIcon("console/iconSettingsConsole.svg"), + self.tr("Settings"), + self.iface.mainWindow(), + ) + self.action_settings.triggered.connect( + lambda: self.iface.showOptionsDialog( + currentPage="mOptionsPage{}".format(__title__) + ) + ) + + # -- Menu + self.iface.addPluginToMenu(__title__, self.action_settings) + self.iface.addPluginToMenu(__title__, self.action_help) + + # -- Help menu + + # documentation + self.iface.pluginHelpMenu().addSeparator() + self.action_help_plugin_menu_documentation = QAction( + QIcon(str(__icon_path__)), + f"{__title__} - Documentation", + self.iface.mainWindow(), + ) + self.action_help_plugin_menu_documentation.triggered.connect( + partial(QDesktopServices.openUrl, QUrl(__uri_homepage__)) + ) + + self.iface.pluginHelpMenu().addAction( + self.action_help_plugin_menu_documentation + ) + + QgsApplication.processingRegistry().addProvider(self.provider) + + self.especes_action = QAction( + # QIcon(), + "Carte par espèces", + self.iface.mainWindow(), + ) + self.especes_action.triggered.connect(self.runEspeces) + try: + # Try to put the button in the LPO menu bar + lpo_menu = [ + a + for a in self.iface.pluginMenu().parent().findChildren(QMenu) + if a.title() == "Plugin LPO" + ][0] + lpo_menu.addAction(self.especes_action) + except IndexError: + # If not successful put the button in the Plugins toolbar + self.iface.addToolBarIcon(self.especes_action) + self.iface.messageBar().pushWarning( + "Attention", + "La carte par espèces est accessible via la barre d'outils d'Extensions", + ) + + def runEspeces(self): # noqa N802 + connection_name = get_connection_name() + if connection_name is not None: + CarteParEspece(connection_name).exec() + + def tr(self, message: str) -> str: + """Get the translation for a string using Qt translation API. + + :param message: string to be translated. + :type message: str + + :returns: Translated version of message. + :rtype: str + """ + return QCoreApplication.translate(self.__class__.__name__, message) + + def unload(self): + """Cleans up when plugin is disabled/uninstalled.""" + # -- Clean up menu + self.iface.removePluginMenu(__title__, self.action_help) + self.iface.removePluginMenu(__title__, self.action_settings) + + # -- Clean up preferences panel in QGIS settings + self.iface.unregisterOptionsWidgetFactory(self.options_factory) + + # remove from QGIS help/extensions menu + if self.action_help_plugin_menu_documentation: + self.iface.pluginHelpMenu().removeAction( + self.action_help_plugin_menu_documentation + ) + + # remove actions + del self.action_settings + del self.action_help + + QgsApplication.processingRegistry().removeProvider(self.provider) + + try: + lpo_menu = [ + a + for a in self.iface.pluginMenu().parent().findChildren(QMenu) + if a.title() == "Plugin LPO" + ][0] + lpo_menu.removeAction(self.especes_action) + except IndexError: + pass + # teardown_logger(Plugin.name) + self.iface.removeToolBarIcon(self.especes_action) + + def run(self): + """Main process. + + :raises Exception: if there is no item in the feed + """ + try: + self.log( + message=self.tr("Everything ran OK."), + log_level=3, + push=False, + ) + except Exception as err: + self.log( + message=self.tr("Houston, we've got a problem: {}".format(err)), + log_level=2, + push=True, + ) diff --git a/plugin_qgis_lpo/processing/__init__.py b/plugin_qgis_lpo/processing/__init__.py index e69de29..626e6bf 100644 --- a/plugin_qgis_lpo/processing/__init__.py +++ b/plugin_qgis_lpo/processing/__init__.py @@ -0,0 +1,2 @@ +#! python3 # noqa: E265 +from .provider import QgisLpoProvider # noqa: F401 diff --git a/plugin_qgis_lpo/processing/extract_data.py b/plugin_qgis_lpo/processing/extract_data.py index 1c21d9d..e8fcd25 100644 --- a/plugin_qgis_lpo/processing/extract_data.py +++ b/plugin_qgis_lpo/processing/extract_data.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ /*************************************************************************** ScriptsLPO : summary_map.py @@ -16,7 +14,6 @@ * * ***************************************************************************/ """ -from typing import Dict from .processing_algorithm import BaseProcessingAlgorithm @@ -31,9 +28,18 @@ def __init__(self) -> None: self._output_name = self._display_name self._group_id = "raw_data" self._group = "Données brutes" - self._short_description = """Besoin d'aide ? Vous pouvez vous référer au Wiki accessible sur ce lien : https://github.com/lpoaura/PluginQGis-LPOData/wiki.

- Cet algorithme vous permet d'extraire des données d'observation contenues dans la base de données LPO (couche PostGIS de type points) à partir d'une zone d'étude présente dans votre projet QGIS (couche de type polygones).

- IMPORTANT : Prenez le temps de lire attentivement les instructions pour chaque étape, et particulièrement les informations en rouge !""" + self._short_description = """Besoin d'aide ? + Vous pouvez vous référer au Wikiaccessible sur ce lien : https://github.com/lpoaura/PluginQGis-LPOData/wiki. +

+Cet algorithme vous permet d'extraire des données d'observation contenues dans la +base de données LPO (couche PostGIS de type points) à partir d'une zone d'étude +présente dans votre projet QGIS (couche de type polygones).

+IMPORTANT : Prenez le temps de lire +attentivement les instructions pour chaque étape, et particulièrement les +informations en rouge +!""" self._icon = "extract_data.png" self._short_help_string = "" self._is_map_layer = True diff --git a/plugin_qgis_lpo/processing/extract_data_observers.py b/plugin_qgis_lpo/processing/extract_data_observers.py index 13a6883..a7f75f9 100644 --- a/plugin_qgis_lpo/processing/extract_data_observers.py +++ b/plugin_qgis_lpo/processing/extract_data_observers.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ /*************************************************************************** ScriptsLPO : summary_map.py @@ -16,7 +14,6 @@ * * ***************************************************************************/ """ -from typing import Dict from .processing_algorithm import BaseProcessingAlgorithm diff --git a/plugin_qgis_lpo/processing/processing_algorithm.py b/plugin_qgis_lpo/processing/processing_algorithm.py index 6780fd1..b234801 100644 --- a/plugin_qgis_lpo/processing/processing_algorithm.py +++ b/plugin_qgis_lpo/processing/processing_algorithm.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- - """Generic Qgis Processing Algorithm classes""" + import ast import json import os @@ -31,21 +30,23 @@ from qgis.PyQt.QtGui import QIcon from qgis.utils import iface +from ..__about__ import __icon_dir_path__ from ..commons.helpers import ( check_layer_is_valid, - construct_queries_list, - construct_sql_array_polygons, - construct_sql_datetime_filter, - construct_sql_geom_type_filter, - construct_sql_select_data_per_time_interval, - construct_sql_source_filter, - construct_sql_taxons_filter, execute_sql_queries, format_layer_export, load_layer, simplify_name, + sql_array_polygons_builder, + sql_datetime_filter_builder, + sql_geom_type_filter_builder, + sql_queries_list_builder, + sql_source_filter_builder, + sql_taxons_filter_builder, + sql_timeinterval_cols_builder, ) from ..commons.widgets import DateTimeWidget +from ..toolbelt.log_handler import PlgLogger from .qgis_processing_postgis import uri_from_name plugin_path = os.path.dirname(__file__) @@ -91,8 +92,10 @@ class BaseProcessingAlgorithm(QgsProcessingAlgorithm): TYPE_GEOM = "TYPE_GEOM" def __init__(self) -> None: + """Init class and set default values""" super().__init__() + self.log = PlgLogger().log # Global settings self._name = "myprocessingalgorithm" self._display_name = "My Processing Algorithm" @@ -170,7 +173,7 @@ def __init__(self) -> None: self._output_name = "output" self._study_area = None self._format_name: str = "output" - self._areas_type: List[str] = [] + self._areas_type: str self._ts = datetime.now() self._array_polygons = None self._taxons_filters: Dict[str, List[str]] = {} @@ -187,9 +190,12 @@ def __init__(self) -> None: self._group_by_species: str = "" self._taxa_fields: Optional[str] = None self._custom_fields: Optional[str] = None - self._x_var: Optional[str] = None + self._x_var: Optional[List[str]] = None self._lr_columns_db: List[str] = ["lr_r"] self._lr_columns_with_alias: List[str] = ['lr_r as "LR Régionale"'] + self._time_interval: str + self._start_year: int + self._end_year: int def tr(self, string: str) -> str: """QgsProcessingAlgorithm translatable string with the self.tr() function.""" @@ -234,7 +240,7 @@ def group(self) -> str: def icon(self) -> QIcon: """Icon script""" - return QIcon(os.path.join(plugin_path, os.pardir, "icons", self._icon)) + return QIcon(str(__icon_dir_path__ / self._icon)) def shortHelpString(self) -> str: # noqa N802 """ @@ -252,7 +258,6 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 Here we define the inputs and output of the algorithm, along with some other properties. """ - super().initAlgorithm(_config) required_text = '(requis)' optional_text = "(facultatif)" @@ -262,7 +267,8 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 QgsProcessingParameterProviderConnection( self.DATABASE, self.tr( - f"""BASE DE DONNÉES {required_text} : sélectionnez votre connexion à la base de données LPO""" + f"""BASE DE DONNÉES {required_text} : + sélectionnez votre connexion à la base de données LPO""" ), "postgres", defaultValue="geonature_lpo", @@ -274,7 +280,9 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 QgsProcessingParameterFeatureSource( self.STUDY_AREA, self.tr( - f"""ZONE D'ÉTUDE {required_text} : sélectionnez votre zone d'étude, à partir de laquelle seront extraits les résultats""" + f"""ZONE D'ÉTUDE {required_text} : + sélectionnez votre zone d'étude, + à partir de laquelle seront extraits les résultats""" ), [QgsProcessing.TypeVectorPolygon], ) @@ -374,7 +382,9 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 period_type = QgsProcessingParameterEnum( self.PERIOD, self.tr( - f"""PÉRIODE {required_text} : sélectionnez une période pour filtrer vos données d'observations""" + f"""PÉRIODE {required_text} : + sélectionnez une période pour filtrer vos données + d'observations""" ), self._period_variables, allowMultiple=False, @@ -406,12 +416,29 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 end_date.setMetadata({"widget_wrapper": {"class": DateTimeWidget}}) self.addParameter(end_date) + if self._return_geo_agg: + areas_types = QgsProcessingParameterEnum( + self.AREAS_TYPE, + self.tr( + f"""TYPE D'ENTITÉS GÉOGRAPHIQUES {required_text} : + Sélectionnez le type d'entités géographiques + qui vous intéresse""" + ), + self._areas_variables, + allowMultiple=False, + ) + areas_types.setMetadata( + {"widget_wrapper": {"useCheckBoxes": True, "columns": 5}} + ) + self.addParameter(areas_types) + # ## Taxons filters ## self.addParameter( QgsProcessingParameterEnum( self.GROUPE_TAXO, self.tr( - f"""TAXONS {optional_text} : filtrer les données par groupes taxonomiques""" + f"""TAXONS {optional_text} : + filtrer les données par groupes taxonomiques""" ), self._db_variables.value("groupe_taxo"), allowMultiple=True, @@ -439,7 +466,8 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 histogram_options = QgsProcessingParameterEnum( self.HISTOGRAM_OPTIONS, self.tr( - f"""HISTOGRAMME {optional_text} : générer un histogramme à partir des résultats.""" + f"""HISTOGRAMME {optional_text} : + générer un histogramme à partir des résultats.""" ), self._histogram_variables, defaultValue="Pas d'histogramme", @@ -453,7 +481,8 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 output_histogram = QgsProcessingParameterFileDestination( self.OUTPUT_HISTOGRAM, self.tr( - """Emplacement de l'enregistrement du ficher (format image PNG) de l'histogramme""" + """Emplacement de l'enregistrement du ficher + (format image PNG) de l'histogramme""" ), self.tr("image PNG (*.png)"), # optional=True, @@ -468,7 +497,9 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 QgsProcessingParameterString( self.OUTPUT_NAME, self.tr( - f"""PARAMÉTRAGE DES RESULTATS EN SORTIE {optional_text} : personnalisez le nom de votre couche en base de données""" + f"""PARAMÉTRAGE DES RESULTATS EN SORTIE + {optional_text} : personnalisez le nom de votre couche + en base de données""" ), self.tr(self._output_name), ) @@ -547,21 +578,6 @@ def initAlgorithm(self, _config: None) -> None: # noqa N802 ) self.addParameter(extra_where) - if self._return_geo_agg: - areas_types = QgsProcessingParameterEnum( - self.AREAS_TYPE, - self.tr( - """TYPE D'ENTITÉS GÉOGRAPHIQUES
- *3/ Sélectionnez le type d'entités géographiques qui vous intéresse""" - ), - self._areas_variables, - allowMultiple=False, - ) - areas_types.setMetadata( - {"widget_wrapper": {"useCheckBoxes": True, "columns": 3}} - ) - self.addParameter(areas_types) - def processAlgorithm( # noqa N802 self, parameters: Dict[str, Any], @@ -580,24 +596,25 @@ def processAlgorithm( # noqa N802 self._connection = self.parameterAsString(parameters, self.DATABASE, context) self._add_table = self.parameterAsBool(parameters, self.ADD_TABLE, context) self._study_area = self.parameterAsSource(parameters, self.STUDY_AREA, context) - feedback.pushDebugInfo( - str( + self.log( + message=str( [ self._db_variables.value("source_data")[i] for i in ( self.parameterAsEnums(parameters, self.SOURCE_DATA, context) ) ] - ) + ), + log_level=4, ) - self._source_data_where = construct_sql_source_filter( + self._source_data_where = sql_source_filter_builder( [ self._db_variables.value("source_data")[i] for i in (self.parameterAsEnums(parameters, self.SOURCE_DATA, context)) ] ) - self._type_geom_where = construct_sql_geom_type_filter( + self._type_geom_where = sql_geom_type_filter_builder( [ self._type_geom_variables[i] for i in (self.parameterAsEnums(parameters, self.TYPE_GEOM, context)) @@ -665,18 +682,18 @@ def processAlgorithm( # noqa N802 # WHERE clauses builder # TODO: Manage use case # self._filters.append( - # f"ST_Intersects(la.geom, ST_union({construct_sql_array_polygons(self._study_area)})" + # f"ST_Intersects(la.geom, ST_union({sql_array_polygons_builder(self._study_area)})" # ) if self._study_area: - self._array_polygons = construct_sql_array_polygons(self._study_area) + self._array_polygons = sql_array_polygons_builder(self._study_area) if not self._is_data_extraction: self._filters += ["is_present", "is_valid"] - taxon_filters = construct_sql_taxons_filter(self._taxons_filters) + taxon_filters = sql_taxons_filter_builder(self._taxons_filters) if taxon_filters: self._filters.append(taxon_filters) # Complete the "where" filter with the datetime filter - time_filter = construct_sql_datetime_filter( + time_filter = sql_datetime_filter_builder( self, self._period_type, self._ts, parameters, context ) if time_filter: @@ -712,22 +729,22 @@ def processAlgorithm( # noqa N802 # self._taxonomic_rank_db = self._taxonomic_ranks_db[taxonomic_rank_index] if self._has_time_interval_form: - time_interval = self._time_interval_variables[ + self._time_interval = self._time_interval_variables[ self.parameterAsEnum(parameters, self.TIME_INTERVAL, context) ] - feedback.pushDebugInfo(f"time_interval {time_interval}") - start_year = self.parameterAsInt(parameters, self.START_YEAR, context) - feedback.pushDebugInfo(f"start_year {start_year}") - end_year = self.parameterAsInt(parameters, self.END_YEAR, context) - feedback.pushDebugInfo(f"end_year {end_year}") - if end_year < start_year: + self.log(message=f"time_interval {self._time_interval}", log_level=4) + self._start_year = self.parameterAsInt(parameters, self.START_YEAR, context) + self.log(message=f"start_year {self._start_year}", log_level=4) + self._end_year = self.parameterAsInt(parameters, self.END_YEAR, context) + self.log(message=f"end_year {self._end_year}", log_level=4) + if self._end_year < self._start_year: raise QgsProcessingException( "Veuillez renseigner une année de fin postérieure à l'année de début !" ) taxonomic_rank = self._taxonomic_ranks_labels[ self.parameterAsEnum(parameters, self.TAXONOMIC_RANK, context) ] - feedback.pushDebugInfo(f"taxonomic_rank {taxonomic_rank}") + self.log(message=f"taxonomic_rank {taxonomic_rank}", log_level=4) aggregation_type = "Nombre de données" self._group_by_species = ( "obs.cd_nom, obs.cd_ref, nom_rang, nom_sci, obs.nom_vern, " @@ -737,19 +754,26 @@ def processAlgorithm( # noqa N802 ( self._custom_fields, self._x_var, - ) = construct_sql_select_data_per_time_interval( + ) = sql_timeinterval_cols_builder( self, - time_interval, - start_year, - end_year, + self._time_interval, + self._start_year, + self._end_year, aggregation_type, parameters, context, feedback, ) # Select species info (optional) - select_species_info = """/*source_id_sp, */obs.cd_nom, obs.cd_ref, nom_rang as "Rang", groupe_taxo AS "Groupe taxo", - obs.nom_vern AS "Nom vernaculaire", nom_sci AS "Nom scientifique\"""" + select_species_info = """ + /*source_id_sp, */ + obs.cd_nom, + obs.cd_ref, + nom_rang as "Rang", + groupe_taxo AS "Groupe taxo", + obs.nom_vern AS "Nom vernaculaire", + nom_sci AS "Nom scientifique\" + """ # Select taxonomic groups info (optional) select_taxo_groups_info = 'groupe_taxo AS "Groupe taxo"' self._taxa_fields = ( @@ -757,7 +781,7 @@ def processAlgorithm( # noqa N802 if taxonomic_rank == "Espèces" else select_taxo_groups_info ) - feedback.pushDebugInfo(self._taxa_fields) + self.log(message=self._taxa_fields, log_level=4) lr_columns = self._db_variables.value("lr_columns") if lr_columns: @@ -789,20 +813,20 @@ def processAlgorithm( # noqa N802 lr_columns_fields="\n, ".join(self._lr_columns_db), lr_columns_with_alias="\n, ".join(self._lr_columns_with_alias), ) - feedback.pushDebugInfo(query) + self.log(message=query, log_level=4) geom_field = "geom" if self._is_map_layer else None if self._add_table: # Define the name of the PostGIS summary table which will be created in the DB table_name = simplify_name(self._format_name) # Define the SQL queries - queries = construct_queries_list(table_name, query) + queries = sql_queries_list_builder(table_name, query) # Execute the SQL queries execute_sql_queries(context, feedback, self._connection, queries) # Format the URI - self._uri.setDataSource(None, table_name, geom_field, "", self._primary_key) + self._uri.setDataSource(None, table_name, geom_field, "", self._primary_key) # type: ignore else: # Format the URI with the query - self._uri.setDataSource("", f"({query})", geom_field, "", self._primary_key) + self._uri.setDataSource("", f"({query})", geom_field, "", self._primary_key) # type: ignore self._layer = QgsVectorLayer(self._uri.uri(), self._format_name, "postgres") check_layer_is_valid(feedback, self._layer) diff --git a/plugin_qgis_lpo/processing/provider.py b/plugin_qgis_lpo/processing/provider.py index 189f81e..a7d06d2 100644 --- a/plugin_qgis_lpo/processing/provider.py +++ b/plugin_qgis_lpo/processing/provider.py @@ -1,102 +1,104 @@ -# -*- coding: utf-8 -*- +#! python3 # noqa: E265 """ -/*************************************************************************** - ScriptsLPO : scripts_lpo_provider.py - ------------------- - - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ + Processing provider module. """ -__author__ = "LPO AuRA" -__date__ = "2020-2024" - -# This will get replaced with a git SHA1 when you do a git archive -__revision__ = "$Format:%H$" - -import os - -from qgis.core import QgsMessageLog, QgsProcessingProvider +# PyQGIS +from qgis.core import QgsProcessingProvider +from qgis.PyQt.QtCore import QCoreApplication from qgis.PyQt.QtGui import QIcon -from .extract_data import ExtractData -from .extract_data_observers import ExtractDataObservers -from .state_of_knowledge import StateOfKnowledge -from .summary_map import SummaryMap -from .summary_table_per_species import SummaryTablePerSpecies -from .summary_table_per_time_interval import SummaryTablePerTimeInterval - -plugin_path = os.path.dirname(__file__) - - -class Provider(QgsProcessingProvider): - # def __init__(self) -> None: - # """ - # Default constructor. - # """ - # super().__init__(self) +# project +from plugin_qgis_lpo.__about__ import __icon_dir_path__, __title__, __version__ +from plugin_qgis_lpo.processing.extract_data import ExtractData +from plugin_qgis_lpo.processing.extract_data_observers import ExtractDataObservers +from plugin_qgis_lpo.processing.state_of_knowledge import StateOfKnowledge +from plugin_qgis_lpo.processing.summary_map import SummaryMap +from plugin_qgis_lpo.processing.summary_table_per_species import SummaryTablePerSpecies +from plugin_qgis_lpo.processing.summary_table_per_time_interval import ( + SummaryTablePerTimeInterval, +) + +# ############################################################################ +# ########## Classes ############### +# ################################## + + +class QgisLpoProvider(QgsProcessingProvider): + """ + Processing provider class. + """ + + def loadAlgorithms(self): + """Loads all algorithms belonging to this provider.""" + algorithms = [ + ExtractData(), + ExtractDataObservers(), + SummaryTablePerSpecies(), + SummaryTablePerTimeInterval(), + StateOfKnowledge(), + SummaryMap(), + ] + for alg in algorithms: + self.addAlgorithm(alg) def id(self) -> str: + """Unique provider id, used for identifying it. This string should be unique, \ + short, character only string, eg "qgis" or "gdal". \ + This string should not be localised. + + :return: provider ID + :rtype: str """ - Returns the unique provider id, used for identifying the provider. This - string should be a unique, short, character only string, eg "qgis" or - "gdal". This string should not be localised. - """ - return "lpoScripts" + return "plugin_qgis_lpo" def name(self) -> str: - """ - Returns the provider name, which is used to describe the provider - within the GUI. + """Returns the provider name, which is used to describe the provider + within the GUI. This string should be short (e.g. "Lastools") and localised. - This string should be short (e.g. "Lastools") and localised. + :return: provider name + :rtype: str """ - return self.tr("Traitements de la LPO") + return __title__ - def icon(self) -> "QIcon": - """ - Should return a QIcon which is used for your provider inside - the Processing toolbox. + def longName(self) -> str: + """Longer version of the provider name, which can include + extra details such as version numbers. E.g. "Lastools LIDAR tools". + This string should be localised. The default + implementation returns the same string as name(). + + :return: provider long name + :rtype: str """ - return QIcon( - os.path.join(plugin_path, os.pardir, "icons", "logo_lpo_aura_carre.png") - ) + return self.tr("{} - Tools".format(__title__)) - # def unload(self) -> None: - # """ - # Unloads the provider. Any tear-down steps required by the provider - # should be implemented here. - # """ + def icon(self) -> QIcon: + """QIcon used for your provider inside the Processing toolbox menu. - def loadAlgorithms(self): # noqa N802 + :return: provider icon + :rtype: QIcon """ - Loads all algorithms belonging to this provider. - """ - algorithms = [ - ExtractData(), - ExtractDataObservers(), - SummaryTablePerSpecies(), - SummaryTablePerTimeInterval(), - StateOfKnowledge(), - SummaryMap(), - ] - for alg in algorithms: - self.addAlgorithm(alg) + return QIcon(str(__icon_dir_path__ / "logo_lpo_aura_carre.png")) + + def tr(self, message: str) -> str: + """Get the translation for a string using Qt translation API. - def longName(self): # noqa N802 + :param message: String for translation. + :type message: str, QString + + :returns: Translated version of message. + :rtype: str """ - Returns the a longer version of the provider name, which can include - extra details such as version numbers. E.g. "Lastools LIDAR tools - (version 2.2.1)". This string should be localised. The default - implementation returns the same string as name(). + # noinspection PyTypeChecker,PyArgumentList,PyCallByClass + return QCoreApplication.translate(self.__class__.__name__, message) + + def versionInfo(self) -> str: + """Version information for the provider, or an empty string if this is not \ + applicable (e.g. for inbuilt Processing providers). For plugin based providers, \ + this should return the plugin’s version identifier. + + :return: version + :rtype: str """ - return self.tr("Scripts d'exploitation de la base de donnée de la LPO") + return __version__ diff --git a/plugin_qgis_lpo/processing/qgis_processing_postgis.py b/plugin_qgis_lpo/processing/qgis_processing_postgis.py index 91f0c1c..b9920c1 100644 --- a/plugin_qgis_lpo/processing/qgis_processing_postgis.py +++ b/plugin_qgis_lpo/processing/qgis_processing_postgis.py @@ -21,7 +21,7 @@ import os import re -from typing import List +from typing import Optional import psycopg2 import psycopg2.extensions # For isolation levels @@ -109,7 +109,6 @@ def __init__(self, row): class TableConstraint(object): - """Class that represents a constraint of a table (relation).""" (TypeCheck, TypeForeignKey, TypePrimaryKey, TypeUnique) = list(range(4)) @@ -617,21 +616,21 @@ def delete_table(self, table: str, schema: str = None): """Delete table from the database.""" table_name = self._table_name(schema, table) - sql = "DROP TABLE %s" % table_name + sql = f"DROP TABLE {table_name}" self._exec_sql_and_commit(sql) def empty_table(self, table: str, schema: str = None): """Delete all rows from table.""" table_name = self._table_name(schema, table) - sql = "DELETE FROM %s" % table_name + sql = f"DELETE FROM {table_name}" self._exec_sql_and_commit(sql) - def rename_table(self, table: str, new_table: str, schema: str = None): + def rename_table(self, table: str, new_table: str, schema: Optional[str] = None): """Rename a table in database.""" table_name = self._table_name(schema, table) - sql = "ALTER TABLE %s RENAME TO %s" % (table_name, self._quote(new_table)) + sql = f"ALTER TABLE {table_name} RENAME TO {self._quote(new_table)}" self._exec_sql_and_commit(sql) # Update geometry_columns if PostGIS is enabled diff --git a/plugin_qgis_lpo/processing/species_map.py b/plugin_qgis_lpo/processing/species_map.py index f8e0652..145afb8 100644 --- a/plugin_qgis_lpo/processing/species_map.py +++ b/plugin_qgis_lpo/processing/species_map.py @@ -2,13 +2,7 @@ import re from pathlib import Path -from qgis.core import ( - QgsDataSourceUri, - QgsMessageLog, - QgsProject, - QgsProviderRegistry, - QgsVectorLayer, -) +from qgis.core import QgsDataSourceUri, QgsProject, QgsProviderRegistry, QgsVectorLayer from qgis.PyQt.QtCore import QEvent, QSortFilterProxyModel, Qt from qgis.PyQt.QtGui import QStandardItem, QStandardItemModel from qgis.PyQt.QtWidgets import ( @@ -249,7 +243,12 @@ def accept(self) -> None: with OverrideCursor(Qt.WaitCursor): layer = QgsVectorLayer(uri.uri(), layer_name, "postgres") layer.loadNamedStyle( - str(Path(__file__).parent.parent / "styles" / "reproduction.qml") + str( + Path(__file__).parent.parent + / "resources" + / "styles" + / "reproduction.qml" + ) ) QgsProject.instance().addMapLayer(layer) diff --git a/plugin_qgis_lpo/processing/state_of_knowledge.py b/plugin_qgis_lpo/processing/state_of_knowledge.py index 964a75c..b5f00fb 100644 --- a/plugin_qgis_lpo/processing/state_of_knowledge.py +++ b/plugin_qgis_lpo/processing/state_of_knowledge.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ /*************************************************************************** ScriptsLPO : state_of_knowledge.py @@ -15,9 +13,6 @@ * * ***************************************************************************/ """ -from typing import Dict - -from qgis.utils import iface from .processing_algorithm import BaseProcessingAlgorithm diff --git a/plugin_qgis_lpo/processing/summary_map.py b/plugin_qgis_lpo/processing/summary_map.py index 31c10a1..f226dff 100644 --- a/plugin_qgis_lpo/processing/summary_map.py +++ b/plugin_qgis_lpo/processing/summary_map.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ /*************************************************************************** ScriptsLPO : summary_map.py @@ -16,7 +14,6 @@ * * ***************************************************************************/ """ -from typing import Dict from .processing_algorithm import BaseProcessingAlgorithm @@ -33,9 +30,18 @@ def __init__(self) -> None: self._output_name = self._display_name self._group_id = "Map" self._group = "Cartes" - self._short_description = """Besoin d'aide ? Vous pouvez vous référer au Wiki accessible sur ce lien : https://github.com/lpoaura/PluginQGis-LPOData/wiki.

- Cet algorithme vous permet, à partir des données d'observation enregistrées dans la base de données LPO, de générer une carte de synthèse (couche PostGIS de type polygones) par maille ou par commune (au choix) basée sur une zone d'étude présente dans votre projet QGIS (couche de type polygones). Les données d'absence sont exclues de ce traitement.

- Pour chaque entité géographique, la table attributaire de la nouvelle couche fournit les informations suivantes : + self._short_description = """Besoin d'aide ? + Vous pouvez vous référer au Wiki accessible sur ce lien : + + https://github.com/lpoaura/PluginQGis-LPOData/wiki.

+ Cet algorithme vous permet, à partir des données d'observation enregistrées + dans la base de données LPO, de générer une carte de synthèse + (couche PostGIS de type polygones) par maille ou par commune (au choix) + basée sur une zone d'étude présente dans votre projet QGIS (couche + de type polygones). Les données d'absence sont + exclues de ce traitement.

+ Pour chaque entité géographique, la table attributaire de la + nouvelle couche fournit les informations suivantes :
  • Code de l'entité
  • Surface (en km2)
  • Nombre de données
  • @@ -45,27 +51,33 @@ def __init__(self) -> None:
  • Nombre de dates
  • Nombre de données de mortalité
  • Liste des espèces observées

- Vous pouvez ensuite modifier la symbologie de la couche comme bon vous semble, en fonction du critère de votre choix.

- IMPORTANT : prenez le temps de lire attentivement les instructions pour chaque étape, et particulièrement les informations en rouge !""" + Vous pouvez ensuite modifier la symbologie de la couche comme bon + vous semble, en fonction du critère de votre choix.

+ IMPORTANT : prenez le temps de lire + attentivement les instructions pour chaque étape, et particulièrement + les informations en rouge + !""" self._icon = "map.png" self._short_help_string = "" self._is_map_layer = True + self._return_geo_agg = True self._query = """/*set random_page_cost to 4;*/ WITH prep AS (SELECT la.id_area, ((st_area(la.geom))::DECIMAL / 1000000) area_surface FROM ref_geo.l_areas la WHERE la.id_type = ref_geo.get_id_area_type('{areas_type}') AND ST_intersects(la.geom, ST_union({array_polygons})) ), - data AS (SELECT row_number() OVER () AS id, - la.id_area, - round(area_surface, 2) AS "Surface (km2)", - count(*) AS "Nb de données", - ROUND(COUNT(*) / ROUND(area_surface, 2), 2) AS "Densité (Nb de données/km2)", - COUNT(DISTINCT cd_ref) FILTER (WHERE id_rang='ES') AS "Nb d'espèces", - COUNT(DISTINCT observateur) AS "Nb d'observateurs", - COUNT(DISTINCT DATE) AS "Nb de dates", - COUNT(DISTINCT obs.id_synthese) FILTER (WHERE mortalite) AS "Nb de données de mortalité", - string_agg(DISTINCT obs.nom_vern,', ') FILTER (WHERE id_rang='ES') AS "Liste des espèces observées" + data AS (SELECT + row_number() OVER () AS id, + la.id_area, + round(area_surface, 2) AS "Surface (km2)", + count(*) AS "Nb de données", + ROUND(COUNT(*) / ROUND(area_surface, 2), 2) AS "Densité (Nb de données/km2)", + COUNT(DISTINCT cd_ref) FILTER (WHERE id_rang='ES') AS "Nb d'espèces", + COUNT(DISTINCT observateur) AS "Nb d'observateurs", + COUNT(DISTINCT DATE) AS "Nb de dates", + COUNT(DISTINCT obs.id_synthese) FILTER (WHERE mortalite) AS "Nb de données de mortalité", + string_agg(DISTINCT obs.nom_vern,', ') FILTER (WHERE id_rang='ES') AS "Liste des espèces observées" FROM prep la LEFT JOIN gn_synthese.cor_area_synthese cor ON la.id_area=cor.id_area diff --git a/plugin_qgis_lpo/processing/summary_table_per_species.py b/plugin_qgis_lpo/processing/summary_table_per_species.py index a708018..05b1285 100644 --- a/plugin_qgis_lpo/processing/summary_table_per_species.py +++ b/plugin_qgis_lpo/processing/summary_table_per_species.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ /*************************************************************************** ScriptsLPO : summary_table_per_species.py @@ -17,11 +15,6 @@ ***************************************************************************/ """ - -from typing import Dict - -from qgis.utils import iface - from .processing_algorithm import BaseProcessingAlgorithm @@ -82,99 +75,100 @@ def __init__(self) -> None: self._icon = "table.png" # self._short_description = "" self._is_map_layer = False - self._query = """WITH obs AS ( - /* selection des cd_nom */ - SELECT observations.* - FROM src_lpodatas.v_c_observations_light observations - WHERE ST_intersects(observations.geom, ST_union({array_polygons})) - and {where_filters}), - communes AS ( - /* selection des communes */ - SELECT DISTINCT obs.id_synthese, la.area_name - FROM obs - LEFT JOIN gn_synthese.cor_area_synthese cor ON obs.id_synthese = cor.id_synthese - JOIN ref_geo.l_areas la ON cor.id_area = la.id_area - WHERE la.id_type = ref_geo.get_id_area_type('COM')), - atlas_code as ( - /* préparation codes atlas */ - SELECT cd_nomenclature, label_fr, hierarchy - FROM ref_nomenclatures.t_nomenclatures - WHERE id_type=( - select ref_nomenclatures.get_id_nomenclature_type('VN_ATLAS_CODE') - ) - ), - total_count AS ( - /* comptage nb total individus */ - SELECT COUNT(*) AS total_count - FROM obs), - data AS ( - /* selection des données + statut */ - SELECT - obs.cd_ref - , obs.vn_id - , r.nom_rang - , groupe_taxo - , string_agg(distinct obs.nom_vern, ', ') nom_vern - , string_agg(distinct obs.nom_sci, ', ') nom_sci - , COUNT(DISTINCT obs.id_synthese) AS nb_donnees - , COUNT(DISTINCT obs.observateur) AS nb_observateurs - , COUNT(DISTINCT obs.date) AS nb_dates - , SUM(CASE WHEN mortalite THEN 1 ELSE 0 END) AS nb_mortalite - , st.lr_france - , {lr_columns_fields} - , st.n2k - , st.prot_nat as protection_nat - , st.conv_berne - , st.conv_bonn - , max(ac.hierarchy) AS max_hierarchy_atlas_code - , max(obs.nombre_total) AS nb_individus_max - , min(obs.date_an) AS premiere_observation - , max(obs.date_an) AS derniere_observation - , string_agg(DISTINCT com.area_name, ', ') AS communes - , string_agg(DISTINCT obs.source, ', ') AS sources - FROM obs - LEFT JOIN atlas_code ac ON obs.oiso_code_nidif = ac.cd_nomenclature::int - LEFT JOIN taxonomie.bib_taxref_rangs r ON obs.id_rang = r.id_rang - LEFT JOIN communes com ON obs.id_synthese = com.id_synthese - left join taxonomie.mv_c_statut st on st.cd_ref=obs.cd_ref - GROUP BY - groupe_taxo - , obs.cd_ref - , obs.vn_id - , r.nom_rang - , st.lr_france - , {lr_columns_fields} - , st.n2k - , st.prot_nat - , st.conv_berne - , st.conv_bonn), - synthese AS ( - SELECT DISTINCT - d.cd_ref - , vn_id - , nom_rang AS "Rang" - , d.groupe_taxo AS "Groupe taxo" - , nom_vern AS "Nom vernaculaire" - , nom_sci AS "Nom scientifique" - , nb_donnees AS "Nb de données" - , ROUND(nb_donnees::DECIMAL / total_count, 4) * 100 AS "Nb données / nb données total (%)" - , nb_observateurs AS "Nb d'observateurs" - , nb_dates AS "Nb de dates" - , nb_mortalite AS "Nb de données de mortalité" - , lr_france AS "LR France" - , {lr_columns_with_alias} - , n2k AS "Natura 2000" - , protection_nat AS "Protection nationale" - , conv_berne AS "Convention de Berne" - , conv_bonn AS "Convention de Bonn" - , ac.label_fr AS "Statut nidif" - , nb_individus_max AS "Nb d'individus max" - , premiere_observation AS "Année première obs" - , derniere_observation AS "Année dernière obs" - , communes AS "Liste de communes" - , sources AS "Sources" - FROM total_count, data d - LEFT JOIN atlas_code ac ON d.max_hierarchy_atlas_code = ac.hierarchy - ORDER BY groupe_taxo,vn_id, nom_vern) - SELECT row_number() OVER () AS id, * - FROM synthese""" + self._query = """ + WITH obs AS ( + /* selection des cd_nom */ + SELECT observations.* + FROM src_lpodatas.v_c_observations_light observations + WHERE ST_intersects(observations.geom, ST_union({array_polygons})) + and {where_filters}), + communes AS ( + /* selection des communes */ + SELECT DISTINCT obs.id_synthese, la.area_name + FROM obs + LEFT JOIN gn_synthese.cor_area_synthese cor ON obs.id_synthese = cor.id_synthese + JOIN ref_geo.l_areas la ON cor.id_area = la.id_area + WHERE la.id_type = ref_geo.get_id_area_type('COM')), + atlas_code as ( + /* préparation codes atlas */ + SELECT cd_nomenclature, label_fr, hierarchy + FROM ref_nomenclatures.t_nomenclatures + WHERE id_type=( + select ref_nomenclatures.get_id_nomenclature_type('VN_ATLAS_CODE') + ) + ), + total_count AS ( + /* comptage nb total individus */ + SELECT COUNT(*) AS total_count + FROM obs), + data AS ( + /* selection des données + statut */ + SELECT + obs.cd_ref + , obs.vn_id + , r.nom_rang + , groupe_taxo + , string_agg(distinct obs.nom_vern, ', ') nom_vern + , string_agg(distinct obs.nom_sci, ', ') nom_sci + , COUNT(DISTINCT obs.id_synthese) AS nb_donnees + , COUNT(DISTINCT obs.observateur) AS nb_observateurs + , COUNT(DISTINCT obs.date) AS nb_dates + , SUM(CASE WHEN mortalite THEN 1 ELSE 0 END) AS nb_mortalite + , st.lr_france + , {lr_columns_fields} + , st.n2k + , st.prot_nat as protection_nat + , st.conv_berne + , st.conv_bonn + , max(ac.hierarchy) AS max_hierarchy_atlas_code + , max(obs.nombre_total) AS nb_individus_max + , min(obs.date_an) AS premiere_observation + , max(obs.date_an) AS derniere_observation + , string_agg(DISTINCT com.area_name, ', ') AS communes + , string_agg(DISTINCT obs.source, ', ') AS sources + FROM obs + LEFT JOIN atlas_code ac ON obs.oiso_code_nidif = ac.cd_nomenclature::int + LEFT JOIN taxonomie.bib_taxref_rangs r ON obs.id_rang = r.id_rang + LEFT JOIN communes com ON obs.id_synthese = com.id_synthese + left join taxonomie.mv_c_statut st on st.cd_ref=obs.cd_ref + GROUP BY + groupe_taxo + , obs.cd_ref + , obs.vn_id + , r.nom_rang + , st.lr_france + , {lr_columns_fields} + , st.n2k + , st.prot_nat + , st.conv_berne + , st.conv_bonn), + synthese AS ( + SELECT DISTINCT + d.cd_ref + , array_to_string(vn_id,', ') as vn_id + , nom_rang AS "Rang" + , d.groupe_taxo AS "Groupe taxo" + , nom_vern AS "Nom vernaculaire" + , nom_sci AS "Nom scientifique" + , nb_donnees AS "Nb de données" + , ROUND(nb_donnees::DECIMAL / total_count, 4) * 100 AS "Nb données / nb données total (%)" + , nb_observateurs AS "Nb d'observateurs" + , nb_dates AS "Nb de dates" + , nb_mortalite AS "Nb de données de mortalité" + , lr_france AS "LR France" + , {lr_columns_with_alias} + , n2k AS "Natura 2000" + , protection_nat AS "Protection nationale" + , conv_berne AS "Convention de Berne" + , conv_bonn AS "Convention de Bonn" + , ac.label_fr AS "Statut nidif" + , nb_individus_max AS "Nb d'individus max" + , premiere_observation AS "Année première obs" + , derniere_observation AS "Année dernière obs" + , communes AS "Liste de communes" + , sources AS "Sources" + FROM total_count, data d + LEFT JOIN atlas_code ac ON d.max_hierarchy_atlas_code = ac.hierarchy + ORDER BY groupe_taxo,vn_id, nom_vern) + SELECT row_number() OVER () AS id, * + FROM synthese""" diff --git a/plugin_qgis_lpo/processing/summary_table_per_species_lpoaura.py b/plugin_qgis_lpo/processing/summary_table_per_species_lpoaura.py.old similarity index 95% rename from plugin_qgis_lpo/processing/summary_table_per_species_lpoaura.py rename to plugin_qgis_lpo/processing/summary_table_per_species_lpoaura.py.old index e201653..ef9b436 100644 --- a/plugin_qgis_lpo/processing/summary_table_per_species_lpoaura.py +++ b/plugin_qgis_lpo/processing/summary_table_per_species_lpoaura.py.old @@ -37,10 +37,10 @@ from ..commons.helpers import ( check_layer_is_valid, - construct_queries_list, - construct_sql_array_polygons, - construct_sql_datetime_filter, - construct_sql_taxons_filter, + sql_queries_list_builder, + sql_array_polygons_builder, + sql_datetime_filter_builder, + sql_taxons_filter_builder, execute_sql_queries, load_layer, simplify_name, @@ -215,7 +215,7 @@ def processAlgorithm(self, parameters, context, feedback): # noqa N802 ### CONSTRUCT "WHERE" CLAUSE (SQL) ### # Construct the sql array containing the study area's features geometry - array_polygons = construct_sql_array_polygons(study_area) + array_polygons = sql_array_polygons_builder(study_area) # Define the "where" clause of the SQL query, aiming to retrieve the output PostGIS layer = summary table where = f"""is_valid and is_present and ST_intersects(obs.geom, ST_union({array_polygons}))""" @@ -230,10 +230,10 @@ def processAlgorithm(self, parameters, context, feedback): # noqa N802 "obs.group1_inpn": group1_inpn, "obs.group2_inpn": group2_inpn, } - taxons_where = construct_sql_taxons_filter(taxons_filters) + taxons_where = sql_taxons_filter_builder(taxons_filters) where += taxons_where # Complete the "where" clause with the datetime filter - datetime_where = construct_sql_datetime_filter( + datetime_where = sql_datetime_filter_builder( self, period_type, ts, parameters, context ) where += datetime_where @@ -350,7 +350,7 @@ def processAlgorithm(self, parameters, context, feedback): # noqa N802 # Define the name of the PostGIS summary table which will be created in the DB table_name = simplify_name(format_name) # Define the SQL queries - queries = construct_queries_list(table_name, query) + queries = sql_queries_list_builder(table_name, query) # Execute the SQL queries execute_sql_queries(context, feedback, connection, queries) # Format the URI diff --git a/plugin_qgis_lpo/processing/summary_table_per_time_interval.py b/plugin_qgis_lpo/processing/summary_table_per_time_interval.py index be7a378..ce989e6 100644 --- a/plugin_qgis_lpo/processing/summary_table_per_time_interval.py +++ b/plugin_qgis_lpo/processing/summary_table_per_time_interval.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ /*************************************************************************** ScriptsLPO : summary_table_per_species.py @@ -17,12 +15,7 @@ ***************************************************************************/ """ - -from typing import Dict - -from qgis.utils import iface - -from .processing_algorithm import BaseProcessingAlgorithm +from plugin_qgis_lpo.processing.processing_algorithm import BaseProcessingAlgorithm class SummaryTablePerTimeInterval(BaseProcessingAlgorithm): @@ -66,7 +59,10 @@ def __init__(self) -> None: "Pas d'histogramme", "Total par pas de temps", ] - self._query = """SELECT row_number() OVER () AS id, {taxa_fields}{custom_fields} + self._query = """SELECT + row_number() OVER () AS id, + {taxa_fields}, + {custom_fields} FROM src_lpodatas.v_c_observations_light obs LEFT JOIN taxonomie.bib_taxref_rangs r ON obs.id_rang = r.id_rang WHERE diff --git a/plugin_qgis_lpo/processing/summary_table_per_time_interval_old.py b/plugin_qgis_lpo/processing/summary_table_per_time_interval_old.py deleted file mode 100644 index e86a02d..0000000 --- a/plugin_qgis_lpo/processing/summary_table_per_time_interval_old.py +++ /dev/null @@ -1,717 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -/*************************************************************************** - ScriptsLPO : summary_table_per_time_interval.py - ------------------- - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ -""" - - -import os -from datetime import datetime -from typing import Dict - -import matplotlib.pyplot as plt -from qgis.core import ( - QgsAction, - QgsProcessing, - QgsProcessingAlgorithm, - QgsProcessingException, - QgsProcessingOutputVectorLayer, - QgsProcessingParameterBoolean, - QgsProcessingParameterDefinition, - QgsProcessingParameterEnum, - QgsProcessingParameterFeatureSource, - QgsProcessingParameterFileDestination, - QgsProcessingParameterNumber, - QgsProcessingParameterProviderConnection, - QgsProcessingParameterString, - QgsSettings, - QgsVectorLayer, -) -from qgis.PyQt.QtCore import QCoreApplication -from qgis.PyQt.QtGui import QIcon -from qgis.utils import iface - -from ..commons.helpers import ( - check_layer_is_valid, - construct_queries_list, - construct_sql_array_polygons, - construct_sql_select_data_per_time_interval, - construct_sql_taxons_filter, - execute_sql_queries, - load_layer, - simplify_name, -) -from .processing_algorithm import BaseProcessingAlgorithm - -# from processing.tools import postgis -from .qgis_processing_postgis import uri_from_name - -plugin_path = os.path.dirname(__file__) - - -class SummaryTablePerTimeIntervalOld(BaseProcessingAlgorithm): - """ - This algorithm takes a connection to a data base and a vector polygons layer and - returns a summary non geometric PostGIS layer. - """ - - # Constants used to refer to parameters and outputs - DATABASE = "DATABASE" - STUDY_AREA = "STUDY_AREA" - TIME_INTERVAL = "TIME_INTERVAL" - ADD_FIVE_YEARS = "ADD_FIVE_YEARS" - TEST = "TEST" - START_MONTH = "START_MONTH" - START_YEAR = "START_YEAR" - END_MONTH = "END_MONTH" - END_YEAR = "END_YEAR" - TAXONOMIC_RANK = "TAXONOMIC_RANK" - AGG = "AGG" - GROUPE_TAXO = "GROUPE_TAXO" - REGNE = "REGNE" - PHYLUM = "PHYLUM" - CLASSE = "CLASSE" - ORDRE = "ORDRE" - FAMILLE = "FAMILLE" - GROUP1_INPN = "GROUP1_INPN" - GROUP2_INPN = "GROUP2_INPN" - EXTRA_WHERE = "EXTRA_WHERE" - OUTPUT = "OUTPUT" - OUTPUT_NAME = "OUTPUT_NAME" - ADD_TABLE = "ADD_TABLE" - OUTPUT_HISTOGRAM = "OUTPUT_HISTOGRAM" - ADD_HISTOGRAM = "ADD_HISTOGRAM" - - def name(self) -> str: - return "SummaryTablePerTimeIntervalOld" - - def displayName(self): # noqa N802 - return "Tableau de synthèse par intervalle de temps (OLD)" - - def icon(self) -> "QIcon": - return QIcon(os.path.join(plugin_path, os.pardir, "icons", "table.png")) - - def groupId(self): # noqa N802 - return "summary_tables" - - def group(self) -> str: - return "Tableaux de synthèse" - - def shortDescription(self): # noqa N802 - return self.tr( - """Besoin d'aide ? Vous pouvez vous référer au Wiki accessible sur ce lien : https://github.com/lpoaura/PluginQGis-LPOData/wiki.

- Cet algorithme vous permet, à partir des données d'observation enregistrées dans la base de données LPO, d'obtenir un tableau bilan (couche PostgreSQL)... -
  • par année ou par mois (au choix)
  • -
  • et par espèce ou par groupe taxonomique (au choix)
- ... basé sur une zone d'étude présente dans votre projet QGis (couche de type polygones) et selon une période de votre choix. - Les données d'absence sont exclues de ce traitement.

- IMPORTANT : Les étapes indispensables sont marquées d'une étoile * avant leur numéro. Prenez le temps de lire attentivement les instructions pour chaque étape, et particulièrement les informations en rouge !""" - ) - - def initAlgorithm(self, config=None): # noqa N802 - """ - Here we define the inputs and output of the algorithm, along - with some other properties. - """ - - self.ts = datetime.now() - self.db_variables = QgsSettings() - self.interval_variables = ["Par année", "Par mois"] - self.months_names_variables = [ - "Janvier", - "Février", - "Mars", - "Avril", - "Mai", - "Juin", - "Juillet", - "Août", - "Septembre", - "Octobre", - "Novembre", - "Décembre", - ] - self.taxonomic_ranks_variables = ["Espèces", "Groupes taxonomiques"] - self.agg_variables = ["Nombre de données", "Nombre d'espèces"] - - # Data base connection - # db_param = QgsProcessingParameterString( - # self.DATABASE, - # self.tr("""CONNEXION À LA BASE DE DONNÉES
- # *1/ Sélectionnez votre connexion à la base de données LPO"""), - # defaultValue='geonature_lpo' - # ) - # db_param.setMetadata( - # { - # 'widget_wrapper': {'class': 'processing.gui.wrappers_postgis.ConnectionWidgetWrapper'} - # } - # ) - # self.addParameter(db_param) - self.addParameter( - QgsProcessingParameterProviderConnection( - self.DATABASE, - self.tr( - """CONNEXION À LA BASE DE DONNÉES
- *1/ Sélectionnez votre connexion à la base de données LPO""" - ), - "postgres", - defaultValue="geonature_lpo", - ) - ) - - # Input vector layer = study area - self.addParameter( - QgsProcessingParameterFeatureSource( - self.STUDY_AREA, - self.tr( - """ZONE D'ÉTUDE
- *2/ Sélectionnez votre zone d'étude, à partir de laquelle seront extraits les résultats""" - ), - [QgsProcessing.TypeVectorPolygon], - ) - ) - - ### Time interval and period ### - time_interval = QgsProcessingParameterEnum( - self.TIME_INTERVAL, - self.tr( - """AGRÉGATION TEMPORELLE ET PÉRIODE
- *3/ Sélectionnez l'agrégation temporelle qui vous intéresse""" - ), - self.interval_variables, - allowMultiple=False, - ) - time_interval.setMetadata( - { - "widget_wrapper": { - "useCheckBoxes": True, - "columns": len(self.interval_variables), - } - } - ) - self.addParameter(time_interval) - - add_five_years = QgsProcessingParameterEnum( - self.ADD_FIVE_YEARS, - self.tr( - """4/ Si (et seulement si !) vous avez sélectionné l'agrégation Par année :
cochez la case ci-dessous si vous souhaitez ajouter des colonnes dîtes "bilan" par intervalle de 5 ans.
- N.B. : En cochant cette case, vous devez vous assurer de renseigner une période en années (cf. *5/) qui soit divisible par 5.
Exemple : 2011 - 2020.
""" - ), - [ - 'Oui, je souhaite ajouter des colonnes dîtes "bilan" par intervalle de 5 ans' - ], - allowMultiple=True, - optional=True, - ) - add_five_years.setMetadata( - {"widget_wrapper": {"useCheckBoxes": True, "columns": 1}} - ) - self.addParameter(add_five_years) - - self.addParameter( - QgsProcessingParameterEnum( - self.START_MONTH, - self.tr( - """*5/ Sélectionnez la période qui vous intéresse
- - Mois de début (nécessaire seulement si vous avez sélectionné l'agrégation Par mois) :""" - ), - self.months_names_variables, - allowMultiple=False, - optional=True, - ) - ) - - self.addParameter( - QgsProcessingParameterNumber( - self.START_YEAR, - self.tr("- *Année de début :"), - QgsProcessingParameterNumber.Integer, - defaultValue=2010, - minValue=1800, - maxValue=int(self.ts.strftime("%Y")), - ) - ) - - self.addParameter( - QgsProcessingParameterEnum( - self.END_MONTH, - self.tr( - """- Mois de fin (nécessaire seulement si vous avez sélectionné l'agrégation Par mois) :""" - ), - self.months_names_variables, - allowMultiple=False, - optional=True, - ) - ) - - self.addParameter( - QgsProcessingParameterNumber( - self.END_YEAR, - self.tr("- *Année de fin :"), - QgsProcessingParameterNumber.Integer, - defaultValue=self.ts.strftime("%Y"), - minValue=1800, - maxValue=int(self.ts.strftime("%Y")), - ) - ) - - # Taxonomic rank - taxonomic_rank = QgsProcessingParameterEnum( - self.TAXONOMIC_RANK, - self.tr( - """RANG TAXONOMIQUE
- *6/ Sélectionnez le rang taxonomique qui vous intéresse""" - ), - self.taxonomic_ranks_variables, - allowMultiple=False, - ) - taxonomic_rank.setMetadata( - { - "widget_wrapper": { - "useCheckBoxes": True, - "columns": len(self.taxonomic_ranks_variables), - } - } - ) - self.addParameter(taxonomic_rank) - - # Aggregation type - aggregation_type = QgsProcessingParameterEnum( - self.AGG, - self.tr( - """AGRÉGATION DES RÉSULTATS
- *7/ Sélectionnez le type d'agrégation qui vous intéresse pour les résultats
- N.B. : Si vous avez choisi Espèces pour le rang taxonomique, Nombre de données sera utilisé par défaut""" - ), - self.agg_variables, - allowMultiple=False, - defaultValue="Nombre de données", - ) - aggregation_type.setMetadata( - { - "widget_wrapper": { - "useCheckBoxes": True, - "columns": len(self.agg_variables), - } - } - ) - self.addParameter(aggregation_type) - - ### Taxons filters ### - self.addParameter( - QgsProcessingParameterEnum( - self.GROUPE_TAXO, - self.tr( - """FILTRES DE REQUÊTAGE
- 8/ Si cela vous intéresse, vous pouvez sélectionner un/plusieurs taxon(s) dans la liste déroulante suivante (à choix multiples)
pour filtrer vos données d'observations. Sinon, vous pouvez ignorer cette étape.
- N.B. : D'autres filtres taxonomiques sont disponibles dans les paramètres avancés (plus bas, juste avant l'enregistrement des résultats).
- - Groupes taxonomiques :""" - ), - self.db_variables.value("groupe_taxo"), - allowMultiple=True, - optional=True, - ) - ) - - regne = QgsProcessingParameterEnum( - self.REGNE, - self.tr("- Règnes :"), - self.db_variables.value("regne"), - allowMultiple=True, - optional=True, - ) - regne.setFlags(regne.flags() | QgsProcessingParameterDefinition.FlagAdvanced) - self.addParameter(regne) - - phylum = QgsProcessingParameterEnum( - self.PHYLUM, - self.tr("- Phylum :"), - self.db_variables.value("phylum"), - allowMultiple=True, - optional=True, - ) - phylum.setFlags(phylum.flags() | QgsProcessingParameterDefinition.FlagAdvanced) - self.addParameter(phylum) - - classe = QgsProcessingParameterEnum( - self.CLASSE, - self.tr("- Classe :"), - self.db_variables.value("classe"), - allowMultiple=True, - optional=True, - ) - classe.setFlags(classe.flags() | QgsProcessingParameterDefinition.FlagAdvanced) - self.addParameter(classe) - - ordre = QgsProcessingParameterEnum( - self.ORDRE, - self.tr("- Ordre :"), - self.db_variables.value("ordre"), - allowMultiple=True, - optional=True, - ) - ordre.setFlags(ordre.flags() | QgsProcessingParameterDefinition.FlagAdvanced) - self.addParameter(ordre) - - famille = QgsProcessingParameterEnum( - self.FAMILLE, - self.tr("- Famille :"), - self.db_variables.value("famille"), - allowMultiple=True, - optional=True, - ) - famille.setFlags( - famille.flags() | QgsProcessingParameterDefinition.FlagAdvanced - ) - self.addParameter(famille) - - group1_inpn = QgsProcessingParameterEnum( - self.GROUP1_INPN, - self.tr( - "- Groupe 1 INPN (regroupement vernaculaire du référentiel national - niveau 1) :" - ), - self.db_variables.value("group1_inpn"), - allowMultiple=True, - optional=True, - ) - group1_inpn.setFlags( - group1_inpn.flags() | QgsProcessingParameterDefinition.FlagAdvanced - ) - self.addParameter(group1_inpn) - - group2_inpn = QgsProcessingParameterEnum( - self.GROUP2_INPN, - self.tr( - "- Groupe 2 INPN (regroupement vernaculaire du référentiel national - niveau 2) :" - ), - self.db_variables.value("group2_inpn"), - allowMultiple=True, - optional=True, - ) - group2_inpn.setFlags( - group2_inpn.flags() | QgsProcessingParameterDefinition.FlagAdvanced - ) - self.addParameter(group2_inpn) - - # Extra "where" conditions - extra_where = QgsProcessingParameterString( - self.EXTRA_WHERE, - self.tr( - """Vous pouvez ajouter des conditions "where" supplémentaires dans l'encadré suivant, en langage SQL (commencez par and)""" - ), - multiLine=True, - optional=True, - ) - extra_where.setFlags( - extra_where.flags() | QgsProcessingParameterDefinition.FlagAdvanced - ) - self.addParameter(extra_where) - - # Output PostGIS layer = summary table - self.addOutput( - QgsProcessingOutputVectorLayer( - self.OUTPUT, - self.tr("Couche en sortie"), - QgsProcessing.TypeVectorAnyGeometry, - ) - ) - - # Output PostGIS layer name - self.addParameter( - QgsProcessingParameterString( - self.OUTPUT_NAME, - self.tr( - """PARAMÉTRAGE DES RESULTATS EN SORTIE
- *9/ Définissez un nom pour votre couche PostGIS""" - ), - self.tr("Tableau synthèse temps"), - ) - ) - - # Boolean : True = add the summary table in the DB ; False = don't - self.addParameter( - QgsProcessingParameterBoolean( - self.ADD_TABLE, - self.tr( - "Enregistrer les résultats en sortie dans une nouvelle table PostgreSQL" - ), - False, - ) - ) - - ### Histogram ### - add_histogram = QgsProcessingParameterBoolean( - self.ADD_HISTOGRAM, - self.tr( - """Exporter les résultats sous la forme d'un histogramme du total par pas de temps choisi""" - ), - # [ - # "Oui, je souhaite exporter les résultats sous la forme d'un histogramme du total par pas de temps choisi" - # ], - # allowMultiple=True, - optional=True, - ) - # add_histogram.setMetadata( - # {"widget_wrapper": {"useCheckBoxes": True, "columns": 1}} - # ) - self.addParameter(add_histogram) - - self.addParameter( - QgsProcessingParameterFileDestination( - self.OUTPUT_HISTOGRAM, - self.tr( - """ENREGISTREMENT DES RESULTATS
- 11/ Si (et seulement si !) vous avez sélectionné l'export sous forme d'histogramme, veuillez renseigner un emplacement
pour l'enregistrer sur votre ordinateur (au format image). Dans le cas contraire, vous pouvez ignorer cette étape.
- Aide : Cliquez sur le bouton [...] puis sur 'Enregistrer vers un fichier...'""" - ), - self.tr("image PNG (*.png)"), - optional=True, - createByDefault=False, - ) - ) - - def processAlgorithm(self, parameters, context, feedback): # noqa N802 - """ - Here is where the processing itself takes place. - """ - - ### RETRIEVE PARAMETERS ### - # Retrieve the input vector layer = study area - study_area = self.parameterAsSource(parameters, self.STUDY_AREA, context) - # Retrieve the output PostGIS layer name and format it - layer_name = self.parameterAsString(parameters, self.OUTPUT_NAME, context) - format_name = f"{layer_name} {str(self.ts.strftime('%Y%m%d_%H%M%S'))}" - # Retrieve the time interval - time_interval = self.interval_variables[ - self.parameterAsEnum(parameters, self.TIME_INTERVAL, context) - ] - # Retrieve the period - start_year = self.parameterAsInt(parameters, self.START_YEAR, context) - end_year = self.parameterAsInt(parameters, self.END_YEAR, context) - if end_year < start_year: - raise QgsProcessingException( - "Veuillez renseigner une année de fin postérieure à l'année de début !" - ) - # Retrieve the taxonomic rank - taxonomic_rank = self.taxonomic_ranks_variables[ - self.parameterAsEnum(parameters, self.TAXONOMIC_RANK, context) - ] - # Retrieve the aggregation type - aggregation_type = "Nombre de données" - if taxonomic_rank == "Groupes taxonomiques": - aggregation_type = self.agg_variables[ - self.parameterAsEnum(parameters, self.AGG, context) - ] - # Retrieve the taxons filters - groupe_taxo = [ - self.db_variables.value("groupe_taxo")[i] - for i in (self.parameterAsEnums(parameters, self.GROUPE_TAXO, context)) - ] - regne = [ - self.db_variables.value("regne")[i] - for i in (self.parameterAsEnums(parameters, self.REGNE, context)) - ] - phylum = [ - self.db_variables.value("phylum")[i] - for i in (self.parameterAsEnums(parameters, self.PHYLUM, context)) - ] - classe = [ - self.db_variables.value("classe")[i] - for i in (self.parameterAsEnums(parameters, self.CLASSE, context)) - ] - ordre = [ - self.db_variables.value("ordre")[i] - for i in (self.parameterAsEnums(parameters, self.ORDRE, context)) - ] - famille = [ - self.db_variables.value("famille")[i] - for i in (self.parameterAsEnums(parameters, self.FAMILLE, context)) - ] - group1_inpn = [ - self.db_variables.value("group1_inpn")[i] - for i in (self.parameterAsEnums(parameters, self.GROUP1_INPN, context)) - ] - group2_inpn = [ - self.db_variables.value("group2_inpn")[i] - for i in (self.parameterAsEnums(parameters, self.GROUP2_INPN, context)) - ] - # Retrieve the extra "where" conditions - extra_where = self.parameterAsString(parameters, self.EXTRA_WHERE, context) - # Retrieve the histogram parameter - add_histogram = self.parameterAsEnums(parameters, self.ADD_HISTOGRAM, context) - if len(add_histogram) > 0: - output_histogram = self.parameterAsFileOutput( - parameters, self.OUTPUT_HISTOGRAM, context - ) - if output_histogram == "": - raise QgsProcessingException( - "Veuillez renseigner un emplacement pour enregistrer votre histogramme !" - ) - - ### CONSTRUCT "SELECT" CLAUSE (SQL) ### - # Select data according to the time interval and the period - select_data, x_var = construct_sql_select_data_per_time_interval( - self, - time_interval, - start_year, - end_year, - aggregation_type, - parameters, - context, - ) - # Select species info (optional) - select_species_info = """/*source_id_sp, */obs.cd_nom, obs.cd_ref, nom_rang as "Rang", groupe_taxo AS "Groupe taxo", - obs.nom_vern AS "Nom vernaculaire", nom_sci AS "Nom scientifique\"""" - # Select taxonomic groups info (optional) - select_taxo_groups_info = 'groupe_taxo AS "Groupe taxo"' - ### CONSTRUCT "WHERE" CLAUSE (SQL) ### - # Construct the sql array containing the study area's features geometry - array_polygons = construct_sql_array_polygons(study_area) - # Define the "where" clause of the SQL query, aiming to retrieve the output PostGIS layer = summary table - where = f"is_valid and is_present and ST_intersects(obs.geom, ST_union({array_polygons}))" - # Define a dictionnary with the aggregated taxons filters and complete the "where" clause thanks to it - taxons_filters = { - "groupe_taxo": groupe_taxo, - "regne": regne, - "phylum": phylum, - "classe": classe, - "ordre": ordre, - "famille": famille, - "obs.group1_inpn": group1_inpn, - "obs.group2_inpn": group2_inpn, - } - # taxons_where = construct_sql_taxons_filter(taxons_filters) - # where += taxons_where - # # Complete the "where" clause with the extra conditions - # where += " " + extra_where - ### CONSTRUCT "GROUP BY" CLAUSE (SQL) ### - # Group by species (optional) - group_by_species = ( - "/*source_id_sp, */obs.cd_nom, obs.cd_ref, nom_rang, nom_sci, obs.nom_vern, " - if taxonomic_rank == "Espèces" - else "" - ) - - ### EXECUTE THE SQL QUERY ### - # Retrieve the data base connection name - connection = self.parameterAsString(parameters, self.DATABASE, context) - # URI --> Configures connection to database and the SQL query - # uri = postgis.uri_from_name(connection) - uri = uri_from_name(connection) - # Define the SQL query - query = f"""SELECT row_number() OVER () AS id, {select_species_info if taxonomic_rank == 'Espèces' else select_taxo_groups_info}{select_data} - FROM src_lpodatas.v_c_observations_light obs - LEFT JOIN taxonomie.bib_taxref_rangs r ON obs.id_rang = r.id_rang - WHERE {where} - GROUP BY {group_by_species}groupe_taxo - ORDER BY groupe_taxo{ ", obs.nom_vern" if taxonomic_rank == 'Espèces' else ''}""" - - feedback.pushDebugInfo(query) - # feedback.pushInfo(query) - # Retrieve the boolean add_table - add_table = self.parameterAsBool(parameters, self.ADD_TABLE, context) - if add_table: - # Define the name of the PostGIS summary table which will be created in the DB - table_name = simplify_name(format_name) - # Define the SQL queries - queries = construct_queries_list(table_name, query) - # Execute the SQL queries - execute_sql_queries(context, feedback, connection, queries) - # Format the URI - uri.setDataSource(None, table_name, None, "", "id") - else: - # Format the URI with the query - uri.setDataSource("", "(" + query + ")", None, "", "id") - - ### GET THE OUTPUT LAYER ### - # Retrieve the output PostGIS layer = summary table - self.layer_summary = QgsVectorLayer(uri.uri(), format_name, "postgres") - # Check if the PostGIS layer is valid - check_layer_is_valid(feedback, self.layer_summary) - # Load the PostGIS layer - load_layer(context, self.layer_summary) - # Add action to layer - # with open(os.path.join(plugin_path, "format_csv.py"), "r") as file: - # action_code = file.read() - # action = QgsAction( - # QgsAction.GenericPython, - # "Exporter la couche sous format Excel dans mon dossier utilisateur avec la mise en forme adaptée", - # action_code, - # os.path.join(plugin_path, "icons", "excel.png"), - # False, - # "Exporter sous format Excel", - # {"Layer"}, - # ) - # self.layer_summary.actions().addAction(action) - # # JOKE - # with open(os.path.join(plugin_path, "joke.py"), "r") as file: - # joke_action_code = file.read() - # joke_action = QgsAction( - # QgsAction.GenericPython, - # "Rédiger mon rapport", - # joke_action_code, - # os.path.join(plugin_path, "icons", "logo_LPO.png"), - # False, - # "Rédiger mon rapport", - # {"Layer"}, - # ) - # self.layer_summary.actions().addAction(joke_action) - - ### CONSTRUCT THE HISTOGRAM ### - if len(add_histogram) > 0: - plt.close() - y_var = [] - for x in x_var: - y = 0 - for feature in self.layer_summary.getFeatures(): - y += feature[x] - y_var.append(y) - if len(x_var) <= 20: - plt.subplots_adjust(bottom=0.4) - elif len(x_var) <= 80: - plt.figure(figsize=(20, 8)) - plt.subplots_adjust(bottom=0.3, left=0.05, right=0.95) - else: - plt.figure(figsize=(40, 16)) - plt.subplots_adjust(bottom=0.2, left=0.03, right=0.97) - plt.bar(range(len(x_var)), y_var, tick_label=x_var) - plt.xticks(rotation="vertical") - x_label = time_interval.split(" ")[1].title() - if x_label[-1] != "s": - x_label += "s" - plt.xlabel(x_label) - plt.ylabel(aggregation_type) - plt.title( - f"{aggregation_type} {(time_interval[0].lower() + time_interval[1:])}" - ) - if output_histogram[-4:] != ".png": - output_histogram += ".png" - plt.savefig(output_histogram) - # plt.show() - - return {self.OUTPUT: self.layer_summary.id()} - - def postProcessAlgorithm(self, _context, _feedback) -> Dict: # noqa N802 - # Open the attribute table of the PostGIS layer - iface.showAttributeTable(self.layer_summary) - iface.setActiveLayer(self.layer_summary) - - return {} - - def tr(self, string: str) -> str: - return QCoreApplication.translate("Processing", string) - - def createInstance(self): # noqa N802 - return type(self)() diff --git a/plugin_qgis_lpo/resources/help/index.html b/plugin_qgis_lpo/resources/help/index.html new file mode 100644 index 0000000..77c455d --- /dev/null +++ b/plugin_qgis_lpo/resources/help/index.html @@ -0,0 +1,16 @@ + + + + + + Redirecting... + + + + + +

Redirection to the online documentation...

+ + + + diff --git a/plugin_qgis_lpo/resources/i18n/plugin_translation.pro b/plugin_qgis_lpo/resources/i18n/plugin_translation.pro new file mode 100644 index 0000000..a6d63b9 --- /dev/null +++ b/plugin_qgis_lpo/resources/i18n/plugin_translation.pro @@ -0,0 +1,8 @@ +FORMS = ../../gui/dlg_settings.ui + +SOURCES= ../../plugin_main.py \ + ../../gui/dlg_settings.py \ + ../../toolbelt/log_handler.py \ + ../../toolbelt/preferences.py + +TRANSLATIONS = plugin_qgis_lpo_en.ts diff --git a/plugin_qgis_lpo/resources/images/default_icon.png b/plugin_qgis_lpo/resources/images/default_icon.png new file mode 100644 index 0000000..89a7044 Binary files /dev/null and b/plugin_qgis_lpo/resources/images/default_icon.png differ diff --git a/plugin_qgis_lpo/icons/excel.png b/plugin_qgis_lpo/resources/images/excel.png similarity index 100% rename from plugin_qgis_lpo/icons/excel.png rename to plugin_qgis_lpo/resources/images/excel.png diff --git a/plugin_qgis_lpo/icons/extract_data.png b/plugin_qgis_lpo/resources/images/extract_data.png similarity index 100% rename from plugin_qgis_lpo/icons/extract_data.png rename to plugin_qgis_lpo/resources/images/extract_data.png diff --git a/plugin_qgis_lpo/icons/histogram.png b/plugin_qgis_lpo/resources/images/histogram.png similarity index 100% rename from plugin_qgis_lpo/icons/histogram.png rename to plugin_qgis_lpo/resources/images/histogram.png diff --git a/plugin_qgis_lpo/icons/logo_LPO.png b/plugin_qgis_lpo/resources/images/logo_LPO.png similarity index 100% rename from plugin_qgis_lpo/icons/logo_LPO.png rename to plugin_qgis_lpo/resources/images/logo_LPO.png diff --git a/plugin_qgis_lpo/icons/logo_lpo_aura.png b/plugin_qgis_lpo/resources/images/logo_lpo_aura.png similarity index 100% rename from plugin_qgis_lpo/icons/logo_lpo_aura.png rename to plugin_qgis_lpo/resources/images/logo_lpo_aura.png diff --git a/plugin_qgis_lpo/icons/logo_lpo_aura_carre.png b/plugin_qgis_lpo/resources/images/logo_lpo_aura_carre.png similarity index 100% rename from plugin_qgis_lpo/icons/logo_lpo_aura_carre.png rename to plugin_qgis_lpo/resources/images/logo_lpo_aura_carre.png diff --git a/plugin_qgis_lpo/icons/map.png b/plugin_qgis_lpo/resources/images/map.png similarity index 100% rename from plugin_qgis_lpo/icons/map.png rename to plugin_qgis_lpo/resources/images/map.png diff --git a/plugin_qgis_lpo/icons/table.png b/plugin_qgis_lpo/resources/images/table.png similarity index 100% rename from plugin_qgis_lpo/icons/table.png rename to plugin_qgis_lpo/resources/images/table.png diff --git a/plugin_qgis_lpo/icons/word.png b/plugin_qgis_lpo/resources/images/word.png similarity index 100% rename from plugin_qgis_lpo/icons/word.png rename to plugin_qgis_lpo/resources/images/word.png diff --git a/plugin_qgis_lpo/styles/reproduction.qml b/plugin_qgis_lpo/resources/styles/reproduction.qml similarity index 100% rename from plugin_qgis_lpo/styles/reproduction.qml rename to plugin_qgis_lpo/resources/styles/reproduction.qml diff --git a/plugin_qgis_lpo/toolbelt/__init__.py b/plugin_qgis_lpo/toolbelt/__init__.py new file mode 100644 index 0000000..435f023 --- /dev/null +++ b/plugin_qgis_lpo/toolbelt/__init__.py @@ -0,0 +1,3 @@ +#! python3 # noqa: E265 +from .log_handler import PlgLogger # noqa: F401 +from .preferences import PlgOptionsManager # noqa: F401 diff --git a/plugin_qgis_lpo/toolbelt/log_handler.py b/plugin_qgis_lpo/toolbelt/log_handler.py new file mode 100644 index 0000000..96de803 --- /dev/null +++ b/plugin_qgis_lpo/toolbelt/log_handler.py @@ -0,0 +1,154 @@ +#! python3 # noqa: E265 + +# standard library +import logging +from functools import partial +from typing import Callable + +# PyQGIS +from qgis.core import QgsMessageLog, QgsMessageOutput +from qgis.gui import QgsMessageBar +from qgis.PyQt.QtWidgets import QPushButton, QWidget +from qgis.utils import iface + +import plugin_qgis_lpo.toolbelt.preferences as plg_prefs_hdlr + +# project package +from plugin_qgis_lpo.__about__ import __title__ + +# ############################################################################ +# ########## Classes ############### +# ################################## + + +class PlgLogger(logging.Handler): + """Python logging handler supercharged with QGIS useful methods.""" + + @staticmethod + def log( + message: str, + application: str = __title__, + log_level: int = 0, + push: bool = False, + duration: int = None, + # widget + button: bool = False, + button_text: str = None, + button_connect: Callable = None, + # parent + parent_location: QWidget = None, + ): + """Send messages to QGIS messages windows and to the user as a message bar. \ + Plugin name is used as title. If debug mode is disabled, only warnings (1) and \ + errors (2) or with push are sent. + + :param message: message to display + :type message: str + :param application: name of the application sending the message. \ + Defaults to __about__.__title__ + :type application: str, optional + :param log_level: message level. Possible values: 0 (info), 1 (warning), \ + 2 (critical), 3 (success), 4 (none - grey). Defaults to 0 (info) + :type log_level: int, optional + :param push: also display the message in the QGIS message bar in addition to \ + the log, defaults to False + :type push: bool, optional + :param duration: duration of the message in seconds. If not set, the \ + duration is calculated from the log level: `(log_level + 1) * 3`. seconds. \ + If set to 0, then the message must be manually dismissed by the user. \ + Defaults to None. + :type duration: int, optional + :param button: display a button in the message bar. Defaults to False. + :type button: bool, optional + :param button_text: text label of the button. Defaults to None. + :type button_text: str, optional + :param button_connect: function to be called when the button is pressed. \ + If not set, a simple dialog (QgsMessageOutput) is used to dislay the message. \ + Defaults to None. + :type button_connect: Callable, optional + :param parent_location: parent location widget. \ + If not set, QGIS canvas message bar is used to push message, \ + otherwise if a QgsMessageBar is available in parent_location it is used instead. \ + Defaults to None. + :type parent_location: Widget, optional + + :Example: + + .. code-block:: python + + log(message="Plugin loaded - INFO", log_level=0, push=False) + log(message="Plugin loaded - WARNING", log_level=1, push=1, duration=5) + log(message="Plugin loaded - ERROR", log_level=2, push=1, duration=0) + log( + message="Plugin loaded - SUCCESS", + log_level=3, + push=1, + duration=10, + button=True + ) + log(message="Plugin loaded - TEST", log_level=4, push=0) + """ + # if not debug mode and not push, let's ignore INFO, SUCCESS and TEST + debug_mode = plg_prefs_hdlr.PlgOptionsManager.get_plg_settings().debug_mode + if not debug_mode and not push and (log_level < 1 or log_level > 2): + return + + # ensure message is a string + if not isinstance(message, str): + try: + message = str(message) + except Exception as err: + err_msg = "Log message must be a string, not: {}. Trace: {}".format( + type(message), err + ) + logging.error(err_msg) + message = err_msg + + # send it to QGIS messages panel + QgsMessageLog.logMessage( + message=message, tag=application, notifyUser=push, level=log_level + ) + + # optionally, display message on QGIS Message bar (above the map canvas) + if push and iface is not None: + msg_bar = None + + # QGIS or custom dialog + if parent_location and isinstance(parent_location, QWidget): + msg_bar = parent_location.findChild(QgsMessageBar) + + if not msg_bar: + msg_bar = iface.messageBar() + + # calc duration + if duration is None: + duration = (log_level + 1) * 3 + + # create message with/out a widget + if button: + # create output message + notification = iface.messageBar().createMessage( + title=application, text=message + ) + widget_button = QPushButton(button_text or "More...") + if button_connect: + widget_button.clicked.connect(button_connect) + else: + mini_dlg = QgsMessageOutput.createMessageOutput() + mini_dlg.setTitle(application) + mini_dlg.setMessage(message, QgsMessageOutput.MessageText) + widget_button.clicked.connect(partial(mini_dlg.showMessage, False)) + + notification.layout().addWidget(widget_button) + msg_bar.pushWidget( + widget=notification, level=log_level, duration=duration + ) + + else: + # send simple message + msg_bar.pushMessage( + title=application, + text=message, + level=log_level, + duration=duration, + ) diff --git a/plugin_qgis_lpo/toolbelt/preferences.py b/plugin_qgis_lpo/toolbelt/preferences.py new file mode 100644 index 0000000..9ad585f --- /dev/null +++ b/plugin_qgis_lpo/toolbelt/preferences.py @@ -0,0 +1,145 @@ +#! python3 # noqa: E265 + +""" + Plugin settings. +""" + +# standard +from dataclasses import asdict, dataclass, fields + +# PyQGIS +from qgis.core import QgsSettings + +# package +import plugin_qgis_lpo.toolbelt.log_handler as log_hdlr +from plugin_qgis_lpo.__about__ import __title__, __version__ + +# ############################################################################ +# ########## Classes ############### +# ################################## + + +@dataclass +class PlgSettingsStructure: + """Plugin settings structure and defaults values.""" + + # global + debug_mode: bool = False + version: str = __version__ + + +class PlgOptionsManager: + @staticmethod + def get_plg_settings() -> PlgSettingsStructure: + """Load and return plugin settings as a dictionary. \ + Useful to get user preferences across plugin logic. + + :return: plugin settings + :rtype: PlgSettingsStructure + """ + # get dataclass fields definition + settings_fields = fields(PlgSettingsStructure) + + # retrieve settings from QGIS/Qt + settings = QgsSettings() + settings.beginGroup(__title__) + + # map settings values to preferences object + li_settings_values = [] + for i in settings_fields: + li_settings_values.append( + settings.value(key=i.name, defaultValue=i.default, type=i.type) + ) + + # instanciate new settings object + options = PlgSettingsStructure(*li_settings_values) + + settings.endGroup() + + return options + + @staticmethod + def get_value_from_key(key: str, default=None, exp_type=None): + """Load and return plugin settings as a dictionary. \ + Useful to get user preferences across plugin logic. + + :return: plugin settings value matching key + """ + if not hasattr(PlgSettingsStructure, key): + log_hdlr.PlgLogger.log( + message="Bad settings key. Must be one of: {}".format( + ",".join(PlgSettingsStructure._fields) + ), + log_level=1, + ) + return None + + settings = QgsSettings() + settings.beginGroup(__title__) + + try: + out_value = settings.value(key=key, defaultValue=default, type=exp_type) + except Exception as err: + log_hdlr.PlgLogger.log( + message="Error occurred trying to get settings: {}.Trace: {}".format( + key, err + ) + ) + out_value = None + + settings.endGroup() + + return out_value + + @classmethod + def set_value_from_key(cls, key: str, value) -> bool: + """Set plugin QSettings value using the key. + + :param key: QSettings key + :type key: str + :param value: value to set + :type value: depending on the settings + :return: operation status + :rtype: bool + """ + if not hasattr(PlgSettingsStructure, key): + log_hdlr.PlgLogger.log( + message="Bad settings key. Must be one of: {}".format( + ",".join(PlgSettingsStructure._fields) + ), + log_level=2, + ) + return False + + settings = QgsSettings() + settings.beginGroup(__title__) + + try: + settings.setValue(key, value) + out_value = True + except Exception as err: + log_hdlr.PlgLogger.log( + message="Error occurred trying to set settings: {}.Trace: {}".format( + key, err + ) + ) + out_value = False + + settings.endGroup() + + return out_value + + @classmethod + def save_from_object(cls, plugin_settings_obj: PlgSettingsStructure): + """Load and return plugin settings as a dictionary. \ + Useful to get user preferences across plugin logic. + + :return: plugin settings value matching key + """ + settings = QgsSettings() + settings.beginGroup(__title__) + + for k, v in asdict(plugin_settings_obj).items(): + cls.set_value_from_key(k, v) + + settings.endGroup() diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 7070a80..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,14 +0,0 @@ -[tool.black] -line-length = 88 -target-version = ['py38'] -include = '\.pyi?$' - -[tool.isort] -# Black compatible values for isort https://black.readthedocs.io/en/stable/compatible_configs.html#isort -profile = "black" - -[tool.pytest.ini_options] -addopts = "-v" - -[tool.coverage.report] -omit = ["plugin_qgis_lpo/qgis_plugin_tools/*"] diff --git a/requirements-dev.in b/requirements-dev.in deleted file mode 100644 index 146b488..0000000 --- a/requirements-dev.in +++ /dev/null @@ -1,24 +0,0 @@ -# Debugging -debugpy - -# Dependency maintenance -pip-tools - -# Testing -pytest -pytest-cov -pytest-qgis - -# Linting and formatting -pre-commit -black -isort -mypy -flake8 -flake8-bugbear -pep8-naming -flake8-annotations -flake8-qgis - -# Stubs -PyQt5-stubs diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 021fde4..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,109 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --output-file=requirements-dev.txt requirements-dev.in -# -astor==0.8.1 - # via flake8-qgis -attrs==23.2.0 - # via - # flake8-annotations - # flake8-bugbear -black==23.12.1 - # via -r requirements-dev.in -build==1.0.3 - # via pip-tools -cfgv==3.4.0 - # via pre-commit -click==8.1.7 - # via - # black - # pip-tools -coverage[toml]==7.4.0 - # via - # coverage - # pytest-cov -debugpy==1.8.0 - # via -r requirements-dev.in -distlib==0.3.8 - # via virtualenv -filelock==3.13.1 - # via virtualenv -flake8==7.0.0 - # via - # -r requirements-dev.in - # flake8-annotations - # flake8-bugbear - # flake8-qgis - # pep8-naming -flake8-annotations==3.0.1 - # via -r requirements-dev.in -flake8-bugbear==24.1.17 - # via -r requirements-dev.in -flake8-qgis==1.0.0 - # via -r requirements-dev.in -identify==2.5.33 - # via pre-commit -iniconfig==2.0.0 - # via pytest -isort==5.13.2 - # via -r requirements-dev.in -mccabe==0.7.0 - # via flake8 -mypy==1.8.0 - # via -r requirements-dev.in -mypy-extensions==1.0.0 - # via - # black - # mypy -nodeenv==1.8.0 - # via pre-commit -packaging==23.2 - # via - # black - # build - # pytest -pathspec==0.12.1 - # via black -pep8-naming==0.13.3 - # via -r requirements-dev.in -pip-tools==7.3.0 - # via -r requirements-dev.in -platformdirs==4.1.0 - # via - # black - # virtualenv -pluggy==1.3.0 - # via pytest -pre-commit==3.6.0 - # via -r requirements-dev.in -pycodestyle==2.11.1 - # via flake8 -pyflakes==3.2.0 - # via flake8 -pyproject-hooks==1.0.0 - # via build -pyqt5-stubs==5.15.6.0 - # via -r requirements-dev.in -pytest==7.4.4 - # via - # -r requirements-dev.in - # pytest-cov - # pytest-qgis -pytest-cov==4.1.0 - # via -r requirements-dev.in -pytest-qgis==2.0.0 - # via -r requirements-dev.in -pyyaml==6.0.1 - # via pre-commit -typing-extensions==4.9.0 - # via mypy -virtualenv==20.25.0 - # via pre-commit -wheel==0.42.0 - # via pip-tools - -# The following packages are considered to be unsafe in a requirements file: -# pip -# setuptools diff --git a/requirements/development.txt b/requirements/development.txt new file mode 100644 index 0000000..599bada --- /dev/null +++ b/requirements/development.txt @@ -0,0 +1,10 @@ +# Develoment dependencies +# ----------------------- + +black + +flake8-builtins>=1.5,<2.3 +flake8-isort>=4.1,<6.2 +flake8-qgis>=1,<1.1 +isort>=5.8,<5.14 +pre-commit>=3,<4 diff --git a/requirements/documentation.txt b/requirements/documentation.txt new file mode 100644 index 0000000..8fc973d --- /dev/null +++ b/requirements/documentation.txt @@ -0,0 +1,7 @@ +# Documentation (for devs) +# ----------------------- + +myst-parser[linkify]>=1,<3 +sphinx-autobuild==2021.* +sphinx-copybutton>=0.2,<1 +sphinx-rtd-theme>=1,<3 diff --git a/requirements/packaging.txt b/requirements/packaging.txt new file mode 100644 index 0000000..bd00b58 --- /dev/null +++ b/requirements/packaging.txt @@ -0,0 +1,4 @@ +# Packaging +# --------- + +qgis-plugin-ci>=2.6,<3 diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 0000000..e8400b1 --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,5 @@ +# Testing dependencies +# -------------------- + +pytest-cov>=3,<5 +packaging>=23 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..99a419b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,82 @@ +# -- Packaging -------------------------------------- +[metadata] +description-file = README.md + +[qgis-plugin-ci] +plugin_path = plugin_qgis_lpo +project_slug = plugin-qgis-lpo + +github_organization_slug = lpoaura + + +# -- Code quality ------------------------------------ + +[flake8] +count = True +exclude = + # No need to traverse our git directory + .git, + # There's no value in checking cache directories + __pycache__, + # The conf file is mostly autogenerated, ignore it + docs/conf.py, + # The old directory contains Flake8 2.0 + old, + # This contains our built documentation + build, + # This contains builds of flake8 that we don't want to check + dist, + # This contains local virtual environments + .venv*, + # do not watch on tests + tests, + # do not consider external packages + */external/*, ext_libs/* +ignore = E121,E123,E126,E203,E226,E24,E704,QGS105,W503,W504 +max-complexity = 15 +max-doc-length = 130 +max-line-length = 100 +output-file = dev_flake8_report.txt +statistics = True +tee = True +builtins-ignorelist = ["id"] + + +[isort] +ensure_newline_before_comments = True +force_grid_wrap = 0 +include_trailing_comma = True +line_length = 88 +multi_line_output = 3 +profile = black +use_parentheses = True + +# -- Tests ---------------------------------------------- +[tool:pytest] +addopts = + --junitxml=junit/test-results.xml + --cov-config=setup.cfg + --cov=plugin_qgis_lpo + --cov-report=html + --cov-report=term + --cov-report=xml + --ignore=tests/_wip/ +norecursedirs = .* build dev development dist docs CVS fixtures _darcs {arch} *.egg venv _wip +python_files = test_*.py +testpaths = tests + +[coverage:run] +branch = True +omit = + .venv/* + *tests* + +[coverage:report] +exclude_lines = + if self.debug: + pragma: no cover + raise NotImplementedError + if __name__ == .__main__.: + +ignore_errors = True +show_missing = True diff --git a/test/conftest.py b/test/conftest.py deleted file mode 100644 index f420bbf..0000000 --- a/test/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -This class contains fixtures and common helper function to keep the test files -shorter. - -pytest-qgis (https://pypi.org/project/pytest-qgis) contains the following helpful -fixtures: - -* qgis_app initializes and returns fully configured QgsApplication. - This fixture is called automatically on the start of pytest session. -* qgis_canvas initializes and returns QgsMapCanvas -* qgis_iface returns mocked QgsInterface -* new_project makes sure that all the map layers and configurations are removed. - This should be used with tests that add stuff to QgsProject. - -""" diff --git a/test/pytest.ini b/test/pytest.ini deleted file mode 100644 index eea2c18..0000000 --- a/test/pytest.ini +++ /dev/null @@ -1 +0,0 @@ -[pytest] diff --git a/test/test_plugin.py b/test/test_plugin.py deleted file mode 100644 index 6d8e568..0000000 --- a/test/test_plugin.py +++ /dev/null @@ -1,5 +0,0 @@ -from plugin_qgis_lpo.qgis_plugin_tools.tools.resources import plugin_name - - -def test_plugin_name(): - assert plugin_name() == "PluginQGISLPO" diff --git a/.gitmodules b/tests/__init__.py similarity index 100% rename from .gitmodules rename to tests/__init__.py diff --git a/tests/qgis/__init__.py b/tests/qgis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/qgis/test_helpers.py b/tests/qgis/test_helpers.py new file mode 100644 index 0000000..668536c --- /dev/null +++ b/tests/qgis/test_helpers.py @@ -0,0 +1,38 @@ +#! python3 # noqa E265 + +""" + Usage from the repo root folder: + + .. code-block:: bash + + # for whole tests + python -m unittest tests.qgis.test_plg_preferences + # for specific test + python -m unittest tests.qgis.test_plg_preferences.TestPlgPreferences.test_plg_preferences_structure +""" + +# standard library +from qgis.testing import unittest + +# project +from plugin_qgis_lpo.commons.helpers import simplify_name + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestHelpers(unittest.TestCase): + def test_simplify_name(self): + """Test settings types and default values.""" + string = "Table des espèces d'oiseaux 20/03/2023" + + # global + self.assertEqual(simplify_name(string), "table_des_especes_doiseaux_20032023") + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main() diff --git a/tests/qgis/test_plg_preferences.py b/tests/qgis/test_plg_preferences.py new file mode 100644 index 0000000..9b91b90 --- /dev/null +++ b/tests/qgis/test_plg_preferences.py @@ -0,0 +1,45 @@ +#! python3 # noqa E265 + +""" + Usage from the repo root folder: + + .. code-block:: bash + + # for whole tests + python -m unittest tests.qgis.test_plg_preferences + # for specific test + python -m unittest tests.qgis.test_plg_preferences.TestPlgPreferences.test_plg_preferences_structure +""" + +# standard library +from qgis.testing import unittest + +# project +from plugin_qgis_lpo.__about__ import __version__ +from plugin_qgis_lpo.toolbelt.preferences import PlgSettingsStructure + +# ################################ +# ########## Classes ############# +# ################################ + + +class TestPlgPreferences(unittest.TestCase): + def test_plg_preferences_structure(self): + """Test settings types and default values.""" + settings = PlgSettingsStructure() + + # global + self.assertTrue(hasattr(settings, "debug_mode")) + self.assertIsInstance(settings.debug_mode, bool) + self.assertEqual(settings.debug_mode, False) + + self.assertTrue(hasattr(settings, "version")) + self.assertIsInstance(settings.version, str) + self.assertEqual(settings.version, __version__) + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_plg_metadata.py b/tests/unit/test_plg_metadata.py new file mode 100644 index 0000000..85e0e56 --- /dev/null +++ b/tests/unit/test_plg_metadata.py @@ -0,0 +1,83 @@ +#! python3 # noqa E265 + +""" + Usage from the repo root folder: + + .. code-block:: bash + # for whole tests + python -m unittest tests.unit.test_plg_metadata + # for specific test + python -m unittest tests.unit.test_plg_metadata.TestPluginMetadata.test_version_semver +""" + +# standard library +import unittest +from pathlib import Path + +# 3rd party +from packaging.version import parse + +# project +from plugin_qgis_lpo import __about__ + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestPluginMetadata(unittest.TestCase): + + """Test about module""" + + def test_metadata_types(self): + """Test types.""" + # plugin metadata.txt file + self.assertIsInstance(__about__.PLG_METADATA_FILE, Path) + self.assertTrue(__about__.PLG_METADATA_FILE.is_file()) + + # plugin dir + self.assertIsInstance(__about__.DIR_PLUGIN_ROOT, Path) + self.assertTrue(__about__.DIR_PLUGIN_ROOT.is_dir()) + + # metadata as dict + self.assertIsInstance(__about__.__plugin_md__, dict) + + # general + self.assertIsInstance(__about__.__author__, str) + self.assertIsInstance(__about__.__copyright__, str) + self.assertIsInstance(__about__.__email__, str) + self.assertIsInstance(__about__.__keywords__, list) + self.assertIsInstance(__about__.__license__, str) + self.assertIsInstance(__about__.__summary__, str) + self.assertIsInstance(__about__.__title__, str) + self.assertIsInstance(__about__.__title_clean__, str) + self.assertIsInstance(__about__.__version__, str) + self.assertIsInstance(__about__.__version_info__, tuple) + + # misc + self.assertLessEqual(len(__about__.__title_clean__), len(__about__.__title__)) + + # QGIS versions + self.assertIsInstance( + __about__.__plugin_md__.get("general").get("qgisminimumversion"), str + ) + + self.assertIsInstance( + __about__.__plugin_md__.get("general").get("qgismaximumversion"), str + ) + + self.assertLessEqual( + float(__about__.__plugin_md__.get("general").get("qgisminimumversion")), + float(__about__.__plugin_md__.get("general").get("qgismaximumversion")), + ) + + def test_version_semver(self): + """Test if version comply with semantic versioning.""" + self.assertTrue(parse(__about__.__version__)) + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main()