diff --git a/WAL.md b/WAL.md index 0a416f37..f658a8c8 100644 --- a/WAL.md +++ b/WAL.md @@ -53,7 +53,431 @@ All tests require QGIS runtime — no value in partial testing without it. Tools ```; - Implement pagination, sorting and new filtering (troug the request on text change, not though the table filtering). -## 6. Add new zoom-selector feature +## 6. Add support of planned processing feature (processing templates) +[ ] +- Implement planned processings, using the templates_service, templates_api, templwtes_view logic + +- User should be able to: + - see created templates (as "planned processings") + - see all AOIs and connected processings in one layer with different colors for unprocessed/in-progress/processed aois + - navigate from template to a processing launched from this template + - create a new template (as "planned processing") from search results + - launch a processing when something is found, using one or several AOIs from the template + - delete template + - update template parameters (search params, aoi, name, etc) + +- UI: + - Show all processings from template in the same layer as template geometries (When we open template, we should immediately call [API] Get all processing ran from the template and add their AOIs as a separate layer with different style) + - Mark image/all as seen (button or context menu "already seen" for every line that is marked as "new" and button "seen all" for the table) + - Display template current results in the search table (After the template is selected in "processings" table and retrieved (see [API] Get template) we need to display it in search result table. Also, display at the map as "Planned processing" layer. Add UI elements for template editing. Button or menu entry to upload edited layer as new geometry. Same button acts for search parameters, model etc. Also, template activation/deactivation button + label) + - When provider is "Imagery search" and the search result table does not have selection, rename button "Start processing" → "Plan processing". When template is loaded AND image(s) are selected, we need option "Start planned processing" in the same button + - Display templates along with processings table (To the same table add labels (color? Or additional column with labels?) or sorting/filtering "show planned/ hide planned/show planned only") + +- Create 002_F_plan_processing_api with the help of this: + + 1) POST /processings/template - Creates a new processing template + Request body example: + { + "name": "string", + "searchParams": { + "aoi": {} + }, + "processingParams": {}, + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "activeUntil": "2026-04-06T11:03:37.721Z" + } + Response example: + { + "template": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:03:37.743Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:03:37.743Z", + "activeUntil": "2026-04-06T11:03:37.743Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + }, + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ] + } + + 2) GET /processings/template - Retrieves all templates for the authenticated user + Response example: + [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:05:13.838Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:05:13.838Z", + "activeUntil": "2026-04-06T11:05:13.838Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + } + ] + + 3) GET /processings/template/{templateId} - Retrieves a specific template by ID + Parameter templateId (string(uuid)) - The id of the template + Response example: + { + "template": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:06:19.534Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:06:19.534Z", + "activeUntil": "2026-04-06T11:06:19.534Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + }, + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ] + } + + 4) POST /processings/template/{templateId} - Runs processing using a template + Request body example: + { + "name": "string", + "description": "string", + "wdName": "string", + "wdId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "geometry": {}, + "params": {}, + "meta": {}, + "blocks": [ + { + "name": "string", + "enabled": true, + "displayName": "string" + } + ], + "updateTemplateGeometry": true + } + + 5) PUT /processings/template/{templateId} - Updates an existing template + Request body example: + { + "name": "string", + "searchParams": { + "aoi": {} + }, + "processingParams": {}, + "activeUntil": "2026-04-06T11:44:33.912Z" + } + Response example: + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:44:33.916Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:44:33.916Z", + "activeUntil": "2026-04-06T11:44:33.916Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + } + + 6) DELETE /processings/template/{templateId} - Marks template as deleted + Parameter templateId (string(uuid)) - The id of the template + + 7) POST /processings/template/{templateId}/v2 - Runs processing using a template with V2 params format + Request body example: + { + "name": "string", + "description": "string", + "wdName": "string", + "wdId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "geometry": {}, + "params": { + "sourceParams": { + "myImagery": { + "imageIds": [ + "string" + ], + "mosaicId": "string" + }, + "imagerySearch": { + "dataProvider": "orbview", + "imageIds": [ + "string" + ], + "zoom": 0 + }, + "dataProvider": { + "providerName": "string", + "zoom": 0 + }, + "userDefined": { + "sourceType": "XYZ", + "url": "string", + "zoom": 0, + "crs": "string", + "rasterLogin": "string", + "rasterPassword": "string" + } + }, + "inferenceParams": { + "key1": "value1", + "key2": "value2", + "keyN": "valueN" + } + }, + "meta": {}, + "blocks": [ + { + "name": "string", + "enabled": true, + "displayName": "string" + } + ], + "updateTemplateGeometry": true + } + + 8) POST /processings/template/{templateId}/pause - Changes template status to Inactive + Response example: + { + "template": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:52:29.861Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:52:29.861Z", + "activeUntil": "2026-04-06T11:52:29.861Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + }, + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ] + } + + 9) POST /processings/template/{templateId}/resume - Changes template status to Active. Note: Expired templates cannot be reactivated without first updating the activeUntil date. + Response example: + { + "template": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:54:46.588Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:54:46.588Z", + "activeUntil": "2026-04-06T11:54:46.588Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + }, + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ] + } + + 10) GET /processings/template/{templateId}/processings - Retrieves all processings associated with a template + Response example: + [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "description": "string", + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "vectorLayer": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "tileJsonUrl": "string", + "tileUrl": "string" + }, + "rasterLayer": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tileJsonUrl": "string", + "tileUrl": "string" + }, + "workflowDef": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "description": "string", + "created": "2026-04-06T11:55:47.335Z", + "updated": "2026-04-06T11:55:47.335Z", + "pricePerSqKm": 0, + "blocks": [ + { + "name": "string", + "description": "string", + "optional": 0, + "price": 0 + } + ] + }, + "aoiCount": 0, + "aoiArea": 0, + "area": 0, + "cost": 0, + "status": "UNPROCESSED", + "reviewStatus": { + "reviewStatus": "ACCEPTED", + "feedback": "2026-04-06T11:55:47.335Z" + }, + "rating": { + "rating": "string", + "feedback": "string" + }, + "percentCompleted": 0, + "params": { + "key": "string", + "value": "string" + }, + "blocks": [ + { + "name": "string", + "enabled": true, + "displayName": "string" + } + ], + "meta": { + "key": "string", + "value": "string" + }, + "messages": [ + { + "code": "string", + "parameters": { + "key": "string", + "value": "string" + } + } + ], + "created": "2026-04-06T11:55:47.335Z", + "updated": "2026-04-06T11:55:47.335Z" + } + ] + + 11) POST /processings/template/{templateId}/image/{imageId}/seen - Marks an image as seen in a template + Parameters: + templateId (string($uuid)) - The id of the template + imageId (string) - The id of the image + + 12) GET /processings/template/user/{userId} - Retrieves all templates for a specific user ID + Response example: + [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:58:06.918Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:58:06.918Z", + "activeUntil": "2026-04-06T11:58:06.918Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + } + ] + + 13) GET /processings/template/project/{projectId} - Retrieves all templates for a specific project ID + Response example: + [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "string", + "status": "ACTIVE", + "createdAt": "2026-04-06T11:59:25.085Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": {}, + "processingParams": {}, + "lastCheckedAt": "2026-04-06T11:59:25.085Z", + "activeUntil": "2026-04-06T11:59:25.085Z", + "searchResults": [ + { + "id": "string", + "metadata": {} + } + ], + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "area": 0, + "newImagesCount": 0 + } + ] + + +## 7. Add new zoom-selector feature [ ] Use 002_E_zoom_selector_api.md - Add small button near zoom selector comboBox to call for zoom selector api, active if selected source is Mapflow data provider diff --git a/WAL_6.md b/WAL_6.md new file mode 100644 index 00000000..31fc8ed2 --- /dev/null +++ b/WAL_6.md @@ -0,0 +1,18 @@ +# WAL_6 implementation plan + +## Scope +- Add spec for planned processing API. +- Add template schema models and API methods. +- Add focused tests for new schema and API path/body behavior. + +## Steps +1. Add `spec/002_F_plan_processing_api.md`. +2. Update `spec/index.md` and `spec/002_api.md` to reference 002_F. +3. Extend `mapflow/schema/processing.py` with template dataclasses and request schemas. +4. Extend `mapflow/functional/api/processing_api.py` with template endpoint methods. +5. Add/update tests under `tests/`. +6. Run focused pytest on updated tests; then run full suite if feasible. + +## Assumptions +- Remote `origin/dev` fetch issue remains unresolved; work proceeds from local branch state. +- Existing local modifications in `WAL.md` and `mapflow/schema/processing.py` are user-owned and preserved. diff --git a/mapflow/config.py b/mapflow/config.py index e7a0dbb3..e522bac7 100644 --- a/mapflow/config.py +++ b/mapflow/config.py @@ -4,6 +4,9 @@ from PyQt5.QtCore import QCoreApplication from qgis.core import QgsSettings + +SEARCH_CAPTURE_TIMEZONE = 'UTC' + @dataclass class ConfigColumns(): def __init__(self): @@ -15,7 +18,7 @@ def __init__(self): QCoreApplication.translate('Config', 'Band Order'): 'colorBandOrder', QCoreApplication.translate('Config', 'Cloud %'): 'cloudCover', QCoreApplication.translate('Config', 'Off Nadir') + f' \N{DEGREE SIGN}': 'offNadirAngle', - QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=time.localtime().tm_zone): 'acquisitionDate', + QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=SEARCH_CAPTURE_TIMEZONE): 'acquisitionDate', QCoreApplication.translate('Config', 'Zoom level'): 'zoom', QCoreApplication.translate('Config', 'Spatial Resolution, m'): 'pixelResolution', QCoreApplication.translate('Config', 'Image ID'): 'id', @@ -80,7 +83,9 @@ class Config: PPRVIEW_INDEX_COLUMN = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('preview') NAME_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('providerName') ZOOM_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('zoom') - MAXAR_DATETIME_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index(QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=TIMEZONE)) + MAXAR_DATETIME_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index( + QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=SEARCH_CAPTURE_TIMEZONE) + ) MAXAR_CLOUD_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index(QCoreApplication.translate('Config', 'Cloud %')) MAXAR_MAX_FREE_ZOOM = 12 diff --git a/mapflow/dialogs/main_dialog.py b/mapflow/dialogs/main_dialog.py index ca4e602a..e20750c3 100644 --- a/mapflow/dialogs/main_dialog.py +++ b/mapflow/dialogs/main_dialog.py @@ -130,6 +130,8 @@ def __init__(self, parent: QWidget, settings: QgsSettings) -> None: self.save_result_action = QAction(self.tr("Save results")) self.download_aoi_action = QAction(self.tr("Download AOI")) self.see_details_action = QAction(self.tr("See details")) + self.see_processings_action = QAction(self.tr("See processings")) + self.see_search_results_action = QAction(self.tr("See search results")) self.processing_update_action = QAction(self.tr("Rename")) self.processing_restart_action = QAction(self.tr("Restart")) self.processing_duplicate_action = QAction(self.tr("Duplicate")) diff --git a/mapflow/dialogs/static/ui/main_dialog.ui b/mapflow/dialogs/static/ui/main_dialog.ui index 154301b2..5a5abf6c 100644 --- a/mapflow/dialogs/static/ui/main_dialog.ui +++ b/mapflow/dialogs/static/ui/main_dialog.ui @@ -1648,7 +1648,7 @@ - + 0 @@ -1670,6 +1670,9 @@ Click and wait for a few seconds until the table below is filled out + + QToolButton::MenuButtonPopup + Search diff --git a/mapflow/entity/processing.py b/mapflow/entity/processing.py index 16d35b6d..098cefe6 100644 --- a/mapflow/entity/processing.py +++ b/mapflow/entity/processing.py @@ -1,10 +1,11 @@ import sys -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import List, Dict, Optional, Tuple from .status import ProcessingStatus, ProcessingReviewStatus from ..errors import ErrorMessage from ..schema.processing import BlockOption, ProcessingParams +from ..schema.base import parse_api_datetime_utc class Processing: @@ -34,7 +35,7 @@ def __init__(self, self.workflow_def = workflow_def self.aoi_area = aoi_area self.cost = int(cost) - self.created = created.astimezone() + self.created = parse_api_datetime_utc(created) self.percent_completed = int(percent_completed) self.errors = errors self.raster_layer = raster_layer @@ -61,7 +62,7 @@ def from_response(cls, processing): created = processing['created'].replace('Z', '+0000') else: created = processing['created'] - created = datetime.strptime(created, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + created = parse_api_datetime_utc(created) percent_completed = processing['percentCompleted'] messages = processing.get('messages', []) errors = [ErrorMessage.from_response(message) for message in messages] @@ -71,7 +72,7 @@ def from_response(cls, processing): review_status = processing.get('reviewStatus', {}).get('reviewStatus') in_review_until_str = processing.get('reviewStatus', {}).get('inReviewUntil') if in_review_until_str: - in_review_until = datetime.strptime(in_review_until_str, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + in_review_until = parse_api_datetime_utc(in_review_until_str) else: in_review_until = None else: @@ -100,7 +101,7 @@ def from_response(cls, processing): @property def is_new(self): - now = datetime.now().astimezone() + now = datetime.now(timezone.utc) one_day = timedelta(1) return now - self.created < one_day @@ -109,7 +110,7 @@ def review_expires(self): if not isinstance(self.in_review_until, datetime)\ or not self.review_status.is_in_review: return False - now = datetime.now().astimezone() + now = datetime.now(timezone.utc) one_day = timedelta(1) return self.in_review_until - now < one_day @@ -129,9 +130,9 @@ def asdict(self): 'percentCompleted': self.percent_completed, 'errors': self.errors, # Serialize datetime and drop seconds for brevity - 'created': self.created.strftime('%Y-%m-%d %H:%M'), + 'created': self.created.astimezone().strftime('%Y-%m-%d %H:%M'), 'rasterLayer': self.raster_layer, - 'reviewUntil': self.in_review_until.strftime('%Y-%m-%d %H:%M') if self.in_review_until else "", + 'reviewUntil': self.in_review_until.astimezone().strftime('%Y-%m-%d %H:%M') if self.in_review_until else "", 'description': self.description, 'meta': self.meta } diff --git a/mapflow/entity/provider/basemap_provider.py b/mapflow/entity/provider/basemap_provider.py index f287ade4..36c09af2 100644 --- a/mapflow/entity/provider/basemap_provider.py +++ b/mapflow/entity/provider/basemap_provider.py @@ -6,7 +6,7 @@ from urllib.parse import urlparse, parse_qs from .provider import SourceType, CRS, UsersProvider, staticproperty -from ...functional.layer_utils import maxar_tile_url, add_connect_id +from .url_utils import add_connect_id, maxar_tile_url from ...requests.maxar_metadata_request import MAXAR_REQUEST_BODY, MAXAR_META_URL from ...schema.processing import PostSourceSchema, UserDefinedParams, UserDefinedSchema, ProcessingParams diff --git a/mapflow/entity/provider/factory.py b/mapflow/entity/provider/factory.py index c9d5caae..1768cfff 100644 --- a/mapflow/entity/provider/factory.py +++ b/mapflow/entity/provider/factory.py @@ -1,6 +1,6 @@ from .basemap_provider import XYZProvider, TMSProvider, QuadkeyProvider, MaxarProvider +from .url_utils import add_connect_id from ...constants import MAXAR_BASE_URL -from ...functional.layer_utils import add_connect_id provider_options = {XYZProvider.option_name: XYZProvider, TMSProvider.option_name: TMSProvider, diff --git a/mapflow/entity/provider/url_utils.py b/mapflow/entity/provider/url_utils.py new file mode 100644 index 00000000..c98732f6 --- /dev/null +++ b/mapflow/entity/provider/url_utils.py @@ -0,0 +1,34 @@ +def add_image_id(url: str, image_id: str): + if not image_id: + return url + if not url.endswith('?'): + url = url + '&' + return url + f"CQL_FILTER=feature_id='{image_id}'" + + +def add_connect_id(url: str, connect_id: str): + if not url.endswith('?'): + url = url + '&' + return url + f'CONNECTID={connect_id}' + + +def maxar_tile_url(base_url, image_id=None): + """ + base_url is copied from maxar website and looks like + https://securewatch.digitalglobe.com/earthservice/wmtsaccess?connectid= + we need to return TileUrl with TileMatrix set and so on + """ + if not base_url.endswith('?'): + base_url = base_url + '&' + url = base_url + "SERVICE=WMTS" \ + "&VERSION=1.0.0" \ + "&STYLE=" \ + "&REQUEST=GetTile" \ + "&LAYER=DigitalGlobe:ImageryTileService" \ + "&FORMAT=image/jpeg" \ + "&TileRow={y}" \ + "&TileCol={x}" \ + "&TileMatrixSet=EPSG:3857" \ + "&TileMatrix=EPSG:3857:{z}" + url = add_image_id(url, image_id) + return url \ No newline at end of file diff --git a/mapflow/entity/status.py b/mapflow/entity/status.py index a3b9d8f2..95873455 100644 --- a/mapflow/entity/status.py +++ b/mapflow/entity/status.py @@ -5,7 +5,7 @@ from PyQt5.QtCore import QObject -from ..schema.base import Serializable, SkipDataClass +from ..schema.base import Serializable, SkipDataClass, parse_api_datetime_utc class ProcessingStatusDict(QObject): @@ -118,7 +118,7 @@ def from_dict(cls, data: Optional[dict]): def __post_init__(self): if self.inReviewUntil: - self.inReviewUntil = datetime.strptime(self.inReviewUntil, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + self.inReviewUntil = parse_api_datetime_utc(self.inReviewUntil) self.reviewStatus = ProcessingReviewStatusEnum(self.reviewStatus) @property diff --git a/mapflow/functional/api/processing_api.py b/mapflow/functional/api/processing_api.py index 453a6c8b..2f071874 100644 --- a/mapflow/functional/api/processing_api.py +++ b/mapflow/functional/api/processing_api.py @@ -1,10 +1,18 @@ -from typing import Callable, Union, Optional +import json +from typing import Callable, List, Optional, Union from uuid import UUID from PyQt5.QtCore import QObject, pyqtSignal, QFile, QIODevice from ...http import Http from ...dialogs.main_dialog import MainDialog -from ...schema.processing import PostProcessingSchema, UpdateProcessingSchema, ProcessingsRequest +from ...schema.processing import ( + PostProcessingSchema, + UpdateProcessingSchema, + ProcessingsRequest, + CreateProcessingTemplateSchema, + UpdateProcessingTemplateSchema, + RunTemplateProcessingSchema, +) class ProcessingApi(QObject): """ @@ -95,3 +103,169 @@ def restart_processing(self, error_handler=error_handler, use_default_error_handler=False ) + + def create_template(self, data: CreateProcessingTemplateSchema, callback: Callable, error_handler: Callable): + self.http.post( + path="processings/template", + callback=callback, + error_handler=error_handler, + use_default_error_handler=False, + body=data.as_json().encode(), + ) + + def get_templates(self, callback: Callable): + self.http.get( + path="processings/template", + callback=callback, + use_default_error_handler=True, + timeout=5, + ) + + def get_template(self, template_id: Union[UUID, str], callback: Callable): + self.http.get( + path=f"processings/template/{template_id}", + callback=callback, + use_default_error_handler=True, + timeout=5, + ) + + def post_template( + self, + template_id: Union[UUID, str], + callback: Callable, + limit: int = 100, + offset: int = 0, + aoi_ids: Optional[List[str]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + ): + body: dict = {"limit": limit, "offset": offset} + if aoi_ids is not None: + body["aoiIds"] = aoi_ids + if sort_by is not None: + body["sortBy"] = sort_by + if sort_order is not None: + body["sortOrder"] = sort_order + self.http.post( + path=f"processings/template/{template_id}/run", + body=json.dumps(body).encode(), + headers={}, + callback=callback, + use_default_error_handler=True, + timeout=5, + ) + + def get_template_images( + self, + template_id: Union[UUID, str], + callback: Callable, + limit: int = 100, + offset: int = 0, + aoi_ids: Optional[List[str]] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + ): + body: dict = {"limit": limit, "offset": offset} + if aoi_ids is not None: + body["aoiIds"] = aoi_ids + if sort_by is not None: + body["sortBy"] = sort_by + if sort_order is not None: + body["sortOrder"] = sort_order + self.http.post( + path=f"processings/template/{template_id}/images", + body=json.dumps(body).encode(), + headers={}, + callback=callback, + use_default_error_handler=True, + timeout=5, + ) + + def update_template(self, + template_id: Union[UUID, str], + data: UpdateProcessingTemplateSchema, + callback: Callable, + error_handler: Optional[Callable] = None): + self.http.put( + path=f"processings/template/{template_id}", + body=data.as_json().encode(), + headers={}, + callback=callback, + error_handler=error_handler, + use_default_error_handler=error_handler is None, + timeout=5, + ) + + def delete_template(self, template_id: Union[UUID, str], callback: Callable, error_handler: Callable): + self.http.delete( + path=f"processings/template/{template_id}", + callback=callback, + error_handler=error_handler, + use_default_error_handler=False, + timeout=5, + ) + + def run_template_processing(self, + template_id: Union[UUID, str], + data: RunTemplateProcessingSchema, + callback: Callable, + error_handler: Callable): + self.http.post( + path=f"processings/template/{template_id}/v2", + callback=callback, + error_handler=error_handler, + use_default_error_handler=False, + body=data.as_json().encode(), + ) + + def stop_template(self, template_id: Union[UUID, str], callback: Callable, error_handler: Callable): + self.http.post( + path=f"processings/template/{template_id}/stop", + callback=callback, + error_handler=error_handler, + use_default_error_handler=False, + ) + + def resume_template(self, template_id: Union[UUID, str], callback: Callable, error_handler: Callable): + self.http.post( + path=f"processings/template/{template_id}/resume", + callback=callback, + error_handler=error_handler, + use_default_error_handler=False, + ) + + def get_template_processings(self, template_id: Union[UUID, str], callback: Callable): + self.http.get( + path=f"processings/template/{template_id}/processings", + callback=callback, + use_default_error_handler=True, + timeout=5, + ) + + def mark_template_image_seen(self, + template_id: Union[UUID, str], + image_id: str, + callback: Callable, + error_handler: Callable): + self.http.post( + path=f"processings/template/{template_id}/image/{image_id}/seen", + callback=callback, + error_handler=error_handler, + use_default_error_handler=False, + ) + + def get_templates_by_user(self, user_id: Union[UUID, str], callback: Callable): + self.http.get( + path=f"processings/template/user/{user_id}", + callback=callback, + use_default_error_handler=True, + timeout=5, + ) + + def get_templates_by_project(self, project_id: Union[UUID, str], callback: Callable): + self.http.get( + path=f"processings/template/project/{project_id}", + callback=callback, + use_default_error_handler=True, + timeout=5, + ) diff --git a/mapflow/functional/app_context.py b/mapflow/functional/app_context.py index fe6a68bd..2a1af023 100644 --- a/mapflow/functional/app_context.py +++ b/mapflow/functional/app_context.py @@ -1,15 +1,13 @@ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Dict, List, Optional, Any +from typing import Dict, List, Optional, Any from qgis.core import QgsGeometry, QgsVectorLayer, QgsProject, QgsSettings -from ..schema.project import UserRole from ..config import Config - -if TYPE_CHECKING: - from ..schema.project import MapflowProject - from ..schema.workflow_def import WorkflowDef - from ..entity.billing import BillingType - from ..entity.provider import ProviderInterface +from ..schema.project import UserRole +from ..schema.project import MapflowProject +from ..schema.workflow_def import WorkflowDef +#! from ..entity.billing import BillingType +from ..entity.provider import ProviderInterface @dataclass diff --git a/mapflow/functional/geometry.py b/mapflow/functional/geometry.py index c1c9cac1..8235d19d 100644 --- a/mapflow/functional/geometry.py +++ b/mapflow/functional/geometry.py @@ -1,7 +1,35 @@ +import json from typing import List +from osgeo import ogr from qgis import processing as qgis_processing # to avoid collisions -from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsFeatureIterator +from qgis.core import ( + QgsCoordinateReferenceSystem, QgsDistanceArea, + QgsFeature, QgsFeatureIterator, QgsGeometry, QgsProject, QgsVectorLayer, +) + + +def make_distance_calculator() -> QgsDistanceArea: + """Return a QgsDistanceArea configured for WGS84 ellipsoidal measurements.""" + calculator = QgsDistanceArea() + wgs84 = QgsCoordinateReferenceSystem("EPSG:4326") + calculator.setEllipsoid("WGS84") + calculator.setSourceCrs(wgs84, QgsProject.instance().transformContext()) + return calculator + + +def geojson_feature_area_sqkm(feature: dict, calculator: QgsDistanceArea) -> float: + """Return the area of a GeoJSON feature dict in sq km, or 0.0 on failure.""" + geom_dict = feature.get("geometry") + if not geom_dict: + return 0.0 + ogr_geom = ogr.CreateGeometryFromJson(json.dumps(geom_dict)) + if ogr_geom is None: + return 0.0 + geom = QgsGeometry.fromWkt(ogr_geom.ExportToWkt()) + if not geom or geom.isEmpty(): + return 0.0 + return calculator.measureArea(geom) / 1e6 def clip_aoi_to_image_extent(aoi_geometry: QgsGeometry, extents: List[QgsFeature]) -> QgsFeatureIterator: diff --git a/mapflow/functional/layer_utils.py b/mapflow/functional/layer_utils.py index 3234078f..881a2228 100644 --- a/mapflow/functional/layer_utils.py +++ b/mapflow/functional/layer_utils.py @@ -34,7 +34,7 @@ from .helpers import WGS84, to_wgs84, WGS84_ELLIPSOID from ..schema.catalog import AoiResponseSchema, PreviewType from ..schema.processing import ProcessingDTO -from ..styles import get_style_name +from ..styles import get_style_name def get_layer_extent(layer: QgsMapLayer) -> QgsGeometry: @@ -665,7 +665,7 @@ def download_results(self, processing) -> None: timeout=300 ) - def download_results_callback(self, response: QNetworkReply, processing: ProcessingDTO) -> None: + def download_results_callback(self, response: QNetworkReply, processing: 'ProcessingDTO') -> None: """Display processing results upon their successful fetch. :param response: The HTTP response. diff --git a/mapflow/functional/service/area_calculator_service.py b/mapflow/functional/service/area_calculator_service.py index 9f1ac8c5..9e40587f 100644 --- a/mapflow/functional/service/area_calculator_service.py +++ b/mapflow/functional/service/area_calculator_service.py @@ -4,7 +4,6 @@ from ..app_context import AppContext from .. import layer_utils from .. import helpers -from ...dialogs import MainDialog from ...entity.provider import (MaxarProvider, SentinelProvider, ImagerySearchProvider, @@ -14,12 +13,14 @@ ImageIdRequired, AoiNotIntersectsImage) from ..geometry import clip_aoi_to_image_extent +from ...dialogs.main_dialog import MainDialog + class AreaCalculatorService(QObject): def __init__(self, iface, app_context: AppContext, - dlg: MainDialog, + dlg: 'MainDialog', config, data_catalog_service, processing_service, @@ -244,3 +245,4 @@ def crop_aoi_with_maxar_image_footprint(self, except StopIteration: raise AoiNotIntersectsImage() return aoi + diff --git a/mapflow/functional/service/processing_service.py b/mapflow/functional/service/processing_service.py index 5d0a68b2..0a62f61d 100644 --- a/mapflow/functional/service/processing_service.py +++ b/mapflow/functional/service/processing_service.py @@ -1,4 +1,5 @@ import json +from datetime import datetime, timezone from uuid import UUID from typing import Dict, Optional, List from PyQt5.QtCore import QObject, QTimer @@ -22,7 +23,8 @@ from ..view.processing_view import ProcessingView from ..api.processing_api import ProcessingApi from ...schema import ProcessingDTO, UpdateProcessingSchema, ProcessingStatus, BillingType, ProcessingHistory, PostProcessingSchemaV2 -from ...schema.processing import ProcessingUIParams, ProcessingsRequest, ProcessingsResult +from ...schema.processing import ProcessingUIParams, ProcessingsRequest, ProcessingsResult, RunTemplateProcessingSchema +from ...schema.processing import ProcessingTemplateDTO from ..service.alert_service import alert, AlertService from ..app_context import AppContext from ...config import Config @@ -54,6 +56,7 @@ def __init__(self, iface=iface, result_loader=self.result_loader) self.processings = {} + self.templates = {} self.processings_data = None # ProcessingsResult self.processings_page_limit = Config.PROCESSINGS_PAGE_LIMIT self.processings_page_offset = 0 @@ -113,6 +116,9 @@ def validate_processing_params(self, processing_params, allow_empty_name: bool = def validate_context_params(self): error = None + planned_selection_error = self.planned_processing_selection_error() + if planned_selection_error: + return planned_selection_error if not self.app_context.aoi: # Here the button must already be disabled, and the warning text set if self.view.dlg.startProcessing.isEnabled(): @@ -129,6 +135,16 @@ def validate_context_params(self): error = None return error + def planned_processing_selection_error(self) -> Optional[str]: + """Return error when template is selected but no search result images are selected.""" + template = self.selected_template() + if not template or self.selected_processing(): + return None + selected_rows = {item.row() for item in self.dlg.metadataTable.selectedItems()} + if selected_rows: + return None + return self.tr("Select one or more images in search results to start planned processing") + def start_processing(self): self.processing_fetch_timer.stop() @@ -172,17 +188,30 @@ def handle_processing_submission(self, processing_params: PostProcessingSchemaV2 Handle the actual processing submission, either directly or after confirmation. """ def post_processing(): - self.iface.messageBar().pushInfo( - self.app_context.plugin_name, - self.tr('Starting the processing...') - ) try: self.dlg.startProcessing.setEnabled(False) - self.api.create_processing( - processing_params, - self.start_processing_callback, - self.start_processing_error_handler - ) + template = self.selected_template() + if template: + self.iface.messageBar().pushInfo( + self.app_context.plugin_name, + self.tr('Starting planned processing...') + ) + self.api.run_template_processing( + template_id=template.id, + data=self._build_run_template_processing_schema(processing_params), + callback=self.start_processing_callback, + error_handler=self.start_processing_error_handler, + ) + else: + self.iface.messageBar().pushInfo( + self.app_context.plugin_name, + self.tr('Starting the processing...') + ) + self.api.create_processing( + processing_params, + self.start_processing_callback, + self.start_processing_error_handler + ) except Exception as e: alert(self.tr("Could not launch processing! Error: {}.").format(str(e))) @@ -192,6 +221,24 @@ def post_processing(): else: post_processing() + def _build_run_template_processing_schema( + self, + processing_params: PostProcessingSchemaV2, + ) -> RunTemplateProcessingSchema: + wd_id = processing_params.wdId + wd_name = None if wd_id else self.dlg.modelCombo.currentText() + return RunTemplateProcessingSchema( + name=processing_params.name, + description=processing_params.description, + wdName=wd_name, + wdId=wd_id, + geometry=processing_params.geometry, + params=processing_params.params, + meta=processing_params.meta or {}, + blocks=processing_params.blocks or [], + updateTemplateGeometry=False, + ) + def show_confirmation_dialog(self, processing_params: PostProcessingSchemaV2, callback): """ Show the processing confirmation dialog with current parameters. @@ -261,14 +308,22 @@ def start_processing_callback(self, response: QNetworkReply) -> None: QMessageBox.Information ) response_data = json.loads(response.readAll().data()) - new_processing = ProcessingDTO.from_dict(response_data) - self.view.clear_processing_name(new_processing.name) + new_processing = None + # Template start responses may differ from processing-create responses. + # Try optimistic local update only when payload looks like a Processing DTO. + if isinstance(response_data, dict) and response_data.get("id") and response_data.get("name"): + new_processing = ProcessingDTO.from_dict(response_data) + self.view.clear_processing_name(new_processing.name) self.processing_fetch_timer.start() # start monitoring - # Add to history - self.processings[new_processing.id] = new_processing - self.processings_history.add(new_processing.id, new_processing.status) - # display - self.view.add_new_processing(new_processing) + if new_processing is not None: + # Add to history + self.processings[new_processing.id] = new_processing + self.processings_history.add(new_processing.id, new_processing.status) + # display + self.view.add_new_processing(new_processing) + # Always refresh full list because template-started processings can affect + # both processings and template status/counts in table. + self.get_processings() self.dlg.startProcessing.setEnabled(True) def start_processing_error_handler(self, response: QNetworkReply) -> None: @@ -312,7 +367,17 @@ def connect_processings_pagination(self): self.dlg.processingsNextPageButton.clicked.connect(self.show_processings_next_page) self.dlg.processingsPreviousPageButton.clicked.connect(self.show_processings_previous_page) self.dlg.filterProcessings.textEdited.connect(self.get_filtered_processings) - self.dlg.sortProcessingsCombo.activated.connect(lambda: self.get_processings()) + self.dlg.sortProcessingsCombo.activated.connect(self._on_combo_sort_changed) + self.view.connect_header_sort(self._on_header_sort_changed) + + def _on_combo_sort_changed(self): + """Combo sort resets any header-click override and re-fetches.""" + self.view._header_sort_by = None + self.get_processings() + + def _on_header_sort_changed(self): + """Re-render the table with current data using the header sort.""" + self.view.update_processing_table(self.combined_processing_rows()) def get_processings(self): if not self.app_context.current_project: @@ -359,6 +424,46 @@ def get_processings_callback(self, response: QNetworkReply): else: self.view.show_processings_pages(False) self.update_local_processings(processings) + self.api.get_templates(callback=self.get_templates_callback) + + def get_templates_callback(self, response: QNetworkReply): + response_data = json.loads(response.readAll().data()) + print ("GT", response_data) + templates = [ProcessingTemplateDTO.from_dict(item) for item in response_data] + self.templates = {template.id: template for template in templates} + self.view.update_processing_table(self.combined_processing_rows()) + + def _sort_key(self, item, sort_by: str): + is_processing = isinstance(item, ProcessingDTO) + if sort_by == "NAME": + return (item.name or "").lower() + if sort_by == "WORKFLOW": + return (item.workflowDef.name if is_processing else "Planned").lower() + if sort_by == "STATUS": + if is_processing: + if item.reviewStatus and not item.reviewStatus.is_none: + return (item.reviewStatus.reviewStatus.display_value or "").lower() + return (item.status.display_value or "").lower() + return item.table_status.lower() + if sort_by == "PROGRESS": + return (item.percentCompleted or 0) if is_processing else -1 + if sort_by == "AREA": + return (item.aoiArea or 0) if is_processing else (item.aoi_area or 0) + if sort_by == "COST": + return (item.cost or 0) if is_processing else 0 + if sort_by == "REVIEW_UNTIL": + if is_processing and item.reviewUntil: + return item.reviewUntil + return datetime.min.replace(tzinfo=timezone.utc) + # CREATED (default) + return item.created if is_processing else item.createdAt + + def combined_processing_rows(self): + sort_by, sort_order = self.view.sort_processings() + reverse = sort_order == "DESC" + templates = sorted(self.templates.values(), key=lambda t: self._sort_key(t, sort_by), reverse=reverse) + processings = sorted(self.processings.values(), key=lambda p: self._sort_key(p, sort_by), reverse=reverse) + return list(templates) + list(processings) def show_processings_next_page(self): self.processings_page_offset += self.processings_page_limit @@ -476,7 +581,19 @@ def disable_processing_start(self, response: QNetworkReply): """ response_text = response.readAll().data().decode() if response_text is not None: - message = api_message_parser(response_text) + try: + message = api_message_parser(response_text) + except Exception: + message = None + + if not message or str(message).strip().lower() in {"none", "null"}: + network_error = "" + try: + network_error = (response.errorString() or "").strip() + except Exception: + network_error = "" + message = network_error or self.tr("Unknown server error") + if not self.app_context.user_role.can_start_processing: reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.app_context.user_role.value) else: @@ -492,6 +609,7 @@ def confirm_delete_processings(self) -> None: # Pause refreshing processings table to avoid conflicts self.processing_fetch_timer.stop() selected_ids = self.view.selected_processing_ids() + selected_ids = [pid for pid in selected_ids if pid in self.processings] # Ask for confirmation if there are selected rows if selected_ids and alert( self.tr('Delete selected processings?'), QMessageBox.Question @@ -523,6 +641,114 @@ def delete_processings(self, 'deleted': deleted, 'failed': list(failed) + [processing_to_delete]}) + # ============ TEMPLATE ACTIONS ============ # + + def pause_template(self): + """Pause the selected template.""" + template = self.selected_template() + if not template: + return + if template.isActive: + template_id = template.id + self.api.stop_template(template_id=template_id, + callback=self.pause_template_callback, + error_handler=self.pause_template_error_handler) + else: + alert(self.tr("Template is not active"), QMessageBox.Information) + + def pause_template_callback(self, response: QNetworkReply): + """Handle pause template response.""" + try: + self.get_processings() # Refresh to get updated template status + alert(self.tr("Template paused successfully"), QMessageBox.Information) + except Exception as e: + alert(self.tr("Failed to pause template: {}").format(str(e)), QMessageBox.Critical) + + def pause_template_error_handler(self, error: str): + """Handle pause template error.""" + alert(self.tr("Error pausing template: {}").format(error), QMessageBox.Critical) + + def resume_template(self): + """Resume the selected template.""" + template = self.selected_template() + if not template: + return + if not template.isActive: + template_id = template.id + self.api.resume_template(template_id=template_id, + callback=self.resume_template_callback, + error_handler=self.resume_template_error_handler) + else: + alert(self.tr("Template is already active"), QMessageBox.Information) + + def resume_template_callback(self, response: QNetworkReply): + """Handle resume template response.""" + try: + self.get_processings() # Refresh to get updated template status + alert(self.tr("Template resumed successfully"), QMessageBox.Information) + except Exception as e: + alert(self.tr("Failed to resume template: {}").format(str(e)), QMessageBox.Critical) + + def resume_template_error_handler(self, error: str): + """Handle resume template error.""" + alert(self.tr("Error resuming template: {}").format(error), QMessageBox.Critical) + + def delete_template(self): + """Delete the selected template after confirmation.""" + template = self.selected_template() + if not template: + return + + reply = QMessageBox.question( + self.dlg, + self.tr("Delete Template"), + self.tr("Are you sure you want to delete the template '{}'?").format(template.name), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.Yes: + template_id = template.id + self.api.delete_template(template_id=template_id, + callback=self.delete_template_callback, + error_handler=self.delete_template_error_handler) + + def delete_template_callback(self, response: QNetworkReply): + """Handle delete template response.""" + try: + self.get_processings() # Refresh to remove deleted template from table + alert(self.tr("Template deleted successfully"), QMessageBox.Information) + except Exception as e: + alert(self.tr("Failed to delete template: {}").format(str(e)), QMessageBox.Critical) + + def delete_template_error_handler(self, error: str): + """Handle delete template error.""" + alert(self.tr("Error deleting template: {}").format(error), QMessageBox.Critical) + + def open_template_details(self): + """Open a dialog showing template details.""" + template = self.selected_template() + if not template: + return + + local_created_at = template.createdAt.astimezone() + local_active_until = template.activeUntil.astimezone() + + # Show template details in a message box for now + # TODO: Create a proper template details dialog + details = ( + f"{template.name}
" + f"Status: {template.status}
" + f"Created: {local_created_at.strftime('%Y-%m-%d %H:%M')}
" + f"Active Until: {local_active_until.strftime('%Y-%m-%d %H:%M')}
" + f"Active: {'Yes' if template.isActive else 'No'}
" + f"Archived: {'Yes' if template.isArchived else 'No'}
" + f"New Images: {template.newImagesCount or 0}
" + f"AOI Intersection: {template.maxAoiIntersectionPercent or 'N/A'}%" + ) + + alert(details, QMessageBox.Information) + def stop(self): self.processing_fetch_timer.stop() self.processing_fetch_timer.deleteLater() @@ -539,6 +765,37 @@ def selected_processing(self) -> Optional[ProcessingDTO]: return None return first[0] + def selected_templates(self, limit=None) -> List[ProcessingTemplateDTO]: + """Get selected templates from the table.""" + pids = self.view.selected_processing_ids(limit=limit) + # Filter to get only templates (not processings) + selected_templates = [self.templates[pid] for pid in filter(lambda pid: pid in self.templates, pids)] + return selected_templates + + def selected_template(self) -> Optional[ProcessingTemplateDTO]: + """Get the first selected template, if any.""" + first = self.selected_templates(limit=1) + if not first: + return None + return first[0] + + def is_processing_selected(self) -> bool: + """Check if selected item is a processing (not a template).""" + selected = self.selected_processing() + return selected is not None + + def is_template_selected(self) -> bool: + """Check if selected item is a template.""" + selected = self.selected_template() + return selected is not None + + def is_only_templates_selected(self) -> bool: + """True only when current table selection contains templates and no processings.""" + pids = self.view.selected_processing_ids() + if not pids: + return False + return all(pid in self.templates for pid in pids) + def restart_processing(self): processing = self.selected_processing() if not processing: diff --git a/mapflow/functional/view/data_catalog_view.py b/mapflow/functional/view/data_catalog_view.py index d147f081..3aa000dc 100644 --- a/mapflow/functional/view/data_catalog_view.py +++ b/mapflow/functional/view/data_catalog_view.py @@ -194,8 +194,9 @@ def display_mosaic_info(self, mosaic: MosaicReturnSchema, images: list[ImageRetu pixel_size=pixel_size, crs=images[0].meta_data.crs, count=images[0].meta_data.count) + local_created_at = mosaic.created_at.astimezone() text += self.tr("Created: {date} at {time} \nTags: {tags}" - ).format(date=mosaic.created_at.date(), time=mosaic.created_at.strftime('%H:%M'), + ).format(date=local_created_at.date(), time=local_created_at.strftime('%H:%M'), tags=tags_str) self.dlg.catalogInfo.setText(text) self.display_image_number(0, len(images)) @@ -245,6 +246,7 @@ def full_image_info(self, image: ImageReturnSchema): # Otherwise - CRS is most likely projected else: pixel_size = round(pixel_size, 2) # meters or other units + local_uploaded_at = image.uploaded_at.astimezone() message = self.tr('Name: {filename}\
Uploaded
: {date} at {time}\
Size
: {file_size}\ @@ -254,8 +256,8 @@ def full_image_info(self, image: ImageReturnSchema):
Height
: {height} pixels\
Pixel size
: {pixel_size}'\ ).format(filename=image.filename, - date=image.uploaded_at.date(), - time=image.uploaded_at.strftime('%H:%M'), + date=local_uploaded_at.date(), + time=local_uploaded_at.strftime('%H:%M'), file_size=get_readable_size(image.file_size), crs=image.meta_data.crs, bands=image.meta_data.count, @@ -423,13 +425,14 @@ def show_image_info(self, image: ImageReturnSchema): # Otherwise - CRS is most likely projected else: pixel_size = round(pixel_size, 2) # meters or other units + local_uploaded_at = image.uploaded_at.astimezone() self.dlg.catalogInfo.setText(self.tr("Uploaded: {date} at {time} \n" "File size: {size} \n" "Pixel size: {pixel_size} \n" "CRS: {crs} \n" "Bands: {count}" - ).format(date=image.uploaded_at.date(), - time=image.uploaded_at.strftime('%H:%M'), + ).format(date=local_uploaded_at.date(), + time=local_uploaded_at.strftime('%H:%M'), size=get_readable_size(image.file_size), pixel_size=pixel_size, crs=image.meta_data.crs, diff --git a/mapflow/functional/view/processing_view.py b/mapflow/functional/view/processing_view.py index 16d68b61..2512c46e 100644 --- a/mapflow/functional/view/processing_view.py +++ b/mapflow/functional/view/processing_view.py @@ -1,11 +1,11 @@ -from typing import List, Optional +from typing import List, Optional, Union from uuid import UUID from PyQt5.QtCore import Qt, QCoreApplication -from PyQt5.QtWidgets import QAbstractItemView, QTableWidgetItem, QMessageBox, QCheckBox +from PyQt5.QtWidgets import QAbstractItemView, QTableWidgetItem, QMessageBox, QCheckBox, QMenu from PyQt5.QtGui import QColor from ...dialogs.main_dialog import MainDialog from ...dialogs import icons -from ...schema.processing import ProcessingDTO, ProcessingUIParams, ProcessingSortBy, ProcessingSortOrder +from ...schema.processing import ProcessingDTO, ProcessingTemplateDTO, ProcessingUIParams, ProcessingSortBy, ProcessingSortOrder from ...config import config from ..service.alert_service import alert @@ -20,6 +20,8 @@ class ProcessingView: """ def __init__(self, dlg: MainDialog): self.dlg = dlg + self._header_sort_by = None # column-based override; None = use combo + self._header_sort_desc = True # Setup pagination icons self.dlg.processingsPreviousPageButton.setIcon(icons.arrow_left_icon) self.dlg.processingsNextPageButton.setIcon(icons.arrow_right_icon) @@ -39,6 +41,107 @@ def __init__(self, dlg: MainDialog): self.dlg.sortProcessingsCombo.setCurrentIndex(0) self.dlg.filterProcessings.setPlaceholderText(self.tr("Filter processings")) + # Column index -> sort_by value mapping + _COLUMN_SORT_MAP = { + 0: "NAME", # name + 1: "WORKFLOW", # workflowDef + 2: "STATUS", # status + 3: "PROGRESS", # percentCompleted + 4: "AREA", # aoiArea + 5: "COST", # cost + 6: "CREATED", # created + 7: "REVIEW_UNTIL", # reviewUntil + } + + def connect_header_sort(self, on_sort_changed): + """Connect column header clicks to a templates-first re-sort.""" + self.dlg.processingsTable.horizontalHeader().sectionClicked.connect( + lambda col: self._on_header_clicked(col, on_sort_changed) + ) + + def _on_header_clicked(self, column: int, on_sort_changed): + sort_by = self._COLUMN_SORT_MAP.get(column) + if sort_by is None: + return # non-sortable column + if self._header_sort_by == sort_by: + self._header_sort_desc = not self._header_sort_desc + else: + self._header_sort_by = sort_by + self._header_sort_desc = True + # Update the visual indicator on the header + order = Qt.DescendingOrder if self._header_sort_desc else Qt.AscendingOrder + self.dlg.processingsTable.horizontalHeader().setSortIndicator(column, order) + on_sort_changed() + + def setup_context_menu( + self, + on_open_template_details, + on_pause_template, + on_resume_template, + on_delete_template, + is_only_templates_selected, + ): + """ + Setup context menu for the processings table. + + Args: + on_open_template_details: Callback for opening template details + on_pause_template: Callback for pause template action + on_resume_template: Callback for resume template action + on_delete_template: Callback for delete template action + is_only_templates_selected: Callback that returns True only for template-only selection + """ + self.dlg.processingsTable.setContextMenuPolicy(Qt.CustomContextMenu) + self.dlg.processingsTable.customContextMenuRequested.connect( + lambda pos: self._show_context_menu( + pos, + on_open_template_details, + on_pause_template, + on_resume_template, + on_delete_template, + is_only_templates_selected, + ) + ) + + def _show_context_menu( + self, + pos, + on_open_template_details, + on_pause_template, + on_resume_template, + on_delete_template, + is_only_templates_selected, + ): + """Show context menu for templates.""" + item = self.dlg.processingsTable.itemAt(pos) + if not item: + return + + row = item.row() + id_column_index = config.PROCESSING_TABLE_ID_COLUMN_INDEX + id_item = self.dlg.processingsTable.item(row, id_column_index) + + if not id_item: + return + + # Keep selection in sync with right-clicked row. + selected_rows = {index.row() for index in self.dlg.processingsTable.selectionModel().selectedIndexes()} + if row not in selected_rows: + self.dlg.processingsTable.clearSelection() + self.dlg.processingsTable.selectRow(row) + + if not is_only_templates_selected(): + return + + menu = QMenu(self.dlg.processingsTable) + menu.addAction(self.tr("Open Details")).triggered.connect(on_open_template_details) + menu.addSeparator() + menu.addAction(self.tr("Pause Template")).triggered.connect(on_pause_template) + menu.addAction(self.tr("Resume Template")).triggered.connect(on_resume_template) + menu.addSeparator() + menu.addAction(self.tr("Delete Template")).triggered.connect(on_delete_template) + menu.exec_(self.dlg.processingsTable.mapToGlobal(pos)) + def tr(self, message: str) -> str: """Translate message using QCoreApplication.""" return QCoreApplication.translate('ProcessingView', message) @@ -74,24 +177,35 @@ def clear_processing_name(self, name): def disable_processing_start(self, reason: str, clear_area: bool): self.dlg.disable_processing_start(reason=reason, clear_area=clear_area) - def create_table_items(self, processing: ProcessingDTO): + def create_table_items(self, processing: Union[ProcessingDTO, ProcessingTemplateDTO]): table_items = [] set_color = False processing_dict = processing.as_processing_table_dict() - if processing.status.is_ok and processing.review_expires: + is_template = isinstance(processing, ProcessingTemplateDTO) + if is_template: + set_color = True + color = QColor(207, 242, 249) # light blue for templates #! Dark theme? + elif not is_template and processing.status.is_ok and processing.review_expires: # setting color for close review set_color = True color = QColor(255, 220, 200) for col, attr in enumerate(config.PROCESSING_TABLE_COLUMNS): table_item = QTableWidgetItem() table_item.setData(Qt.DisplayRole, processing_dict[attr]) - if processing.status.is_failed: + if is_template: + template_tip = self.tr("Planned processing") + if processing.newImagesCount and processing.newImagesCount > 0: + template_tip = self.tr("Planned processing. New images: {count}").format( + count=processing.newImagesCount + ) + table_item.setToolTip(template_tip) + elif processing.status.is_failed: table_item.setToolTip(processing.error_message(raw=config.SHOW_RAW_ERROR)) elif processing.reviewUntil: table_item.setToolTip(self.tr("Please review or accept this processing until {}." " Double click to add results" " to the map").format( - processing.reviewUntil.strftime('%Y-%m-%d %H:%M') if processing.reviewUntil else "")) + processing.reviewUntil.astimezone().strftime('%Y-%m-%d %H:%M') if processing.reviewUntil else "")) elif processing.status.is_ok: table_item.setToolTip(self.tr("Double click to add results to the map." )) @@ -100,7 +214,7 @@ def create_table_items(self, processing: ProcessingDTO): table_items.append(table_item) return table_items - def update_processing_table(self, processings: List[ProcessingDTO]): + def update_processing_table(self, processings: List[Union[ProcessingDTO, ProcessingTemplateDTO]]): # UPDATE THE TABLE # Memorize the selection to restore it after table update selected_processings = self.selected_processing_ids() @@ -118,7 +232,14 @@ def update_processing_table(self, processings: List[ProcessingDTO]): self.dlg.processingsTable.setItem(row, col, item) if proc.id in selected_processings: self.dlg.processingsTable.selectRow(row) - self.dlg.processingsTable.setSortingEnabled(True) + # Keep Qt sorting disabled — row order is set by combined_processing_rows (templates first). + # Re-show sort indicator if a header sort is active (setSortingEnabled(False) hides it) + if self._header_sort_by is not None: + col = next(c for c, s in self._COLUMN_SORT_MAP.items() if s == self._header_sort_by) + order = Qt.DescendingOrder if self._header_sort_desc else Qt.AscendingOrder + header = self.dlg.processingsTable.horizontalHeader() + header.setSortIndicatorShown(True) + header.setSortIndicator(col, order) # Restore extended selection and filtering self.dlg.processingsTable.setSelectionMode(QAbstractItemView.ExtendedSelection) @@ -194,6 +315,11 @@ def enable_processings_pages(self, enable: bool = False): self.dlg.processingsPreviousPageButton.setEnabled(enable) def sort_processings(self): + # Header click overrides the combo if set + if self._header_sort_by is not None: + sort_by = self._header_sort_by + sort_order = "DESC" if self._header_sort_desc else "ASC" + return sort_by, sort_order index = self.dlg.sortProcessingsCombo.currentIndex() # Sort by if index in (0, 1): # Newest/Oldest first diff --git a/mapflow/mapflow.py b/mapflow/mapflow.py index 39e79531..954fb9bd 100644 --- a/mapflow/mapflow.py +++ b/mapflow/mapflow.py @@ -3,7 +3,7 @@ import shutil from base64 import b64encode, b64decode from configparser import ConfigParser # parse metadata.txt -> QGIS version check (compatibility) -from datetime import datetime # processing creation datetime formatting +from datetime import datetime, timedelta # processing creation datetime formatting from pathlib import Path from typing import List, Optional, Union, Callable, Tuple from osgeo import gdal, ogr @@ -16,7 +16,7 @@ from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from PyQt5.QtWidgets import ( QAbstractItemView, QAction, QApplication, QFileDialog, QMenu, - QMessageBox, QPushButton, QTableWidgetItem, QWidget + QMessageBox, QPushButton, QTableWidgetItem, QWidget, QToolButton ) from PyQt5.QtXml import QDomDocument from qgis.core import ( @@ -73,6 +73,7 @@ PreviewType, ProductType) from .schema.project import MapflowProject, ProjectsRequest, UserRole +from .schema.processing import CreateProcessingTemplateSchema, SearchParams from .schema.workflow_def import WorkflowDef # Dialogs from .dialogs import (ErrorMessageWidget, @@ -314,6 +315,7 @@ def __init__(self, iface) -> None: self.processing_service.connect_processings_pagination() # Processings ratings self.dlg.processingsTable.itemSelectionChanged.connect(self.enable_feedback) + self.dlg.processingsTable.itemSelectionChanged.connect(self.on_processings_selection_changed) self.dlg.ratingSubmitButton.clicked.connect(self.submit_processing_rating) self.dlg.enable_rating(False, False) # by default disabled self.dlg.enable_review(False) @@ -337,16 +339,20 @@ def __init__(self, iface) -> None: self.dlg.removeProvider.clicked.connect(self.remove_provider) self.config_search_columns = ConfigColumns() + self.active_template_id = None self.meta_table_layer_connection = self.dlg.metadataTable.itemSelectionChanged.connect( self.sync_table_selection_with_image_id_and_layer) + self.dlg.metadataTable.itemSelectionChanged.connect(self.update_start_processing_button_state) self.app_context.meta_layer_table_connection = None - self.dlg.getMetadata.clicked.connect(self.get_metadata) + self.dlg.getMetadata.clicked.connect(self.handle_metadata_button_click) self.dlg.metadataTable.cellDoubleClicked.connect(self.preview) + self.dlg.metadataTable.cellClicked.connect(self.on_metadata_table_cell_clicked) self.dlg.rasterSourceChanged.connect(self.on_provider_change) self.dlg.clearSearch.clicked.connect(self.clear_metadata) self.dlg.metadataTableFilled.connect(self.filter_metadata) self.dlg.searchRightButton.clicked.connect(self.show_search_next_page) self.dlg.searchLeftButton.clicked.connect(self.show_search_previous_page) + self.setup_metadata_search_dropdown() # ========== 14. ZOOM SELECTOR CONFIGURATION ========== if self.app_context.zoom_selector: @@ -420,12 +426,363 @@ def setup_add_layer_menu(self): def setup_options_menu_connections(self): self.dlg.save_result_action.triggered.connect(self.download_results_file) self.dlg.download_aoi_action.triggered.connect(self.download_aoi_file) - self.dlg.see_details_action.triggered.connect(self.show_details) + self.dlg.see_details_action.triggered.connect(self.show_selected_details) + self.dlg.see_processings_action.triggered.connect(self.select_template_processings) + self.dlg.see_search_results_action.triggered.connect(self.show_template_search_results) self.dlg.processing_update_action.triggered.connect(self.processing_service.update_processing) self.dlg.processing_restart_action.triggered.connect(self.processing_service.restart_processing) self.dlg.processing_duplicate_action.triggered.connect(self.check_dir_and_duplicate_processing) + self.dlg.options_menu.aboutToShow.connect(self.update_processing_options_menu) self.dlg.saveOptionsButton.setMenu(self.dlg.options_menu) + def update_processing_options_menu(self): + """Render processing options menu depending on selected row type.""" + menu = self.dlg.options_menu + menu.clear() + + selected_template = self.processing_service.selected_template() + selected_processing = self.processing_service.selected_processing() + + # Template selection: only template details action. + if selected_template and not selected_processing: + menu.addAction(self.dlg.see_details_action) + menu.addAction(self.dlg.see_search_results_action) + menu.addAction(self.dlg.see_processings_action) + return + + # Processing selection: show processing-related actions. + if not selected_processing: + return + + menu.addAction(self.dlg.save_result_action) + menu.addAction(self.dlg.download_aoi_action) + menu.addAction(self.dlg.see_details_action) + + if self.app_context.user_role.can_delete_rename_review_processing: + menu.addAction(self.dlg.processing_update_action) + + if self.app_context.user_role.can_start_processing: + menu.addAction(self.dlg.processing_restart_action) + menu.addAction(self.dlg.processing_duplicate_action) + + def show_selected_details(self): + """Open details based on selected entity type.""" + template = self.processing_service.selected_template() + if template and not self.processing_service.selected_processing(): + self.show_template_details_and_navigation(template) + return + self.show_details() + + def on_processings_selection_changed(self): + """Check if selected row is a template, not a processing.""" + selected_template = self.processing_service.selected_template() + if not selected_template: + self.active_template_id = None + else: + self.active_template_id = str(selected_template.id) + self.update_start_processing_button_state() + + def update_start_processing_button_text(self): + selected_template = self.processing_service.selected_template() + selected_processing = self.processing_service.selected_processing() + if selected_template and not selected_processing: + self.dlg.startProcessing.setText(self.tr("Start planned processing")) + return + self.dlg.startProcessing.setText(self.tr("Start processing")) + + def update_start_processing_button_state(self): + """Render start button text and enforce planned-processing image selection gate.""" + self.update_start_processing_button_text() + error = self.processing_service.planned_processing_selection_error() + if error: + self.dlg.disable_processing_start(reason=error, clear_area=False) + return + + # No gate error: re-enable the button and clear any planned-processing reason label. + self.dlg.startProcessing.setEnabled(True) + planned_reason = self.tr("Select one or more images in search results to start planned processing") + if self.dlg.processingProblemsLabel.text() == planned_reason: + self.dlg.processingProblemsLabel.clear() + + def get_selected_template_callback(self, response: QNetworkReply): + """Render selected template search results in the search table.""" + response_json = json.loads(response.readAll().data()) + if not response_json.get("images"): + self.dlg.metadataTable.clearContents() + self.dlg.metadataTable.setRowCount(0) + self.alert(self.tr("No images was found"), QMessageBox.Information) + return + raw_images = response_json.get("images", []) + response_data = ImageCatalogResponseSchema(**response_json) + geoms = response_data.as_geojson() + for position, feature in enumerate(geoms.get("features", ())): + feature["properties"]["local_index"] = position + if raw_images[position].get("isNew"): + feature["properties"]["productType"] = ( + (feature["properties"].get("productType") or "") + " (new)" + ).strip() + self.dlg.fill_metadata_table(geoms) + + def populate_search_table_from_template(self, search_results: List[dict]): + """Populate search table with search results returned for a selected template.""" #! + self.dlg.metadataTable.clearSelection() + self.dlg.metadataTable.setSortingEnabled(False) + self.dlg.metadataTable.setRowCount(len(search_results)) + + for row, result in enumerate(search_results): + metadata = result.get("metadata") or {} + if metadata.get("id") is None: + metadata["id"] = result.get("id") + metadata["local_index"] = row + if result.get("isNew"): + metadata["productType"] = ( + (metadata.get("productType") or "") + " (new)" + ).strip() + + for col, attr in enumerate(self.config_search_columns.METADATA_TABLE_ATTRIBUTES.values()): + item = QTableWidgetItem() + item.setData(Qt.DisplayRole, metadata.get(attr)) + self.dlg.metadataTable.setItem(row, col, item) + + self.dlg.metadataTable.setSortingEnabled(True) + self.dlg.metadataTable.resizeColumnsToContents() + + def _aoi_ids_from_template(self, template) -> List[str]: + """Extract AOI IDs from template searchParams.aoiDetails features.""" + search_params = template.searchParams or {} + if isinstance(search_params, dict): + aoi_details = search_params.get("aoiDetails", {}) + else: + aoi_details = search_params.aoiDetails + + if not aoi_details: + return [] + + ids = [] + for feature in aoi_details.get("features", []): + aoi_id = feature.get("id") or feature.get("properties", {}).get("id") + if aoi_id: + ids.append(str(aoi_id)) + return ids + + def _linked_processings_from_template(self, template) -> List[dict]: + """Extract processing links from template AOI details.""" + links = [] + search_params = template.searchParams or {} + if isinstance(search_params, dict): + aoi_details = search_params.get("aoiDetails", {}) + else: + aoi_details = search_params.aoiDetails + + if not aoi_details: + return links + + for feature in aoi_details.get("features", []): + feature_processings = feature.get("properties", {}).get("processings", []) + links.extend(feature_processings) + return links + + def show_template_details_and_navigation(self, template): + """Show template summary.""" + linked_processings = self._linked_processings_from_template(template) + linked_count = len(linked_processings) + new_images = template.newImagesCount or 0 + local_created_at = template.createdAt.astimezone() + local_active_until = template.activeUntil.astimezone() + + details = ( + f"{template.name}
" + f"Status: {template.status}
" + f"Created: {local_created_at.strftime('%Y-%m-%d %H:%M')}
" + f"Active Until: {local_active_until.strftime('%Y-%m-%d %H:%M')}
" + f"Linked processings: {linked_count}
" + f"New images: {new_images}" + ) + + alert(details, QMessageBox.Information) + + def select_template_processings(self): + """Select all processings in the table that were launched from selected template.""" + template = self.processing_service.selected_template() + if not template or self.processing_service.selected_processing(): + return + + template_group_name = str(template.name) + processings_subgroup_name = self.tr("Processings AOI") + + template_aoi_layer = None + try: + template_aoi_layer = self._add_geojson_aoi_layer( + features=template._aoi_features(), + layer_name=self.tr("Planned AOI: {name}").format(name=template.name), + style_name='aoi.qml', + template_group_name=template_group_name, + ) + except Exception: + template_aoi_layer = None + + links = self._linked_processings_from_template(template) + linked_ids = [str(item.get("processingId")) for item in links if item.get("processingId")] + if not linked_ids: + return + + linked_items = [] + seen_ids = set() + for item in links: + processing_id = item.get("processingId") + if not processing_id: + continue + processing_id = str(processing_id) + if processing_id in seen_ids: + continue + seen_ids.add(processing_id) + processing_name = item.get("processingName") or item.get("name") or self.tr("Unknown") + linked_items.append((processing_name, processing_id)) + + linked_details = "
".join( + f"- {name} ({pid})" for name, pid in linked_items + ) + alert( + self.tr("Linked processings:
{details}").format(details=linked_details), + QMessageBox.Information, + ) + + # Add linked processings AOIs to the same group as template AOI + for processing_id in linked_ids: + self.http.get( + url=f'{self.server}/processings/{processing_id}/aois', + callback=self._add_template_processing_aoi_callback, + callback_kwargs={ + 'processing_id': processing_id, + 'template_group_name': template_group_name, + 'processings_subgroup_name': processings_subgroup_name, + 'reference_layer_id': template_aoi_layer.id() if template_aoi_layer else None, + }, + use_default_error_handler=True, + timeout=30, + ) + + def _add_geojson_aoi_layer(self, + features: list, + layer_name: str, + style_name: str, + template_group_name: Optional[str] = None, + subgroup_name: Optional[str] = None, + reference_layer_id: Optional[str] = None) -> Optional[QgsVectorLayer]: + if not features: + return None + aoi_layer = QgsVectorLayer('Polygon?crs=epsg:4326', layer_name, 'memory') + provider = aoi_layer.dataProvider() + qgs_features = [] + for feature in features: + geom_dict = feature.get("geometry") + if not geom_dict: + continue + try: + ogr_geom = ogr.CreateGeometryFromJson(json.dumps(geom_dict)) + if not ogr_geom: + continue + qgs_geom = QgsGeometry.fromWkt(ogr_geom.ExportToWkt()) + qgs_feat = QgsFeature() + qgs_feat.setGeometry(qgs_geom) + qgs_features.append(qgs_feat) + except Exception: + continue + if not qgs_features: + return None + provider.addFeatures(qgs_features) + aoi_layer.updateExtents() + + root = self.app_context.project.layerTreeRoot() + if template_group_name: + mapflow_group_name = self.app_context.settings.value('layerGroup') or self.app_context.plugin_name + mapflow_group = root.findGroup(mapflow_group_name) + + parent_group = mapflow_group if mapflow_group else root + template_group = parent_group.findGroup(template_group_name) + if not template_group: + template_group = parent_group.insertGroup(0, template_group_name) + + target_group = template_group + if subgroup_name: + subgroup = template_group.findGroup(subgroup_name) + if not subgroup: + subgroup = template_group.insertGroup(0, subgroup_name) + target_group = subgroup + + self.app_context.project.addMapLayer(aoi_layer, addToLegend=False) + target_group.insertLayer(0, aoi_layer) + elif reference_layer_id: + root = self.app_context.project.layerTreeRoot() + ref_node = root.findLayer(reference_layer_id) + if ref_node and ref_node.parent(): + self.app_context.project.addMapLayer(aoi_layer, addToLegend=False) + ref_node.parent().insertLayer(0, aoi_layer) + else: + self.app_context.project.addMapLayer(aoi_layer) + else: + self.app_context.project.addMapLayer(aoi_layer) + + aoi_layer.loadNamedStyle(os.path.join(self.plugin_dir, 'static', 'styles', style_name)) + self.add_to_layers(aoi_layer) + self.iface.setActiveLayer(aoi_layer) + return aoi_layer + + def _add_template_processing_aoi_callback(self, + response: QNetworkReply, + processing_id: str, + template_group_name: Optional[str] = None, + processings_subgroup_name: Optional[str] = None, + reference_layer_id: Optional[str] = None): + try: + data = json.loads(response.readAll().data()) + geojson = AoiResponseSchema(data).aoi_as_geojson() + features = geojson.get('features', []) + self._add_geojson_aoi_layer( + features=features, + layer_name=self.tr("AOI: {id}").format(id=processing_id), + style_name='aoi_templates_processing.qml', + template_group_name=template_group_name, + subgroup_name=processings_subgroup_name, + reference_layer_id=reference_layer_id, + ) + except Exception: + return + + def show_template_search_results(self): + """Open imagery search tab and fill it with selected template search results.""" + template = self.processing_service.selected_template() + if not template or self.processing_service.selected_processing(): + return + + imagery_search_tab = self.dlg.tabWidget.findChild(QWidget, "providersTab") + if imagery_search_tab: + self.dlg.tabWidget.setCurrentWidget(imagery_search_tab) + + aoi_ids = self._aoi_ids_from_template(template) + self.processing_service.api.get_template_images( + template_id=template.id, + callback=self.get_selected_template_callback, + limit=self.search_page_limit, + offset=self.search_page_offset, + aoi_ids=aoi_ids or None, + ) + + def select_processing_in_table(self, processing_id: str): + """Select processing row by ID and open processing details.""" + id_column_index = self.config.PROCESSING_TABLE_ID_COLUMN_INDEX + id_items = self.dlg.processingsTable.findItems(str(processing_id), Qt.MatchExactly) + + for item in id_items: + if item.column() != id_column_index: + continue + row = item.row() + self.dlg.processingsTable.clearSelection() + self.dlg.processingsTable.selectRow(row) + self.dlg.processingsTable.scrollToItem(item) + self.show_details() + return + def create_aoi_layer_from_map(self, action: QAction): aoi_geometry = helpers.to_wgs84( QgsGeometry.fromRect(self.iface.mapCanvas().extent()), @@ -527,7 +884,10 @@ def show_wd_options(self, wd: WorkflowDef): for block in wd.optional_blocks: self.dlg.add_model_option(block.displayName, checked=bool(self.app_context.settings.value(f"wd/{wd.id}/{block.name}", False))) # Other wigets are disabled before the appearence of these checkboxes, so we do it here separately after adding them - self.dlg.enable_model_options(self.app_context.user_role.can_start_processing) + can_start_processing = True + if self.app_context.user_role: + can_start_processing = self.app_context.user_role.can_start_processing + self.dlg.enable_model_options(can_start_processing) def save_options_settings(self, wd: WorkflowDef, enabled_blocks: List[bool]): enabled_blocks_dict = wd.get_enabled_blocks(enabled_blocks) @@ -856,6 +1216,87 @@ def replace_search_provider_index(self): if not provider_supports_search: self.dlg.setProviderIndex(self.provider_service.imagery_search_provider_index) + def setup_metadata_search_dropdown(self): + """Set Search button as dropdown with Search and Plan search modes.""" + self.metadata_search_mode = "search" + self.metadata_search_menu = QMenu(self.dlg.getMetadata) + search_action = self.metadata_search_menu.addAction(self.tr("Search")) + plan_action = self.metadata_search_menu.addAction(self.tr("Plan search")) + search_action.triggered.connect(lambda: self.set_metadata_search_mode("search")) + plan_action.triggered.connect(lambda: self.set_metadata_search_mode("plan")) + self.dlg.getMetadata.setPopupMode(QToolButton.MenuButtonPopup) + self.dlg.getMetadata.setMenu(self.metadata_search_menu) + self.set_metadata_search_mode("search") + + def set_metadata_search_mode(self, mode: str): + self.metadata_search_mode = mode + self.dlg.getMetadata.setText(self.tr("Plan search") if mode == "plan" else self.tr("Search")) + + def handle_metadata_button_click(self): + if getattr(self, "metadata_search_mode", "search") == "plan": + self.create_search_template() + return + self.get_metadata() + + def create_search_template(self): + """Create planned search template using current AOI and imagery-search filters.""" + self.replace_search_provider_index() + if not self.app_context.aoi: + self.alert(self.tr('Please, select a valid area of interest')) + return + + from_time = self.dlg.metadataFrom.dateTime().toUTC().toString("yyyy-MM-ddTHH:mm:ss.zzz'Z'") + to_time = self.dlg.metadataTo.dateTime().toUTC().toString("yyyy-MM-ddTHH:mm:ss.zzz'Z'") + max_cloud_cover = self.dlg.maxCloudCover.value() + min_intersection = self.dlg.minIntersection.value() + hide_unavailable = self.dlg.hideUnavailableResults.isChecked() + product_types = self.selected_search_product_types() or [] + search_providers = self.dlg.searchProvidersCombo.checkedItemsData() or [] + + search_params = SearchParams( + aoi=json.loads(self.app_context.aoi.asJson()), + acquisitionDateFrom=from_time, + acquisitionDateTo=to_time, + maxCloudCover=max_cloud_cover, + minAoiIntersectionPercent=min_intersection, + minOffNadirAngle=0, + maxOffNadirAngle=25, + hideUnavailable=hide_unavailable, + productTypes=product_types, + dataProviders=search_providers, + ) + + template_name = self.dlg.processingName.text().strip() or self.tr("Planned search {date}").format( + date=datetime.now().strftime('%Y-%m-%d %H:%M') + ) + template_payload = CreateProcessingTemplateSchema( + name=template_name, + searchParams=search_params, + projectId=str(self.app_context.project_id), + activeUntil=(datetime.utcnow() + timedelta(days=30)).strftime('%Y-%m-%dT%H:%M:%S.0Z'), + ) + + self.dlg.getMetadata.setEnabled(False) + self.iface.messageBar().pushInfo(self.app_context.plugin_name, self.tr('Creating planned search...')) + self.processing_service.api.create_template( + data=template_payload, + callback=self.create_search_template_callback, + error_handler=self.create_search_template_error_handler, + ) + + def create_search_template_callback(self, response: QNetworkReply): + self.dlg.getMetadata.setEnabled(True) + alert(self.tr("Planned search created successfully."), QMessageBox.Information) + self.processing_service.get_processings() + + def create_search_template_error_handler(self, response: QNetworkReply): + self.dlg.getMetadata.setEnabled(True) + self.report_http_error( + response, + self.tr("Template creation failed"), + error_message_parser=api_message_parser, + ) + def get_metadata(self, _: Optional[bool] = False, offset: Optional[int] = 0) -> None: """Metadata is image footprints with attributes like acquisition date or cloud cover.""" try: # disconnect to prevent adding mutliple previews if table was refilled (multiple searches) @@ -2002,6 +2443,62 @@ def preview(self) -> None: else: # XYZ providers self.preview_xyz(provider=provider, image_id=image_id) + def on_metadata_table_cell_clicked(self, row: int, column: int): + """Mark template image as seen when user clicks a row with the (new) badge.""" + if not self.active_template_id: + return + product_type_col = 0 # 'productType' is the first column in METADATA_TABLE_ATTRIBUTES + cell = self.dlg.metadataTable.item(row, product_type_col) + if not cell: + return + text = cell.text() + if not text.endswith(" (new)"): + return + id_column_index = self.config.MAXAR_ID_COLUMN_INDEX + id_item = self.dlg.metadataTable.item(row, id_column_index) + if not id_item: + return + image_id = id_item.text() + cell.setText(text[: -len(" (new)")]) + self.processing_service.api.mark_template_image_seen( + template_id=self.active_template_id, + image_id=image_id, + callback=lambda _: None, + error_handler=lambda _: None, + ) + self._decrement_template_new_images_count(self.active_template_id) + + def _decrement_template_new_images_count(self, template_id: str): + """Decrement newImagesCount on the in-memory template DTO and update processings table status cell.""" + from uuid import UUID + try: + key = UUID(template_id) + except (ValueError, AttributeError): + key = template_id + template = self.processing_service.templates.get(key) + if template is None: + return + if template.newImagesCount and template.newImagesCount > 0: + template.newImagesCount -= 1 + + # Update the status cell in processingsTable + id_col = self.config.PROCESSING_TABLE_ID_COLUMN_INDEX + status_col = list(self.config.PROCESSING_TABLE_COLUMNS).index('status') + table = self.dlg.processingsTable + for row in range(table.rowCount()): + id_item = table.item(row, id_col) + if id_item and id_item.text() == str(template_id): + status_item = table.item(row, status_col) + if status_item: + status_item.setData(Qt.DisplayRole, template.table_status) + tip = self.tr("Planned processing") + if template.newImagesCount and template.newImagesCount > 0: + tip = self.tr("Planned processing. New images: {count}").format( + count=template.newImagesCount + ) + status_item.setToolTip(tip) + break + def preview_search_from_cell (self, row, column): if column == self.config.PPRVIEW_INDEX_COLUMN: id_column_index = self.config.MAXAR_ID_COLUMN_INDEX @@ -2175,6 +2672,14 @@ def enable_restart_action(self, enabled: bool): # =================== Results management ==================== # def load_results(self): + # Check if it's a template first + template = self.processing_service.selected_template() + if template: + self.show_template_search_results() + self.select_template_processings() + return + + # Otherwise, it's a processing processing = self.processing_service.selected_processing() if not processing: return diff --git a/mapflow/schema/base.py b/mapflow/schema/base.py index aaf4893c..723a6e5f 100644 --- a/mapflow/schema/base.py +++ b/mapflow/schema/base.py @@ -1,7 +1,7 @@ import dataclasses import json from dataclasses import dataclass, fields -from datetime import datetime +from datetime import datetime, timezone from typing import Optional @@ -44,3 +44,19 @@ def as_dict(self, skip_none=True): def as_json(self, skip_none=True): return json.dumps(self.as_dict(skip_none=skip_none)) + + +def parse_api_datetime_utc(value: Optional[object]) -> Optional[datetime]: + """Parse API datetime preserving UTC semantics for internal state.""" + if value is None or value == "": + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + if isinstance(value, str): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + raise TypeError(f"Unsupported datetime value type: {type(value)}") diff --git a/mapflow/schema/catalog.py b/mapflow/schema/catalog.py index 79e77e69..27b8bb12 100644 --- a/mapflow/schema/catalog.py +++ b/mapflow/schema/catalog.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Optional, Mapping, Any, Union, List -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc from ..config import Config @@ -77,7 +77,7 @@ class ImageSchema(Serializable, SkipDataClass): def __post_init__(self): if isinstance(self.acquisitionDate, str): - self.acquisitionDate = datetime.fromisoformat(self.acquisitionDate.replace("Z", "+00:00")) + self.acquisitionDate = parse_api_datetime_utc(self.acquisitionDate) elif not isinstance(self.acquisitionDate, datetime): raise TypeError("Acquisition date must be either datetime or ISO-formatted str") self.cloudCover = self.cloudCover diff --git a/mapflow/schema/data_catalog.py b/mapflow/schema/data_catalog.py index 56c29444..ecf72e74 100644 --- a/mapflow/schema/data_catalog.py +++ b/mapflow/schema/data_catalog.py @@ -4,7 +4,7 @@ from typing import Sequence, Union, Optional, List from dataclasses import dataclass -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc from .layer import RasterLayer @@ -41,7 +41,7 @@ class MosaicCreateReturnSchema(SkipDataClass): tags: Union[Sequence[str], None] = () def __post_init__(self): - self.created_at = datetime.fromisoformat(self.created_at.replace("Z", "+00:00")) + self.created_at = parse_api_datetime_utc(self.created_at) @dataclass class MosaicReturnSchema(SkipDataClass): @@ -54,7 +54,7 @@ class MosaicReturnSchema(SkipDataClass): tags: Union[Sequence[str], None] = () def __post_init__(self): - self.created_at = datetime.fromisoformat(self.created_at.replace("Z", "+00:00")) + self.created_at = parse_api_datetime_utc(self.created_at) self.rasterLayer = RasterLayer.from_dict(self.rasterLayer) @@ -87,5 +87,5 @@ class ImageReturnSchema(SkipDataClass): available_for_download: bool = True def __post_init__(self): - self.uploaded_at = datetime.fromisoformat(self.uploaded_at.replace("Z", "+00:00")) + self.uploaded_at = parse_api_datetime_utc(self.uploaded_at) self.meta_data = ImageMetadataSchema.from_dict(self.meta_data) diff --git a/mapflow/schema/processing.py b/mapflow/schema/processing.py index a0760c1b..2c87efde 100644 --- a/mapflow/schema/processing.py +++ b/mapflow/schema/processing.py @@ -2,16 +2,17 @@ from qgis.core import QgsVectorLayer from dataclasses import dataclass, fields -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional, Mapping, Any, Union, Iterable, List from uuid import UUID -from .base import SkipDataClass, Serializable +from .base import SkipDataClass, Serializable, parse_api_datetime_utc from .status import ProcessingStatus, ProcessingReviewStatus from .layer import RasterLayer, VectorLayer from .workflow_def import WorkflowDef from ..entity.provider.provider import SourceType from ..errors import ErrorMessage +from ..functional.geometry import geojson_feature_area_sqkm, make_distance_calculator @dataclass class PostSourceSchema(Serializable, SkipDataClass): @@ -88,6 +89,7 @@ class ImagerySearchSchema(Serializable): dataProvider: str imageIds: List[str] zoom: int + searchId: Optional[str] = None @dataclass @@ -130,6 +132,7 @@ def from_dict(cls, params_dict: Optional[dict]): elif source_params.get("myImagery"): source_params = MyImageryParams(MyImagerySchema(**source_params.get("myImagery"))) elif source_params.get("imagerySearch"): + #! print (str(source_params)) source_params = ImagerySearchParams(ImagerySearchSchema(**source_params.get("imagerySearch"))) elif source_params.get("userDefined"): source_params = UserDefinedParams(UserDefinedSchema(**source_params.get("userDefined"))) @@ -150,6 +153,170 @@ class PostProcessingSchemaV2(Serializable): blocks: Optional[Iterable[BlockOption]] +def _parse_iso_datetime(dt_str: str) -> datetime: + """Parse ISO 8601 datetime strings with or without microseconds.""" + return parse_api_datetime_utc(dt_str) + + +@dataclass +class SearchParams(Serializable, SkipDataClass): + aoiDetails: Optional[Mapping[str, Any]] = None + aoi: Optional[Mapping[str, Any]] = None + acquisitionDateFrom: Optional[str] = None + acquisitionDateTo: Optional[str] = None + minResolution: Optional[float] = None + maxResolution: Optional[float] = None + maxCloudCover: Optional[float] = None + minOffNadirAngle: Optional[float] = None + maxOffNadirAngle: Optional[float] = None + minAoiIntersectionPercent: Optional[float] = None + maxAoiIntersectionPercent: Optional[float] = None + returnErrors: Optional[bool] = None + limit: Optional[int] = None + offset: Optional[int] = None + hideUnavailable: Optional[bool] = None + dataProviders: Optional[List[str]] = None + productTypes: Optional[List[str]] = None + + +@dataclass +class ProcessingTemplateDTO(Serializable, SkipDataClass): + id: UUID + name: str + status: str + createdAt: datetime + userId: UUID + searchParams: SearchParams + projectId: UUID + activeUntil: datetime + processingParams: Optional[Mapping[str, Any]] = None + lastCheckedAt: Optional[datetime] = None + newImagesCount: Optional[int] = None + isActive: bool = False + isArchived: bool = False + maxAoiIntersectionPercent: Optional[float] = None + + def __post_init__(self): + self.createdAt = _parse_iso_datetime(self.createdAt) + if self.lastCheckedAt: + self.lastCheckedAt = _parse_iso_datetime(self.lastCheckedAt) + self.activeUntil = _parse_iso_datetime(self.activeUntil) + if isinstance(self.searchParams, dict): + self.searchParams = SearchParams.from_dict(self.searchParams) + + @property + def is_template(self) -> bool: + return True + + def _aoi_features(self): + """Return template AOI features from either aoiDetails or aoi shape.""" + if isinstance(self.searchParams, SearchParams): + aoi_details = self.searchParams.aoiDetails + if aoi_details: + return (aoi_details or {}).get("features", []) + if self.searchParams.aoi: + return [{ + "type": "Feature", + "geometry": self.searchParams.aoi, + "properties": {}, + }] + return [] + + search_params = self.searchParams or {} + aoi_details = search_params.get("aoiDetails", {}) + if aoi_details: + return (aoi_details or {}).get("features", []) + + aoi = search_params.get("aoi") + if aoi: + return [{ + "type": "Feature", + "geometry": aoi, + "properties": {}, + }] + return [] + + @property + def aoi_area(self) -> Optional[float]: + """Total AOI area in sq km, computed from searchParams.aoiDetails features.""" + try: + features = self._aoi_features() + if not features: + return None + calculator = make_distance_calculator() + total = sum(geojson_feature_area_sqkm(f, calculator) for f in features) + return round(total, 4) if total else None + except Exception: + return None + + @property + def table_status(self) -> str: + print (self.name, self.newImagesCount) + if (self.status or "").upper() == "FAILED": + return "Failed" + if not self.isActive: + return "Inactive" + if self.lastCheckedAt: + status = "Updated" + else: + status = "Created" + if self.newImagesCount and self.newImagesCount > 0: + return f"{status} ({self.newImagesCount})" + return status + + def as_processing_table_dict(self): + return { + "name": self.name, + "workflowDef": "Planned", #! Translate + "status": self.table_status, + "percentCompleted": "N/A", + "aoiArea": self.aoi_area, + "cost": None, + "created": self.createdAt.astimezone().strftime('%Y-%m-%d %H:%M'), + "reviewUntil": None, + "id": self.id, + } + + +@dataclass +class ProcessingTemplateDetails(Serializable, SkipDataClass): + template: ProcessingTemplateDTO + + def __post_init__(self): + if isinstance(self.template, dict): + self.template = ProcessingTemplateDTO.from_dict(self.template) + + +@dataclass +class CreateProcessingTemplateSchema(Serializable, SkipDataClass): + name: str + searchParams: Mapping[str, Any] + projectId: str + activeUntil: str + processingParams: Optional[Mapping[str, Any]] = None + + +@dataclass +class UpdateProcessingTemplateSchema(Serializable, SkipDataClass): + name: str + searchParams: Mapping[str, Any] + processingParams: Mapping[str, Any] + activeUntil: str + + +@dataclass +class RunTemplateProcessingSchema(Serializable, SkipDataClass): + name: str + description: Optional[str] + wdName: Optional[str] + wdId: Optional[str] + geometry: Mapping[str, Any] + params: Mapping[str, Any] + meta: Mapping[str, Any] + blocks: List[Mapping[str, Any]] + updateTemplateGeometry: bool + + @dataclass class ProcessingUIParams(Serializable, SkipDataClass): name: Optional[str] @@ -181,7 +348,7 @@ class ProcessingDTO(Serializable, SkipDataClass): def __post_init__(self): self.status = ProcessingStatus(self.status) - self.created = datetime.strptime(self.created, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + self.created = _parse_iso_datetime(self.created) self.params = ProcessingParams.from_dict(self.params) self.blocks = [BlockOption.from_dict(block) for block in self.blocks] self.workflowDef = WorkflowDef.from_dict(self.workflowDef) @@ -198,7 +365,7 @@ def review_expires(self): if not isinstance(self.reviewStatus.inReviewUntil, datetime)\ or not self.reviewStatus.is_in_review: return False - now = datetime.now().astimezone() + now = datetime.now(timezone.utc) one_day = timedelta(1) return self.reviewStatus.inReviewUntil - now < one_day @@ -231,7 +398,7 @@ def as_processing_table_dict(self): "percentCompleted": self.percentCompleted, "aoiArea": self.aoiArea/1000000, "cost": self.cost, - "created": self.created.strftime('%Y-%m-%d %H:%M'), + "created": self.created.astimezone().strftime('%Y-%m-%d %H:%M'), "reviewUntil": self.reviewUntil, "id": self.id } diff --git a/mapflow/schema/project.py b/mapflow/schema/project.py index 61ff8a61..1b3401f6 100644 --- a/mapflow/schema/project.py +++ b/mapflow/schema/project.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Optional, List, Dict -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc from .workflow_def import WorkflowDef from ..config import Config @@ -38,12 +38,14 @@ def __post_init__(self): self.users = [ShareProjectUser.from_dict(item) for item in self.users] def get_user_role(self, email): - for owner in self.owners: + for owner in self.owners or []: if owner.email == email: return UserRole.owner - for user in self.users: + for user in self.users or []: if user.email == email: return UserRole(user.role) + # Shared project payload may not include the current user; default to least privilege. + return UserRole.readonly @dataclass @@ -76,8 +78,8 @@ def __post_init__(self): else: self.shareProject = None if self.created and self.updated: - self.created = datetime.fromisoformat(self.created.replace("Z", "+00:00")) - self.updated = datetime.fromisoformat(self.updated.replace("Z", "+00:00")) + self.created = parse_api_datetime_utc(self.created) + self.updated = parse_api_datetime_utc(self.updated) class UserRole(str, Enum): readonly = "readonly" diff --git a/mapflow/schema/status.py b/mapflow/schema/status.py index 86e41bd4..08bea75e 100644 --- a/mapflow/schema/status.py +++ b/mapflow/schema/status.py @@ -5,7 +5,7 @@ from PyQt5.QtCore import QObject -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc class ProcessingStatusDict(QObject): @@ -114,7 +114,7 @@ def from_dict(cls, data: Optional[dict]): def __post_init__(self): if self.inReviewUntil: - self.inReviewUntil = datetime.strptime(self.inReviewUntil, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + self.inReviewUntil = parse_api_datetime_utc(self.inReviewUntil) self.reviewStatus = ProcessingReviewStatusEnum(self.reviewStatus) @property diff --git a/mapflow/static/styles/aoi_templates_processing.qml b/mapflow/static/styles/aoi_templates_processing.qml new file mode 100644 index 00000000..4761b752 --- /dev/null +++ b/mapflow/static/styles/aoi_templates_processing.qml @@ -0,0 +1,326 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + 2 + diff --git a/spec/002_F_plan_processing_api.md b/spec/002_F_plan_processing_api.md new file mode 100644 index 00000000..7c150861 --- /dev/null +++ b/spec/002_F_plan_processing_api.md @@ -0,0 +1,110 @@ +# 002_F Planned Processing API + +## Purpose +Define REST API contracts for planned processing consumed by the plugin. + +## Endpoints + +### `POST /processings/template` +Create a template. + +Request fields: +- `name` (string) +- `searchParams` (object, includes `aoi`) +- `processingParams` (object) +- `projectId` (uuid) +- `activeUntil` (datetime) + +Response: +- `template` (Template object) +- `searchResults` (array of `{id, metadata}`) + +### `GET /processings/template` +Get all templates for authenticated user. + +Response: +- array of Template objects + +### `GET /processings/template/{templateId}` +Get one template by id. + +Response: +- `template` (Template object) +- `searchResults` (array of `{id, metadata}`) + +### `POST /processings/template/{templateId}` +Run processing from template with legacy params payload. + +Body includes: +- `name`, `description`, `wdName`, `wdId`, `geometry`, `params`, `meta`, `blocks`, `updateTemplateGeometry` + +### `POST /processings/template/{templateId}/v2` +Run processing from template with v2 params payload. + +Body includes: +- `name`, `description`, `wdName`, `wdId`, `geometry`, `params`, `meta`, `blocks`, `updateTemplateGeometry` + +### `PUT /processings/template/{templateId}` +Update template. + +Body fields: +- `name` (string) +- `searchParams` (object) +- `processingParams` (object) +- `activeUntil` (datetime) + +Response: +- updated Template object + +### `DELETE /processings/template/{templateId}` +Mark template as deleted. + +### `POST /processings/template/{templateId}/stop` +Set template status to Inactive. + +Response: +- `template` (Template object) +- `searchResults` (array) + +### `POST /processings/template/{templateId}/resume` +Set template status to Active. + +Response: +- `template` (Template object) +- `searchResults` (array) + +### `GET /processings/template/{templateId}/processings` +Get all processings associated with template. + +Response: +- array of Processing objects + +### `POST /processings/template/{templateId}/image/{imageId}/seen` +Mark one image as seen for template. + +### `GET /processings/template/user/{userId}` +Get templates for a specific user id. + +Response: +- array of Template objects + +### `GET /processings/template/project/{projectId}` +Get templates for a specific project id. + +Response: +- array of Template objects + +## Template Object +- `id` (uuid) +- `name` (string) +- `status` (string) +- `createdAt` (datetime) +- `userId` (uuid) +- `searchParams` (object) +- `processingParams` (object) +- `lastCheckedAt` (datetime) +- `activeUntil` (datetime) +- `searchResults` (array of `{id, metadata}`) +- `projectId` (uuid) +- `area` (number) +- `newImagesCount` (integer) \ No newline at end of file diff --git a/spec/002_api.md b/spec/002_api.md index f60b39cd..24319ae9 100644 --- a/spec/002_api.md +++ b/spec/002_api.md @@ -25,6 +25,7 @@ Detailed endpoint documentation is split into sub-files: - **[002_B_processing_api.md](002_B_processing_api.md)** — Processings: submit, list, update - **[002_C_myimagery_api.md](002_C_myimagery_api.md)** — Data Catalog (My Imagery): mosaics, images, upload, download, storage - **[002_D_search_api.md](002_D_search_api.md)** — Imagery Search: catalog search, external APIs (Maxar, Sentinel) +- **[002_F_plan_processing_api.md](002_F_plan_processing_api.md)** — Planned processing: CRUD, run, status, and template-linked processings ### Error model diff --git a/spec/index.md b/spec/index.md index cd97be12..e93295b0 100644 --- a/spec/index.md +++ b/spec/index.md @@ -20,6 +20,9 @@ Data Catalog (My Imagery): mosaics, images, upload, download, storage limits. In ### 002_D_search_api.md Imagery Search: catalog search, external APIs (Maxar, Sentinel — legacy). +## 002_F_plan_processing_api.md +Planned processing: create/list/update/delete, run from template, and template status/actions. + ## 002_E_zoom_selector_api.md Zoom selector API for automatic zoom detection based on imagery source resolution. diff --git a/tests/test_data_catalog.py b/tests/test_data_catalog.py index fdf497e7..b8b91d0c 100644 --- a/tests/test_data_catalog.py +++ b/tests/test_data_catalog.py @@ -4,7 +4,7 @@ """ import json from unittest.mock import MagicMock, patch -from datetime import datetime +from datetime import datetime, timedelta import pytest @@ -68,6 +68,7 @@ def test_uploaded_at_parsed(self): data = _image_data() image = ImageReturnSchema.from_dict(data) assert isinstance(image.uploaded_at, datetime) + assert image.uploaded_at.utcoffset() == timedelta(0) def test_meta_data_parsed(self): """meta_data dict is parsed into ImageMetadataSchema.""" diff --git a/tests/test_mapflow_user_role_guard.py b/tests/test_mapflow_user_role_guard.py new file mode 100644 index 00000000..931a6402 --- /dev/null +++ b/tests/test_mapflow_user_role_guard.py @@ -0,0 +1,20 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from mapflow.mapflow import Mapflow + + +def test_show_wd_options_handles_missing_user_role(): + plugin = Mapflow.__new__(Mapflow) + plugin.dlg = MagicMock() + plugin.app_context = SimpleNamespace(settings=MagicMock(), user_role=None) + plugin.app_context.settings.value.return_value = False + + wd = SimpleNamespace( + id="wd-id", + optional_blocks=[SimpleNamespace(displayName="Block 1", name="block_1")], + ) + + plugin.show_wd_options(wd) + + plugin.dlg.enable_model_options.assert_called_once_with(True) diff --git a/tests/test_processing_templates.py b/tests/test_processing_templates.py new file mode 100644 index 00000000..e61fdb21 --- /dev/null +++ b/tests/test_processing_templates.py @@ -0,0 +1,248 @@ +import json +from unittest.mock import MagicMock +from datetime import timedelta + +import pytest + +from mapflow.functional.api.processing_api import ProcessingApi +from mapflow.schema.processing import ( + CreateProcessingTemplateSchema, + UpdateProcessingTemplateSchema, + RunTemplateProcessingSchema, + ProcessingTemplateDTO, + ProcessingTemplateDetails, + SearchParams, +) + + +def _template_payload(template_id: str = "3fa85f64-5717-4562-b3fc-2c963f66afa6"): + return { + "id": template_id, + "name": "Template A", + "status": "READY", + "createdAt": "2025-09-26T06:25:55.820336Z", + "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "searchParams": { + "aoiDetails": {"type": "FeatureCollection", "features": []}, + "acquisitionDateFrom": "2022-04-06T07:34:43.637Z", + "acquisitionDateTo": "2025-09-24T07:34:43.637Z", + "maxCloudCover": 50.0, + "hideUnavailable": True, + "dataProviders": ["arcgis_world_imagery"], + "productTypes": ["IMAGE"] + }, + "processingParams": None, + "lastCheckedAt": None, + "activeUntil": "2026-03-15T17:00:00Z", + "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "newImagesCount": 4, + "isActive": False, + "isArchived": False, + "maxAoiIntersectionPercent": 85.5, + } + + +class TestTemplateSchemas: + def test_template_dto_from_dict(self): + dto = ProcessingTemplateDTO.from_dict(_template_payload()) + assert dto.name == "Template A" + assert dto.status == "READY" + assert dto.newImagesCount == 4 + assert dto.isArchived is False + assert dto.maxAoiIntersectionPercent == 85.5 + + def test_template_datetimes_kept_in_utc(self): + dto = ProcessingTemplateDTO.from_dict(_template_payload()) + assert dto.createdAt.utcoffset() == timedelta(0) + assert dto.activeUntil.utcoffset() == timedelta(0) + + def test_template_details_from_dict(self): + details = ProcessingTemplateDetails.from_dict({ + "template": _template_payload(), + }) + assert details.template.name == "Template A" + assert details.template.status == "READY" + + def test_template_aoi_features_from_plain_aoi(self): + payload = _template_payload() + payload["searchParams"] = { + "aoi": { + "type": "Polygon", + "coordinates": [[[13.0, 52.0], [14.0, 52.0], [14.0, 51.0], [13.0, 51.0], [13.0, 52.0]]], + } + } + dto = ProcessingTemplateDTO.from_dict(payload) + features = dto._aoi_features() + assert len(features) == 1 + assert features[0]["geometry"]["type"] == "Polygon" + + def test_create_template_serialization(self): + body = CreateProcessingTemplateSchema( + name="T1", + searchParams={ + "aoi": { + "type": "Polygon", + "coordinates": [[[13.0, 52.0], [14.0, 52.0], [14.0, 51.0], [13.0, 51.0], [13.0, 52.0]]], + }, + "acquisitionDateFrom": "2022-09-24T17:00:00.000Z", + "acquisitionDateTo": "2026-09-24T17:00:00.000Z", + "productTypes": ["MOSAIC", "IMAGE"], + "hideUnavailable": True, + "dataProviders": ["arcgis_world_imagery"], + "maxCloudCover": 43, + "minAoiIntersectionPercent": 20, + "minOffNadirAngle": 0, + "maxOffNadirAngle": 25, + }, + processingParams=None, + projectId="3fa85f64-5717-4562-b3fc-2c963f66afa6", + activeUntil="2026-05-06T11:03:37.743Z", + ) + data = json.loads(body.as_json()) + assert data["name"] == "T1" + assert data["projectId"] == "3fa85f64-5717-4562-b3fc-2c963f66afa6" + assert data["searchParams"]["aoi"]["type"] == "Polygon" + assert "aoiDetails" not in data["searchParams"] + assert "processingParams" not in data + + def test_create_template_serialization_with_search_params_schema(self): + body = CreateProcessingTemplateSchema( + name="T1", + searchParams=SearchParams( + aoi={ + "type": "Polygon", + "coordinates": [[[13.0, 52.0], [14.0, 52.0], [14.0, 51.0], [13.0, 51.0], [13.0, 52.0]]], + }, + acquisitionDateFrom="2022-09-24T17:00:00.000Z", + acquisitionDateTo="2026-09-24T17:00:00.000Z", + productTypes=["MOSAIC", "IMAGE"], + hideUnavailable=True, + dataProviders=["arcgis_world_imagery"], + maxCloudCover=43, + minAoiIntersectionPercent=20, + minOffNadirAngle=0, + maxOffNadirAngle=25, + ), + processingParams=None, + projectId="3fa85f64-5717-4562-b3fc-2c963f66afa6", + activeUntil="2026-05-06T11:03:37.743Z", + ) + data = json.loads(body.as_json()) + assert data["searchParams"]["aoi"]["type"] == "Polygon" + assert "aoiDetails" not in data["searchParams"] + assert "processingParams" not in data + + @pytest.mark.parametrize( + ("payload_updates", "expected_status"), + [ + ({"status": "FAILED", "isActive": False}, "Failed"), + ({"status": "READY", "isActive": False}, "Inactive"), + ({"status": "READY", "isActive": True, "lastCheckedAt": None, "newImagesCount": 0}, "Created"), + ({ + "status": "READY", + "isActive": True, + "lastCheckedAt": None, + "newImagesCount": 3, + }, "Created (3)"), + ({ + "status": "READY", + "isActive": True, + "lastCheckedAt": "2026-03-10T17:00:00Z", + "newImagesCount": 4, + }, "Updated (4)"), + ({ + "status": "READY", + "isActive": True, + "lastCheckedAt": "2026-03-10T17:00:00Z", + "newImagesCount": 0, + }, "Updated"), + ], + ) + def test_template_table_mapping(self, payload_updates, expected_status): + dto = ProcessingTemplateDTO.from_dict({**_template_payload(), **payload_updates}) + table_row = dto.as_processing_table_dict() + assert table_row["workflowDef"] == "Planned" + assert table_row["status"] == expected_status + assert table_row["aoiArea"] is None + + def test_template_table_created_is_localized_for_display(self): + dto = ProcessingTemplateDTO.from_dict(_template_payload()) + table_row = dto.as_processing_table_dict() + assert table_row["created"] == dto.createdAt.astimezone().strftime("%Y-%m-%d %H:%M") + + +class TestTemplateApi: + def setup_method(self): + self.http = MagicMock() + self.api = ProcessingApi(http=self.http, dlg=MagicMock(), iface=MagicMock(), result_loader=MagicMock()) + + def test_get_templates_path(self): + callback = MagicMock() + self.api.get_templates(callback=callback) + self.http.get.assert_called_once() + assert self.http.get.call_args.kwargs["path"] == "processings/template" + + def test_get_template_by_id_path(self): + callback = MagicMock() + self.api.get_template(template_id="tpl-1", callback=callback) + assert self.http.get.call_args.kwargs["path"] == "processings/template/tpl-1" + + def test_create_template_body(self): + callback = MagicMock() + error_handler = MagicMock() + body = CreateProcessingTemplateSchema( + name="T1", + searchParams={"aoi": {}}, + processingParams=None, + projectId="p-1", + activeUntil="2026-05-06T11:03:37.743Z", + ) + + self.api.create_template(data=body, callback=callback, error_handler=error_handler) + + self.http.post.assert_called_once() + kwargs = self.http.post.call_args.kwargs + assert kwargs["path"] == "processings/template" + payload = json.loads(kwargs["body"].decode()) + assert payload["name"] == "T1" + assert payload["searchParams"] == {"aoi": {}} + assert "processingParams" not in payload + + def test_update_template_path(self): + callback = MagicMock() + body = UpdateProcessingTemplateSchema( + name="T1", + searchParams={"aoi": {}}, + processingParams={"wdId": "wf-1"}, + activeUntil="2026-05-06T11:03:37.743Z", + ) + + self.api.update_template(template_id="tpl-2", data=body, callback=callback) + + self.http.put.assert_called_once() + assert self.http.put.call_args.kwargs["path"] == "processings/template/tpl-2" + + def test_run_template_v2_path(self): + callback = MagicMock() + error_handler = MagicMock() + body = RunTemplateProcessingSchema( + name="Run 1", + description=None, + wdName=None, + wdId="wf-1", + geometry={"type": "Polygon", "coordinates": []}, + params={}, + meta={}, + blocks=[], + updateTemplateGeometry=True, + ) + + self.api.run_template_processing( + template_id="tpl-3", + data=body, + callback=callback, + error_handler=error_handler, + ) + + self.http.post.assert_called_once() + assert self.http.post.call_args.kwargs["path"] == "processings/template/tpl-3/v2" diff --git a/tests/test_project_role_resolution.py b/tests/test_project_role_resolution.py new file mode 100644 index 00000000..7814f20b --- /dev/null +++ b/tests/test_project_role_resolution.py @@ -0,0 +1,40 @@ +from mapflow.schema.project import ShareProject, UserRole + + +def test_get_user_role_returns_owner_for_owner_email(): + share = ShareProject.from_dict( + { + "owners": [{"role": "owner", "email": "owner@example.com"}], + "users": [{"role": "contributor", "email": "user@example.com"}], + } + ) + + assert share.get_user_role("owner@example.com") == UserRole.owner + + +def test_get_user_role_returns_user_role_for_shared_user_email(): + share = ShareProject.from_dict( + { + "owners": [{"role": "owner", "email": "owner@example.com"}], + "users": [{"role": "maintainer", "email": "user@example.com"}], + } + ) + + assert share.get_user_role("user@example.com") == UserRole.maintainer + + +def test_get_user_role_falls_back_to_readonly_when_email_not_found(): + share = ShareProject.from_dict( + { + "owners": [{"role": "owner", "email": "owner@example.com"}], + "users": [{"role": "contributor", "email": "user@example.com"}], + } + ) + + assert share.get_user_role("missing@example.com") == UserRole.readonly + + +def test_get_user_role_falls_back_to_readonly_when_lists_are_none(): + share = ShareProject.from_dict({"owners": None, "users": None}) + + assert share.get_user_role("any@example.com") == UserRole.readonly diff --git a/tests/test_template_start_processing.py b/tests/test_template_start_processing.py new file mode 100644 index 00000000..952e0afc --- /dev/null +++ b/tests/test_template_start_processing.py @@ -0,0 +1,223 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from mapflow.functional.service import processing_service as processing_service_module +from mapflow.functional.service.processing_service import ProcessingService +from mapflow.mapflow import Mapflow +from mapflow.schema.processing import PostProcessingSchemaV2, RunTemplateProcessingSchema + + +def _processing_payload(): + return PostProcessingSchemaV2( + name="Run 1", + description=None, + projectId="project-1", + wdId="wd-1", + geometry={"type": "Polygon", "coordinates": []}, + params={"sourceParams": {"imagerySearch": {"dataProvider": "orbview", "imageIds": ["img-1"], "zoom": 18}}}, + meta={}, + blocks=[{"name": "Block 1", "enabled": True, "displayName": "Block 1"}], + ) + + +def test_handle_processing_submission_uses_template_run_when_template_selected(): + service = ProcessingService.__new__(ProcessingService) + service.tr = lambda text: text + service.dlg = MagicMock() + service.dlg.cornfirmProcessingStart.isChecked.return_value = False + service.dlg.modelCombo.currentText.return_value = "Buildings" + service.iface = MagicMock() + service.app_context = SimpleNamespace(plugin_name="Mapflow") + service.api = MagicMock() + service.start_processing_callback = MagicMock() + service.start_processing_error_handler = MagicMock() + service.selected_template = MagicMock(return_value=SimpleNamespace(id="template-1")) + + service.handle_processing_submission(_processing_payload()) + + service.api.run_template_processing.assert_called_once() + kwargs = service.api.run_template_processing.call_args.kwargs + assert kwargs["template_id"] == "template-1" + assert isinstance(kwargs["data"], RunTemplateProcessingSchema) + assert kwargs["data"].wdId == "wd-1" + assert kwargs["data"].wdName is None + assert kwargs["data"].updateTemplateGeometry is False + service.api.create_processing.assert_not_called() + + +def test_handle_processing_submission_uses_regular_processing_when_no_template_selected(): + service = ProcessingService.__new__(ProcessingService) + service.tr = lambda text: text + service.dlg = MagicMock() + service.dlg.cornfirmProcessingStart.isChecked.return_value = False + service.iface = MagicMock() + service.app_context = SimpleNamespace(plugin_name="Mapflow") + service.api = MagicMock() + service.start_processing_callback = MagicMock() + service.start_processing_error_handler = MagicMock() + service.selected_template = MagicMock(return_value=None) + + payload = _processing_payload() + service.handle_processing_submission(payload) + + service.api.create_processing.assert_called_once_with( + payload, + service.start_processing_callback, + service.start_processing_error_handler, + ) + service.api.run_template_processing.assert_not_called() + + +def test_planned_processing_selection_error_requires_selected_metadata_rows(): + service = ProcessingService.__new__(ProcessingService) + service.tr = lambda text: text + service.dlg = MagicMock() + service.selected_template = MagicMock(return_value=SimpleNamespace(id="template-1")) + service.selected_processing = MagicMock(return_value=None) + + service.dlg.metadataTable.selectedItems.return_value = [] + assert service.planned_processing_selection_error() == ( + "Select one or more images in search results to start planned processing" + ) + + selected_item = MagicMock() + selected_item.row.return_value = 0 + service.dlg.metadataTable.selectedItems.return_value = [selected_item] + assert service.planned_processing_selection_error() is None + + +def test_on_processings_selection_changed_sets_planned_start_button_text(): + plugin = Mapflow.__new__(Mapflow) + plugin.tr = lambda text: text + plugin.dlg = MagicMock() + plugin.processing_service = MagicMock() + plugin.processing_service.selected_template.return_value = SimpleNamespace(id="template-1") + plugin.processing_service.selected_processing.return_value = None + plugin.processing_service.planned_processing_selection_error.return_value = ( + "Select one or more images in search results to start planned processing" + ) + + plugin.on_processings_selection_changed() + + plugin.dlg.startProcessing.setText.assert_called_with("Start planned processing") + plugin.dlg.disable_processing_start.assert_called_once() + assert plugin.active_template_id == "template-1" + + +def test_on_processings_selection_changed_restores_default_start_button_text_without_template(): + plugin = Mapflow.__new__(Mapflow) + plugin.tr = lambda text: text + plugin.dlg = MagicMock() + plugin.dlg.processingProblemsLabel.text.return_value = ( + "Select one or more images in search results to start planned processing" + ) + plugin.processing_service = MagicMock() + plugin.processing_service.selected_template.return_value = None + plugin.processing_service.selected_processing.return_value = None + plugin.processing_service.planned_processing_selection_error.return_value = None + + plugin.on_processings_selection_changed() + + plugin.dlg.startProcessing.setText.assert_called_with("Start processing") + plugin.dlg.startProcessing.setEnabled.assert_called_with(True) + plugin.dlg.processingProblemsLabel.clear.assert_called_once() + plugin.dlg.disable_processing_start.assert_not_called() + assert plugin.active_template_id is None + + +def test_on_processings_selection_changed_restores_default_when_processing_selected(): + plugin = Mapflow.__new__(Mapflow) + plugin.tr = lambda text: text + plugin.dlg = MagicMock() + plugin.dlg.processingProblemsLabel.text.return_value = ( + "Select one or more images in search results to start planned processing" + ) + plugin.processing_service = MagicMock() + plugin.processing_service.selected_template.return_value = SimpleNamespace(id="template-1") + plugin.processing_service.selected_processing.return_value = SimpleNamespace(id="processing-1") + plugin.processing_service.planned_processing_selection_error.return_value = None + + plugin.on_processings_selection_changed() + + plugin.dlg.startProcessing.setText.assert_called_with("Start processing") + plugin.dlg.startProcessing.setEnabled.assert_called_with(True) + plugin.dlg.processingProblemsLabel.clear.assert_called_once() + plugin.dlg.disable_processing_start.assert_not_called() + + +def test_load_results_double_click_template_triggers_both_actions(): + plugin = Mapflow.__new__(Mapflow) + plugin.processing_service = MagicMock() + plugin.processing_service.selected_template.return_value = SimpleNamespace(id="template-1") + plugin.select_template_processings = MagicMock() + plugin.show_template_search_results = MagicMock() + + plugin.load_results() + + plugin.select_template_processings.assert_called_once() + plugin.show_template_search_results.assert_called_once() + + +def test_start_processing_callback_refreshes_processings_for_regular_response(): + service = ProcessingService.__new__(ProcessingService) + service.tr = lambda text: text + service.dlg = MagicMock() + service.view = MagicMock() + service.processing_fetch_timer = MagicMock() + service.processings = {} + service.processings_history = MagicMock() + service.get_processings = MagicMock() + + response = MagicMock() + response.readAll.return_value.data.return_value = b'{"id": "proc-1", "name": "Run 1"}' + mock_processing = SimpleNamespace(id="proc-1", name="Run 1", status=SimpleNamespace()) + + with patch.object(processing_service_module, "alert"), \ + patch.object(processing_service_module.ProcessingDTO, "from_dict", return_value=mock_processing): + service.start_processing_callback(response) + + service.get_processings.assert_called_once() + service.view.add_new_processing.assert_called_once() + service.dlg.startProcessing.setEnabled.assert_called_with(True) + + +def test_start_processing_callback_refreshes_processings_for_template_response_shape(): + service = ProcessingService.__new__(ProcessingService) + service.tr = lambda text: text + service.dlg = MagicMock() + service.view = MagicMock() + service.processing_fetch_timer = MagicMock() + service.processings = {} + service.processings_history = MagicMock() + service.get_processings = MagicMock() + + response = MagicMock() + response.readAll.return_value.data.return_value = b'{"template": {"id": "tpl-1"}, "searchResults": []}' + + with patch.object(processing_service_module, "alert"): + service.start_processing_callback(response) + + service.get_processings.assert_called_once() + service.view.add_new_processing.assert_not_called() + service.dlg.startProcessing.setEnabled.assert_called_with(True) + + +def test_disable_processing_start_uses_fallback_when_api_message_is_none(): + service = ProcessingService.__new__(ProcessingService) + service.tr = lambda text: text + service.view = MagicMock() + service.app_context = SimpleNamespace( + user_role=SimpleNamespace(can_start_processing=True, value="owner") + ) + + response = MagicMock() + response.readAll.return_value.data.return_value = b"{}" + response.errorString.return_value = "" + + with patch.object(processing_service_module, "api_message_parser", return_value=None): + service.disable_processing_start(response) + + service.view.disable_processing_start.assert_called_once_with( + "Processing cost is not available:\nUnknown server error", + clear_area=False, + )