├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── main.yml
├── .gitignore
├── .qgis-plugin-ci
├── CHANGELOG.md
├── CONTRIBUTING.md
├── README.md
├── images
└── plugin_showcase.png
├── mapflow
├── LICENSE
├── __init__.py
├── config.py
├── constants.py
├── dialogs
│ ├── __init__.py
│ ├── dialogs.py
│ ├── icons.py
│ ├── login_dialog.py
│ ├── main_dialog.py
│ ├── mosaic_dialog.py
│ ├── processing_dialog.py
│ ├── project_dialog.py
│ ├── provider_dialog.py
│ └── static
│ │ ├── icons
│ │ ├── LICENSE.txt
│ │ ├── add.svg
│ │ ├── arrow_left.svg
│ │ ├── arrow_right.svg
│ │ ├── bar-chart-2.svg
│ │ ├── coins-solid.svg
│ │ ├── edit_provider.svg
│ │ ├── ellipsis-solid.svg
│ │ ├── images.svg
│ │ ├── info.svg
│ │ ├── log-out.svg
│ │ ├── magnifying-glass-solid.svg
│ │ ├── mapflow.png
│ │ ├── mapflow_logo.png
│ │ ├── mapflow_logo.svg
│ │ ├── processing.svg
│ │ ├── refresh.svg
│ │ ├── remove_provider.svg
│ │ ├── settings.svg
│ │ └── user-gear-solid.svg
│ │ └── ui
│ │ ├── error_message.ui
│ │ ├── login_dialog.ui
│ │ ├── main_dialog.ui
│ │ ├── mosaic_dialog.ui
│ │ ├── processing_dialog.ui
│ │ ├── processing_start_confirmation.ui
│ │ ├── project_dialog.ui
│ │ ├── provider_dialog.ui
│ │ ├── raster_layers_dialog.ui
│ │ └── review_dialog.ui
├── entity
│ ├── __init__.py
│ ├── billing.py
│ ├── processing.py
│ ├── provider
│ │ ├── __init__.py
│ │ ├── basemap_provider.py
│ │ ├── collection.py
│ │ ├── default.py
│ │ ├── factory.py
│ │ └── provider.py
│ ├── status.py
│ └── workflow_def.py
├── errors
│ ├── __init__.py
│ ├── api_errors.py
│ ├── data_errors.py
│ ├── error_message_list.py
│ ├── errors.py
│ ├── plugin_errors.py
│ └── processing_errors.py
├── exceptions.py
├── functional
│ ├── __init__.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── data_catalog_api.py
│ │ └── project_api.py
│ ├── auth.py
│ ├── controller
│ │ ├── __init__.py
│ │ └── data_catalog_controller.py
│ ├── geometry.py
│ ├── helpers.py
│ ├── layer_utils.py
│ ├── processing.py
│ ├── result_loader.py
│ ├── service
│ │ ├── __init__.py
│ │ ├── data_catalog.py
│ │ └── project.py
│ └── view
│ │ ├── __init__.py
│ │ ├── data_catalog_view.py
│ │ └── project_view.py
├── http.py
├── i18n
│ ├── mapflow.pro
│ ├── mapflow_ru.qm
│ └── mapflow_ru.ts
├── icon.png
├── mapflow.py
├── metadata.txt
├── requests
│ ├── __init__.py
│ └── maxar_metadata_request.py
├── schema
│ ├── __init__.py
│ ├── base.py
│ ├── catalog.py
│ ├── data_catalog.py
│ ├── processing.py
│ ├── project.py
│ └── provider.py
├── static
│ └── styles
│ │ ├── aoi.qml
│ │ ├── file
│ │ ├── building_heights.qml
│ │ ├── buildings.qml
│ │ ├── buildings_noclass.qml
│ │ ├── construction.qml
│ │ ├── default.qml
│ │ ├── forest.qml
│ │ ├── forest_with_heights.qml
│ │ └── roads.qml
│ │ ├── metadata.qml
│ │ └── tiles
│ │ ├── building_heights.qml
│ │ ├── buildings.qml
│ │ ├── construction.qml
│ │ ├── default.qml
│ │ ├── forest.qml
│ │ └── roads.qml
└── styles.py
├── release.yml
└── tests
└── test_layer_utils.py
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Report a bug
4 | title: ''
5 | labels: bug
6 | assignees: GregNed
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. See the error
18 |
19 | **Expected behavior**
20 | A clear and concise description of what you expected to happen.
21 |
22 | **Screenshots**
23 | If applicable, add screenshots to help explain your problem.
24 |
25 | **Environment:**
26 | - OS: [e.g. Ubuntu]
27 | - QGIS version [e.g. 3.20.1]
28 | - Plugin version [e.g. 1.3.1]
29 |
30 | **Additional context**
31 | Add any other context about the problem here.
32 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Propose new functionality
4 | title: ''
5 | labels: feature
6 | assignees: GregNed
7 |
8 | ---
9 |
10 | As a [Geoalert user / Regular user],
11 | I'd like to [be able to do something],
12 | So that [I could do something else faster]
13 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 |
2 | name: GitlabSync
3 |
4 | on:
5 | - push
6 | - delete
7 |
8 | jobs:
9 | sync:
10 | runs-on: ubuntu-latest
11 | name: Git Repo Sync
12 | steps:
13 | - uses: actions/checkout@v2
14 | with:
15 | fetch-depth: 0
16 | - uses: wangchucheng/git-repo-sync@v0.1.0
17 | with:
18 | target-url: ${{ secrets.TARGET_URL }}
19 | target-username: ${{ secrets.TARGET_USERNAME }}
20 | target-token: ${{ secrets.TARGET_TOKEN }}
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # IDE configs
2 | .vscode
3 | .idea
4 |
5 | # Dev plugin
6 | mapflow_dev
7 |
8 | # Byte-compiled / optimized / DLL files
9 | __pycache__/
10 | *.py[cod]
11 | *$py.class
12 |
13 | # C extensions
14 | *.so
15 |
16 | # Distribution / packaging
17 | .Python
18 | build/
19 | develop-eggs/
20 | dist/
21 | downloads/
22 | eggs/
23 | .eggs/
24 | lib/
25 | lib64/
26 | parts/
27 | sdist/
28 | var/
29 | wheels/
30 | share/python-wheels/
31 | *.egg-info/
32 | .installed.cfg
33 | *.egg
34 | MANIFEST
35 |
36 | # PyInstaller
37 | # Usually these files are written by a python script from a template
38 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
39 | *.manifest
40 | *.spec
41 |
42 | # Installer logs
43 | pip-log.txt
44 | pip-delete-this-directory.txt
45 |
46 | # Unit test / coverage reports
47 | htmlcov/
48 | .tox/
49 | .nox/
50 | .coverage
51 | .coverage.*
52 | .cache
53 | nosetests.xml
54 | coverage.xml
55 | *.cover
56 | *.py,cover
57 | .hypothesis/
58 | .pytest_cache/
59 | cover/
60 |
61 | # Translations
62 | *.mo
63 | *.pot
64 |
65 | # Django stuff:
66 | *.log
67 | local_settings.py
68 | db.sqlite3
69 | db.sqlite3-journal
70 |
71 | # Flask stuff:
72 | instance/
73 | .webassets-cache
74 |
75 | # Scrapy stuff:
76 | .scrapy
77 |
78 | # Sphinx documentation
79 | docs/_build/
80 |
81 | # PyBuilder
82 | .pybuilder/
83 | target/
84 |
85 | # Jupyter Notebook
86 | .ipynb_checkpoints
87 |
88 | # IPython
89 | profile_default/
90 | ipython_config.py
91 |
92 | # pyenv
93 | # For a library or package, you might want to ignore these files since the code is
94 | # intended to run in multiple environments; otherwise, check them in:
95 | # .python-version
96 |
97 | # pipenv
98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
101 | # install all needed dependencies.
102 | #Pipfile.lock
103 |
104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
105 | __pypackages__/
106 |
107 | # Celery stuff
108 | celerybeat-schedule
109 | celerybeat.pid
110 |
111 | # SageMath parsed files
112 | *.sage.py
113 |
114 | # Environments
115 | .env
116 | .venv
117 | env/
118 | venv/
119 | ENV/
120 | env.bak/
121 | venv.bak/
122 |
123 | # Spyder project settings
124 | .spyderproject
125 | .spyproject
126 |
127 | # Rope project settings
128 | .ropeproject
129 |
130 | # mkdocs documentation
131 | /site
132 |
133 | # mypy
134 | .mypy_cache/
135 | .dmypy.json
136 | dmypy.json
137 |
138 | # Pyre type checker
139 | .pyre/
140 |
141 | # pytype static type analyzer
142 | .pytype/
143 |
144 | # Cython debug symbols
145 | cython_debug/
146 |
147 | .DS_Store
148 |
149 | mapflow_dev/
--------------------------------------------------------------------------------
/.qgis-plugin-ci:
--------------------------------------------------------------------------------
1 | plugin_path: mapflow
2 | github_organization_slug: Geoalert
3 | project_slug: mapflow-qgis
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # For external contributors
2 | This plugin is developed and maintained by [Geoalert LLC](https://www.geoalert.io).
3 | It's tightly connected with [Mapflow](https://mapflow.ai/), our AI platform, and as such requires the knowledge about it.
4 |
5 | The best way of contributing at the moment is to report bugs and make suggestions for improvement via [Issues](https://github.com/Geoalert/mapflow-qgis/issues). If you'd really like to contribute to the plugin directly, have a question about it or something else to discuss, please, feel free to send us an [email](mailto:help@geoalert.io?subject=QGIS plugin).
6 |
7 | # For the fellow Geoalert developers
8 | This is a Python plugin, so the first and foremost request is to follow the Python standards and conventions. For code style, see [PEP8](https://www.python.org/dev/peps/pep-0008/). For the code itself, just try to use contemporary Python idioms e.g. comprehensions and f-strings. The current code is written for Python 3.6+ and QGIS 3.16+. At the same time, try not to use any recently added Python features. First, QGIS lags slightly behind Python, so the latest version of Python may not be compatible. Second, many users tend to stick to slightly older versions of QGIS.
9 |
10 | Note that QGIS lies at the junction of Python and C++. PyQGIS, for example, uses `camelCase` convention for naming API functions and variables. Although this isn't a strict requirement, try to use Python's `snake_case` when writing your code. At least, don't mix the two to keep the code clean. As for the UI elements, since they're closer to Qt itself, we use `camelCase` for them, e.g. `getUrl`. This also helps distinguish them from the related python functions, e.g. `get_url`.
11 |
12 | Remember that PyQGIS is a rather special Python framework that borrow a lot from Qt and hence C++. For example, there's support for function overloading, a signal-slot system and an own threading approach. In most cases, a developer can still stick with the Python's native approaches, but at times the 'local' Qt method may be more efficient and less error-prone. Don't be afraid to consult the [Qt C++ documentation](https://doc.qt.io/) especially since [PyQGIS docs](https://www.riverbankcomputing.com/static/Docs/PyQt5/) are pretty scarce. And of course don't forget to read the [QGIS developer docs](https://docs.qgis.org/3.16/en/docs/pyqgis_developer_cookbook/index.html), especially the [plugin section](https://docs.qgis.org/3.16/en/docs/pyqgis_developer_cookbook/plugins/index.html).
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Get maps from pixels with Mapflow by Geoalert
2 |
3 | At [Geoalert](https://www.geoalert.io/en-US/), we employ Artificial Intelligence (AI) and Machine Learning (ML) to detect and extract real-world objects a.k.a. 'features' from satellite or aerial imagery.
4 |
5 | You choose what type of features you want to extract, where and from which imagery, and [Mapflow](https://mapflow.ai/) will do the work for you.
6 |
7 | Currently we can detect:
8 | - building footprints (optionally, with height)
9 | - forest (optionally, with height)
10 | - construction sites
11 | - roads
12 |
13 | More info about our AI models can be found [here](https://docs.mapflow.ai/userguides/pipelines).
14 |
15 | Mapflow supports various imagery sources types. You can upload your local GeoTIFF image, or use one of the tile services on the Web. By default, we use [Mapbox Satellite](https://www.mapbox.com/maps/satellite), but you can specify a link to another imagery in XYZ, WMS, etc. Among the providers, we have a special support for Maxar [SecureWatch](https://www.maxar.com/products/securewatch) service.
16 |
17 | 
18 |
19 |
20 | ## Installation
21 | The plugin can be found in the [official QGIS plugin repository](https://plugins.qgis.org/plugins/mapflow/) and can be installed by going to Plugins -> Manage and Install Plugins in QGIS, and then searching for 'Mapflow'. Make sure the 'all' tab is activated.
22 |
23 | ## Use
24 |
25 | To learn how to use the plugin, please, follow our [guide](https://docs.mapflow.ai/api/qgis_mapflow).
26 |
27 | ## License
28 |
29 | This software is released under the [GNU Public License (GPL)](mapflow/LICENSE) Version 2 or any later version. This license means that you can inspect and modify the source code and guarantees that you always have access to this software under the same termas as QGIS, that is free of cost and can be freely modified.
30 |
--------------------------------------------------------------------------------
/images/plugin_showcase.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/images/plugin_showcase.png
--------------------------------------------------------------------------------
/mapflow/__init__.py:
--------------------------------------------------------------------------------
1 | from qgis.gui import QgisInterface
2 |
3 | from .mapflow import Mapflow
4 |
5 | """
6 | This plugin is developed and maintained by Geoalert LLC (https://www.geoalert.io) and provides a QGIS interface
7 | for Mapflow, - an AI-based platform for detecting real-world object in satellite/aerial imagery (https://mapflow.ai/).
8 |
9 | Python modules:
10 | dialogs: defines the plugin's interfaces, - a main tabbed dialog and a smaller login dialog
11 | mapflow: the 'main' module; it contains the Mapflow class that implements the plugin logic
12 | workers: contains classes that represent the logic exectuted concurrently in a separate thread to avoid UI blocking
13 | helpers: contains functions that are used in both geoalert and workers and needn't be methods of the Mapflow class
14 | """
15 |
16 |
17 | def classFactory(iface: QgisInterface) -> Mapflow:
18 | """Initialize the plugin."""
19 | return Mapflow(iface)
20 |
--------------------------------------------------------------------------------
/mapflow/config.py:
--------------------------------------------------------------------------------
1 | import time
2 | from dataclasses import dataclass
3 |
4 | from PyQt5.QtCore import QCoreApplication
5 | from qgis.core import QgsSettings
6 |
7 | @dataclass
8 | class ConfigColumns():
9 | def __init__(self):
10 | self.METADATA_TABLE_ATTRIBUTES = {
11 | QCoreApplication.translate('Config', 'Product Type'): 'productType',
12 | QCoreApplication.translate('Config', 'Provider Name'): 'providerName',
13 | QCoreApplication.translate('Config', 'Preview'): 'preview',
14 | QCoreApplication.translate('Config', 'Sensor'): 'source',
15 | QCoreApplication.translate('Config', 'Band Order'): 'colorBandOrder',
16 | QCoreApplication.translate('Config', 'Cloud %'): 'cloudCover',
17 | QCoreApplication.translate('Config', 'Off Nadir') + f' \N{DEGREE SIGN}': 'offNadirAngle',
18 | QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=time.localtime().tm_zone): 'acquisitionDate',
19 | QCoreApplication.translate('Config', 'Zoom level'): 'zoom',
20 | QCoreApplication.translate('Config', 'Spatial Resolution, m'): 'pixelResolution',
21 | QCoreApplication.translate('Config', 'Image ID'): 'id',
22 | 'local_index': 'local_index'
23 | }
24 | self.PROJECTS_TABLE_COLUMNS = [
25 | "ID",
26 | QCoreApplication.translate('Config', "Project"),
27 | QCoreApplication.translate('Config', "Succeeded"),
28 | QCoreApplication.translate('Config', "Failed"),
29 | QCoreApplication.translate('Config', "Author"),
30 | QCoreApplication.translate('Config', "Updated at"),
31 | QCoreApplication.translate('Config', "Created at")
32 | ]
33 | self.MAX_WIDTH = 200
34 |
35 | @dataclass
36 | class Config:
37 | TIMEZONE = time.localtime().tm_zone
38 | PLUGIN_NAME = 'Mapflow'
39 | DEFAULT_MODEL = '🏠 Buildings'
40 | MAPFLOW_ENV = QgsSettings().value('variables/mapflow_env', "") or 'production'
41 | PROJECT_ID = QgsSettings().value("variables/mapflow_project_id", "") or "default"
42 | SERVER = "https://whitemaps-{env}.mapflow.ai/rest".format(env=MAPFLOW_ENV)
43 | BILLING_HISTORY_URL = "https://app.mapflow.ai/account/billing-history"
44 | TOP_UP_URL = "https://app.mapflow.ai/account/balance"
45 | MODEL_DOCS_URL = "https://docs.mapflow.ai/userguides/pipelines.html"
46 | IMAGERY_DOCS_URL = "https://docs.mapflow.ai/userguides/my_imagery.html#my-imagery-in-qgis"
47 | ZOOM_SELECTOR = QgsSettings().value("variables/zoom_selector", "false")
48 |
49 | # PROCESSINGS
50 | PROCESSING_TABLE_REFRESH_INTERVAL = 6 # in seconds
51 | PROCESSING_TABLE_COLUMNS = ('name',
52 | 'workflowDef',
53 | 'status',
54 | 'percentCompleted',
55 | 'aoiArea',
56 | 'cost',
57 | 'created',
58 | 'reviewUntil',
59 | 'id')
60 |
61 | """
62 | todo: add tabs in code, not in designer ?
63 | {'name' : self.tr("Name"),
64 | 'workflowDef': self.tr("Model"),
65 | 'status': self.tr("Status"),
66 | 'percentCompleted': self.tr("Progress %"),
67 | 'aoiArea': self.tr("Area, sq.km"),
68 | 'cost': self.tr("Cost"),
69 | 'created': self.tr("Created"),
70 | 'reviewUntil': self.tr("review until"),
71 | 'id': self.tr("ID")}
72 | """
73 | PROCESSING_TABLE_ID_COLUMN_INDEX = PROCESSING_TABLE_COLUMNS.index('id')
74 | PROCESSING_TABLE_SORT_COLUMN_INDEX = PROCESSING_TABLE_COLUMNS.index('created')
75 | DEFAULT_HIDDEN_COLUMNS = (PROCESSING_TABLE_COLUMNS.index(item) for item in ('id', 'reviewUntil', 'cost'))
76 |
77 | # MAXAR
78 | MAXAR_ID_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('id')
79 | LOCAL_INDEX_COLUMN = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('local_index')
80 | PPRVIEW_INDEX_COLUMN = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('preview')
81 | MAXAR_DATETIME_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index(QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=TIMEZONE))
82 | MAXAR_CLOUD_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index(QCoreApplication.translate('Config', 'Cloud %'))
83 | MAXAR_MAX_FREE_ZOOM = 12
84 |
85 | # MISC
86 | ENABLE_SENTINEL = (QgsSettings().value('variables/mapflow_enable_sentinel', "false").lower() == "true")
87 | SHOW_RAW_ERROR = (QgsSettings().value("variables/mapflow_raw_error", "false").lower() == "true")
88 | SKYWATCH_METADATA_MAX_AREA = 1e11 # 100,000 sq.km
89 | SKYWATCH_METADATA_MAX_SIDE_LENGTH = 1e6 # 1,000 km
90 | INVALID_TOKEN_WARNING_OBJECT_NAME = 'invalidToken'
91 | METADATA_MORE_BUTTON_OBJECT_NAME = 'getMoreMetadata'
92 | SENTINEL_WD_NAME_PATTERN = 'Sentinel-2'
93 | SENTINEL_ATTRIBUTES = f'Date & Time ({TIMEZONE})', 'Cloud %', 'Image ID', 'Preview'
94 | SENTINEL_ID_COLUMN_INDEX = SENTINEL_ATTRIBUTES.index('Image ID')
95 | SENTINEL_PREVIEW_COLUMN_INDEX = SENTINEL_ATTRIBUTES.index('Preview')
96 | SENTINEL_DATETIME_COLUMN_INDEX = SENTINEL_ATTRIBUTES.index(f'Date & Time ({TIMEZONE})')
97 | SENTINEL_CLOUD_COLUMN_INDEX = SENTINEL_ATTRIBUTES.index('Cloud %')
98 | SKYWATCH_POLL_INTERVAL = 2
99 | MAX_ZOOM = 21
100 | DEFAULT_ZOOM = MAXAR_MAX_FREE_ZOOM
101 | USER_STATUS_UPDATE_INTERVAL = 30 # seconds
102 |
103 | MAX_FILE_SIZE_PIXELS = 30_000
104 | MAX_FILE_SIZE_BYTES = 2*(1024**3)
105 |
106 | MAX_AOIS_PER_PROCESSING = int(QgsSettings().value("variables/mapflow_max_aois", "10"))
107 |
108 | SEARCH_RESULTS_PAGE_LIMIT = 1000 # objects per page
109 | PROJECTS_PAGE_LIMIT = 20
110 |
111 | # OAuth2
112 | OAUTH2_URL = "https://auth-duty.mapflow.ai/auth/realms/mapflow-duty/protocol/openid-connect"
113 | AUTH_CONFIG_NAME = f"mapflow_{MAPFLOW_ENV}"
114 | AUTH_CONFIG_MAPS = {'duty': '{"accessMethod":0,"apiKey":"","clientId":"qgis","clientSecret":"","configType":1,"customHeader":"","description":"","grantFlow":1,"id":"","name":"","objectName":"","password":"","persistToken":false,"queryPairs":{},"redirectPort":7070,"redirectUrl":"qgis","refreshTokenUrl":"https://auth-duty.mapflow.ai/auth/realms/mapflow-duty/protocol/openid-connect/token","requestTimeout":30,"requestUrl":"https://auth-duty.mapflow.ai/auth/realms/mapflow-duty/protocol/openid-connect/auth","scope":"","tokenUrl":"https://auth-duty.mapflow.ai/auth/realms/mapflow-duty/protocol/openid-connect/token","username":"","version":1}',
115 | 'staging': '{"accessMethod":0,"apiKey":"","clientId":"qgis","clientSecret":"","configType":1,"customHeader":"","description":"","grantFlow":1,"id":"","name":"","objectName":"","password":"","persistToken":false,"queryPairs":{},"redirectPort":7070,"redirectUrl":"qgis","refreshTokenUrl":"https://auth.mapflow.ai/auth/realms/mapflow-staging/protocol/openid-connect/token","requestTimeout":30,"requestUrl":"https://auth.mapflow.ai/auth/realms/mapflow-staging/protocol/openid-connect/auth","scope":"","tokenUrl":"https://auth.mapflow.ai/auth/realms/mapflow-staging/protocol/openid-connect/token","username":"","version":1}',
116 | 'internal': '{"accessMethod":0,"apiKey":"","clientId":"qgis","clientSecret":"","configType":1,"customHeader":"","description":"","grantFlow":1,"id":"","name":"","objectName":"","password":"","persistToken":false,"queryPairs":{},"redirectPort":7070,"redirectUrl":"qgis","refreshTokenUrl":"https://auth.mapflow.ai/auth/realms/mapflow-internal/protocol/openid-connect/token","requestTimeout":30,"requestUrl":"https://auth.mapflow.ai/auth/realms/mapflow-internal/protocol/openid-connect/auth","scope":"","tokenUrl":"https://auth.mapflow.ai/auth/realms/mapflow-internal/protocol/openid-connect/token","username":"","version":1}',
117 | 'production': '{"accessMethod":0,"apiKey":"","clientId":"qgis","clientSecret":"","configType":1,"customHeader":"","description":"","grantFlow":1,"id":"","name":"","objectName":"","password":"","persistToken":false,"queryPairs":{},"redirectPort":7070,"redirectUrl":"qgis","refreshTokenUrl":"https://auth.mapflow.ai/auth/realms/mapflow/protocol/openid-connect/token","requestTimeout":30,"requestUrl":"https://auth.mapflow.ai/auth/realms/mapflow/protocol/openid-connect/auth","scope":"","tokenUrl":"https://auth.mapflow.ai/auth/realms/mapflow/protocol/openid-connect/token","username":"","version":1}'}
118 | AUTH_CONFIG_MAP = AUTH_CONFIG_MAPS.get(MAPFLOW_ENV, '')
119 |
120 |
121 | config = Config()
122 |
--------------------------------------------------------------------------------
/mapflow/constants.py:
--------------------------------------------------------------------------------
1 | PROVIDERS_KEY = 'mapflow_data_providers'
2 |
3 | LEGACY_PROVIDERS_KEY = 'providers'
4 | LEGACY_PROVIDER_LOGIN_KEY = 'providerUsername'
5 | LEGACY_PROVIDER_PASSWORD_KEY = 'providerPassword'
6 | MAXAR_BASE_URL = 'https://securewatch.digitalglobe.com/earthservice/wmtsaccess?'
7 | OSM = 'type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png&zmax=19&zmin=0'
8 |
9 | SENTINEL_OPTION_NAME = 'Sentinel-2'
10 | SEARCH_OPTION_NAME = "🔎 Imagery Search"
11 | CATALOG_OPTION_NAME = "🖼️ My imagery"
12 |
13 | DEFAULT_HTTP_TIMEOUT_SECONDS = 5
14 |
--------------------------------------------------------------------------------
/mapflow/dialogs/__init__.py:
--------------------------------------------------------------------------------
1 | from .dialogs import ErrorMessageWidget, ReviewDialog
2 | from .login_dialog import MapflowLoginDialog
3 | from .main_dialog import MainDialog
4 | from .processing_dialog import UpdateProcessingDialog
5 | from .project_dialog import UpdateProjectDialog, CreateProjectDialog
6 | from .provider_dialog import ProviderDialog
7 |
--------------------------------------------------------------------------------
/mapflow/dialogs/dialogs.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from PyQt5 import uic
4 | from PyQt5.QtCore import Qt
5 | from PyQt5.QtWidgets import QWidget, QDialogButtonBox, QAbstractItemView, QListWidgetItem
6 | from qgis.core import QgsMapLayerProxyModel
7 |
8 | from .icons import plugin_icon
9 |
10 | ui_path = Path(__file__).parent/'static'/'ui'
11 |
12 |
13 | class ErrorMessageWidget(*uic.loadUiType(ui_path / 'error_message.ui')):
14 | def __init__(self, parent: QWidget, text: str, title: str = None, email_body: str = '') -> None:
15 | """A message box notifying user about a plugin error, with a 'Send a report' button."""
16 | super().__init__(parent)
17 | self.setupUi(self)
18 | self.setWindowIcon(plugin_icon)
19 | self.text.setText(text)
20 | if title:
21 | self.title.setText(title)
22 | self.mailTo.setText(
23 | '
Let us know
')
26 | )
27 |
28 |
29 | class ReviewDialog(*uic.loadUiType(ui_path/'review_dialog.ui')):
30 | def __init__(self, parent: QWidget) -> None:
31 | """Auth dialog."""
32 | super().__init__(parent)
33 | self.setupUi(self)
34 | self.setWindowIcon(plugin_icon)
35 | self.reviewLayerCombo.setFilters(QgsMapLayerProxyModel.HasGeometry)
36 | self.reviewLayerCombo.setAllowEmptyLayer(True)
37 |
38 | self.processing = None
39 |
40 | def setup(self, processing):
41 | self.processing = processing
42 | self.setWindowTitle(self.tr("Review {processing}").format(processing=processing.name))
43 | self.reviewLayerCombo.setCurrentIndex(0)
44 | ok = self.buttonBox.button(QDialogButtonBox.Ok)
45 | ok.setEnabled(self.review_submit_allowed())
46 | # Enabled only if the text is entered
47 | self.reviewComment.textChanged.connect(lambda: ok.setEnabled(self.review_submit_allowed()))
48 | self.reviewLayerCombo.layerChanged.connect(lambda: ok.setEnabled(self.review_submit_allowed()))
49 |
50 | def review_submit_allowed(self):
51 | text_ok = self.reviewComment.toPlainText() != ""
52 | # Not check geometry yet
53 | geometry_ok = True
54 | return text_ok and geometry_ok
55 |
56 |
57 | class UploadRasterLayersDialog(*uic.loadUiType(ui_path / 'raster_layers_dialog.ui')):
58 | def __init__(self, parent: QWidget) -> None:
59 | """Dialog for choosing raster layer(s) when uploading image to mosaic."""
60 | super().__init__(parent)
61 | self.setupUi(self)
62 | self.setWindowIcon(plugin_icon)
63 |
64 | def setup(self, layers):
65 | self.setWindowTitle(self.tr("Choose raster layers to upload to imagery collection"))
66 | for layer in layers:
67 | item_to_add = QListWidgetItem()
68 | item_to_add.setText(layer.name())
69 | item_to_add.setData(Qt.UserRole, layer.source())
70 | self.listWidget.addItem(item_to_add)
71 | self.listWidget.setSelectionMode(QAbstractItemView.ExtendedSelection)
72 | self.exec()
73 |
74 | def get_selected_rasters_list(self, callback):
75 | # Get selected layers' paths
76 | layers_paths = []
77 | for item in self.listWidget.selectedItems():
78 | layers_paths.append(item.data(Qt.UserRole))
79 | # Run service.upload_raster_layers_to_mosaic
80 | callback(layers_paths)
81 |
82 |
83 | class ConfirmProcessingStart(*uic.loadUiType(ui_path / 'processing_start_confirmation.ui')):
84 | def __init__(self, parent: QWidget) -> None:
85 | """A confirmation dialog for processing start with 'don't show again' checkbox, connected to Settings tab."""
86 | super().__init__(parent)
87 | self.setupUi(self)
88 | self.setWindowIcon(plugin_icon)
89 | self.setWindowTitle(self.tr("Confirm processing start"))
90 |
91 | def setup(self, name, price, provider, zoom, area, model, blocks) -> None:
92 | elided_name = self.modelLabel.fontMetrics().elidedText(name, Qt.ElideRight, self.nameLabel.width() + 100)
93 | self.nameLabel.setText(elided_name)
94 | if price is not None:
95 | self.priceHeader.setVisible(True)
96 | self.priceLabel.setVisible(True)
97 | self.priceLabel.setText(price)
98 | else:
99 | self.priceHeader.setVisible(False)
100 | self.priceLabel.setVisible(False)
101 | elided_provider = self.dataSourceLabel.fontMetrics().elidedText(provider, Qt.ElideRight, self.modelLabel.width() + 150)
102 | self.dataSourceLabel.setText(elided_provider)
103 | if not zoom:
104 | zoom = self.tr("No zoom selected")
105 | self.zoomLabel.setText(zoom)
106 | self.areaLabel.setText(area)
107 | elided_model = self.modelLabel.fontMetrics().elidedText(model, Qt.ElideRight, self.modelLabel.width() + 100)
108 | self.modelLabel.setText(elided_model)
109 | if len(blocks) == 0:
110 | blocks = [self.tr("No options selected")]
111 | else:
112 | blocks = [block.text() for block in blocks if block.isChecked()]
113 | self.modelOptionsLabel.setText(', \n'.join(blocks))
114 | self.exec()
115 |
--------------------------------------------------------------------------------
/mapflow/dialogs/icons.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from PyQt5.QtGui import QIcon
4 |
5 | icon_path = Path(__file__).parent/'static'/'icons'
6 | plugin_icon = QIcon(str(icon_path/'mapflow.png'))
7 | coins_icon = QIcon(str(icon_path/'coins-solid.svg'))
8 | plus_icon = QIcon(str(icon_path/'add.svg'))
9 | info_icon = QIcon(str(icon_path/'info.svg'))
10 | logout_icon = QIcon(str(icon_path/'log-out.svg'))
11 | minus_icon = QIcon(str(icon_path/'remove_provider.svg'))
12 | edit_icon = QIcon(str(icon_path/'edit_provider.svg'))
13 | chart_icon = QIcon(str(icon_path/'bar-chart-2.svg'))
14 | lens_icon = QIcon(str(icon_path/'magnifying-glass-solid.svg'))
15 | user_gear_icon = QIcon(str(icon_path/'user-gear-solid.svg'))
16 | processing_icon = QIcon(str(icon_path/'processing.svg'))
17 | options_icon = QIcon(str(icon_path/'ellipsis-solid.svg'))
18 | images_icon = QIcon(str(icon_path/'images.svg'))
19 | arrow_right_icon = QIcon(str(icon_path/'arrow_right.svg'))
20 | arrow_left_icon = QIcon(str(icon_path/'arrow_left.svg'))
21 | refresh_icon = QIcon(str(icon_path/'refresh.svg'))
22 |
--------------------------------------------------------------------------------
/mapflow/dialogs/login_dialog.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from PyQt5 import uic
4 | from PyQt5.QtWidgets import QWidget
5 |
6 | from .icons import plugin_icon
7 |
8 | ui_path = Path(__file__).parent/'static'/'ui'
9 |
10 |
11 | class MapflowLoginDialog(*uic.loadUiType(ui_path / 'login_dialog.ui')):
12 | def __init__(self, parent: QWidget, use_oauth: bool = False, token: str = "") -> None:
13 | """Auth dialog."""
14 | super().__init__(parent)
15 | self.setupUi(self)
16 | self.setWindowIcon(plugin_icon)
17 | self.use_oauth = use_oauth
18 | self.set_auth_type(self.use_oauth, token)
19 | self.invalidToken.setVisible(False)
20 | self.useOauth.setChecked(use_oauth)
21 |
22 | def token_value(self):
23 | if not self.use_oauth:
24 | return self.token.text().strip()
25 | else:
26 | return None
27 |
28 | def set_auth_type(self, use_oauth: bool = False, token: str = ""):
29 | self.use_oauth = use_oauth
30 | self.invalidToken.setVisible(False)
31 | if use_oauth:
32 | self.needAnAccount.setText(self.tr('You will be redirecrted to web browser
to enter your Mapflow login and password
'))
33 | self.invalidToken.setText(self.tr('Authorization is not completed!
'
34 | '
1. Complete authorization in browser.
'
35 | '
2. If it does not help, restart QGIS. '
36 | '
See documentation for help
'))
37 | else: # basic
38 | self.needAnAccount.setText(self.tr('Get token
Terms of use
Register at mapflow.ai to use the plugin
'))
39 | self.invalidToken.setText(self.tr('Invalid credentials'))
40 | self.invalidToken.setStyleSheet('color: rgb(239, 41, 41);')
41 | self.token.setText(token)
42 | self.token.setVisible(not use_oauth)
43 | self.labelPassword.setVisible(not use_oauth)
44 |
45 |
46 |
--------------------------------------------------------------------------------
/mapflow/dialogs/mosaic_dialog.py:
--------------------------------------------------------------------------------
1 | from PyQt5 import uic
2 | from PyQt5.QtWidgets import QWidget, QDialogButtonBox
3 |
4 | from ..schema.data_catalog import MosaicCreateSchema, MosaicUpdateSchema, MosaicReturnSchema
5 | from .dialogs import ui_path, plugin_icon
6 |
7 | class MosaicDialog(*uic.loadUiType(ui_path/'mosaic_dialog.ui')):
8 | def __init__(self, parent: QWidget) -> None:
9 | """A dialog for adding or editing mosaics"""
10 | super().__init__(parent)
11 | self.setupUi(self)
12 | self.setWindowIcon(plugin_icon)
13 | self.ok = self.buttonBox.button(QDialogButtonBox.Ok)
14 |
15 | self.mosaicName.textChanged.connect(self.on_name_change)
16 |
17 | def on_name_change(self):
18 | if not self.mosaicName.text():
19 | self.ok.setEnabled(False)
20 | self.ok.setToolTip(self.tr("Imagery collection name must not be empty!"))
21 | else:
22 | self.ok.setEnabled(True)
23 | self.ok.setToolTip("")
24 |
25 | class CreateMosaicDialog(MosaicDialog):
26 | def __init__(self, parent: QWidget):
27 | super().__init__(parent)
28 | self.ok.setEnabled(False)
29 |
30 | def setup(self):
31 | self.setWindowTitle("")
32 | self.mosaicName.setText("")
33 | self.mosaicTags.setText("")
34 | self.exec()
35 |
36 | def mosaic(self):
37 | if not self.mosaicName:
38 | raise AssertionError(self.tr("Imagery collection name must not be empty!"))
39 | tags_list = self.mosaicTags.text().split(", ") if self.mosaicTags.text() else None
40 | return MosaicUpdateSchema(name = self.mosaicName.text(),
41 | tags = tags_list if self.mosaicTags.text() else [])
42 |
43 | class UpdateMosaicDialog(MosaicDialog):
44 | def __init__(self, parent: QWidget):
45 | super().__init__(parent)
46 | self.ok.setEnabled(True)
47 |
48 | def setup(self, mosaic: MosaicReturnSchema):
49 | if not mosaic:
50 | raise TypeError(self.tr("UpdateMosaicDialog requires a imagery collection to update"))
51 | self.setWindowTitle(self.tr("Edit imagery collection {}").format(mosaic.name))
52 | self.mosaicName.setText(mosaic.name)
53 | if mosaic.tags:
54 | self.mosaicTags.setText(", ".join(mosaic.tags))
55 | else:
56 | self.mosaicTags.setText("")
57 | self.createMosaicCombo.setVisible(False)
58 | self.setFixedHeight(self.sizeHint().height())
59 | self.exec()
60 |
61 | def mosaic(self):
62 | if not self.mosaicName:
63 | raise AssertionError(self.tr("Imagery collection name must not be empty!"))
64 | tags_list = self.mosaicTags.text().split(", ") if self.mosaicTags.text() else None
65 | return MosaicUpdateSchema(name = self.mosaicName.text(),
66 | tags = tags_list if self.mosaicTags.text() else [])
67 |
--------------------------------------------------------------------------------
/mapflow/dialogs/processing_dialog.py:
--------------------------------------------------------------------------------
1 | from PyQt5 import uic
2 | from PyQt5.QtWidgets import QWidget, QDialogButtonBox
3 |
4 | from .dialogs import ui_path, plugin_icon
5 | from ..entity.processing import Processing
6 | from ..schema.processing import UpdateProcessingSchema
7 |
8 |
9 | class UpdateProcessingDialog(*uic.loadUiType(ui_path/'processing_dialog.ui')):
10 | def __init__(self, parent: QWidget) -> None:
11 | """A dialog for adding or editing an imagery provider."""
12 | super().__init__(parent)
13 | self.setupUi(self)
14 | self.setWindowIcon(plugin_icon)
15 | self.ok = self.buttonBox.button(QDialogButtonBox.Ok)
16 |
17 | self.processingName.textChanged.connect(self.on_name_change)
18 |
19 | def on_name_change(self):
20 | if not self.processingName.text():
21 | self.ok.setEnabled(False)
22 | self.ok.setToolTip(self.tr("Processing name must not be empty!"))
23 | else:
24 | self.ok.setEnabled(True)
25 | self.ok.setToolTip("")
26 |
27 | def setup(self, processing: Processing):
28 | if not processing:
29 | raise TypeError("Can edit only existing processing!")
30 | self.setWindowTitle(self.tr("Edit processing {}").format(processing.name))
31 | self.processingName.setText(processing.name)
32 | self.processingDescription.setText(processing.description or "")
33 | self.exec()
34 |
35 | def processing(self):
36 | if not self.processingName.text():
37 | raise AssertionError("Processing name must not be empty!")
38 | return UpdateProcessingSchema(name = self.processingName.text(),
39 | description = self.processingDescription.text() or None)
40 |
--------------------------------------------------------------------------------
/mapflow/dialogs/project_dialog.py:
--------------------------------------------------------------------------------
1 | from PyQt5 import uic
2 | from PyQt5.QtCore import QCoreApplication
3 | from PyQt5.QtWidgets import QWidget, QDialogButtonBox
4 |
5 | from .dialogs import ui_path, plugin_icon
6 | from ..schema.project import MapflowProject, CreateProjectSchema, UpdateProjectSchema
7 |
8 |
9 | class ProjectDialog(*uic.loadUiType(ui_path/'project_dialog.ui')):
10 | def __init__(self, parent: QWidget) -> None:
11 | """A dialog for adding or editing an imagery provider."""
12 | super().__init__(parent)
13 | self.setupUi(self)
14 | self.setWindowIcon(plugin_icon)
15 | self.ok = self.buttonBox.button(QDialogButtonBox.Ok)
16 |
17 | # we store here the provider that we are editing right now
18 | self.current_project = None
19 | self.result = None
20 |
21 | self.projectName.textChanged.connect(self.on_name_change)
22 |
23 | def on_name_change(self):
24 | if not self.projectName.text():
25 | self.ok.setEnabled(False)
26 | self.ok.setToolTip(self.tr("Project name must not be empty!"))
27 | else:
28 | self.ok.setEnabled(True)
29 | self.ok.setToolTip("")
30 |
31 | class CreateProjectDialog(ProjectDialog):
32 | def __init__(self, parent: QWidget):
33 | super().__init__(parent)
34 | self.ok.setEnabled(False)
35 |
36 | def setup(self):
37 | self.setWindowTitle("")
38 | self.projectName.setText("")
39 | self.projectDescription.setText("")
40 | self.exec()
41 |
42 | def project(self):
43 | if not self.projectName:
44 | raise AssertionError("Project name must not be empty!")
45 | return CreateProjectSchema(name = self.projectName.text(),
46 | description = self.projectDescription.text() or None)
47 |
48 | class UpdateProjectDialog(ProjectDialog):
49 | def __init__(self, parent: QWidget):
50 | super().__init__(parent)
51 | self.ok.setEnabled(True)
52 |
53 | def setup(self, project: MapflowProject):
54 | if not project:
55 | raise TypeError("UpdateProjectDialog requires a project to update")
56 | self.setWindowTitle(QCoreApplication.translate('ProjectDialog', "Edit project ") + project.name)
57 | self.projectName.setText(project.name)
58 | self.projectDescription.setText(project.description or "")
59 | self.exec()
60 |
61 |
62 | def project(self):
63 | if not self.projectName:
64 | raise AssertionError("Project name must not be empty!")
65 | return UpdateProjectSchema(name = self.projectName.text(),
66 | description = self.projectDescription.text() or None)
67 |
--------------------------------------------------------------------------------
/mapflow/dialogs/provider_dialog.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from PyQt5 import uic
4 | from PyQt5.QtWidgets import QWidget, QDialogButtonBox
5 |
6 | from .dialogs import ui_path, plugin_icon
7 | from ..entity.provider import (CRS,
8 | BasicAuth,
9 | UsersProvider,
10 | XYZProvider,
11 | TMSProvider,
12 | QuadkeyProvider,
13 | MaxarProvider)
14 | from ..functional.helpers import QUAD_KEY_REGEX, XYZ_REGEX, MAXAR_PROVIDER_REGEX
15 |
16 |
17 | class ProviderDialog(*uic.loadUiType(ui_path/'provider_dialog.ui')):
18 | def __init__(self, parent: QWidget) -> None:
19 | """A dialog for adding or editing an imagery provider."""
20 | super().__init__(parent)
21 | self.setupUi(self)
22 | self.setWindowIcon(plugin_icon)
23 | ok = self.buttonBox.button(QDialogButtonBox.Ok)
24 | ok.setEnabled(False)
25 |
26 | self.type.currentTextChanged.connect(self.on_type_change)
27 | self.name.textChanged.connect(lambda: ok.setEnabled(self.validate_and_create_provider()))
28 | self.url.textChanged.connect(lambda: ok.setEnabled(self.validate_and_create_provider()))
29 | self.login.textChanged.connect(lambda: ok.setEnabled(self.validate_and_create_provider()))
30 | self.password.textChanged.connect(lambda: ok.setEnabled(self.validate_and_create_provider()))
31 | self.crs.currentTextChanged.connect(lambda: ok.setEnabled(self.validate_and_create_provider()))
32 | self.save_credentials.toggled.connect(lambda: ok.setEnabled(self.validate_and_create_provider()))
33 |
34 | # we store here the provider that we are editing right now
35 | self.current_provider = None
36 | self.result = None
37 |
38 | def setup(self, provider: Optional[UsersProvider] = None, title: str = ''):
39 | self.current_provider = provider
40 | self.result = None
41 |
42 | if provider:
43 | name = provider.name
44 | url = provider.url
45 | source_type = provider.option_name
46 | crs = provider.crs
47 | title = name
48 | login = provider.credentials.login
49 | password = provider.credentials.password
50 | save_credentials = provider.save_credentials
51 | else:
52 | name = ""
53 | url = ""
54 | source_type = "xyz"
55 | crs = CRS.web_mercator
56 | title = title
57 | login = ""
58 | password = ""
59 | save_credentials = False
60 |
61 | # Fill out the edit dialog with the current data
62 | self.setWindowTitle(title)
63 | self.type.setCurrentText(source_type)
64 | self.name.setText(name)
65 | self.url.setText(url)
66 | self.crs.setCurrentText(crs.value)
67 | self.login.setText(login)
68 | self.password.setText(password)
69 | self.save_credentials.setChecked(save_credentials)
70 |
71 | self.show()
72 |
73 | def on_type_change(self):
74 | if self.type.currentText == MaxarProvider.option_name:
75 | self.crs.setCurrentText('EPSG:3857')
76 | self.crs.setDisabled(True)
77 | else: # ['xyz', 'tms', 'quadkey']
78 | self.url.setEnabled(True)
79 | self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(self.validate_and_create_provider())
80 |
81 | def validate_and_create_provider(self):
82 | name = self.name.text()
83 | url = self.url.text()
84 | source_type = self.type.currentText()
85 | crs = self.crs.currentText()
86 | login = self.login.text()
87 | password = self.password.text()
88 | save_credentials = self.save_credentials.isChecked()
89 |
90 | res = False
91 | if name and url: # non-empty
92 | if source_type in (TMSProvider.option_name, XYZProvider.option_name):
93 | res = bool(XYZ_REGEX.match(url))
94 | elif source_type == QuadkeyProvider.option_name:
95 | res = bool(QUAD_KEY_REGEX.match(url))
96 | elif source_type == MaxarProvider.option_name:
97 | res = bool(MAXAR_PROVIDER_REGEX.match(url)) and bool(login) and bool(password)
98 | else:
99 | raise AssertionError("Unexpected source type")
100 | if res:
101 | self.result = dict(option_name=source_type,
102 | name=name,
103 | url=url,
104 | crs=crs,
105 | credentials=BasicAuth(login, password),
106 | save_credentials=save_credentials)
107 |
108 | else:
109 | self.result = None
110 | return res
111 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/LICENSE.txt:
--------------------------------------------------------------------------------
1 | LICENSE
2 |
3 | Original GIS icons theme was created by Robert Szczepanek [1] and is licensed under a Creative Commons Attribution-Share Alike 3.0 Unported License [2].
4 | Feel free to use it for GIS software or for any other purposes. I only ask you to let me know about that and to include licence.txt file in your work.
5 |
6 | TITLE: gis-0.2
7 | DESCRIPTION: GIS icon theme
8 | AUTHOR: Robert Szczepanek, Anita Graser
9 | CONTACT: robert at szczepanek pl
10 | SITE: http://robert.szczepanek.pl/
11 |
12 |
13 | ACKNOWLEDGEMENTS
14 |
15 | OSGeo community [3] helped in whole design process.
16 | Especially I want to acknowledge GRASS and QGIS team members for creative comments and support:
17 | Martin Landa
18 | Michael Barton
19 | Tim Sutton
20 | Borys Jurgiel
21 |
22 | Robert Szczepanek
23 | Cracow 2011
24 | Poland
25 |
26 | [1] http://robert.szczepanek.pl/
27 | [2] http://creativecommons.org/licenses/by-sa/3.0/
28 | [3] http://www.osgeo.org/
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/add.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/arrow_left.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/arrow_right.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/bar-chart-2.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/coins-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/edit_provider.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/ellipsis-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/images.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/info.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/log-out.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/magnifying-glass-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/mapflow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/dialogs/static/icons/mapflow.png
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/mapflow_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/dialogs/static/icons/mapflow_logo.png
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/processing.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/refresh.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/remove_provider.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/icons/user-gear-solid.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/error_message.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | ErrorDialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 372
10 | 158
11 |
12 |
13 |
14 |
15 | 0
16 | 0
17 |
18 |
19 |
20 | Mapflow
21 |
22 |
23 |
24 | 0
25 |
26 | -
27 |
28 |
29 | Qt::AlignCenter
30 |
31 |
32 | true
33 |
34 |
35 |
36 | -
37 |
38 |
39 |
40 | 0
41 | 0
42 |
43 |
44 |
45 |
46 |
47 |
48 | Qt::AlignCenter
49 |
50 |
51 | true
52 |
53 |
54 |
55 | -
56 |
57 |
58 |
59 | 75
60 | true
61 |
62 |
63 |
64 | Error
65 |
66 |
67 | Qt::AlignCenter
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/login_dialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | LoginDialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 360
10 | 219
11 |
12 |
13 |
14 |
15 | 0
16 | 0
17 |
18 |
19 |
20 |
21 | 360
22 | 200
23 |
24 |
25 |
26 |
27 | 400
28 | 326
29 |
30 |
31 |
32 | Mapflow - Log In
33 |
34 |
35 | -
36 |
37 |
38 | true
39 |
40 |
41 |
42 | 0
43 | 0
44 |
45 |
46 |
47 |
48 | 0
49 | 0
50 |
51 |
52 |
53 | <html><head/><body><p><span style=" color:#ff0000;">Authorization is not configured! </span></p><p><br/>Setup authorization config <br/>and restart QGIS before login. <br/><a href="https://docs.mapflow.ai/api/qgis_mapflow.html#oauth2_setup"><span style=" text-decoration: underline; color:#094fd1;">See documentation for help </span></a></p></body></html>
54 |
55 |
56 | Qt::RichText
57 |
58 |
59 | Qt::AlignCenter
60 |
61 |
62 |
63 | -
64 |
65 |
-
66 |
67 |
68 | Token
69 |
70 |
71 |
72 | -
73 |
74 |
75 | This plugin is an interface to to the Mapflow.ai satellite image processing platform. You need to register an account to use it.
76 |
77 |
78 | 100
79 |
80 |
81 | true
82 |
83 |
84 |
85 |
86 |
87 | -
88 |
89 |
90 | <html><head/><body><p><a href="https://app.mapflow.ai/account/api"><span style=" text-decoration: underline; color:#0057ae;">Get token</span></a></p><p><a href="https://mapflow.ai/terms-of-use-en.pdf"><span style=" text-decoration: underline; color:#0057ae;">Terms of use</span></a></p><p>Register at <a href="https://mapflow.ai"><span style=" text-decoration: underline; color:#0057ae;">mapflow.ai</span></a> to use the plugin</p><p><br/></p></body></html>
91 |
92 |
93 | Qt::RichText
94 |
95 |
96 | Qt::AlignCenter
97 |
98 |
99 | true
100 |
101 |
102 |
103 | -
104 |
105 |
106 | 15
107 |
108 |
-
109 |
110 |
111 | Use Oauth2
112 |
113 |
114 |
115 | -
116 |
117 |
118 | Qt::Horizontal
119 |
120 |
121 |
122 | 40
123 | 20
124 |
125 |
126 |
127 |
128 | -
129 |
130 |
131 | Cancel
132 |
133 |
134 |
135 | -
136 |
137 |
138 | Log in
139 |
140 |
141 | true
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 | QgsPasswordLineEdit
152 | QLineEdit
153 |
154 |
155 |
156 |
157 |
158 |
159 | cancel
160 | clicked()
161 | LoginDialog
162 | reject()
163 |
164 |
165 | 209
166 | 214
167 |
168 |
169 | 179
170 | 119
171 |
172 |
173 |
174 |
175 |
176 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/mosaic_dialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | ProjectDialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 400
10 | 239
11 |
12 |
13 |
14 | Project
15 |
16 |
17 | -
18 |
19 |
20 | Name
21 |
22 |
23 |
24 | -
25 |
26 |
27 | 128
28 |
29 |
30 |
31 | -
32 |
33 |
34 | Tags
35 |
36 |
37 |
38 | -
39 |
40 |
41 | 1024
42 |
43 |
44 |
45 | -
46 |
47 |
48 | color: rgb(128, 128, 128);
49 |
50 |
51 | Note: separate tags with comma (", ")
52 |
53 |
54 |
55 | -
56 |
57 |
58 | Qt::Vertical
59 |
60 |
61 | QSizePolicy::MinimumExpanding
62 |
63 |
64 |
65 | 20
66 | 5
67 |
68 |
69 |
70 |
71 | -
72 |
73 |
-
74 |
75 | Create empty mosaic
76 |
77 |
78 | -
79 |
80 | Upload from files
81 |
82 |
83 | -
84 |
85 | Choose raster layers
86 |
87 |
88 |
89 |
90 | -
91 |
92 |
93 | Qt::Vertical
94 |
95 |
96 | QSizePolicy::MinimumExpanding
97 |
98 |
99 |
100 | 20
101 | 10
102 |
103 |
104 |
105 |
106 | -
107 |
108 |
109 | Qt::Horizontal
110 |
111 |
112 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | buttonBox
122 | accepted()
123 | ProjectDialog
124 | accept()
125 |
126 |
127 | 248
128 | 254
129 |
130 |
131 | 157
132 | 274
133 |
134 |
135 |
136 |
137 | buttonBox
138 | rejected()
139 | ProjectDialog
140 | reject()
141 |
142 |
143 | 316
144 | 260
145 |
146 |
147 | 286
148 | 274
149 |
150 |
151 |
152 |
153 |
154 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/processing_dialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 380
10 | 159
11 |
12 |
13 |
14 | Dialog
15 |
16 |
17 | -
18 |
19 |
20 | Name
21 |
22 |
23 |
24 | -
25 |
26 |
27 | 255
28 |
29 |
30 |
31 | -
32 |
33 |
34 | Description
35 |
36 |
37 |
38 | -
39 |
40 |
41 | 255
42 |
43 |
44 |
45 | -
46 |
47 |
48 | Qt::Horizontal
49 |
50 |
51 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | buttonBox
61 | accepted()
62 | Dialog
63 | accept()
64 |
65 |
66 | 248
67 | 254
68 |
69 |
70 | 157
71 | 274
72 |
73 |
74 |
75 |
76 | buttonBox
77 | rejected()
78 | Dialog
79 | reject()
80 |
81 |
82 | 316
83 | 260
84 |
85 |
86 | 286
87 | 274
88 |
89 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/project_dialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | ProjectDialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 400
10 | 152
11 |
12 |
13 |
14 | Project
15 |
16 |
17 | -
18 |
19 |
20 | Name
21 |
22 |
23 |
24 | -
25 |
26 |
27 | 128
28 |
29 |
30 |
31 | -
32 |
33 |
34 | Description
35 |
36 |
37 |
38 | -
39 |
40 |
41 | 1024
42 |
43 |
44 |
45 | -
46 |
47 |
48 | Qt::Horizontal
49 |
50 |
51 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | buttonBox
61 | accepted()
62 | ProjectDialog
63 | accept()
64 |
65 |
66 | 248
67 | 254
68 |
69 |
70 | 157
71 | 274
72 |
73 |
74 |
75 |
76 | buttonBox
77 | rejected()
78 | ProjectDialog
79 | reject()
80 |
81 |
82 | 316
83 | 260
84 |
85 |
86 | 286
87 | 274
88 |
89 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/provider_dialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | ProviderDialog
4 |
5 |
6 | Qt::WindowModal
7 |
8 |
9 |
10 | 0
11 | 0
12 | 325
13 | 308
14 |
15 |
16 |
17 |
18 | 0
19 | 0
20 |
21 |
22 |
23 |
24 | 325
25 | 225
26 |
27 |
28 |
29 |
30 | 325
31 | 325
32 |
33 |
34 |
35 | Provider
36 |
37 |
38 | -
39 |
40 |
41 | Qt::Horizontal
42 |
43 |
44 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
45 |
46 |
47 |
48 | -
49 |
50 |
-
51 |
52 |
53 | Type
54 |
55 |
56 |
57 | -
58 |
59 |
60 |
61 | 0
62 | 0
63 |
64 |
65 |
66 | Tile coordinate scheme. XYZ is the most popular format, use it if you are not sure
67 |
68 |
-
69 |
70 | xyz
71 |
72 |
73 | -
74 |
75 | tms
76 |
77 |
78 | -
79 |
80 | quadkey
81 |
82 |
83 | -
84 |
85 | Maxar WMTS
86 |
87 |
88 |
89 |
90 | -
91 |
92 |
93 | Name
94 |
95 |
96 |
97 | -
98 |
99 |
100 |
101 |
102 |
103 |
104 | -
105 |
106 |
107 | URL
108 |
109 |
110 |
111 | -
112 |
113 |
114 | -
115 |
116 |
117 | Login
118 |
119 |
120 |
121 | -
122 |
123 |
124 | -
125 |
126 |
127 | Password
128 |
129 |
130 |
131 | -
132 |
133 |
134 | CRS
135 |
136 |
137 |
138 | -
139 |
140 |
141 |
142 | 0
143 | 0
144 |
145 |
146 |
147 |
148 | 120
149 | 0
150 |
151 |
152 |
153 |
154 | 120
155 | 16777215
156 |
157 |
158 |
159 | Projection of the tile layer. The most popular is Web Mercator, use it if you are not sure
160 |
161 |
-
162 |
163 | EPSG:3857
164 |
165 |
166 | -
167 |
168 | EPSG:3395
169 |
170 |
171 |
172 |
173 | -
174 |
175 |
176 | Warninig! Login and password, if saved, will be stored in QGIS settings without encryption!
177 |
178 |
179 | Save login and password
180 |
181 |
182 |
183 | -
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 | QgsPasswordLineEdit
193 | QLineEdit
194 |
195 |
196 |
197 |
198 |
199 |
200 | buttonBox
201 | rejected()
202 | ProviderDialog
203 | reject()
204 |
205 |
206 | 316
207 | 260
208 |
209 |
210 | 286
211 | 274
212 |
213 |
214 |
215 |
216 | buttonBox
217 | accepted()
218 | ProviderDialog
219 | accept()
220 |
221 |
222 | 162
223 | 149
224 |
225 |
226 | 162
227 | 87
228 |
229 |
230 |
231 |
232 |
233 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/raster_layers_dialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | raterLayerSelection
4 |
5 |
6 |
7 | 0
8 | 0
9 | 380
10 | 320
11 |
12 |
13 |
14 | Multiple selection
15 |
16 |
17 |
18 | 6
19 |
20 |
21 | 9
22 |
23 |
24 | 9
25 |
26 |
27 | 9
28 |
29 |
30 | 9
31 |
32 | -
33 |
34 |
35 | 0
36 |
37 |
-
38 |
39 |
40 | -
41 |
42 |
43 | Qt::Horizontal
44 |
45 |
46 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | buttonBox
58 | accepted()
59 | raterLayerSelection
60 | accept()
61 |
62 |
63 | 248
64 | 254
65 |
66 |
67 | 157
68 | 274
69 |
70 |
71 |
72 |
73 | buttonBox
74 | rejected()
75 | raterLayerSelection
76 | reject()
77 |
78 |
79 | 316
80 | 260
81 |
82 |
83 | 286
84 | 274
85 |
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/mapflow/dialogs/static/ui/review_dialog.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | reviewDialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 400
10 | 300
11 |
12 |
13 |
14 | Dialog
15 |
16 |
17 | -
18 |
19 |
20 | 11
21 |
22 |
-
23 |
24 |
25 | Map layer with review
26 |
27 |
28 |
29 | -
30 |
31 |
32 |
33 |
34 | -
35 |
36 |
37 | -
38 |
39 |
40 | Qt::Horizontal
41 |
42 |
43 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | QgsMapLayerComboBox
52 | QComboBox
53 |
54 |
55 |
56 |
57 |
58 |
59 | buttonBox
60 | accepted()
61 | reviewDialog
62 | accept()
63 |
64 |
65 | 248
66 | 254
67 |
68 |
69 | 157
70 | 274
71 |
72 |
73 |
74 |
75 | buttonBox
76 | rejected()
77 | reviewDialog
78 | reject()
79 |
80 |
81 | 316
82 | 260
83 |
84 |
85 | 286
86 | 274
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/mapflow/entity/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/entity/__init__.py
--------------------------------------------------------------------------------
/mapflow/entity/billing.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class BillingType(str, Enum):
5 | credits = 'CREDITS'
6 | area = 'AREA'
7 | none = 'NONE'
--------------------------------------------------------------------------------
/mapflow/entity/processing.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from datetime import datetime, timedelta
3 | from typing import List, Dict, Optional, Tuple
4 |
5 | from .status import ProcessingStatus, ProcessingReviewStatus
6 | from ..errors import ErrorMessage
7 | from ..schema.processing import ProcessingParamsSchema, BlockOption
8 |
9 |
10 | class Processing:
11 | def __init__(self,
12 | id_,
13 | name,
14 | status,
15 | workflow_def,
16 | aoi_area,
17 | cost,
18 | created,
19 | percent_completed,
20 | raster_layer,
21 | vector_layer,
22 | errors=None,
23 | review_status=None,
24 | in_review_until=None,
25 | params: Optional[ProcessingParamsSchema] = None,
26 | blocks: Optional[List[BlockOption]] = None,
27 | description: Optional[str] = None,
28 | **kwargs):
29 | self.id_ = id_
30 | self.name = name
31 | self.status = ProcessingStatus(status)
32 | self.workflow_def = workflow_def
33 | self.aoi_area = aoi_area
34 | self.cost = int(cost)
35 | self.created = created.astimezone()
36 | self.percent_completed = int(percent_completed)
37 | self.errors = errors
38 | self.raster_layer = raster_layer
39 | self.vector_layer = vector_layer
40 | self.review_status = ProcessingReviewStatus(review_status)
41 | self.in_review_until = in_review_until
42 | self.params = params
43 | self.blocks = blocks
44 | self.description = description
45 |
46 | @classmethod
47 | def from_response(cls, processing):
48 | id_ = processing['id']
49 | name = processing['name']
50 | status = processing['status']
51 | description = processing.get("description") or None
52 | workflow_def = processing['workflowDef']['name']
53 | aoi_area = round(processing['aoiArea'] / 10 ** 6, 2)
54 |
55 | if sys.version_info.minor < 7: # python 3.6 doesn't understand 'Z' as UTC
56 | created = processing['created'].replace('Z', '+0000')
57 | else:
58 | created = processing['created']
59 | created = datetime.strptime(created, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone()
60 | percent_completed = processing['percentCompleted']
61 | messages = processing.get('messages', [])
62 | errors = [ErrorMessage.from_response(message) for message in messages]
63 | raster_layer = processing['rasterLayer']
64 | vector_layer = processing['vectorLayer']
65 | if processing.get('reviewStatus'):
66 | review_status = processing.get('reviewStatus', {}).get('reviewStatus')
67 | in_review_until_str = processing.get('reviewStatus', {}).get('inReviewUntil')
68 | if in_review_until_str:
69 | in_review_until = datetime.strptime(in_review_until_str, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone()
70 | else:
71 | in_review_until = None
72 | else:
73 | review_status = in_review_until = None
74 | cost = processing.get('cost', 0)
75 | params = ProcessingParamsSchema.from_dict(processing.get("params"))
76 | blocks = [BlockOption.from_dict(block) for block in processing.get("blocks", [])]
77 | return cls(id_,
78 | name,
79 | status,
80 | workflow_def,
81 | aoi_area,
82 | cost,
83 | created,
84 | percent_completed,
85 | raster_layer,
86 | vector_layer,
87 | errors,
88 | review_status,
89 | in_review_until,
90 | params,
91 | blocks,
92 | description)
93 |
94 | @property
95 | def is_new(self):
96 | now = datetime.now().astimezone()
97 | one_day = timedelta(1)
98 | return now - self.created < one_day
99 |
100 | @property
101 | def review_expires(self):
102 | if not isinstance(self.in_review_until, datetime)\
103 | or not self.review_status.is_in_review:
104 | return False
105 | now = datetime.now().astimezone()
106 | one_day = timedelta(1)
107 | return self.in_review_until - now < one_day
108 |
109 | def error_message(self, raw=False):
110 | if not self.errors:
111 | return ""
112 | return "\n".join([error.to_str(raw=raw) for error in self.errors])
113 |
114 | def asdict(self):
115 | return {
116 | 'id': self.id_,
117 | 'name': self.name,
118 | 'status': self.status_with_review,
119 | 'workflowDef': self.workflow_def,
120 | 'aoiArea': self.aoi_area,
121 | 'cost': self.cost,
122 | 'percentCompleted': self.percent_completed,
123 | 'errors': self.errors,
124 | # Serialize datetime and drop seconds for brevity
125 | 'created': self.created.strftime('%Y-%m-%d %H:%M'),
126 | 'rasterLayer': self.raster_layer,
127 | 'reviewUntil': self.in_review_until.strftime('%Y-%m-%d %H:%M') if self.in_review_until else "",
128 | "description": self.description
129 | }
130 |
131 | @property
132 | def status_with_review(self):
133 | """
134 | Review status is set instead of status if applicable, that is
135 | when the status is OK and review_status is set (not None)
136 | """
137 | if self.status.is_ok and not self.review_status.is_none:
138 | return self.review_status.display_value
139 | else:
140 | return self.status.display_value
141 |
142 |
143 | def parse_processings_request_dict(response: list) -> Dict[str, Processing]:
144 | res = {}
145 | for processing in response:
146 | new_processing = Processing.from_response(processing)
147 | res[new_processing.id_] = new_processing
148 | return res
149 |
150 |
151 | def parse_processings_request(response: list) -> List[Processing]:
152 | return [Processing.from_response(resp) for resp in response]
153 |
154 |
155 | class ProcessingHistory:
156 | """
157 | History of the processings, including failed and finished processings, that are stored in settings
158 | """
159 | def __init__(self,
160 | failed: Optional[List[str]] = None,
161 | finished: Optional[List[str]] = None):
162 | self.failed = failed or []
163 | self.finished = finished or []
164 |
165 | def asdict(self):
166 | return {
167 | 'failed': [id_ for id_ in self.failed],
168 | 'finished': [id_ for id_ in self.finished]
169 | }
170 |
171 | def update(self,
172 | failed: Optional[List[Processing]] = None,
173 | finished: Optional[List[Processing]] = None):
174 | self.failed = [processing.id_ for processing in failed]
175 | self.failed = [processing.id_ for processing in finished]
176 |
177 | @classmethod
178 | def from_settings(cls, settings: Dict[str, List[str]]):
179 | return cls(failed=settings.get('failed', []), finished=settings.get('finished', []))
180 |
181 |
182 | def updated_processings(processings: List[Processing],
183 | history: ProcessingHistory) -> Tuple[List[Processing], List[Processing], ProcessingHistory]:
184 | failed = []
185 | finished = []
186 | failed_ids = []
187 | finished_ids = []
188 | for processing in processings:
189 | if processing.status.is_failed:
190 | failed_ids.append(processing.id_)
191 | if processing.id_ not in history.failed:
192 | failed.append(processing)
193 | # Find recently finished processings and alert the user
194 | elif processing.percent_completed == 100:
195 | finished_ids.append(processing.id_)
196 | if processing.id_ not in history.finished:
197 | finished.append(processing)
198 |
199 | return failed, finished, ProcessingHistory(failed_ids, finished_ids)
200 |
--------------------------------------------------------------------------------
/mapflow/entity/provider/__init__.py:
--------------------------------------------------------------------------------
1 | from .basemap_provider import XYZProvider, MaxarProvider, TMSProvider, QuadkeyProvider
2 | from .collection import ProvidersList
3 | from .default import DefaultProvider, SentinelProvider, ImagerySearchProvider, MyImageryProvider
4 | from .factory import create_provider
5 | from .provider import UsersProvider, CRS, SourceType, BasicAuth, ProviderInterface
6 |
--------------------------------------------------------------------------------
/mapflow/entity/provider/basemap_provider.py:
--------------------------------------------------------------------------------
1 | """
2 | Basic, non-authentification XYZ provider
3 | """
4 | from abc import ABC
5 | from typing import Optional
6 | from urllib.parse import urlparse, parse_qs
7 |
8 | from .provider import SourceType, CRS, UsersProvider, staticproperty
9 | from ...functional.layer_utils import maxar_tile_url, add_connect_id
10 | from ...requests.maxar_metadata_request import MAXAR_REQUEST_BODY, MAXAR_META_URL
11 | from ...schema.processing import PostSourceSchema
12 |
13 |
14 | class BasemapProvider(UsersProvider, ABC):
15 | def to_processing_params(self,
16 | image_id: Optional[str] = None,
17 | provider_name: Optional[str] = None,
18 | url: Optional[str] = None,
19 | zoom: Optional[str] = None,
20 | requires_id: Optional[bool] = False):
21 | params = {
22 | 'url': self.url,
23 | 'projection': self.crs.value.lower(),
24 | 'source_type': self.source_type.value,
25 | 'zoom': zoom
26 | }
27 | if self.credentials:
28 | params.update(raster_login=self.credentials.login,
29 | raster_password=self.credentials.password)
30 | return PostSourceSchema(**params), {}
31 |
32 | @property
33 | def requires_image_id(self):
34 | return False
35 |
36 | def preview_url(self, image_id=None):
37 | return self.url
38 |
39 | @property
40 | def is_default(self):
41 | return False
42 |
43 | @staticproperty
44 | def option_name():
45 | # option for interface and settings
46 | raise NotImplementedError
47 |
48 |
49 | class XYZProvider(BasemapProvider):
50 | def __init__(self, **kwargs):
51 | kwargs.update(source_type=SourceType.xyz)
52 | super().__init__(**kwargs)
53 |
54 | @staticproperty
55 | def option_name():
56 | return 'xyz'
57 |
58 | @property
59 | def meta_url(self):
60 | return None
61 |
62 |
63 | class TMSProvider(BasemapProvider):
64 | def __init__(self, **kwargs):
65 | kwargs.update(source_type=SourceType.tms)
66 | super().__init__(**kwargs)
67 |
68 | @staticproperty
69 | def option_name():
70 | return 'tms'
71 |
72 | @property
73 | def meta_url(self):
74 | return None
75 |
76 |
77 | class QuadkeyProvider(BasemapProvider):
78 | def __init__(self, **kwargs):
79 | kwargs.update(source_type=SourceType.quadkey)
80 | super().__init__(**kwargs)
81 |
82 | @staticproperty
83 | def option_name():
84 | return 'quadkey'
85 |
86 | @property
87 | def meta_url(self):
88 | return None
89 |
90 |
91 | class MaxarProvider(UsersProvider):
92 | """
93 | Direct use of MAXAR vivid/secureWatch with user's credentials
94 | This is a case of WMTS provider, and in the future we will add general WMTSProvider which will include it,
95 | but currently the parameters are fixed for just Maxar SecureWatch and Vivid, and form a XYZ link
96 | from a Maxar WMTS link with connectId
97 | """
98 |
99 | def __init__(self,
100 | **kwargs):
101 | kwargs.update(crs=CRS.web_mercator)
102 | super().__init__(**kwargs)
103 |
104 | try:
105 | self.connect_id = parse_qs(urlparse(self.url.lower()).query)['connectid'][0]
106 | except (KeyError, IndexError):
107 | # could not extract connectId from URL!
108 | raise ValueError("Maxar provider link must contain your ConnectID parameter")
109 |
110 | @staticproperty
111 | def option_name():
112 | return 'Maxar WMTS'
113 |
114 | @property
115 | def meta_url(self):
116 | return add_connect_id(MAXAR_META_URL, self.connect_id)
117 |
118 | def meta_request(self, from_, to, max_cloud_cover, geometry):
119 | return MAXAR_REQUEST_BODY.format(from_=from_,
120 | to=to,
121 | max_cloud_cover=max_cloud_cover,
122 | geometry=geometry).encode()
123 |
124 | def to_processing_params(self,
125 | image_id: Optional[str] = None,
126 | provider_name: Optional[str] = None,
127 | url: Optional[str] = None,
128 | requires_id: Optional[bool] = False):
129 | params = PostSourceSchema(url=maxar_tile_url(self.url, image_id),
130 | source_type=self.source_type,
131 | projection=self.crs.value,
132 | raster_login=self.credentials.login,
133 | raster_password=self.credentials.password)
134 | return params, {}
135 |
136 | def preview_url(self, image_id=None):
137 | return maxar_tile_url(self.url, image_id)
138 |
139 | @property
140 | def requires_image_id(self):
141 | return True
142 |
143 | @property
144 | def is_default(self):
145 | return False
146 |
147 |
148 |
149 |
150 |
--------------------------------------------------------------------------------
/mapflow/entity/provider/collection.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from .factory import create_provider, create_provider_old
4 | from .provider import NoneProvider
5 | from ...constants import PROVIDERS_KEY, LEGACY_PROVIDERS_KEY, LEGACY_PROVIDER_LOGIN_KEY, LEGACY_PROVIDER_PASSWORD_KEY
6 |
7 |
8 | def decorate(base_name, existing_names):
9 | """
10 | Transform `name` -> `name (i)` with first non-occupied i
11 | """
12 | i = 1
13 | name = base_name + f' ({i})'
14 | while name in existing_names:
15 | name = base_name + f' ({i})'
16 | i += 1
17 | return name
18 |
19 |
20 | class ProvidersList(list):
21 |
22 | @classmethod
23 | def from_dict(cls, providers_dict):
24 | return ProvidersList(providers_dict.values())
25 |
26 | @classmethod
27 | def from_settings(cls, settings):
28 | errors = []
29 | providers = {}
30 | providers_settings = json.loads(settings.value(PROVIDERS_KEY, "{}"))
31 | if providers_settings:
32 | for name, params in providers_settings.items():
33 | if name in providers.keys():
34 | name = decorate(name, providers.keys())
35 | params["name"] = name
36 | try:
37 | providers.update({name: create_provider(**params)})
38 | except Exception as e:
39 | errors.append(name)
40 |
41 | # Importing providers from old plugin settings
42 | old_login = settings.value(LEGACY_PROVIDER_LOGIN_KEY, "")
43 | old_password = settings.value(LEGACY_PROVIDER_PASSWORD_KEY, "")
44 | old_providers = settings.value(LEGACY_PROVIDERS_KEY, {})
45 | for name, params in old_providers.items():
46 | if any(key not in params.keys() for key in ('type', 'url')):
47 | # settings are not understandable
48 | errors.append(name)
49 | provider = create_provider_old(name=name,
50 | source_type=params.get("type"),
51 | url=params.get("url"),
52 | login=old_login,
53 | password=old_password,
54 | connect_id=params.get("connectId", ""))
55 | if not provider:
56 | # this means that the provider should not be added
57 | continue
58 | if name in providers.keys():
59 | name = decorate(name, providers.keys())
60 | provider.name = name
61 | providers.update({name: provider})
62 | # clear old providers so that they will not be loaded again
63 | settings.remove(LEGACY_PROVIDERS_KEY)
64 | settings.remove(LEGACY_PROVIDER_PASSWORD_KEY)
65 | settings.remove(LEGACY_PROVIDER_LOGIN_KEY)
66 |
67 | return cls.from_dict(providers), errors
68 |
69 | def dict(self):
70 | return {provider.name: provider.to_dict() for provider in self}
71 |
72 | @property
73 | def default_providers(self):
74 | return ProvidersList([provider for provider in self if provider.is_default])
75 |
76 | @property
77 | def users_providers(self):
78 | return ProvidersList([provider for provider in self if not provider.is_default])
79 |
80 | def to_settings(self, settings):
81 | users_providers = self.users_providers.dict()
82 | settings.setValue(PROVIDERS_KEY, json.dumps(users_providers))
83 |
84 | def __getitem__(self, i):
85 | if i < 0:
86 | return NoneProvider()
87 | else:
88 | return super().__getitem__(i)
89 |
--------------------------------------------------------------------------------
/mapflow/entity/provider/default.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from .provider import BasicAuth
4 | from .provider import ProviderInterface, SourceType, CRS
5 | from ...constants import SENTINEL_OPTION_NAME, SEARCH_OPTION_NAME, CATALOG_OPTION_NAME
6 | from ...errors.plugin_errors import ImageIdRequired
7 | from ...schema import PostSourceSchema, PostProviderSchema
8 | from ...schema.provider import ProviderReturnSchema
9 |
10 |
11 | class SentinelProvider(ProviderInterface):
12 | def __init__(self,
13 | proxy,
14 | **kwargs):
15 | super().__init__(name=SENTINEL_OPTION_NAME)
16 | self.proxy = proxy
17 |
18 | def preview_url(self, image_id=None):
19 | return None
20 |
21 | @property
22 | def requires_image_id(self):
23 | return True
24 |
25 | def to_processing_params(self,
26 | image_id: Optional[str] = None,
27 | provider_name: Optional[str] = None,
28 | url: Optional[str] = None,
29 | requires_id: Optional[bool] = False):
30 | if not image_id and requires_id is True:
31 | raise ImageIdRequired("Sentinel provider must have image ID to launch the processing")
32 | return PostSourceSchema(url=image_id,
33 | source_type=SourceType.sentinel_l2a), {}
34 |
35 | @property
36 | def meta_url(self):
37 | return self.proxy + '/meta/skywatch/id'
38 |
39 | @property
40 | def is_default(self):
41 | return True
42 |
43 |
44 | class ImagerySearchProvider(ProviderInterface):
45 | """
46 | Represents imagery-based providers that are accessible via Mapflow API
47 | It allows to search the images based on metadata, see their footprings on the map,
48 | and order processing with particular image.
49 |
50 | It works with all the providers that are linked to the user via the same interface, regardless of
51 | real data provider
52 | """
53 |
54 | def __init__(self,
55 | proxy,
56 | **kwargs):
57 | super().__init__(name=SEARCH_OPTION_NAME)
58 | self.proxy = proxy
59 |
60 | def preview_url(self, image_id=None):
61 | return None
62 |
63 | @property
64 | def requires_image_id(self):
65 | return True
66 |
67 | def to_processing_params(self,
68 | image_id: Optional[str] = None,
69 | provider_name: Optional[str] = None,
70 | url: Optional[str] = None,
71 | zoom: Optional[str] = None,
72 | requires_id: Optional[bool] = False):
73 | if not image_id and requires_id is True:
74 | raise ImageIdRequired("Search provider must have image ID to launch the processing")
75 | return PostProviderSchema(data_provider=provider_name,
76 | url=image_id,
77 | zoom=zoom), {}
78 |
79 | @property
80 | def meta_url(self):
81 | return self.proxy + '/catalog/meta'
82 |
83 | @property
84 | def is_default(self):
85 | return True
86 |
87 |
88 | class MyImageryProvider(ProviderInterface):
89 | """
90 | Allows to create mosaics and upload user's imagery
91 | to later run a processing using it
92 | """
93 |
94 | def __init__(self):
95 | super().__init__(name=CATALOG_OPTION_NAME)
96 |
97 | @property
98 | def meta_url(self):
99 | return None
100 |
101 | @property
102 | def is_default(self):
103 | return True
104 |
105 | @property
106 | def requires_image_id(self):
107 | return False
108 |
109 | @classmethod
110 | def to_processing_params(self,
111 | image_id: Optional[str] = None,
112 | provider_name: Optional[str] = None,
113 | url: Optional[str] = None,
114 | zoom: Optional[str] = None,
115 | requires_id: Optional[bool] = False):
116 | return PostSourceSchema(url=url,
117 | source_type='local',
118 | zoom=zoom), {}
119 |
120 | class DefaultProvider(ProviderInterface):
121 | """
122 | Represents a tile-based (mosaic) data provider returned by the server.
123 | "Tile-based" means that the provider does not support imagery search and selection,
124 | and has only one mosaic for any place
125 | They are "default" in the sense that they are not set up by user.
126 | """
127 |
128 | def __init__(self,
129 | id: str,
130 | name: str,
131 | api_name: str,
132 | price: dict,
133 | preview_url: Optional[str] = None,
134 | source_type: SourceType = SourceType.xyz,
135 | credentials: BasicAuth = BasicAuth()):
136 | super().__init__(name=name)
137 | self.id = id
138 | self.api_name = api_name
139 | self.price = price
140 | self._preview_url = preview_url
141 |
142 | # In the default (server-side) providers these params are for preview only
143 | # By now, this is the only option for the server-side providers.
144 | # If this will change, we will initialize this from api request
145 | self.source_type = source_type
146 | self.credentials = credentials
147 | self.crs = CRS.web_mercator
148 |
149 | def preview_url(self, image_id=None):
150 | # We cannot provide preview via our proxy
151 | if not self._preview_url:
152 | raise NotImplementedError
153 | return self._preview_url
154 |
155 | @property
156 | def is_default(self):
157 | return True
158 |
159 | @property
160 | def is_payed(self):
161 | return any(value > 0 for value in self.price.values())
162 |
163 | @property
164 | def meta_url(self):
165 | return None
166 |
167 | @property
168 | def requires_image_id(self):
169 | return False
170 |
171 | @classmethod
172 | def from_response(cls, response: ProviderReturnSchema):
173 | return cls(id=response.id,
174 | name=response.displayName,
175 | api_name=response.name,
176 | price=response.price_dict,
177 | preview_url=response.previewUrl)
178 |
179 | def to_processing_params(self,
180 | zoom: Optional[str] = None,
181 | image_id: Optional[str] = None,
182 | provider_name: Optional[str] = None,
183 | url: Optional[str] = None,
184 | requires_id: Optional[bool] = False):
185 | return PostProviderSchema(data_provider=self.api_name, zoom=zoom), {}
186 |
--------------------------------------------------------------------------------
/mapflow/entity/provider/factory.py:
--------------------------------------------------------------------------------
1 | from .basemap_provider import XYZProvider, TMSProvider, QuadkeyProvider, MaxarProvider
2 | from ...constants import MAXAR_BASE_URL
3 | from ...functional.layer_utils import add_connect_id
4 |
5 | provider_options = {XYZProvider.option_name: XYZProvider,
6 | TMSProvider.option_name: TMSProvider,
7 | QuadkeyProvider.option_name: QuadkeyProvider,
8 | MaxarProvider.option_name: MaxarProvider}
9 |
10 |
11 | def create_provider(option_name,
12 | **kwargs):
13 | provider = provider_options[option_name]
14 | return provider(**kwargs)
15 |
16 |
17 | def create_provider_old(name, source_type, url, login, password, connect_id):
18 | """
19 | Load the list of the providers saved in old format, to not remove old user's providers
20 | """
21 | # connectid, login and password are only for maxar providers with user's own credentials
22 |
23 | if "securewatch" in name.lower() or "vivid" in name.lower() or "basemaps" in name.lower():
24 | if bool(connect_id):
25 | return MaxarProvider(name=name,
26 | url=add_connect_id(MAXAR_BASE_URL, connect_id),
27 | credentials=(login, password),
28 | save_credentials=bool(login) and bool(password))
29 | else:
30 | # this means default provider Maxar SecureWatch which now is not saved in settings
31 | return None
32 | elif "sentinel" in name.lower():
33 | # Sentinel provider is a default one and is not allowed for adding
34 | return None
35 | else: # not proxied XYZ provider
36 | if source_type == 'xyz':
37 | return XYZProvider(name=name, url=url)
38 | elif source_type == 'tms':
39 | return TMSProvider(name=name, url=url)
40 | elif source_type == "quadkey":
41 | return QuadkeyProvider(name=name, url=url)
42 | else:
43 | return None
44 |
--------------------------------------------------------------------------------
/mapflow/entity/provider/provider.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from abc import ABC
4 | from enum import Enum
5 | from typing import Iterable, Union, Optional
6 | from pathlib import Path
7 |
8 |
9 | class staticproperty(staticmethod):
10 | def __get__(self, *_):
11 | return self.__func__()
12 |
13 |
14 | class StrEnum(str, Enum):
15 | pass
16 |
17 |
18 | class SourceType(StrEnum):
19 | xyz = 'xyz'
20 | tms = 'tms'
21 | quadkey = 'quadkey'
22 | sentinel_l2a = 'sentinel_l2a'
23 |
24 | @property
25 | def requires_crs(self):
26 | return self.value in (self.xyz, self.tms, self.quadkey)
27 |
28 |
29 | class CRS(StrEnum):
30 | web_mercator = 'EPSG:3857'
31 | world_mercator = 'EPSG:3395'
32 |
33 |
34 | class BasicAuth:
35 | def __init__(self, login: str = "", password: str = ""):
36 | if not isinstance(login, str) or not isinstance(password, str):
37 | raise TypeError("Login and password must be string")
38 | self.login = login
39 | self.password = password
40 |
41 | def __iter__(self):
42 | # to convert to tuple/list
43 | yield self.login
44 | yield self.password
45 |
46 | def __bool__(self):
47 | return bool(self.login) or bool(self.password)
48 |
49 | def __str__(self):
50 | return f'{self.login}:{self.password}'
51 |
52 |
53 | class ProviderInterface:
54 | def __init__(self,
55 | name: str):
56 | self.name = name
57 |
58 | @property
59 | def is_default(self):
60 | raise NotImplementedError
61 |
62 | @property
63 | def requires_image_id(self):
64 | raise NotImplementedError
65 |
66 | @property
67 | def meta_url(self):
68 | raise NotImplementedError
69 |
70 | @property
71 | def is_payed(self):
72 | return False
73 |
74 | def preview_url(self, image_id=None):
75 | raise NotImplementedError
76 |
77 | def to_processing_params(self,
78 | image_id: Optional[str] = None,
79 | provider_name: Optional[str] = None,
80 | url: Optional[str] = None,
81 | zoom: Optional[str] = None,
82 | requires_id: Optional[bool] = False):
83 | """ You cannot create a processing with generic provider without implementation"""
84 | raise NotImplementedError
85 |
86 | @property
87 | def metadata_layer_name(self):
88 | if not self.meta_url:
89 | return None
90 | else:
91 | return f"{self.name} imagery search"
92 |
93 | def save_search_layer(self, folder, data: dict) -> Optional[str]:
94 | """
95 | saves to file (a single file specific to the provider) in specified folder, to be loaded later;
96 | """
97 | if not self.metadata_layer_name or not data:
98 | return
99 | with open(Path(folder, self.metadata_layer_name), 'w') as saved_results:
100 | saved_results.write(json.dumps(data))
101 | return str(Path(folder, self.metadata_layer_name))
102 |
103 | def load_search_layer(self, folder) -> Optional[dict]:
104 | """
105 | loads geometries as geojson dict
106 | Returns nothing if the provider does not support metadata search, or if the file does not exist
107 | """
108 | if not self.metadata_layer_name or not folder:
109 | return None
110 | try:
111 | with open(os.path.join(folder, self.metadata_layer_name), 'r') as saved_results:
112 | return json.load(saved_results)
113 | except FileNotFoundError:
114 | return None
115 |
116 | def clear_saved_search(self, folder) -> None:
117 | if not self.metadata_layer_name or not folder:
118 | return
119 | try:
120 | os.remove(os.path.join(folder, self.metadata_layer_name))
121 | except OSError:
122 | pass
123 |
124 |
125 | class UsersProvider(ProviderInterface, ABC):
126 | def __init__(self,
127 | name: str,
128 | url: str,
129 | source_type: Union[SourceType, str] = SourceType.xyz,
130 | crs: Optional[Union[CRS, str]] = CRS.web_mercator,
131 | credentials: Union[BasicAuth, Iterable[str]] = BasicAuth(),
132 | save_credentials: bool = False,
133 | **kwargs):
134 | super().__init__(name=name)
135 | self.source_type = SourceType(source_type)
136 | self.url = url
137 | if not crs and self.source_type.requires_crs:
138 | self.crs = CRS.web_mercator
139 | elif not self.source_type.requires_crs:
140 | self.crs = None
141 | else:
142 | self.crs = CRS(crs)
143 | if isinstance(credentials, BasicAuth):
144 | self.credentials = credentials
145 | else:
146 | self.credentials = BasicAuth(*credentials)
147 | self.save_credentials = save_credentials
148 |
149 | def to_dict(self):
150 | """
151 | Used to save it to the settinigs
152 | """
153 | if self.save_credentials:
154 | credentials = tuple(self.credentials)
155 | else:
156 | credentials = ("", "")
157 | if self.crs:
158 | crs = self.crs.value
159 | else:
160 | crs = None
161 | data = {
162 | 'name': self.name,
163 | 'source_type': self.source_type.value,
164 | 'option_name': self.option_name,
165 | 'url': self.url,
166 | 'credentials': credentials,
167 | 'save_credentials': self.save_credentials,
168 | 'crs': crs
169 | }
170 | return data
171 |
172 | @staticproperty
173 | def option_name():
174 | """
175 | Used to display the provider type in the interface
176 | """
177 | raise NotImplementedError
178 |
179 |
180 | class NoneProvider(ProviderInterface):
181 | def __init__(self):
182 | super().__init__(name="")
183 |
184 | def __bool__(self):
185 | return False
186 |
--------------------------------------------------------------------------------
/mapflow/entity/status.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | from PyQt5.QtCore import QObject
4 |
5 |
6 | class ProcessingStatusDict(QObject):
7 | def __init__(self):
8 | super().__init__()
9 | self.value_map = {None: None,
10 | 'OK': self.tr("Ok"),
11 | 'IN_PROGRESS': self.tr("In progress"),
12 | 'FAILED': self.tr("Failed"),
13 | 'REFUNDED': self.tr("Refunded"),
14 | 'CANCELLED': self.tr("Cancelled"),
15 | 'AWAITING': self.tr("Awaiting")}
16 |
17 |
18 | class ProcessingReviewStatusDict(QObject):
19 | def __init__(self):
20 | super().__init__()
21 | self.value_map = {None: None,
22 | 'IN_REVIEW': self.tr("Review required"),
23 | 'NOT_ACCEPTED': self.tr("In review"),
24 | 'REFUNDED': self.tr("Refunded"),
25 | 'ACCEPTED': self.tr("Ok")}
26 |
27 |
28 | class NamedEnum(Enum):
29 | def __init__(self, value):
30 | super().__init__()
31 | self.value_map = {}
32 |
33 | @property
34 | def display_value(self):
35 | return self.value_map.get(self.value, self.value)
36 |
37 |
38 | class ProcessingStatus(NamedEnum):
39 | none = None
40 | ok = 'OK'
41 | in_progress = 'IN_PROGRESS'
42 | failed = 'FAILED'
43 | refunded = 'REFUNDED'
44 | cancelled = 'CANCELLED'
45 | awaiting = 'AWAITING'
46 |
47 | def __init__(self, value):
48 | super().__init__(value)
49 | self.value_map = ProcessingStatusDict().value_map
50 |
51 | @property
52 | def is_ok(self):
53 | return self == ProcessingStatus.ok
54 |
55 | @property
56 | def is_in_progress(self):
57 | return self == ProcessingStatus.in_progress
58 |
59 | @property
60 | def is_failed(self):
61 | return self == ProcessingStatus.failed
62 |
63 | @property
64 | def is_refunded(self):
65 | return self == ProcessingStatus.refunded
66 |
67 | @property
68 | def is_cancelled(self):
69 | return self == ProcessingStatus.cancelled
70 |
71 | @property
72 | def is_awaiting(self):
73 | return self == ProcessingStatus.awaiting
74 |
75 |
76 | class ProcessingReviewStatus(NamedEnum):
77 | none = None
78 | in_review = 'IN_REVIEW'
79 | not_accepted = 'NOT_ACCEPTED'
80 | refunded = 'REFUNDED'
81 | accepted = 'ACCEPTED'
82 |
83 | def __init__(self, value):
84 | super().__init__(value)
85 | self.value_map = ProcessingReviewStatusDict().value_map
86 |
87 | @property
88 | def is_in_review(self):
89 | return self == ProcessingReviewStatus.in_review
90 |
91 | @property
92 | def is_not_accepted(self):
93 | return self == ProcessingReviewStatus.not_accepted
94 |
95 | @property
96 | def is_none(self):
97 | return self == ProcessingReviewStatus.none
--------------------------------------------------------------------------------
/mapflow/entity/workflow_def.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Optional, List
3 |
4 | from ..schema import SkipDataClass
5 |
6 |
7 | @dataclass
8 | class BlockConfig(SkipDataClass):
9 | name: str
10 | displayName: str
11 | price: int
12 | optional: bool
13 | # defaultEnable: bool
14 |
15 |
16 | @dataclass
17 | class WorkflowDef(SkipDataClass):
18 | id: str
19 | name: str
20 | description: str = ""
21 | pricePerSqKm: float = 1.0
22 | created: str = ""
23 | updated: str = ""
24 | blocks: Optional[List[dict]] = None
25 |
26 | def __post_init__(self):
27 | if self.blocks:
28 | self.blocks = [BlockConfig.from_dict(item) for item in self.blocks]
29 | # Store obligatory price in pricePerSqKm
30 | self.pricePerSqKm = sum(block.price for block in self.non_optional_blocks)
31 | else:
32 | self.blocks = []
33 |
34 | @property
35 | def optional_blocks(self):
36 | return [block for block in self.blocks if block.optional]
37 |
38 | @property
39 | def non_optional_blocks(self):
40 | return [block for block in self.blocks if not block.optional]
41 |
42 | def get_price(self, enable_blocks: Optional[List[bool]]):
43 | price = self.pricePerSqKm
44 | if len(enable_blocks) != len(self.optional_blocks):
45 | raise ValueError(f"enable_blocks param {enable_blocks }must correspond WD`s optional blocks {self.optional_blocks}!")
46 | for block, enabled in zip(self.optional_blocks, enable_blocks):
47 | if enabled:
48 | price += block.price
49 | return price
50 |
51 | def get_enabled_blocks(self, enable_blocks: List[bool]):
52 | """
53 | Form a dict to send it to API instead of list of toggles that we get from UIs
54 | """
55 | if len(enable_blocks) != len(self.optional_blocks):
56 | raise ValueError(f"enable_blocks param {enable_blocks }must correspond WD`s optional blocks {self.optional_blocks}!")
57 | return [{"name": block.name, "enabled": enabled} for block, enabled in zip(self.optional_blocks, enable_blocks)]
--------------------------------------------------------------------------------
/mapflow/errors/__init__.py:
--------------------------------------------------------------------------------
1 | from .errors import ErrorMessage, ErrorMessageList
2 | from .plugin_errors import *
3 |
--------------------------------------------------------------------------------
/mapflow/errors/api_errors.py:
--------------------------------------------------------------------------------
1 | from .error_message_list import ErrorMessageList
2 |
3 |
4 | class ApiErrors(ErrorMessageList):
5 | def __init__(self):
6 | super().__init__()
7 | self.error_descriptions = {
8 | "MAXAR_PROVIDERS_UNAVAILABLE": self.tr("Upgrade your subscription to get access to Maxar imagery")
9 | }
--------------------------------------------------------------------------------
/mapflow/errors/data_errors.py:
--------------------------------------------------------------------------------
1 | from .error_message_list import ErrorMessageList
2 |
3 |
4 | class DataErrors(ErrorMessageList):
5 | def __init__(self):
6 | super().__init__()
7 | self.error_descriptions = {
8 | "FileCheckFailed": self.tr("File {filename} cannot be processed. "
9 | "Parameters {bad_parameters} are incompatible with our catalog. "
10 | "See the documentation for more info."),
11 | "MemoryLimitExceeded": self.tr("Your file has size {memory_requested} bytes, "
12 | "but you have only {available_memory} left. "
13 | "Upgrade your subscription or remove older imagery from your catalog"),
14 | "FileTooBig": self.tr("Max file size allowed to upload is {max_file_size} bytes, "
15 | "your file is {actual_file_size} bytes instead. "
16 | "Compress your file or cut it into smaller parts"),
17 | "ItemNotFound": self.tr("{instance_type} with id: {uid} can't be found"),
18 | "AccessDenied": self.tr("You do not have access to {instance_type} with id {uid}"),
19 | "FileValidationFailed": self.tr("File {filename} cannot be uploaded to imagery collection: {mosaic_id}. "
20 | "{param_name} of the file is {got_param}, "
21 | "it should be {expected_param} to fit the collection. "
22 | "Fix your file, or upload it to another imagery collection"),
23 | "ImageOutOfBounds": self.tr("File can't be uploaded, because its extent is out of coordinate range."
24 | "Check please CRS and transform of the image, they may be invalid"),
25 | "FileOpenError": self.tr("File cannot be opened as a GeoTIFF file. "
26 | "Only valid geotiff files are allowed for uploading. "
27 | "You can use Raster->Conversion->Translate to change your file type to GeoTIFF"),
28 | "ImageExtentTooBig": self.tr("File can't be uploaded, because the geometry of the image is too big,"
29 | " we will not be able to process it properly."
30 | "Make sure that your image has valid CRS and transform, "
31 | "or cut the image into parts"
32 | )
33 |
34 |
35 | }
--------------------------------------------------------------------------------
/mapflow/errors/error_message_list.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import QObject
2 |
3 |
4 | class ErrorMessageList(QObject):
5 | def __init__(self):
6 | super().__init__()
7 | self.error_descriptions = {}
8 |
9 | def update(self, other):
10 | # sanity check
11 | assert set(self.error_descriptions.keys()).intersection(set(other.error_descriptions.keys())) == set()
12 | self.error_descriptions.update(other.error_descriptions)
13 |
14 | def get(self, key, default=None):
15 | if not default:
16 | default = self.tr("Unknown error. Contact us to resolve the issue! help@geoalert.io")
17 | return self.error_descriptions.get(key, default)
18 |
--------------------------------------------------------------------------------
/mapflow/errors/errors.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional
2 |
3 | from PyQt5.QtCore import QObject
4 |
5 | from .api_errors import ApiErrors
6 | from .data_errors import DataErrors
7 | from .error_message_list import ErrorMessageList
8 | from .processing_errors import ProcessingErrors
9 |
10 | """
11 | ["messages":[{"code":"source-validator.PixelSizeTooHigh","parameters":{"max_res":"1.2","level":"error","actual_res":"5.620983603290215"}}]}]
12 | """
13 |
14 |
15 | error_message_list = ErrorMessageList()
16 | error_message_list.update(ProcessingErrors())
17 | error_message_list.update(DataErrors())
18 | error_message_list.update(ApiErrors())
19 |
20 |
21 | class ErrorMessage(QObject):
22 | def __init__(self,
23 | code: str,
24 | parameters: Optional[Dict[str, str]] = None,
25 | message: Optional[str] = None):
26 | super().__init__()
27 | self.code = code
28 | self.parameters = parameters or {}
29 | self.message = message
30 |
31 | @classmethod
32 | def from_response(cls, response: Dict):
33 | return cls(response["code"], response["parameters"])
34 |
35 | def to_str(self, raw=False):
36 | default = "Unknown error. Contact us to resolve the issue! help@geoalert.io"
37 | message = error_message_list.get(self.code, default=default)
38 | if message == default and raw:
39 | return f"Raw error: code = {self.code}, parameters={self.parameters}, message={self.message}"
40 | try:
41 | message = message.format(**self.parameters)
42 | except KeyError as e:
43 | message = message \
44 | + self.tr("\n Warning: some error parameters were not loaded : {}!").format(str(e))
45 | except Exception as e:
46 | if self.message:
47 | # Default message which can be send by api
48 | message = self.tr("Error {code}: {message}").format(code=self.code, message=self.message)
49 | else:
50 | message = self.tr('Unknown error while fetching errors: {exception}'
51 | '\n Error code: {code}'
52 | '\n Contact us to resolve the issue! help@geoalert.io').format(exception=str(e),
53 | code=self.code)
54 | return message
55 |
--------------------------------------------------------------------------------
/mapflow/errors/plugin_errors.py:
--------------------------------------------------------------------------------
1 | class PluginError(ValueError):
2 | """Base class for exceptions in this module."""
3 | pass
4 |
5 |
6 | class BadProcessingInput(PluginError):
7 | """Raised when there is an error in UI input for processing."""
8 | pass
9 |
10 |
11 | class ProcessingInputDataMissing(PluginError):
12 | """Raised when some of necessary data fields for processing are not filled."""
13 | pass
14 |
15 |
16 | class ProcessingLimitExceeded(PluginError):
17 | """Raised when the user has exceeded the processing limit."""
18 | pass
19 |
20 |
21 | class ImageIdRequired(PluginError):
22 | pass
23 |
24 |
25 | class AoiNotIntersectsImage(PluginError):
26 | pass
27 |
28 | class ProxyIsAlreadySet(RuntimeError):
29 | pass
--------------------------------------------------------------------------------
/mapflow/errors/processing_errors.py:
--------------------------------------------------------------------------------
1 | from .error_message_list import ErrorMessageList
2 |
3 |
4 | class ProcessingErrors(ErrorMessageList):
5 | def __init__(self):
6 | super().__init__()
7 | self.error_descriptions = {
8 | "vector-processor.EmptyFolder": self.tr("Folder `{s3_link}` selected for processing "
9 | "does not contain any images. "),
10 | "source-validator.TaskMustContainAoi": self.tr("Task for source-validation must contain area of interest "
11 | "(`geometry` section)"),
12 | "source-validator.ImageReadError": self.tr("We could not open and read the image you have uploaded"),
13 | "source-validator.BadImageProfile": self.tr("Image profile (metadata) must have keys "
14 | "{required_keys}, got profile {profile}"),
15 | "source-validator.AOINotInCell": self.tr(
16 | "AOI does not intersect the selected Sentinel-2 granule {actual_cell}"),
17 | "source-validator.UrlMustBeString": self.tr("Key \'url\' in your request must be a string, "
18 | "got {url_type} instead."),
19 | "source-validator.UrlBlacklisted": self.tr("The specified basemap {url} is forbidden for processing"
20 | " because it contains a map, not satellite image. "
21 | "Our models are suited for satellite imagery."),
22 | "source-validator.UrlMustBeLink": self.tr("Your URL must be a link "
23 | "starting with \"http://\" or \"https://\"."),
24 | "source-validator.UrlFormatInvalid": self.tr("Format of \'url\' is invalid and cannot be parsed. "
25 | "Error: {parse_error_message}"),
26 | "source-validator.ZoomMustBeInteger": self.tr("Zoom must be either empty, or integer, got {actual_zoom}"),
27 | "source-validator.InvalidZoomValue": self.tr("Zoom must be between 0 and 22, got {actual_zoom}"),
28 | "source-validator.TooHighZoom": self.tr("Zoom must be between 0 and 22, got {actual_zoom}"),
29 | "source-validator.TooLowZoom": self.tr("Zoom must be not lower than {min_zoom}, got {actual_zoom}"),
30 | "source-validator.ImageMetadataMustBeDict": self.tr("Image metadata must be a dict (json)"),
31 | "source-validator.ImageMetadataKeyError": self.tr("Image metadata must have keys: "
32 | "crs, transform, dtype, count"),
33 | "source-validator.S3URLError": self.tr("URL of the image at s3 storage must be a string "
34 | "starting with s3://, got {actual_s3_link}"),
35 | "source-validator.LocalRequestKeyError": self.tr("Request must contain either 'profile' or 'url' keys"),
36 | "source-validator.ReadFromS3Failed": self.tr("Failed to read file from {s3_link}."),
37 | "source-validator.DtypeNotAllowed": self.tr("Image data type (Dtype) must be one of "
38 | "{required_dtypes}, got {request_dtype}"),
39 | "source-validator.NChannelsNotAllowed": self.tr("Number of channels in image must be one of "
40 | "{required_nchannels}. Got {real_nchannels}"),
41 | "source-validator.PixelSizeTooLow": self.tr("Spatial resolution of you image is too high: "
42 | "pixel size is {actual_res}, "
43 | "minimum allowed pixel size is {min_res}"),
44 | "source-validator.PixelSizeTooHigh": self.tr("Spatial resolution of you image is too low:"
45 | " pixel size is {actual_res}, "
46 | "maximum allowed pixel size is {max_res}"),
47 | "source-validator.ImageCheckError": self.tr("Error occurred during image {checked_param} check: {message}. "
48 | "Image metadata = {metadata}."),
49 | "source-validator.QuadkeyLinkFormatError": self.tr("Your \'url\' doesn't match the format, "
50 | "Quadkey basemap must be a link "
51 | "containing \"q\" placeholder."),
52 | "source-validator.SentinelInputStringKeyError": self.tr("Input string {input_string} is of unknown format. "
53 | "It must represent Sentinel-2 granule ID."),
54 | "source-validator.GridCellOutOfBound": self.tr("Selected Sentinel-2 image cell is {actual_cell}, "
55 | "this model is for the cells: {allowed_cells}"),
56 | "source-validator.MonthOutOfBounds": self.tr("Selected Sentinel-2 image month is {actual_month}, "
57 | "this model is for: {allowed_months}"),
58 | "source-validator.TMSLinkFormatError": self.tr("You request TMS basemap link doesn't match the format, "
59 | "it must be a link containing \"x\", \"y\", \"z\" "
60 | "placeholders, correct it and start processing again."),
61 | "source-validator.RequirementsMustBeDict": self.tr("Requirements must be dict, got {requirements_type}."),
62 | "source-validator.RequestMustBeDict": self.tr("Request must be dict, got {request_type}."),
63 | "source-validator.RequestMustHaveSourceType": self.tr("Request must contain \"source_type\" key"),
64 | "source-validator.SourceTypeIsNotAllowed": self.tr("Source type {source_type} is not allowed. "
65 | "Use one of: {allowed_sources}"),
66 | "source-validator.RequiredSectionMustBeDict": self.tr("\"Required\" section of the requirements "
67 | "must contain dict, not {required_section_type}"),
68 | "source-validator.RecommendedSectionMustBeDict": self.tr("\"Recommended\" section of the requirements "
69 | "must contain dict, not {recommended_section_type}"),
70 | "source-validator.XYZLinkFormatError": self.tr("You XYZ basemap link doesn't match the format, "
71 | "it must be a link "
72 | "containing \"x\", \"y\", \"z\" placeholders."),
73 | "source-validator.UnhandledException": self.tr("Internal error in process of data source validation."
74 | " We are working on the fix, our support will contact you."),
75 | "source-validator.internalError": self.tr("Internal error in process of data source validation."
76 | " We are working on the fix, our support will contact you."),
77 | "dataloader.internalError": self.tr("Internal error in process of loading data. "
78 | "We are working on the fix, our support will contact you."),
79 | "dataloader.UnknownSourceType": self.tr("Wrong source type {real_source_type}."
80 | " Specify one of the allowed types {allowed_source_types}."),
81 | "dataloader.MemoryLimitExceeded": self.tr("Your data loading task requires {estimated_size} MB of memory, "
82 | "which exceeded allowed memory limit {allowed_size}"),
83 | "dataloader.LoaderArgsError": self.tr("Dataloader argument {argument_name} has type {argument_type},"
84 | " excpected to be {expected_type}"),
85 | "dataloader.WrongChannelsNum": self.tr("Loaded tile has {real_nchannels} channels, "
86 | "required number is {expected_nchannels}"),
87 | "dataloader.WrongTileSize": self.tr("Loaded tile has size {real_size}, expected tile size "
88 | "is {expected_size}"),
89 | "dataloader.TileNotLoaded": self.tr("Tile at location {tile_location} cannot be loaded, "
90 | "server response is {status}"),
91 | "dataloader.TileNotReadable": self.tr("Response content at {tile_location} cannot be decoded as an image"),
92 | "dataloader.CrsIsNotSupported": self.tr("Internal error in process of loading data. "
93 | "We are working on the fix, our support will contact you."),
94 | "dataloader.MaploaderInternalError": self.tr("Internal error in process of loading data. "
95 | "We are working on the fix, our support will contact you."),
96 | "dataloader.SentinelLoaderInternalError": self.tr("Internal error in process of loading data. "
97 | "We are working on the fix, our support will contact you."),
98 | "dataloader.NoDataTile": self.tr("The data provider contains no data for your area of interest "
99 | "(returned NoData tiles). Try other the data sources to get the results."),
100 | "raster-processor.internalError": self.tr("Internal error in process of data preparation. "
101 | "We are working on the fix, our support will contact you."),
102 | "inference.internalError": self.tr("Internal error in process of data processing. "
103 | "We are working on the fix, our support will contact you."),
104 | "vector-processor.internalError": self.tr("Internal error in process of saving the results. "
105 | "We are working on the fix, our support will contact you.")
106 | }
--------------------------------------------------------------------------------
/mapflow/exceptions.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/exceptions.py
--------------------------------------------------------------------------------
/mapflow/functional/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/functional/__init__.py
--------------------------------------------------------------------------------
/mapflow/functional/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/functional/api/__init__.py
--------------------------------------------------------------------------------
/mapflow/functional/api/project_api.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Optional, Callable
3 |
4 | from PyQt5.QtCore import QObject
5 |
6 | from ...schema.project import CreateProjectSchema, UpdateProjectSchema, ProjectsRequest
7 | from ...http import Http
8 |
9 |
10 | class ProjectApi(QObject):
11 | def __init__(self,
12 | http: Http,
13 | server: str):
14 | super().__init__()
15 | self.server = server
16 | self.http = http
17 |
18 | def create_project(self, project: CreateProjectSchema, callback: Callable):
19 | self.http.post(url=f"{self.server}/projects",
20 | body=project.as_json().encode(),
21 | headers={},
22 | callback=callback,
23 | use_default_error_handler=True,
24 | timeout=5)
25 |
26 | def delete_project(self, project_id, callback: Callable):
27 | self.http.delete(url=f"{self.server}/projects/{project_id}",
28 | headers={},
29 | callback=callback,
30 | use_default_error_handler=True,
31 | timeout=5)
32 |
33 | def update_project(self, project_id, project: UpdateProjectSchema, callback: Callable):
34 | self.http.put(url=f"{self.server}/projects/{project_id}",
35 | body=project.as_json().encode(),
36 | headers={},
37 | callback=callback,
38 | use_default_error_handler=True,
39 | timeout=5)
40 |
41 | def get_project(self, project_id, callback: Callable, error_handler: Callable):
42 | self.http.get(url=f"{self.server}/projects/{project_id}",
43 | headers={},
44 | callback=callback,
45 | use_default_error_handler= False,
46 | error_handler=error_handler,
47 | timeout=5)
48 |
49 | def get_projects(self,
50 | request_body: ProjectsRequest,
51 | callback: Callable):
52 | self.http.post(url=f"{self.server}/projects/page",
53 | headers={},
54 | body=request_body.as_json().encode(),
55 | callback=callback,
56 | use_default_error_handler=True,
57 | timeout=10)
58 |
--------------------------------------------------------------------------------
/mapflow/functional/auth.py:
--------------------------------------------------------------------------------
1 | from qgis.core import QgsApplication, QgsAuthMethodConfig
2 |
3 | def get_auth_id(auth_config_name, auth_config_map):
4 | """
5 | Returns Tuple (config_id, reload)
6 | If reload is True, QGIS restart is required
7 | """
8 | auth_manager = QgsApplication.authManager()
9 | for config_id in auth_manager.configIds():
10 | auth_config = QgsAuthMethodConfig()
11 | auth_manager.loadAuthenticationConfig(config_id, auth_config)
12 | if auth_config.name() == auth_config_name:
13 | return config_id, False
14 | return setup_auth_config(auth_config_name, auth_config_map), True
15 |
16 | def setup_auth_config(auth_config_name, auth_config_map):
17 | auth_manager = QgsApplication.authManager()
18 | config = QgsAuthMethodConfig()
19 | config.setName(auth_config_name)
20 | config.setMethod("OAuth2")
21 | config.setConfig('oauth2config', auth_config_map)
22 | if config.isValid():
23 | auth_manager.storeAuthenticationConfig(config)
24 | config_id = config.id()
25 | new_config = QgsAuthMethodConfig()
26 | auth_manager.loadAuthenticationConfig(config_id, new_config, full=True)
27 | return config_id
28 | else:
29 | return None
30 |
31 |
--------------------------------------------------------------------------------
/mapflow/functional/controller/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/functional/controller/__init__.py
--------------------------------------------------------------------------------
/mapflow/functional/controller/data_catalog_controller.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import QObject, QTimer
2 | from ..service.data_catalog import DataCatalogService
3 | from ...dialogs.main_dialog import MainDialog
4 |
5 |
6 | class DataCatalogController(QObject):
7 | def __init__(self, dlg: MainDialog, data_catalog_service: DataCatalogService):
8 | self.dlg = dlg
9 | self.service = data_catalog_service
10 | self.view = self.service.view
11 |
12 | # At first, when mosaic and image are not selected, make buttons unavailable or hidden
13 | self.dlg.deleteCatalogButton.setEnabled(False)
14 | self.dlg.seeMosaicsButton.setEnabled(False)
15 | self.dlg.seeImagesButton.setEnabled(False)
16 |
17 | # Mosaic
18 | self.dlg.editMosaicButton.clicked.connect(self.service.update_mosaic)
19 | self.dlg.previewMosaicButton.clicked.connect(self.service.mosaic_preview)
20 | self.dlg.mosaicTable.selectionModel().selectionChanged.connect(self.service.check_mosaic_selection)
21 | self.dlg.showImagesButton.clicked.connect(self.view.show_images_table)
22 | self.dlg.seeImagesButton.clicked.connect(self.view.show_images_table)
23 | self.dlg.mosaicTable.cellDoubleClicked.connect(self.view.show_images_table)
24 | self.dlg.nextImageButton.clicked.connect(self.service.get_next_preview)
25 | self.dlg.previousImageButton.clicked.connect(self.service.get_previous_preview)
26 |
27 | # Image
28 | self.dlg.addImageButton.setMenu(self.view.upload_image_menu)
29 | self.view.upload_from_file.triggered.connect(self.service.upload_images_to_mosaic)
30 | self.view.choose_raster_layer.triggered.connect(self.service.choose_raster_layers)
31 | self.dlg.imageInfoButton.clicked.connect(self.service.image_info)
32 | self.dlg.previewImageButton.clicked.connect(self.service.get_image_preview_l)
33 | self.dlg.imageTable.selectionModel().selectionChanged.connect(self.service.check_image_selection)
34 | self.dlg.seeMosaicsButton.clicked.connect(self.service.switch_to_mosaics_table)
35 |
36 | # Mosaic or image (depending on selection)
37 | self.dlg.addCatalogButton.clicked.connect(self.service.add_mosaic_or_image)
38 | self.dlg.deleteCatalogButton.clicked.connect(self.service.delete_mosaic_or_image)
39 | self.dlg.sortCatalogCombo.activated.connect(self.view.sort_catalog)
40 | self.dlg.refreshCatalogButton.clicked.connect(self.service.refresh_catalog)
41 | self.dlg.filterCatalog.textChanged.connect(self.view.filter_catalog_table)
42 |
43 | # Show free and taken space if limit is not None
44 | self.service.mosaicsUpdated.connect(self.service.get_user_limit)
45 |
46 | self.dlg.myImageryDocsButton.clicked.connect(self.service.open_imagery_docs)
47 |
--------------------------------------------------------------------------------
/mapflow/functional/geometry.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from qgis import processing as qgis_processing # to avoid collisions
4 | from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsFeatureIterator
5 |
6 | def clip_aoi_to_image_extent(aoi_geometry: QgsGeometry,
7 | extents: List[QgsFeature]) -> QgsFeatureIterator:
8 | """Clip user AOI to image extent if the image doesn't cover the entire AOI.
9 | args:
10 | aoi_geometry: AOI geometry - selected by user area of interest (input)
11 | extents: list of QgsFeature objects from image extent(s) (overlay)
12 | """
13 | aoi_layer = QgsVectorLayer('Polygon?crs=epsg:4326', '', 'memory')
14 | aoi = QgsFeature()
15 | aoi.setGeometry(aoi_geometry)
16 | aoi_layer.dataProvider().addFeatures([aoi])
17 | aoi_layer.updateExtents()
18 | # Create a temp layer for the image extent
19 | image_extent_layer = QgsVectorLayer('MultiPolygon?crs=epsg:4326', '', 'memory')
20 | image_extent_layer.dataProvider().addFeatures(extents)
21 | image_extent_layer.updateExtents()
22 | try:
23 | # Find the intersection
24 | intersection = intersect_geoms(aoi_layer, image_extent_layer)
25 | except:
26 | # If intersection function fails, fix mosaic geometries beforehand
27 | fixed_image_layer = fix_geoms(image_extent_layer)
28 | fixed_aoi_layer = fix_geoms(aoi_layer)
29 | # And then use fixed layers for intersection
30 | intersection = intersect_geoms(fixed_aoi_layer, fixed_image_layer)
31 | return intersection.getFeatures()
32 |
33 | def clip_aoi_to_catalog_extent(catalog_aoi: QgsGeometry,
34 | selected_aoi: QgsGeometry) -> QgsFeatureIterator:
35 | # Create AOI layer from WGS84 geometry
36 | aoi_layer = QgsVectorLayer('Polygon?crs=epsg:4326', '', 'memory')
37 | aoi_feature = QgsFeature()
38 | aoi_feature.setGeometry(selected_aoi)
39 | aoi_layer.dataProvider().addFeatures([aoi_feature])
40 | aoi_layer.updateExtents()
41 | # Create a layer from chosen mosaic or image footprint
42 | catalog_layer = QgsVectorLayer('Polygon?crs=epsg:4326', '', 'memory')
43 | catalog_feature = QgsFeature()
44 | catalog_feature.setGeometry(catalog_aoi)
45 | catalog_layer.dataProvider().addFeatures([catalog_feature])
46 | catalog_layer.updateExtents()
47 | try:
48 | # Find the intersection
49 | intersection = intersect_geoms(aoi_layer, catalog_layer)
50 | except:
51 | # If intersection function fails, fix geometries beforehand
52 | fixed_aoi_layer = fix_geoms(aoi_layer)
53 | fixed_catalog_layer = fix_geoms(catalog_layer)
54 | # And then use fixed layers for intersection
55 | intersection = intersect_geoms(fixed_aoi_layer, fixed_catalog_layer)
56 | return intersection.getFeatures()
57 |
58 | def fix_geoms(layer: QgsVectorLayer) -> QgsVectorLayer:
59 | fixed_layer = qgis_processing.run(
60 | 'native:fixgeometries',
61 | {'INPUT':layer, 'METHOD':1, 'OUTPUT':'memory:'}
62 | )['OUTPUT']
63 | return fixed_layer
64 |
65 | def intersect_geoms(input_layer: QgsVectorLayer,
66 | overlay_layer: QgsVectorLayer) -> QgsVectorLayer:
67 | intersection = qgis_processing.run('qgis:intersection',
68 | {'INPUT': input_layer, 'OVERLAY': overlay_layer, 'OUTPUT': 'memory:'}
69 | )['OUTPUT']
70 | return intersection
--------------------------------------------------------------------------------
/mapflow/functional/helpers.py:
--------------------------------------------------------------------------------
1 | import re
2 | from pathlib import Path
3 | from typing import Tuple, Union, Optional
4 |
5 | from PyQt5.QtCore import QUrl, QCoreApplication
6 | from PyQt5.QtGui import QDesktopServices
7 | from qgis.core import (
8 | QgsGeometry, QgsProject, QgsCoordinateReferenceSystem, QgsCoordinateTransform,
9 | QgsRasterLayer
10 | )
11 |
12 | from ..config import config
13 | from ..entity.billing import BillingType
14 | from ..schema.project import UserRole
15 |
16 | PROJECT = QgsProject.instance()
17 | WGS84 = QgsCoordinateReferenceSystem('EPSG:4326')
18 | WGS84_ELLIPSOID = WGS84.ellipsoidAcronym()
19 | WEB_MERCATOR = QgsCoordinateReferenceSystem('EPSG:3857')
20 | UUID_REGEX = re.compile(r'[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}\Z')
21 | URL_PATTERN = r'https?://(www\.)?([-\w]{1,256}\.)+[a-zA-Z0-9]{1,6}' # schema + domains
22 | URL_REGEX = re.compile(URL_PATTERN)
23 | XYZ_REGEX = re.compile(URL_PATTERN + r'(.*\{[xyz]\}){3}.*', re.I)
24 | QUAD_KEY_REGEX = re.compile(URL_PATTERN + r'(.*\{q\}).*', re.I)
25 | MAXAR_PROVIDER_REGEX = re.compile(URL_PATTERN) # todo: make actual regex
26 | SENTINEL_DATETIME_REGEX = re.compile(r'\d{8}T\d{6}', re.I)
27 | SENTINEL_COORDINATE_REGEX = re.compile(r'T\d{2}[A-Z]{3}', re.I)
28 | SENTINEL_PRODUCT_NAME_REGEX = re.compile(r'\/(?:20[0-2][0-9])\/(?:1[0-2]|0?[1-9])\/(?:0?[1-9]|[1-2]\d|3[0-1])\/(\d{1,2})\/$')
29 |
30 |
31 | def to_wgs84(geometry: QgsGeometry, source_crs: QgsCoordinateReferenceSystem) -> QgsGeometry:
32 | """Reproject a geometry to WGS84.
33 |
34 | :param geometry: A feature's geometry
35 | :param source_crs: The current CRS of the passed geometry
36 | """
37 | spherical_geometry = QgsGeometry(geometry) # clone
38 | spherical_geometry.transform(QgsCoordinateTransform(source_crs, WGS84, PROJECT.transformContext()))
39 | return spherical_geometry
40 |
41 |
42 | def from_wgs84(geometry: QgsGeometry, target_crs: QgsCoordinateReferenceSystem) -> QgsGeometry:
43 | """Transform a geometry from WGS84.
44 |
45 | :param geometry: A feature's geometry
46 | :param target_crs: The current CRS of the passed geometry
47 | """
48 | projected_geometry = QgsGeometry(geometry) # clone
49 | projected_geometry.transform(QgsCoordinateTransform(WGS84, target_crs, PROJECT.transformContext()))
50 | return projected_geometry
51 |
52 |
53 | def check_version(local_version: str,
54 | server_version: str,
55 | latest_reported_version: str) -> Tuple[bool, bool]:
56 | """
57 | Returns: (force_upgrade, recommend_upgrade)
58 | force_upgrade is True if the user MUST reinstall/upgrade plugin to work with the server
59 | recommend_upgrade is True if the user MAY reinstall if he wants to have fixes/new features
60 | """
61 | if server_version == 1:
62 | return False, False
63 | # Legacy for current, before-versioning server behavior
64 | if server_version == latest_reported_version:
65 | # we have already reported the version on the server
66 | # should we expect the situation when the reported version can be higher than the current server version?
67 | # probably not
68 | return False, False
69 |
70 | loc_major, loc_minor, loc_patch = local_version.split('.')
71 | try:
72 | srv_major, srv_minor, srv_patch = server_version.split('.')
73 | except ValueError as e:
74 | # Means that server has wrong format of version, so we ignore this message
75 | return False, False
76 |
77 | major_changed = srv_major > loc_major
78 | minor_changed = loc_major == srv_major and loc_minor < srv_minor
79 | patch_changed = loc_major == srv_major and loc_minor == srv_minor and loc_patch < srv_patch
80 | return major_changed, (minor_changed or patch_changed)
81 |
82 |
83 | def raster_layer_is_allowed(layer: QgsRasterLayer,
84 | max_size_pixels: int = config.MAX_FILE_SIZE_PIXELS,
85 | max_size_bytes: int = config.MAX_FILE_SIZE_BYTES):
86 | filepath = Path(layer.dataProvider().dataSourceUri())
87 | res = layer.crs().isValid() \
88 | and (layer.width() < max_size_pixels) \
89 | and (layer.height() < max_size_pixels) \
90 | and filepath.suffix.lower() in ('.tif', '.tiff') \
91 | and filepath.exists() \
92 | and filepath.stat().st_size < max_size_bytes
93 | return res
94 |
95 |
96 | def check_aoi(aoi: Union[QgsGeometry, None]) -> bool:
97 | """Check if aoi is within the limits of [[-360:360] [-90:90]]"""
98 | if not aoi:
99 | return False
100 | b_box = aoi.boundingBox()
101 | x_max, x_min, y_max, y_min = b_box.xMaximum(), b_box.xMinimum(), b_box.yMaximum(), b_box.yMinimum()
102 | if x_max > 360 or x_max < -360 or x_min > 360 or x_min < -360:
103 | return False
104 | if y_max > 90 or y_max < -90 or y_min > 90 or y_min < -90:
105 | return False
106 | return True
107 |
108 |
109 | def open_url(url: str):
110 | url = QUrl(url)
111 | QDesktopServices.openUrl(url)
112 |
113 |
114 | def open_model_info(model_name: str):
115 | """Open model info page in browser"""
116 | if 'aerial' in model_name.lower() or 'uav' in model_name.lower():
117 | section = "buildings-aerial-imagery"
118 | elif 'roads' in model_name.lower():
119 | section = "roads"
120 | elif 'fields' in model_name.lower():
121 | section = "agriculture-fields"
122 | elif 'constructions' in model_name.lower():
123 | section = "constructions"
124 | elif "forest" in model_name.lower():
125 | section = "forest"
126 | elif "dense" in model_name.lower():
127 | section = "high-density-housing"
128 | elif 'buildings' in model_name.lower():
129 | section = "buildings"
130 | else:
131 | section = ""
132 | open_url(f"{config.MODEL_DOCS_URL}#{section}")
133 |
134 |
135 | def open_imagery_docs():
136 | open_url(config.IMAGERY_DOCS_URL)
137 |
138 |
139 | def check_processing_limit(billing_type: BillingType,
140 | remaining_limit: Optional[float],
141 | remaining_credits: Optional[int],
142 | aoi_size: float,
143 | processing_cost: int):
144 | """Check if the user has exceeded the processing limit."""
145 | if billing_type == BillingType.area:
146 | return remaining_limit >= aoi_size
147 | elif billing_type == BillingType.credits:
148 | return remaining_credits >= processing_cost
149 | else: # billing_type == BillingType.none
150 | return True
151 |
152 |
153 | def generate_plugin_header(plugin_name: str,
154 | env: Optional[str],
155 | project_name: Optional[str] = None,
156 | user_role: Optional[str] = None,
157 | project_owner: Optional[str] = None) -> str:
158 | header = plugin_name
159 | if env and env != "production":
160 | header = header + f" {env}"
161 | if project_name and project_name != "Default":
162 | header = header + QCoreApplication.translate('Header', ' | Project: ') + project_name
163 | if user_role and project_owner:
164 | if user_role != UserRole.owner:
165 | header = header + f" ({user_role.value}, " + QCoreApplication.translate('Header', 'owner: ') + f"{project_owner})"
166 | return header
167 |
168 |
169 | def get_readable_size(bytes: int) -> str:
170 | for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
171 | if abs(bytes) < 1024.0:
172 | return f"{bytes:3.1f} {unit}B"
173 | bytes /= 1024.0
174 | return f"{bytes:.1f} YB"
175 |
--------------------------------------------------------------------------------
/mapflow/functional/processing.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import QObject, pyqtSignal
2 | from PyQt5.QtNetwork import QNetworkReply
3 |
4 | from ..schema.processing import UpdateProcessingSchema
5 |
6 |
7 | class ProcessingService(QObject):
8 | processingUpdated = pyqtSignal()
9 |
10 | def __init__(self, http, server):
11 | super().__init__()
12 | self.http = http
13 | self.server = server
14 | self.projects = []
15 |
16 | def get_processings(self, project_id, callback):
17 | if not project_id:
18 | return
19 | self.http.get(
20 | url=f'{self.server}/projects/{project_id}/processings',
21 | callback=callback,
22 | use_default_error_handler=False # ignore errors to prevent repetitive alerts
23 | )
24 |
25 | def update_processing(self, processing_id, processing: UpdateProcessingSchema):
26 | self.http.put(url=f"{self.server}/processings/{processing_id}",
27 | body=processing.as_json().encode(),
28 | headers={},
29 | callback=self.update_processing_callback,
30 | use_default_error_handler=True,
31 | timeout=5)
32 |
33 | def update_processing_callback(self, response: QNetworkReply):
34 | self.processingUpdated.emit()
35 |
--------------------------------------------------------------------------------
/mapflow/functional/result_loader.py:
--------------------------------------------------------------------------------
1 | from ..entity.processing import Processing
2 |
3 |
4 | class ResultLoader:
5 | def __init__(self, processing: Processing, http):
6 | self.processing = processing
7 | self.raster_tilejson = None
8 | self.vector_tilejson = None
9 | self.raster_layer = None
10 | self.vector_layer = None
11 | self.http = http
12 |
13 | def __call__(self):
14 | """ Entrypoint """
15 | self.get_raster_tilejson()
16 |
17 | def get_raster_tilejson(self):
18 | pass
19 |
20 | def get_vector_tilejson(self, response):
21 | pass
22 |
--------------------------------------------------------------------------------
/mapflow/functional/service/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/functional/service/__init__.py
--------------------------------------------------------------------------------
/mapflow/functional/view/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/functional/view/__init__.py
--------------------------------------------------------------------------------
/mapflow/functional/view/project_view.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import QObject, Qt
2 | from PyQt5.QtWidgets import QWidget, QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView
3 |
4 | from ...dialogs.main_dialog import MainDialog
5 | from ...dialogs import icons
6 | from ...config import ConfigColumns
7 | from ...schema.project import MapflowProject, ProjectSortBy, ProjectSortOrder
8 |
9 |
10 | class ProjectView(QObject):
11 | def __init__(self, dlg: MainDialog):
12 | super().__init__()
13 | self.dlg = dlg
14 | self.dlg.projectsPreviousPageButton.setIcon(icons.arrow_left_icon)
15 | self.dlg.projectsNextPageButton.setIcon(icons.arrow_right_icon)
16 | # Buttons < and > in projects and processings are different because of stacked widget
17 | # So we specify to always disabled buttons just for looks
18 | self.dlg.switchProjectsButton.setIcon(icons.arrow_left_icon)
19 | self.dlg.switchProcessingsButton.setIcon(icons.arrow_right_icon)
20 | self.dlg.switchProjectsFakeButton.setIcon(icons.arrow_left_icon)
21 | self.dlg.switchProcessingsFakeButton.setIcon(icons.arrow_right_icon)
22 | self.dlg.switchProjectsButton.setToolTip(self.tr("See projects"))
23 | self.dlg.switchProjectsFakeButton.setToolTip(self.tr("See projects"))
24 | self.dlg.switchProcessingsButton.setToolTip(self.tr("See processings"))
25 | self.dlg.switchProcessingsFakeButton.setToolTip(self.tr("See processings"))
26 | self.dlg.switchProcessingsButton.setEnabled(False)
27 | self.dlg.filterProjects.setPlaceholderText(self.tr("Filter projects by name"))
28 | self.dlg.createProject.setToolTip(self.tr("Create project"))
29 | # Add sorting options for projects and set updated recently as default
30 | self.dlg.sortProjectsCombo.addItems([self.tr("A-Z"), self.tr("Z-A"),
31 | self.tr("Newest first"), self.tr("Oldest first"),
32 | self.tr("Updated recently"), self.tr("Updated long ago")])
33 | self.dlg.sortProjectsCombo.setCurrentIndex(4)
34 | self.columns_config = ConfigColumns()
35 |
36 | def show_projects_pages(self, enable: bool = False, page_number: int = 1, total_pages: int = 1):
37 | self.dlg.projectsPreviousPageButton.setVisible(enable)
38 | self.dlg.projectsNextPageButton.setVisible(enable)
39 | self.dlg.projectsPageLabel.setVisible(enable)
40 | if enable is True:
41 | self.dlg.projectsPageLabel.setText(f"{page_number}/{total_pages}")
42 | # Disable next arrow for the last page
43 | if page_number == total_pages:
44 | self.dlg.projectsNextPageButton.setEnabled(False)
45 | else:
46 | self.dlg.projectsNextPageButton.setEnabled(True)
47 | # Disable previous arrow for the first page
48 | if page_number == 1:
49 | self.dlg.projectsPreviousPageButton.setEnabled(False)
50 | else:
51 | self.dlg.projectsPreviousPageButton.setEnabled(True)
52 |
53 | def enable_projects_pages(self, enable: bool = False):
54 | self.dlg.projectsNextPageButton.setEnabled(enable)
55 | self.dlg.projectsPreviousPageButton.setEnabled(enable)
56 |
57 | def setup_projects_table(self, projects: dict[str, MapflowProject]):
58 | if not projects:
59 | return
60 | # First column is ID, hidden; second is name
61 | self.dlg.projectsTable.setColumnCount(len(self.columns_config.PROJECTS_TABLE_COLUMNS))
62 | self.dlg.projectsTable.setColumnHidden(0, True)
63 | self.dlg.projectsTable.setRowCount(len(projects))
64 | self.dlg.projectsTable.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
65 | self.dlg.projectsTable.setSelectionBehavior(QAbstractItemView.SelectRows)
66 | for row, project in enumerate(projects):
67 | id_item = QTableWidgetItem()
68 | id_item.setData(Qt.DisplayRole, project.id)
69 | self.dlg.projectsTable.setItem(row, 0, id_item)
70 | name_item = QTableWidgetItem()
71 | name_item.setData(Qt.DisplayRole, project.name)
72 | self.dlg.projectsTable.setItem(row, 1, name_item)
73 | ok_item = QTableWidgetItem()
74 | ok_item.setData(Qt.DisplayRole, project.processingCounts['succeeded'])
75 | self.dlg.projectsTable.setItem(row, 2, ok_item)
76 | failed_item = QTableWidgetItem()
77 | failed_item.setData(Qt.DisplayRole, project.processingCounts['failed'])
78 | self.dlg.projectsTable.setItem(row, 3, failed_item)
79 | owner_item = QTableWidgetItem()
80 | owner_item.setData(Qt.DisplayRole, project.shareProject.owners[0].email)
81 | self.dlg.projectsTable.setItem(row, 4, owner_item)
82 | updated_item = QTableWidgetItem()
83 | updated_item.setData(Qt.DisplayRole, project.updated.astimezone().strftime('%Y-%m-%d %H:%M'))
84 | self.dlg.projectsTable.setItem(row, 5, updated_item)
85 | created_item = QTableWidgetItem()
86 | created_item.setData(Qt.DisplayRole, project.created.astimezone().strftime('%Y-%m-%d %H:%M'))
87 | self.dlg.projectsTable.setItem(row, 6, created_item)
88 | self.dlg.projectsTable.setHorizontalHeaderLabels(self.columns_config.PROJECTS_TABLE_COLUMNS)
89 |
90 | self.dlg.projectsTable.resizeColumnsToContents()
91 | for column_idx in (1, 4):
92 | # these columns are user-defined and can expand too wide, so we bound them
93 | if self.dlg.projectsTable.columnWidth(column_idx) > self.columns_config.MAX_WIDTH:
94 | self.dlg.projectsTable.setColumnWidth(column_idx, self.columns_config.MAX_WIDTH)
95 | self.dlg.projectsTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive)
96 | self.dlg.projectsTable.horizontalHeader().setStretchLastSection(True)
97 | self.dlg.projectsTable.setSelectionMode(QAbstractItemView.SingleSelection)
98 |
99 | def select_project(self, project_id):
100 | try:
101 | item = self.dlg.projectsTable.findItems(project_id, Qt.MatchExactly)[0]
102 | self.dlg.projectsTable.setCurrentItem(item)
103 | except IndexError:
104 | self.switch_to_projects()
105 | pass
106 |
107 | def switch_to_projects(self):
108 | self.dlg.stackedProjectsWidget.setCurrentIndex(0)
109 | processings_tab = self.dlg.tabWidget.findChild(QWidget, "processingsTab")
110 | tab_index = self.dlg.tabWidget.indexOf(processings_tab)
111 | self.dlg.tabWidget.setTabText(tab_index, self.tr("Project"))
112 |
113 | def switch_to_processings(self):
114 | self.dlg.stackedProjectsWidget.setCurrentIndex(1)
115 | processings_tab = self.dlg.tabWidget.findChild(QWidget, "processingsTab")
116 | tab_index = self.dlg.tabWidget.indexOf(processings_tab)
117 | self.dlg.tabWidget.setTabText(tab_index, self.tr("Processing"))
118 |
119 | def sort_projects(self):
120 | index = self.dlg.sortProjectsCombo.currentIndex()
121 | # Define sorting field
122 | if index in (0, 1): # sort by name
123 | sort_by = ProjectSortBy.name
124 | elif index in (2, 3): # sort by date of creation
125 | sort_by = ProjectSortBy.created
126 | else: # sort by date of updating
127 | sort_by = ProjectSortBy.updated
128 | # Define sorting order
129 | if index in (0, 3, 5): # A-Z, Oldest first, Updated long ago
130 | sort_order = ProjectSortOrder.ascending
131 | else: # Z-A, Newest first, Updated recently
132 | sort_order = ProjectSortOrder.descending
133 | return sort_by.value, sort_order.value
134 |
--------------------------------------------------------------------------------
/mapflow/http.py:
--------------------------------------------------------------------------------
1 | import html
2 | import json
3 | from typing import Callable, Union, Optional
4 |
5 | from PyQt5.QtCore import QObject, QTimer, QUrl, qVersion
6 | from PyQt5.QtNetwork import QHttpMultiPart, QNetworkReply, QNetworkRequest
7 | from qgis.core import QgsNetworkAccessManager, Qgis, QgsApplication, QgsAuthMethodConfig
8 |
9 | from .constants import DEFAULT_HTTP_TIMEOUT_SECONDS
10 | from .errors import ErrorMessage, ProxyIsAlreadySet
11 |
12 |
13 | class Http(QObject):
14 | """"""
15 |
16 | def __init__(self,
17 | plugin_version: str,
18 | default_error_handler: Callable) -> None:
19 | """
20 | oauth_id is defined if we are using oauth2 configuration
21 | """
22 | self.oauth_id = None
23 | self.plugin_version = plugin_version
24 | self._basic_auth = b''
25 | self._oauth = None
26 | self.proxy_is_set = False
27 | self.nam = QgsNetworkAccessManager.instance()
28 | self.default_error_handler = default_error_handler
29 |
30 | def setup_auth(self,
31 | basic_auth_token: Optional[str] = None,
32 | oauth_id: Optional[int] = None):
33 | if oauth_id:
34 | if basic_auth_token is not None:
35 | raise ValueError("Only one auth method (basic auth / oauth2) may be set, got both")
36 | if self.proxy_is_set:
37 | # If the proxy is set, the OAuth2 flow will
38 | raise ProxyIsAlreadySet
39 | self._setup_oauth(oauth_id)
40 | elif basic_auth_token:
41 | # Proxy management blocks oauth2 redirect to browser, so it is activated only for default Basic Auth
42 | self.nam.setupDefaultProxyAndCache()
43 | self.proxy_is_set = True
44 | self.basic_auth = basic_auth_token
45 | else:
46 | raise ValueError("One of the auth methods (basic auth / oauth2) must be set, got none")
47 |
48 | def _setup_oauth(self, config_id: str):
49 | self.oauth_id = config_id
50 | self._oauth = QgsApplication.authManager()
51 | auth_config = QgsAuthMethodConfig()
52 | self._oauth.loadAuthenticationConfig(config_id, auth_config)
53 |
54 | def logout(self):
55 | if self._oauth:
56 | self._oauth.clearCachedConfig(self.oauth_id)
57 | self._oauth = None
58 |
59 | elif self._basic_auth:
60 | self._basic_auth = b''
61 |
62 | @property
63 | def basic_auth(self):
64 | """"""
65 | return self._basic_auth.decode()
66 |
67 | @basic_auth.setter
68 | def basic_auth(self, value: str):
69 | """"""
70 | self._basic_auth = value.encode()
71 |
72 | def get(self, **kwargs) -> QNetworkReply:
73 | """Send a GET request."""
74 | return self.send_request(self.nam.get, **kwargs)
75 |
76 | def post(self, **kwargs) -> QNetworkReply:
77 | """Send a POST request."""
78 | return self.send_request(self.nam.post, **kwargs)
79 |
80 | def put(self, **kwargs) -> QNetworkReply:
81 | """Send a PUT request"""
82 | return self.send_request(self.nam.put, **kwargs)
83 |
84 | def delete(self, **kwargs) -> QNetworkReply:
85 | """Send a DELETE request."""
86 | return self.send_request(self.nam.deleteResource, **kwargs)
87 |
88 | def response_dispatcher(
89 | self,
90 | response: QNetworkReply,
91 | callback: Callable,
92 | callback_kwargs: dict,
93 | error_handler: Callable,
94 | error_handler_kwargs: dict,
95 | use_default_error_handler: bool,
96 | ) -> None:
97 | """"""
98 | if response.error():
99 | if use_default_error_handler:
100 | if self.default_error_handler(response):
101 | return # a general error occurred and has been handled
102 | error_handler(response,
103 | **error_handler_kwargs) # handle specific errors
104 | else:
105 | callback(response, **callback_kwargs)
106 |
107 | def authorize(self, request: QNetworkRequest, auth: Optional[bytes] = None):
108 | if auth is not None:
109 | # Override of autorization, use basic auth
110 | request.setRawHeader(b'authorization', auth)
111 | elif self._oauth:
112 | updated, request = self._oauth.updateNetworkRequest(request, self.oauth_id)
113 | if not updated:
114 | raise Exception(f"Failed to apply Auth config to request {request.url}")
115 | elif self._basic_auth:
116 | request.setRawHeader(b'authorization', self._basic_auth)
117 | # else: assume that the request is non-authorized
118 | return request
119 |
120 | def send_request(
121 | self,
122 | method: Callable,
123 | url: str,
124 | headers: dict = None,
125 | auth: bytes = None,
126 | callback: Callable = None,
127 | callback_kwargs: dict = None,
128 | error_handler: Optional[Callable] = None,
129 | error_handler_kwargs: dict = None,
130 | use_default_error_handler: bool = True,
131 | timeout: int = DEFAULT_HTTP_TIMEOUT_SECONDS,
132 | body: Union[QHttpMultiPart, bytes] = None
133 | ) -> QNetworkReply:
134 | """Send an actual request."""
135 | request = QNetworkRequest(QUrl(url))
136 | if isinstance(body, bytes):
137 | request.setHeader(QNetworkRequest.ContentTypeHeader, 'application/json')
138 | if headers:
139 | for key, value in headers.items():
140 | request.setRawHeader(key.encode(), value.encode())
141 | request.setRawHeader(b'x-plugin-version', self.plugin_version.encode())
142 | try:
143 | request = self.authorize(request, auth)
144 | except Exception as e:
145 | # We skip the exception handling, then the request goes out unauthorized and the error response is handled
146 | pass
147 |
148 | response = method(request, body) if (method == self.nam.post or method == self.nam.put) else method(request)
149 |
150 | response.finished.connect(lambda response=response,
151 | callback=callback,
152 | callback_kwargs=callback_kwargs or {},
153 | error_handler=error_handler or (lambda _: None),
154 | error_handler_kwargs=error_handler_kwargs or {},
155 | use_default_error_handler=use_default_error_handler:
156 | self.response_dispatcher(response=response,
157 | callback=callback,
158 | callback_kwargs=callback_kwargs,
159 | error_handler=error_handler,
160 | error_handler_kwargs=error_handler_kwargs,
161 | use_default_error_handler=use_default_error_handler))
162 |
163 | def abort_request():
164 | if not response.isFinished():
165 | response.abort()
166 | QTimer.singleShot(timeout * 1000, abort_request)
167 |
168 | return response
169 |
170 |
171 | def update_processing_limit():
172 | pass
173 |
174 |
175 | def default_message_parser(response_body: str) -> str:
176 | return json.loads(response_body)['message']
177 |
178 |
179 | def data_catalog_message_parser(response_body: str) -> str:
180 | error_data = json.loads(response_body)['detail']
181 | message = ErrorMessage.from_response(error_data)
182 | return message.to_str()
183 |
184 |
185 | def api_message_parser(response_body: str) -> str:
186 | error_data = json.loads(response_body)
187 | message = ErrorMessage(code=error_data.get("code", "API_ERROR"),
188 | parameters=error_data.get("parameters", {}),
189 | message=error_data.get("message", "Unknown error"))
190 | return message.message
191 |
192 |
193 | def securewatch_message_parser(response_body: str) -> str:
194 | # todo: parse this HTML page for useful info, or display it as is?
195 | return response_body
196 |
197 |
198 | def get_error_report_body(response: QNetworkReply,
199 | response_body: str,
200 | plugin_version: str,
201 | error_message_parser: Optional[Callable] = None):
202 | if error_message_parser is None:
203 | error_message_parser = default_message_parser
204 | if response.error() == QNetworkReply.OperationCanceledError:
205 | send_error_text = show_error_text = 'Request timed out'
206 | else:
207 | try: # handled standardized backend exception ({"code": , "message": })
208 | show_error_text = error_message_parser(response_body=response_body)
209 | except: # unhandled error - plain text
210 | show_error_text = 'Unknown error'
211 | send_error_text = response_body
212 | report = {
213 | # escape in case the error text is HTML
214 | 'Error summary': html.escape(send_error_text),
215 | 'URL': response.request().url().toDisplayString(),
216 | 'HTTP code': response.attribute(QNetworkRequest.HttpStatusCodeAttribute),
217 | 'Qt code': response.error(),
218 | 'Plugin version': plugin_version,
219 | 'QGIS version': Qgis.QGIS_VERSION,
220 | 'Qt version': qVersion(),
221 | }
222 | email_body = '%0a'.join(f'{key}: {value}' for key, value in report.items())
223 |
224 | return show_error_text, email_body
225 |
--------------------------------------------------------------------------------
/mapflow/i18n/mapflow.pro:
--------------------------------------------------------------------------------
1 | FORM_DIRECTORY = ../dialogs/static/ui
2 |
3 | FORMS = $$FORM_DIRECTORY/login_dialog.ui $$FORM_DIRECTORY/main_dialog.ui\
4 | $$FORM_DIRECTORY/provider_dialog.ui $$FORM_DIRECTORY/mosaic_dialog.ui\
5 | $$FORM_DIRECTORY/project_dialog.ui $$FORM_DIRECTORY/processing_start_confirmation.ui
6 |
7 | SOURCES = ../mapflow.py ../errors/data_errors.py ../errors/error_message_list.py ../errors/errors.py ../errors/processing_errors.py ../errors/api_errors.py ../dialogs/login_dialog.py ../dialogs/dialogs.py ../dialogs/main_dialog.py ../functional/service/data_catalog.py ../functional/view/data_catalog_view.py ../functional/api/data_catalog_api.py ../dialogs/mosaic_dialog.py ../config.py ../dialogs/dialogs.py ../dialogs/project_dialog.py ../functional/helpers.py ../functional/view/project_view.py
8 | TRANSLATIONS = mapflow_ru.ts
9 |
--------------------------------------------------------------------------------
/mapflow/i18n/mapflow_ru.qm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/i18n/mapflow_ru.qm
--------------------------------------------------------------------------------
/mapflow/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/icon.png
--------------------------------------------------------------------------------
/mapflow/requests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Geoalert/mapflow-qgis/f2343c634ad5063ec5070f6088e2093962986150/mapflow/requests/__init__.py
--------------------------------------------------------------------------------
/mapflow/requests/maxar_metadata_request.py:
--------------------------------------------------------------------------------
1 |
2 | MAXAR_META_URL = 'https://securewatch.digitalglobe.com/catalogservice/wfsaccess?width=3000&height=3000'
3 |
4 | MAXAR_REQUEST_BODY = """
5 |
11 |
12 | productType
13 | source
14 | colorBandOrder
15 | cloudCover
16 | offNadirAngle
17 | acquisitionDate
18 | legacyId
19 | licenseType
20 | ageDays
21 | CE90Accuracy
22 | RMSEAccuracy
23 | geometry
24 |
25 |
26 |
27 | acquisitionDate
28 |
29 | {from_}
30 |
31 |
32 | {to}
33 |
34 |
35 |
36 |
37 | cloudCover
38 | {max_cloud_cover}
39 |
40 |
41 | cloudCover
42 |
43 |
44 |
45 | geometry
46 | {geometry}
47 |
48 |
49 |
50 |
51 |
52 | acquisitionDate
53 | DESC
54 |
55 |
56 |
57 | """
58 |
--------------------------------------------------------------------------------
/mapflow/schema/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import SkipDataClass
2 | from .catalog import ImageCatalogRequestSchema, ImageCatalogResponseSchema
3 | from .processing import PostSourceSchema, PostProviderSchema, PostProcessingSchema
4 | from .provider import ProviderReturnSchema
5 |
--------------------------------------------------------------------------------
/mapflow/schema/base.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | import json
3 | from dataclasses import dataclass, fields
4 | from datetime import datetime
5 |
6 |
7 | @dataclass
8 | class SkipDataClass:
9 | """
10 | Dataclass that skips all the unknown input arguments. Designed to withstand non-breaking API changes,
11 | so all the schemas for response parsing should inherit it
12 |
13 | This is abstract class and will do nothing, as it has no fields
14 | """
15 | @classmethod
16 | def from_dict(cls, params_dict: dict):
17 | clsf = [f.name for f in fields(cls)]
18 | return cls(**{k: v for k, v in params_dict.items() if k in clsf})
19 |
20 |
21 | @dataclass
22 | class Serializable:
23 | @staticmethod
24 | def decorate_value(value):
25 | """
26 | Common serialization dacorations
27 | """
28 | if isinstance(value, datetime):
29 | return value.isoformat()
30 | else:
31 | return value
32 |
33 | def as_dict(self, skip_none=True):
34 | if skip_none:
35 | return dataclasses.asdict(self,
36 | dict_factory=lambda x: {k: self.decorate_value(v) for (k, v) in x if v is not None})
37 | else:
38 | return dataclasses.asdict(self,
39 | dict_factory=lambda x: {k: self.decorate_value(v) for (k, v) in x})
40 |
41 | def as_json(self, skip_none=True):
42 | return json.dumps(self.as_dict(skip_none=skip_none))
43 |
--------------------------------------------------------------------------------
/mapflow/schema/catalog.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from datetime import datetime
3 | from enum import Enum
4 | from typing import Optional, Mapping, Any, Union, List
5 |
6 | from .base import Serializable, SkipDataClass
7 |
8 | from ..config import Config
9 |
10 | class PreviewType(str, Enum):
11 | png = "png"
12 | xyz = "xyz"
13 | tms = "tms"
14 | wms = "wms"
15 |
16 | class ProductType(str, Enum):
17 | mosaic = "Mosaic"
18 | image = "Image"
19 |
20 | @dataclass
21 | class ImageCatalogRequestSchema(Serializable):
22 | aoi: Mapping[str, Any]
23 | acquisitionDateFrom: Union[datetime, str, None] = None
24 | acquisitionDateTo: Union[datetime, str, None] = None
25 | minResolution: Optional[float] = None
26 | maxResolution: Optional[float] = None
27 | maxCloudCover: Optional[float] = None
28 | minOffNadirAngle: Optional[float] = None
29 | maxOffNadirAngle: Optional[float] = None
30 | minAoiIntersectionPercent: Optional[float] = None
31 | limit: Optional[int] = Config.SEARCH_RESULTS_PAGE_LIMIT
32 | offset: Optional[int] = 0
33 | hideUnavailable: Optional[bool] = False
34 | productTypes: Optional[List[ProductType]] = None
35 | dataProviders: Optional[List[str]] = None
36 |
37 | @dataclass
38 | class ImageSchema(Serializable, SkipDataClass):
39 | id: str
40 | footprint: Optional[dict]
41 | pixelResolution: Optional[float]
42 | acquisitionDate: Union[datetime, str]
43 | productType: Optional[str]
44 | sensor: Optional[str]
45 | colorBandOrder: Optional[str]
46 | cloudCover: Optional[float]
47 | offNadirAngle: Optional[float]
48 | source: Optional[str] = None # Duplicate of sensor for the table (like in Maxar)
49 | previewType: Optional[PreviewType] = None
50 | previewUrl: Optional[str] = None
51 | providerName: Optional[str] = None
52 | zoom: Optional[str] = None
53 |
54 | def __post_init__(self):
55 | if isinstance(self.acquisitionDate, str):
56 | self.acquisitionDate = datetime.fromisoformat(self.acquisitionDate.replace("Z", "+00:00"))
57 | elif not isinstance(self.acquisitionDate, datetime):
58 | raise TypeError("Acquisition date must be either datetime or ISO-formatted str")
59 | self.cloudCover = self.cloudCover
60 | self.source = self.sensor
61 | self.previewType = PreviewType(self.previewType)
62 |
63 | def as_geojson(self):
64 | properties = {k: v for k, v in self.as_dict().items() if k != "footprint"}
65 | res = {"type": "Feature",
66 | "geometry": self.footprint,
67 | "properties": properties}
68 | return res
69 |
70 | @dataclass
71 | class ImageCatalogResponseSchema(Serializable):
72 | images: List[ImageSchema]
73 | total: int = Config.SEARCH_RESULTS_PAGE_LIMIT
74 | limit: int = Config.SEARCH_RESULTS_PAGE_LIMIT
75 | offset: int = 0
76 |
77 | def __post_init__(self):
78 | self.images = [ImageSchema.from_dict(image) for image in self.images]
79 |
80 | def as_geojson(self):
81 | return {"type": "FeatureCollection", "features": [image.as_geojson() for image in self.images]}
82 |
83 | @dataclass
84 | class Aoi:
85 | id: str
86 | status: str
87 | percentCompleted: int
88 | area: int
89 | messages: list
90 | geometry: dict
91 |
92 | def aoi_as_feature(self):
93 | feature = {"type": "Feature",
94 | "geometry" : self.geometry,
95 | "properties" : { "id": self.id,
96 | "status": self.status,
97 | "percentCompleted": self.percentCompleted,
98 | "area": self.area,
99 | "messages": self.messages }
100 | }
101 | return feature
102 |
103 | @dataclass
104 | class AoiResponseSchema:
105 | aois: List[Aoi]
106 |
107 | def __post_init__(self):
108 | self.aois = [Aoi(**data) for data in self.aois]
109 |
110 | def aoi_as_geojson(self):
111 | geojson = { "type": "FeatureCollection",
112 | "features": [aoi.aoi_as_feature() for aoi in self.aois]}
113 | return geojson
--------------------------------------------------------------------------------
/mapflow/schema/data_catalog.py:
--------------------------------------------------------------------------------
1 | from uuid import UUID
2 | from enum import Enum
3 | from datetime import datetime
4 | from typing import Sequence, Union, Optional, List, Dict
5 | from dataclasses import dataclass
6 |
7 | from .base import Serializable, SkipDataClass
8 |
9 | class PreviewSize(str, Enum):
10 | large = 'l'
11 | small = 's'
12 |
13 | @dataclass
14 | class RasterLayer(SkipDataClass):
15 | tileUrl: str
16 | tileJsonUrl: str
17 |
18 | @dataclass
19 | class UserLimitSchema(SkipDataClass):
20 | memoryLimit: Optional[int] = None
21 | memoryUsed: Optional[int] = None
22 | memoryFree: Optional[int] = None
23 | maxUploadFileSize: Optional[int] = None
24 | maxPixelCount: Optional[int] = None
25 |
26 |
27 | # ========== MOSAIC ============== #
28 |
29 | @dataclass
30 | class MosaicCreateSchema(Serializable):
31 | name: str
32 | tags: Sequence[str] = ()
33 |
34 | @dataclass
35 | class MosaicUpdateSchema(MosaicCreateSchema):
36 | pass
37 |
38 | @dataclass
39 | class MosaicCreateReturnSchema(SkipDataClass):
40 | id: UUID
41 | name: str
42 | created_at: datetime
43 | tags: Union[Sequence[str], None] = ()
44 |
45 | def __post_init__(self):
46 | self.created_at = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
47 |
48 | @dataclass
49 | class MosaicReturnSchema(SkipDataClass):
50 | id: UUID
51 | rasterLayer: RasterLayer
52 | name: str
53 | created_at: datetime
54 | footprint: str
55 | sizeInBytes: int
56 | tags: Union[Sequence[str], None] = ()
57 |
58 | def __post_init__(self):
59 | self.created_at = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
60 | self.rasterLayer = RasterLayer.from_dict(self.rasterLayer)
61 |
62 |
63 | # ============ IMAGE =============== #
64 |
65 | @dataclass
66 | class ImageMetadataSchema(SkipDataClass):
67 | crs: str
68 | count: int
69 | width: int
70 | height: int
71 | dtypes: List[str]
72 | nodata: float
73 | pixel_size: List[float]
74 |
75 | @dataclass
76 | class ImageReturnSchema(SkipDataClass):
77 | id: UUID
78 | image_url: str
79 | preview_url_l: str
80 | preview_url_s: str
81 | uploaded_at: datetime
82 | file_size: int # Bytes
83 | footprint: dict
84 | filename: str
85 | checksum: str
86 | meta_data: ImageMetadataSchema
87 | cog_link: Optional[str]
88 |
89 | def __post_init__(self):
90 | self.uploaded_at = datetime.fromisoformat(self.uploaded_at.replace("Z", "+00:00"))
91 | self.meta_data = ImageMetadataSchema.from_dict(self.meta_data)
92 |
--------------------------------------------------------------------------------
/mapflow/schema/processing.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Optional, Mapping, Any, Union, Iterable
3 |
4 | from .base import SkipDataClass, Serializable
5 |
6 |
7 | @dataclass
8 | class PostSourceSchema(Serializable, SkipDataClass):
9 | url: str
10 | source_type: str
11 | projection: Optional[str] = None
12 | raster_login: Optional[str] = None
13 | raster_password: Optional[str] = None
14 | zoom: Optional[str] = None
15 |
16 |
17 | @dataclass
18 | class BlockOption(Serializable, SkipDataClass):
19 | name: str
20 | enabled: bool
21 |
22 |
23 | @dataclass
24 | class PostProviderSchema(Serializable, SkipDataClass):
25 | # Data provider name
26 | data_provider: str
27 | url: Optional[str] = None
28 | zoom: Optional[str] = None
29 |
30 |
31 | @dataclass
32 | class ProcessingParamsSchema(SkipDataClass):
33 | data_provider: Optional[str] = None
34 | url: Optional[str] = None
35 | projection: Optional[str] = None
36 | source_type: Optional[str] = None
37 |
38 |
39 | @dataclass
40 | class PostProcessingSchema(Serializable):
41 | name: str
42 | wdId: Optional[str]
43 | params: Union[PostSourceSchema, PostProviderSchema]
44 | blocks: Optional[Iterable[BlockOption]]
45 | geometry: Mapping[str, Any]
46 | meta: Optional[Mapping[str, Any]]
47 | projectId: Optional[str] = None
48 |
49 | def __post_init__(self):
50 | if self.blocks:
51 | self.blocks = [BlockOption(**item) for item in self.blocks]
52 | else:
53 | self.blocks = []
54 |
55 |
56 | @dataclass
57 | class UpdateProcessingSchema(Serializable):
58 | name: str
59 | description: str
60 |
61 |
--------------------------------------------------------------------------------
/mapflow/schema/project.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from datetime import datetime
3 | from enum import Enum
4 | from typing import Optional, List, Dict
5 |
6 | from .base import Serializable, SkipDataClass
7 | from ..entity.workflow_def import WorkflowDef
8 | from ..config import Config
9 |
10 |
11 | @dataclass
12 | class PostProjectSchema(Serializable):
13 | name: str
14 | description: Optional[str] = None
15 |
16 | @dataclass
17 | class CreateProjectSchema(PostProjectSchema):
18 | pass
19 |
20 | @dataclass
21 | class UpdateProjectSchema(PostProjectSchema):
22 | pass
23 |
24 | @dataclass
25 | class ShareProjectUser(SkipDataClass):
26 | role: str
27 | email: str
28 |
29 | @dataclass
30 | class ShareProject(SkipDataClass):
31 | owners: Optional[List[ShareProjectUser]]
32 | users: Optional[List[ShareProjectUser]]
33 |
34 | def __post_init__(self):
35 | if self.owners:
36 | self.owners = [ShareProjectUser.from_dict(item) for item in self.owners]
37 | if self.users:
38 | self.users = [ShareProjectUser.from_dict(item) for item in self.users]
39 |
40 | @dataclass
41 | class MapflowProjectInfo(SkipDataClass):
42 | id: str
43 | name: str
44 | ownerEmail: str
45 |
46 | @dataclass
47 | class MapflowProject(SkipDataClass):
48 | id: str
49 | name: str
50 | isDefault: bool
51 | description: Optional[str]
52 | workflowDefs: Optional[List[dict]] = None
53 | shareProject: Optional[Dict[str, ShareProject]] = None
54 | updated: Optional[datetime] = None
55 | created: Optional[datetime] = None
56 | processingCounts: Optional[Dict[str, int]] = None
57 | total: Optional[int] = Config.PROJECTS_PAGE_LIMIT
58 |
59 | def __post_init__(self):
60 | if self.workflowDefs:
61 | self.workflowDefs = [WorkflowDef.from_dict(item) for item in self.workflowDefs]
62 | else:
63 | self.workflowDefs = []
64 |
65 | if self.shareProject:
66 | self.shareProject = ShareProject.from_dict(self.shareProject)
67 | else:
68 | self.shareProject = []
69 | if self.created and self.updated:
70 | self.created = datetime.fromisoformat(self.created.replace("Z", "+00:00"))
71 | self.updated = datetime.fromisoformat(self.updated.replace("Z", "+00:00"))
72 |
73 | class UserRole(str, Enum):
74 | readonly = "readonly"
75 | contributor = "contributor"
76 | maintainer = "maintainer"
77 | owner = "owner"
78 |
79 | @property
80 | def can_start_processing(self):
81 | return self.value != UserRole.readonly
82 |
83 | @property
84 | def can_delete_rename_review_processing(self):
85 | return self.value in (UserRole.maintainer, UserRole.owner)
86 |
87 | @property
88 | def can_delete_rename_project(self):
89 | return self.value == UserRole.owner
90 |
91 | class ProjectSortBy(str, Enum):
92 | name = "NAME"
93 | created = "CREATED"
94 | updated = "UPDATED"
95 |
96 | class ProjectSortOrder(str, Enum):
97 | ascending = "ASC"
98 | descending = "DESC"
99 |
100 | @dataclass
101 | class ProjectsRequest(Serializable):
102 | limit: int = Config.PROJECTS_PAGE_LIMIT
103 | offset: int = 0
104 | filter: Optional[str] = None
105 | sortBy: Optional[ProjectSortBy] = ProjectSortBy.updated
106 | sortOrder: Optional[ProjectSortOrder] = ProjectSortOrder.descending
107 |
108 | @dataclass
109 | class ProjectsResult(SkipDataClass):
110 | results: Optional[List[MapflowProject]] = None
111 | total: int = 0
112 | count: int = None
113 |
--------------------------------------------------------------------------------
/mapflow/schema/provider.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import List, Dict, Optional
3 |
4 | from .base import SkipDataClass
5 |
6 |
7 | @dataclass
8 | class ProviderReturnSchema(SkipDataClass):
9 | id: str
10 | name: str
11 | displayName: str
12 | price: List[Dict]
13 | previewUrl: Optional[str] = None
14 |
15 | def __post_init__(self):
16 | self.price_dict = {price["zoom"]: price["priceSqKm"] for price in self.price}
17 |
--------------------------------------------------------------------------------
/mapflow/static/styles/aoi.qml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
9 |
10 |
11 |
12 |
13 |
14 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | 0
62 | 0
63 | 2
64 |
65 |
--------------------------------------------------------------------------------
/mapflow/static/styles/file/construction.qml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 0
32 | 0
33 | 2
34 |
35 |
--------------------------------------------------------------------------------
/mapflow/static/styles/file/default.qml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 0
32 | 0
33 | 2
34 |
35 |
--------------------------------------------------------------------------------
/mapflow/static/styles/file/roads.qml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 0
32 | 0
33 | 2
34 |
35 |
--------------------------------------------------------------------------------
/mapflow/static/styles/tiles/construction.qml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 1
5 | 1
6 | 1
7 | 0
8 |
9 |
10 |
11 |
12 |
13 |
14 |
65 |
66 |
67 |
68 |
69 |
70 | 0
71 | 1
72 |
73 |
--------------------------------------------------------------------------------
/mapflow/static/styles/tiles/roads.qml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 1
5 | 1
6 | 1
7 | 0
8 |
9 |
10 |
11 |
12 |
13 |
14 |
49 |
50 |
51 |
52 |
53 |
54 | 0
55 | 1
56 |
57 |
--------------------------------------------------------------------------------
/mapflow/styles.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from qgis.core import QgsVectorLayer, QgsVectorTileLayer
4 |
5 | STYLES = {
6 | '🏠 Buildings': 'buildings',
7 | 'Buildings Detection With Heights': 'buildings',
8 | '🌲 Forest': 'forest',
9 | '🌲 Forest and trees': 'forest',
10 | '🌲↕️ Forest with heights': 'forest_with_heights',
11 | '🚗 Roads': 'roads',
12 | '🏗️ Construction sites': 'construction'
13 | }
14 |
15 | DEFAULT_STYLE = "default"
16 |
17 |
18 | def get_style_name(wd_name: str, layer: QgsVectorLayer):
19 | if isinstance(layer, QgsVectorTileLayer):
20 | return get_tile_style_name(wd_name)
21 | else:
22 | return get_local_style_name(wd_name, layer)
23 |
24 |
25 | def get_tile_style_name(wd_name):
26 | if "building" in wd_name.lower() and "height" in wd_name.lower():
27 | name = 'building_heights'
28 | elif "building" in wd_name.lower():
29 | name = 'buildings'
30 | elif "forest" in wd_name.lower():
31 | name = "forest"
32 | elif "road" in wd_name.lower():
33 | name = "roads"
34 | elif "construction" in wd_name.lower():
35 | name = "construction"
36 | else:
37 | name = 'default'
38 | res = str(Path(__file__).parent / 'static' / 'styles' / 'tiles' / (name + '.qml'))
39 | return res
40 |
41 |
42 | def get_local_style_name(wd_name, layer):
43 | name = STYLES.get(wd_name, DEFAULT_STYLE)
44 | # Buildings classes look bad in legend if there are no classes in layer, so we discard the style in this case
45 | if "building" in wd_name.lower() and "height" in wd_name.lower():
46 | name = 'building_heights'
47 | elif "building" in wd_name.lower() and "class_id" not in layer.fields().names():
48 | name = "buildings_noclass"
49 | elif "building" in wd_name.lower():
50 | name = 'buildings'
51 | # Show forest heights for new (updated) forest with block config
52 | elif name == "forest" and "class_id" in layer.fields().names():
53 | name = "forest_with_heights"
54 | return str(Path(__file__).parent / 'static' / 'styles' / 'file' / (name + '.qml'))
55 |
--------------------------------------------------------------------------------
/release.yml:
--------------------------------------------------------------------------------
1 | on:
2 | release:
3 | types: published
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - name: Set up Python 3.9
11 | uses: actions/setup-python@v1
12 | with:
13 | python-version: 3.9
14 | - name: Install qgis-plugin-ci
15 | run: pip3 install qgis-plugin-ci
16 | - name: Deploy plugin
17 | run: >-
18 | qgis-plugin-ci
19 | release ${GITHUB_REF/refs\/tags\//}
20 | --github-token ${{ secrets.GITHUB_TOKEN }}
21 | --osgeo-username ${{ secrets.OSGEO_USER }}
22 | --osgeo-password ${{ secrets.OSGEO_PASSWORD }}
23 |
--------------------------------------------------------------------------------
/tests/test_layer_utils.py:
--------------------------------------------------------------------------------
1 | from mapflow.functional.layer_utils import *
2 |
3 |
4 | def test_xyz_no_creds():
5 | result = generate_xyz_layer_definition('https://xyz.tile.server/{z}/{x}/{y}.png',
6 | "", "", 18, "xyz")
7 | assert result == 'type=xyz' \
8 | '&url=https://xyz.tile.server/{z}/{x}/{y}.png' \
9 | '&zmin=0' \
10 | '&zmax=18' \
11 | '&username=' \
12 | '&password='
13 |
14 |
--------------------------------------------------------------------------------