├── .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 | ![**Geoalert Mapflow plugin for QGIS**](images/plugin_showcase.png) 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 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /mapflow/dialogs/static/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 |
qgspasswordlineedit.h
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 |
qgspasswordlineedit.h
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 |
qgsmaplayercombobox.h
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 | 12 | 13 | 14 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 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 | 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 | 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 | 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 | 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 | 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 | --------------------------------------------------------------------------------