From 33d8d9bdce826fd9a18d2628c98fb350505d214d Mon Sep 17 00:00:00 2001 From: Alexey Trekin Date: Wed, 6 Aug 2025 13:50:57 +0500 Subject: [PATCH 1/4] WIP processing service structure --- mapflow/functional/api/processing_api.py | 32 ++++++++++++++ .../controller/processing_controller.py | 12 ++++++ .../functional/service/processing_service.py | 43 +++++++++++++++++++ mapflow/functional/view/processing_view.py | 27 ++++++++++++ mapflow/functional/view/qgs_project_view.py | 11 +++++ 5 files changed, 125 insertions(+) create mode 100644 mapflow/functional/api/processing_api.py create mode 100644 mapflow/functional/controller/processing_controller.py create mode 100644 mapflow/functional/service/processing_service.py create mode 100644 mapflow/functional/view/processing_view.py create mode 100644 mapflow/functional/view/qgs_project_view.py diff --git a/mapflow/functional/api/processing_api.py b/mapflow/functional/api/processing_api.py new file mode 100644 index 00000000..11f5910a --- /dev/null +++ b/mapflow/functional/api/processing_api.py @@ -0,0 +1,32 @@ +from typing import Callable +from PyQt5.QtCore import QObject, pyqtSignal, QFile, QIODevice +from ...http import Http +from ...dialogs.main_dialog import MainDialog +from ...schema.processing import PostProcessingSchema + +class ProcessingApi(QObject): + """ + + """ + + def __init__(self, + http: Http, + server: str, + dlg: MainDialog, + iface, + result_loader, + plugin_version): + super().__init__() + self.server = server + self.http = http + self.iface = iface + self.dlg = dlg + self.result_loader = result_loader + self.plugin_version = plugin_version + + # project CRUD + def create_processing(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable): + self.http.post(url=f"{self.server}/processing", + body=data.as_json().encode(), + callback=callback, + error_handler=error_handler) diff --git a/mapflow/functional/controller/processing_controller.py b/mapflow/functional/controller/processing_controller.py new file mode 100644 index 00000000..70307dde --- /dev/null +++ b/mapflow/functional/controller/processing_controller.py @@ -0,0 +1,12 @@ +from PyQt5.QtCore import QObject, QTimer +from ..service.processing_service import ProcessingService +from ...dialogs.main_dialog import MainDialog + + +class ProcessingController(QObject): + def __init__(self, dlg: MainDialog, processing_service: ProcessingService): + self.dlg = dlg + self.service = processing_service + self.view = self.service.view + + self.dlg.startProcessing.clicked.connect(self.service.start_processing) diff --git a/mapflow/functional/service/processing_service.py b/mapflow/functional/service/processing_service.py new file mode 100644 index 00000000..037be550 --- /dev/null +++ b/mapflow/functional/service/processing_service.py @@ -0,0 +1,43 @@ +from PyQt5.QtCore import QObject, pyqtSignal, Qt +from ...dialogs.main_dialog import MainDialog +from ...http import Http +from ..view.project_view import ProcessingView +from ..api.processing_api import ProcessingApi + +class ProcessingService(QObject): + """ + A service to store & query the mapflow processings. + """ + + def __init__(self, + http: Http, + server: str, + dlg: MainDialog, + iface, + result_loader, + plugin_version, + temp_dir): + super().__init__() + self.http = http + self.server = server + self.iface = iface + self.result_loader = result_loader + self.plugin_version = plugin_version + self.temp_dir = temp_dir + self.view = ProcessingView(dlg=dlg) + self.api = ProcessingApi(http=http, server=server, dlg=dlg, iface=iface, result_loader=self.result_loader, plugin_version=self.plugin_version) + + def validate_params(self, ui_start_params): + pass + + def start_processing(self): + ui_start_params = self.view.read_processing_start_params() + # maybe some logic behind validation? + self.validate_params(ui_start_params) + provider = self.get_data_provider(ui_start_params) + source_params = provider.source_params() + # gather all the other logic + self.http.post() + + def refresh_processings(self): + pass diff --git a/mapflow/functional/view/processing_view.py b/mapflow/functional/view/processing_view.py new file mode 100644 index 00000000..e3c063a4 --- /dev/null +++ b/mapflow/functional/view/processing_view.py @@ -0,0 +1,27 @@ +from mapflow.functional.view.main_dialog import MainDialog + + +class ProcessingView: + """ + This class incorporates everything we take responsible for the processing start and cost update + + - readings from the UI elements + - some checks on the correspondence of UI controls + - changes in the UI with the processing start(?) + - display of the finished processings in the table (not yet?) + """ + def __init__(self, dlg: MainDialog): + self.dlg = dlg + + @property + def processing_name(self): + # this is a sample function, maybe we'll not need it + return self.dlg.processingName.text() + + @property + def processing_name_valid(self): + # this is a sample function, maybe we'll not need it + return self.processing_name != "" + + def read_processing_start_params(self): + return {} diff --git a/mapflow/functional/view/qgs_project_view.py b/mapflow/functional/view/qgs_project_view.py new file mode 100644 index 00000000..8146378a --- /dev/null +++ b/mapflow/functional/view/qgs_project_view.py @@ -0,0 +1,11 @@ + + +class QgsProjectView(): + def __init__(self): + pass + + def get_selected_aoi(self): + pass + + def get_selected_layer(self): + pass From 7111340dd2934dd1d4c94cf0c636b25b0104e387 Mon Sep 17 00:00:00 2001 From: Alexey Trekin Date: Tue, 12 Aug 2025 09:52:29 +0500 Subject: [PATCH 2/4] WIP big refactor --- mapflow/entity/processing.py | 8 - mapflow/functional/api/processing_api.py | 29 ++- .../controller/processing_controller.py | 16 +- mapflow/functional/processing.py | 34 --- mapflow/functional/service/__init__.py | 3 + .../functional/service/processing_service.py | 209 +++++++++++++++++- .../{project.py => project_service.py} | 5 + mapflow/functional/view/processing_view.py | 118 +++++++++- mapflow/mapflow.py | 46 +--- mapflow/schema/data_catalog.py | 8 +- mapflow/schema/layer.py | 15 ++ mapflow/schema/processing.py | 63 +++++- mapflow/schema/project.py | 2 +- mapflow/{entity => schema}/workflow_def.py | 2 +- 14 files changed, 451 insertions(+), 107 deletions(-) delete mode 100644 mapflow/functional/processing.py rename mapflow/functional/service/{project.py => project_service.py} (97%) create mode 100644 mapflow/schema/layer.py rename mapflow/{entity => schema}/workflow_def.py (97%) diff --git a/mapflow/entity/processing.py b/mapflow/entity/processing.py index ef843d84..1875b887 100644 --- a/mapflow/entity/processing.py +++ b/mapflow/entity/processing.py @@ -140,14 +140,6 @@ def status_with_review(self): return self.status.display_value -def parse_processings_request_dict(response: list) -> Dict[str, Processing]: - res = {} - for processing in response: - new_processing = Processing.from_response(processing) - res[new_processing.id_] = new_processing - return res - - def parse_processings_request(response: list) -> List[Processing]: return [Processing.from_response(resp) for resp in response] diff --git a/mapflow/functional/api/processing_api.py b/mapflow/functional/api/processing_api.py index 11f5910a..efdfb968 100644 --- a/mapflow/functional/api/processing_api.py +++ b/mapflow/functional/api/processing_api.py @@ -2,7 +2,7 @@ from PyQt5.QtCore import QObject, pyqtSignal, QFile, QIODevice from ...http import Http from ...dialogs.main_dialog import MainDialog -from ...schema.processing import PostProcessingSchema +from ...schema.processing import PostProcessingSchema, UpdateProcessingSchema class ProcessingApi(QObject): """ @@ -26,7 +26,28 @@ def __init__(self, # project CRUD def create_processing(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable): - self.http.post(url=f"{self.server}/processing", - body=data.as_json().encode(), + self.http.post( + url=f'{self.server}/processings/v2', + callback=callback, + error_handler=error_handler, + use_default_error_handler=False, + body=data.as_json().encode() + ) + + def update_processing(self, processing_id, processing: UpdateProcessingSchema, callback: Callable, error_handler: Callable): + self.http.put(url=f"{self.server}/processings/{processing_id}", + body=processing.as_json().encode(), + headers={}, callback=callback, - error_handler=error_handler) + use_default_error_handler=True, + timeout=5) + + + def get_cost(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable): + self.http.post( + url=f"{self.server}/processing/cost/v2", + callback=callback, + body=data.as_json().encode(), + use_default_error_handler=False, + error_handler=callback + ) diff --git a/mapflow/functional/controller/processing_controller.py b/mapflow/functional/controller/processing_controller.py index 70307dde..bae028b2 100644 --- a/mapflow/functional/controller/processing_controller.py +++ b/mapflow/functional/controller/processing_controller.py @@ -1,12 +1,20 @@ from PyQt5.QtCore import QObject, QTimer from ..service.processing_service import ProcessingService +from ..service.project_service import ProjectService from ...dialogs.main_dialog import MainDialog class ProcessingController(QObject): - def __init__(self, dlg: MainDialog, processing_service: ProcessingService): + def __init__(self, dlg: MainDialog, processing_service: ProcessingService, project_service: ProjectService): + super().__init__() self.dlg = dlg - self.service = processing_service - self.view = self.service.view + self.processing_service = processing_service + self.project_service = project_service + self.view = self.processing_service.view - self.dlg.startProcessing.clicked.connect(self.service.start_processing) + # Connect UI actions + self.dlg.startProcessing.clicked.connect(self.processing_service.start_processing) + self.processing_service.processing_fetch_timer.timeout.connect(self.processing_service.get_processings) + + # Connect project service signals to processing service slots + self.project_service.projectChanged.connect(self.processing_service.set_current_project) diff --git a/mapflow/functional/processing.py b/mapflow/functional/processing.py deleted file mode 100644 index 2e114d44..00000000 --- a/mapflow/functional/processing.py +++ /dev/null @@ -1,34 +0,0 @@ -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtNetwork import QNetworkReply - -from ..schema.processing import UpdateProcessingSchema - - -class ProcessingService(QObject): - processingUpdated = pyqtSignal() - - def __init__(self, http, server): - super().__init__() - self.http = http - self.server = server - self.projects = [] - - def get_processings(self, project_id, callback): - if not project_id: - return - self.http.get( - url=f'{self.server}/projects/{project_id}/processings/v2', - callback=callback, - use_default_error_handler=False # ignore errors to prevent repetitive alerts - ) - - def update_processing(self, processing_id, processing: UpdateProcessingSchema): - self.http.put(url=f"{self.server}/processings/{processing_id}", - body=processing.as_json().encode(), - headers={}, - callback=self.update_processing_callback, - use_default_error_handler=True, - timeout=5) - - def update_processing_callback(self, response: QNetworkReply): - self.processingUpdated.emit() diff --git a/mapflow/functional/service/__init__.py b/mapflow/functional/service/__init__.py index e69de29b..8aafb8e2 100644 --- a/mapflow/functional/service/__init__.py +++ b/mapflow/functional/service/__init__.py @@ -0,0 +1,3 @@ +from .data_catalog import DataCatalogService +from .processing_service import ProcessingService +from .project_service import ProjectService \ No newline at end of file diff --git a/mapflow/functional/service/processing_service.py b/mapflow/functional/service/processing_service.py index 037be550..82d922ee 100644 --- a/mapflow/functional/service/processing_service.py +++ b/mapflow/functional/service/processing_service.py @@ -1,8 +1,49 @@ -from PyQt5.QtCore import QObject, pyqtSignal, Qt +import json +from uuid import UUID +from dataclasses import dataclass, field +from typing import Set, Dict, Optional +from PyQt5.QtCore import QObject, pyqtSignal, Qt, pyqtSlot, QTimer +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest +from PyQt5.QtWidgets import QMessageBox from ...dialogs.main_dialog import MainDialog -from ...http import Http -from ..view.project_view import ProcessingView +from ...http import Http, api_message_parser +from ..view.processing_view import ProcessingView from ..api.processing_api import ProcessingApi +from ...schema.processing import ProcessingDTO, UpdateProcessingSchema +from ...entity.status import ProcessingStatus + +@dataclass +class ProcessingHistory: + """ + History of the processings for a specific project, including failed and finished processings, + that are stored in settings + """ + project_id: Optional[UUID] + failed: Set[UUID] = field(default_factory=set) + finished: Set[UUID] = field(default_factory=set) + other: Dict[UUID, ProcessingStatus] = field(default_factory=dict) + + def to_settings(self, settings): + settings.setValue(f"{self.project_id}", json.dumps({"failed": list(self.failed), "finished": list(self.finished)})) + + @classmethod + def from_settings(cls, settings, project_id: UUID): + data = json.loads(settings.value(f"{project_id}")) + return cls(project_id, set(data["failed"]), set(data["finished"])) + + def is_finished(self, processing_id: UUID): + return processing_id in self.finished + + def is_failed(self, processing_id: UUID): + return processing_id in self.failed + + def add(self, processing_id: UUID, status: ProcessingStatus): + if status.is_failed: + self.failed.add(processing_id) + elif status.is_ok: + self.finished.add(processing_id) + else: + self.other[processing_id] = status class ProcessingService(QObject): """ @@ -16,7 +57,8 @@ def __init__(self, iface, result_loader, plugin_version, - temp_dir): + temp_dir, + settings): super().__init__() self.http = http self.server = server @@ -26,11 +68,51 @@ def __init__(self, self.temp_dir = temp_dir self.view = ProcessingView(dlg=dlg) self.api = ProcessingApi(http=http, server=server, dlg=dlg, iface=iface, result_loader=self.result_loader, plugin_version=self.plugin_version) + self.settings = settings + + self.current_project_id: Optional[str] = None + self.processings = set() + self.processings_history = ProcessingHistory() # local storage for active processings list + self.processing_fetch_timer = QTimer(self.dlg) + self.processing_fetch_timer.setInterval(self.config.PROCESSING_TABLE_REFRESH_INTERVAL * 1000) + + def set_current_project(self, project_id: str): + """ + Set the current project ID and optionally refresh processings. + This slot is connected to ProjectService.projectChanged signal. + + Args: + project_id: The ID of the project to set as current + """ + if not project_id: + self.current_project_id = None + return + + self.current_project_id = project_id + # Update processing history context for the new project + if self.current_project_id: + try: + self.processings_history = ProcessingHistory.from_settings( + self.settings, UUID(self.current_project_id) + ) + except (ValueError, KeyError): + # If no history exists for this project, create new one + self.processings_history = ProcessingHistory(project_id=UUID(self.current_project_id)) + + # Optionally refresh processings for the new project + # self.get_processings() # Uncomment when this method is implemented + def validate_params(self, ui_start_params): pass def start_processing(self): + """ + if self.project_id != 'default': + request_body.projectId = self.project_id + + """ + self.processing_fetch_timer.stop() ui_start_params = self.view.read_processing_start_params() # maybe some logic behind validation? self.validate_params(ui_start_params) @@ -39,5 +121,120 @@ def start_processing(self): # gather all the other logic self.http.post() - def refresh_processings(self): - pass + def start_processing_callback(self, response: QNetworkReply) -> None: + """Display a success message and clear the processing name field.""" + self.alert( + self.tr("Success! We'll notify you when the processing has finished."), + QMessageBox.Information + ) + new_processing = ProcessingDTO.from_dict(json.loads(response.readAll().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) + self.dlg.startProcessing.setEnabled(True) + + def start_processing_error_handler(self): + self.dlg.startProcessing.setEnabled(True) + self.processing_fetch_timer.start() + self.alert(self.tr("Failed to start processing"), QMessageBox.Warning) + + def get_processings(self): + project_id = None + # todo: get real project id. + # - signal from ProjectService? + # - initialize ProjcessingService with a ProjectService? + self.api.get_processings(project_id=project_id, + callback=self.get_processings_callback) + + def get_processings_callback(self, response: QNetworkReply): + """Update the processing table and user limit. + + :param response: The HTTP response. + """ + response_data = json.loads(response.readAll().data()) + processings = [ProcessingDTO.from_dict(entry) for entry in response_data] + if all(not (p.status.is_in_progress or p.status.is_awaiting) + and p.review_status.is_not_accepted + for p in processings): + # We do not re-fetch the processings, if nothing is going to change. + # What can change from server-side: processing can finish if IN_PROGRESS or AWAITING + # or review can be accepted if NOT_ACCEPTED. + # Any other processings can change only from client-side + self.processing_fetch_timer.stop() + self.processings = {processing.id: processing for processing in processings} + self.update_local_processings(processings) + + def save_processing(self, new_processing): + # add new processing status to settings + self.settings.setValue("", "") + + def update_processing(self, processing_id: UUID, processing: UpdateProcessingSchema): + self.api.update_processing(processing_id=processing_id, processing=processing, callback=self.update_processing_callback) + + def update_processing_callback(self, response: QNetworkReply): + processing = ProcessingDTO.from_dict(json.loads(response.readAll().data())) + self.save_processing(processing) + self.view.update_processing_name(processing_id=processing.id, new_name=processing.name) + + # Processing cost + def update_processing_cost(self, aoi, workflow_defs): + if not aoi: + # Here the button must already be disabled, and the warning text set + if self.dlg.startProcessing.isEnabled(): + if not self.user_role.can_start_processing: + reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) + else: + reason = self.tr("Set AOI to start processing") + self.view.disable_processing_start(reason, clear_area=False) + elif not self.workflow_defs: + self.dlg.disable_processing_start(reason=self.tr("Error! Models are not initialized.\n" + "Please, make sure you have selected a project"), + clear_area=True) + elif self.billing_type != BillingType.credits: + self.dlg.startProcessing.setEnabled(True) + self.dlg.processingProblemsLabel.clear() + request_body, error = self.create_processing_request(allow_empty_name=True) + else: # self.billing_type == BillingType.credits: f + provider = self.providers[self.dlg.providerIndex()] + request_body, error = self.create_processing_request(allow_empty_name=True) + if not request_body: + self.dlg.disable_processing_start(self.tr("Processing cost is not available:\n" + "{error}").format(error=error)) + elif isinstance(provider, ImagerySearchProvider) and \ + not self.dlg.metadataTable.selectionModel().hasSelection(): + self.dlg.disable_processing_start(self.tr("This provider requires image ID. " + "Use search tab to find imagery for you requirements, " + "and select image in the table.")) + elif isinstance(provider, MyImageryProvider) and\ + not self.dlg.mosaicTable.selectionModel().hasSelection(): + self.dlg.disable_processing_start(reason=self.tr('Choose imagery to start processing')) + else: + if self.user_role.can_start_processing: + self.http.post( + url=f"{self.server}/processing/cost/v2", + callback=self.calculate_processing_cost_callback, + body=request_body.as_json().encode(), + use_default_error_handler=False, + error_handler=self.clear_processing_cost + ) + + def disable_processing_start(self, response: QNetworkReply): + """ + We do not display the result in case of error, + the errors are also not displayed to not confuse the user. + + If the user tries to start the processing, he will see the errors + """ + response_text = response.readAll().data().decode() + if response_text is not None: + message = api_message_parser(response_text) + if not self.user_role.can_start_processing: + reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) + else: + reason = self.tr('Processing cost is not available:\n{message}').format(message=message) + self.view.disable_processing_start(reason, clear_area=False) + diff --git a/mapflow/functional/service/project.py b/mapflow/functional/service/project_service.py similarity index 97% rename from mapflow/functional/service/project.py rename to mapflow/functional/service/project_service.py index 564be41a..53ff570c 100644 --- a/mapflow/functional/service/project.py +++ b/mapflow/functional/service/project_service.py @@ -14,6 +14,7 @@ class ProjectService(QObject): projectsUpdated = pyqtSignal() + projectChanged = pyqtSignal(str) # Emits project_id when project selection changes def __init__(self, http, server, settings, dlg: MainDialog): super().__init__() @@ -40,6 +41,7 @@ def create_project(self, project: CreateProjectSchema): def create_project_callback(self, response: QNetworkReply): project = MapflowProject.from_dict(json.loads(response.readAll().data())) self.project_id = project.id + self.projectChanged.emit(str(project.id)) self.get_projects() def delete_project(self, project_id): @@ -58,6 +60,7 @@ def update_project(self, project_id, project: UpdateProjectSchema): def update_project_callback(self, response: QNetworkReply): project = MapflowProject.from_dict(json.loads(response.readAll().data())) self.project_id = project.id + self.projectChanged.emit(str(project.id)) self.get_projects() def get_project(self, project_id, callback: Callable, error_handler: Callable): @@ -185,6 +188,8 @@ def switch_to_processings(self, self.settings.setValue('projectsPage', projects_page) self.view.switch_to_processings() self.project_id = project_id + if project_id: + self.projectChanged.emit(str(project_id)) def get_filtered_projects(self): """Get projects, resetting filtered offset value. diff --git a/mapflow/functional/view/processing_view.py b/mapflow/functional/view/processing_view.py index e3c063a4..d9543ab0 100644 --- a/mapflow/functional/view/processing_view.py +++ b/mapflow/functional/view/processing_view.py @@ -1,5 +1,11 @@ +from typing import List +from uuid import UUID +from PyQt5.QtCore import Qt, QCoreApplication +from PyQt5.QtWidgets import QAbstractItemView, QTableWidgetItem +from PyQt5.QtGui import QColor from mapflow.functional.view.main_dialog import MainDialog - +from ...schema.processing import ProcessingDTO, ProcessingUIParams +from ...config import config class ProcessingView: """ @@ -13,6 +19,10 @@ class ProcessingView: def __init__(self, dlg: MainDialog): self.dlg = dlg + def tr(self, message: str) -> str: + """Translate message using QCoreApplication.""" + return QCoreApplication.translate('ProcessingView', message) + @property def processing_name(self): # this is a sample function, maybe we'll not need it @@ -24,4 +34,108 @@ def processing_name_valid(self): return self.processing_name != "" def read_processing_start_params(self): - return {} + return ProcessingUIParams( + name = self.dlg.processingName or None, + wd_name = self.dlg.modelCombo.currentText(), + data_source_index = self.dlg.providerIndex(), + + ) + + def clear_processing_name(self, name): + # If the name is expected, we clear it after the processsing is launched; + # Otherwise it means that the user has altered the text already and it should be preserved + if self.dlg.processingName.text == name: + self.dlg.processingName.clear() + + 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): + table_items = [] + set_color = False + processing_dict = processing.as_dict() + if 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: + table_item.setToolTip(processing.error_message(raw=config.SHOW_RAW_ERROR)) + elif processing.in_review_until: + table_item.setToolTip(self.tr("Please review or accept this processing until {}." + " Double click to add results" + " to the map").format( + processing.in_review_until.strftime('%Y-%m-%d %H:%M') if processing.in_review_until else "")) + elif processing.status.is_ok: + table_item.setToolTip(self.tr("Double click to add results to the map." + )) + if set_color: + table_item.setBackground(color) + table_items.append(table_item) + return table_items + + def update_processing_table(self, processings: dict[UUID, ProcessingDTO]): + # UPDATE THE TABLE + # Memorize the selection to restore it after table update + selected_processings = self.dlg.selected_processing_ids() + # Explicitly clear selection since resetting row count won't do it + self.dlg.processingsTable.clearSelection() + # Temporarily enable multi selection so that selectRow won't clear previous selection + self.dlg.processingsTable.setSelectionMode(QAbstractItemView.MultiSelection) + # Row insertion triggers sorting -> row indexes shift -> duplicate rows, so turn sorting off + self.dlg.processingsTable.setSortingEnabled(False) + self.dlg.processingsTable.setRowCount(len(processings)) + # Fill out the table + for row, proc in enumerate(processings.values()): + table_items = self.create_table_items(processing=proc) + for col, item in enumerate(table_items): + self.dlg.processingsTable.setItem(row, col, item) + if proc.id in selected_processings: + self.dlg.processingsTable.selectRow(row) + self.dlg.processingsTable.setSortingEnabled(True) + # Restore extended selection and filtering + self.dlg.processingsTable.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.dlg.filter_processings_table(self.dlg.filterProcessings.text()) + + def add_new_processing(self, processing: ProcessingDTO): + self.dlg.processingsTable.insertRow(0) + table_items = self.create_table_items(processing=processing) + for col, item in enumerate(table_items): + self.dlg.processingsTable.setItem(0, col, item) + + def update_processing_name(self, processing_id: str, new_name: str) -> bool: + """ + Update the name of a processing in the table by finding its row using processing ID. + + Args: + processing_id: The ID of the processing to update + new_name: The new name to set + + Returns: + bool: True if the processing was found and updated, False otherwise + """ + table = self.dlg.processingsTable + id_column_index = config.PROCESSING_TABLE_ID_COLUMN_INDEX + name_column_index = config.PROCESSING_TABLE_COLUMNS.index('name') + + # Find items with matching processing ID in the ID column + id_items = table.findItems(str(processing_id), Qt.MatchExactly) + + # Filter to only items in the ID column (in case ID appears in other columns) + for item in id_items: + if item.column() == id_column_index: + # Get the name item in the same row and update it + name_item = table.item(item.row(), name_column_index) + if name_item: + name_item.setData(Qt.DisplayRole, new_name) + else: + # Create new name item if it doesn't exist + name_item = QTableWidgetItem() + name_item.setData(Qt.DisplayRole, new_name) + table.setItem(item.row(), name_column_index, name_item) + return True + + # Processing ID not found in table + return False diff --git a/mapflow/mapflow.py b/mapflow/mapflow.py index 933d1068..0e3f6e25 100644 --- a/mapflow/mapflow.py +++ b/mapflow/mapflow.py @@ -8,10 +8,10 @@ from typing import List, Optional, Union, Callable, Tuple from PyQt5.QtCore import ( - QDate, QObject, QCoreApplication, QTimer, QTranslator, Qt, QFile, QIODevice, QTextStream, QByteArray, QTemporaryDir, QVariant + QDate, QObject, QCoreApplication, QTimer, QTranslator, Qt, QTextStream, QByteArray, QVariant ) from PyQt5.QtGui import QColor -from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest, QHttpMultiPart, QHttpPart +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from PyQt5.QtWidgets import ( QApplication, QMessageBox, QFileDialog, QPushButton, QTableWidgetItem, QAbstractItemView, QMenu, QAction, QWidget ) @@ -21,7 +21,6 @@ QgsProject, QgsSettings, QgsMapLayer, QgsVectorLayer, QgsRasterLayer, QgsFeature, Qgis, QgsCoordinateReferenceSystem, QgsDistanceArea, QgsGeometry, QgsWkbTypes, QgsPoint, QgsMapLayerType, QgsRectangle ) -from qgis.gui import QgsMessageBarItem from . import constants from .dialogs import (MainDialog, @@ -49,7 +48,7 @@ ImagerySearchProvider, MyImageryProvider, ProviderInterface) -from .entity.workflow_def import WorkflowDef +from mapflow.schema.workflow_def import WorkflowDef from .errors import (ProcessingInputDataMissing, BadProcessingInput, PluginError, @@ -59,9 +58,7 @@ from .functional import layer_utils, helpers from .functional.auth import get_auth_id from .functional.geometry import clip_aoi_to_image_extent -from .functional.processing import ProcessingService -from .functional.service.project import ProjectService -from .functional.service.data_catalog import DataCatalogService +from .functional.service import ProcessingService, ProjectService, DataCatalogService from .http import (Http, get_error_report_body, data_catalog_message_parser, @@ -75,7 +72,7 @@ ImageCatalogResponseSchema, PostProcessingSchemaV2) from .schema.catalog import PreviewType, ProductType -from .schema.project import MapflowProject, UserRole, ProjectsRequest +from .schema.project import MapflowProject, UserRole class Mapflow(QObject): @@ -207,9 +204,6 @@ def __init__(self, iface) -> None: self.project_service.projectsUpdated.connect(self.update_projects) self.processing_service = ProcessingService(self.http, self.server) - self.processing_service.processingUpdated.connect( - lambda: self.processing_service.get_processings(project_id=self.project_id, - callback=self.get_processings_callback)) # load providers from settings errors = [] try: @@ -1676,20 +1670,6 @@ def calculate_aoi_area_polygon_layer(self, layer: Union[QgsVectorLayer, None]) - if isinstance(provider, MyImageryProvider): self.calculate_aoi_area_catalog() - def calculate_aoi_area_raster(self, layer: Optional[QgsRasterLayer]) -> None: - """Get the AOI size when a new entry in the raster combo box is selected. - - :param layer: The current raster layer - """ - provider = self.providers[self.dlg.providerIndex()] - if layer: - geometry = QgsGeometry.collectGeometry([QgsGeometry.fromRect(layer.extent())]) - self.calculate_aoi_area(geometry, layer.crs()) - elif isinstance(provider, MyImageryProvider): - self.calculate_aoi_area_catalog() - else: - self.calculate_aoi_area_polygon_layer(self.dlg.polygonCombo.currentLayer()) - def calculate_aoi_area_use_image_extent(self) -> None: """Get the AOI size when the Use image extent checkbox is toggled. @@ -1832,22 +1812,6 @@ def update_processing_cost(self): error_handler=self.clear_processing_cost ) - def clear_processing_cost(self, response: QNetworkReply): - """ - We do not display the result in case of error, - the errors are also not displayed to not confuse the user. - - If the user tries to start the processing, he will see the errors - """ - response_text = response.readAll().data().decode() - if response_text is not None: - message = api_message_parser(response_text) - if not self.user_role.can_start_processing: - reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) - else: - reason = self.tr('Processing cost is not available:\n{message}').format(message=message) - self.dlg.disable_processing_start(reason, clear_area=False) - def calculate_processing_cost_callback(self, response: QNetworkReply): response_data = response.readAll().data().decode() self.processing_cost = int(response_data) diff --git a/mapflow/schema/data_catalog.py b/mapflow/schema/data_catalog.py index 6327fb37..adf283da 100644 --- a/mapflow/schema/data_catalog.py +++ b/mapflow/schema/data_catalog.py @@ -1,19 +1,17 @@ from uuid import UUID from enum import Enum from datetime import datetime -from typing import Sequence, Union, Optional, List, Dict +from typing import Sequence, Union, Optional, List from dataclasses import dataclass from .base import Serializable, SkipDataClass +from .layer import RasterLayer + class PreviewSize(str, Enum): large = 'l' small = 's' -@dataclass -class RasterLayer(SkipDataClass): - tileUrl: str - tileJsonUrl: str @dataclass class UserLimitSchema(SkipDataClass): diff --git a/mapflow/schema/layer.py b/mapflow/schema/layer.py new file mode 100644 index 00000000..6f131130 --- /dev/null +++ b/mapflow/schema/layer.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +from mapflow.schema import SkipDataClass + + +@dataclass +class RasterLayer(SkipDataClass): + tileUrl: str + tileJsonUrl: str + +@dataclass +class VectorLayer(SkipDataClass): + tileUrl: str + tileJsonUrl: str + diff --git a/mapflow/schema/processing.py b/mapflow/schema/processing.py index aa867078..c3069180 100644 --- a/mapflow/schema/processing.py +++ b/mapflow/schema/processing.py @@ -1,8 +1,14 @@ +from qgis.core import QgsVectorLayer from dataclasses import dataclass, fields +from datetime import datetime, timedelta from typing import Optional, Mapping, Any, Union, Iterable, List - +from uuid import UUID from .base import SkipDataClass, Serializable from ..entity.provider.provider import SourceType +from ..entity.status import ProcessingStatus, ProcessingReviewStatus +from ..errors import ErrorMessage +from ..schema.layer import RasterLayer, VectorLayer +from .workflow_def import WorkflowDef @dataclass class PostSourceSchema(Serializable, SkipDataClass): @@ -135,3 +141,58 @@ class PostProcessingSchemaV2(Serializable): params: ProcessingParams meta: Optional[Mapping[str, Any]] blocks: Optional[Iterable[BlockOption]] + + +@dataclass +class ProcessingUIParams(Serializable, SkipDataClass): + name: Optional[str] + area: Optional[QgsVectorLayer] + data_source_index: int + zoom: Optional[int] + wd_name: str + model_options: list[BlockOption] + + +@dataclass +class ProcessingDTO(Serializable, SkipDataClass): + id: UUID + name: str + projectId: UUID + status: ProcessingStatus + description: Optional[str] + workflowDef: WorkflowDef + aoiArea: int + cost: int + created: datetime + rasterLayer: RasterLayer + vectorLayer: VectorLayer + messages: list[ErrorMessage] + + percent_completed: int + review_status: ProcessingReviewStatus + in_review_until: datetime + params: ProcessingParams + blocks: List[BlockOption] + + def __post_init__(self): + self.review_status = ProcessingReviewStatus(self.review_status) + self.status = ProcessingStatus(self.status) + self.created = datetime.strptime(self.created, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + 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) + self.messages = [ErrorMessage.from_response(message) for message in self.messages] + + @property + 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() + one_day = timedelta(1) + return self.in_review_until - now < one_day + + def error_message(self, raw=False): + if not self.errors: + return "" + return "\n".join([error.to_str(raw=raw) for error in self.errors]) diff --git a/mapflow/schema/project.py b/mapflow/schema/project.py index 106efa21..41f0ff7e 100644 --- a/mapflow/schema/project.py +++ b/mapflow/schema/project.py @@ -4,7 +4,7 @@ from typing import Optional, List, Dict from .base import Serializable, SkipDataClass -from ..entity.workflow_def import WorkflowDef +from mapflow.schema.workflow_def import WorkflowDef from ..config import Config diff --git a/mapflow/entity/workflow_def.py b/mapflow/schema/workflow_def.py similarity index 97% rename from mapflow/entity/workflow_def.py rename to mapflow/schema/workflow_def.py index 5d51556f..16a8648d 100644 --- a/mapflow/entity/workflow_def.py +++ b/mapflow/schema/workflow_def.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional, List -from ..schema import SkipDataClass +from mapflow.schema import SkipDataClass @dataclass From a05cce648e0c239ae62f363f0c60d1746af59292 Mon Sep 17 00:00:00 2001 From: Alexey Trekin Date: Thu, 27 Nov 2025 18:10:58 +0500 Subject: [PATCH 3/4] move delete processing to the service; add handling of multi deletion --- mapflow/functional/api/processing_api.py | 34 ++++++++- .../functional/service/processing_service.py | 74 +++++++++++++++++-- mapflow/functional/view/processing_view.py | 8 +- mapflow/functional/view/project_view.py | 2 +- mapflow/mapflow.py | 62 +++++----------- mapflow/schema/__init__.py | 1 + mapflow/schema/project.py | 2 +- 7 files changed, 128 insertions(+), 55 deletions(-) diff --git a/mapflow/functional/api/processing_api.py b/mapflow/functional/api/processing_api.py index efdfb968..d22615f4 100644 --- a/mapflow/functional/api/processing_api.py +++ b/mapflow/functional/api/processing_api.py @@ -1,4 +1,6 @@ -from typing import Callable +from typing import Callable, Union +from uuid import UUID + from PyQt5.QtCore import QObject, pyqtSignal, QFile, QIODevice from ...http import Http from ...dialogs.main_dialog import MainDialog @@ -6,7 +8,13 @@ class ProcessingApi(QObject): """ - + API for processing requests: + - get processings of a project + - get single processing + - request processing cost + - create new processing + - update existing processing + - delete processing """ def __init__(self, @@ -25,7 +33,7 @@ def __init__(self, self.plugin_version = plugin_version # project CRUD - def create_processing(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable): + def create_processing(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable) -> None: self.http.post( url=f'{self.server}/processings/v2', callback=callback, @@ -34,7 +42,7 @@ def create_processing(self, data: PostProcessingSchema, callback: Callable, erro body=data.as_json().encode() ) - def update_processing(self, processing_id, processing: UpdateProcessingSchema, callback: Callable, error_handler: Callable): + def update_processing(self, processing_id: Union[UUID, str], processing: UpdateProcessingSchema, callback: Callable, error_handler: Callable): self.http.put(url=f"{self.server}/processings/{processing_id}", body=processing.as_json().encode(), headers={}, @@ -42,6 +50,24 @@ def update_processing(self, processing_id, processing: UpdateProcessingSchema, c use_default_error_handler=True, timeout=5) + def delete_processing(self, processing_id: Union[UUID, str], + callback: Callable, + error_handler: Callable, + callback_kwargs: dict, + error_handler_kwargs: dict) -> None: + self.http.delete(url=f"{self.server}/processings/{processing_id}", + callback = callback, + callback_kwargs = callback_kwargs, + use_default_error_handler=False, + error_handler = error_handler, + error_handler_kwargs = error_handler_kwargs, + timeout=5) + + def get_processing(self, processing_id: Union[UUID, str], callback: Callable) -> None: + self.http.get(url=f"{self.server}/processings/{processing_id}/v2", + callback=callback, + use_default_error_handler=True, + timeout=5) def get_cost(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable): self.http.post( diff --git a/mapflow/functional/service/processing_service.py b/mapflow/functional/service/processing_service.py index 82d922ee..bc3a7adb 100644 --- a/mapflow/functional/service/processing_service.py +++ b/mapflow/functional/service/processing_service.py @@ -11,6 +11,8 @@ from ..api.processing_api import ProcessingApi from ...schema.processing import ProcessingDTO, UpdateProcessingSchema from ...entity.status import ProcessingStatus +from ...entity.billing import BillingType +from ...entity.provider import MyImageryProvider, ImagerySearchProvider @dataclass class ProcessingHistory: @@ -58,7 +60,8 @@ def __init__(self, result_loader, plugin_version, temp_dir, - settings): + settings, + timer_interval): super().__init__() self.http = http self.server = server @@ -67,14 +70,20 @@ def __init__(self, self.plugin_version = plugin_version self.temp_dir = temp_dir self.view = ProcessingView(dlg=dlg) - self.api = ProcessingApi(http=http, server=server, dlg=dlg, iface=iface, result_loader=self.result_loader, plugin_version=self.plugin_version) + self.api = ProcessingApi(http=http, + server=server, + dlg=dlg, + iface=iface, + result_loader=self.result_loader, + plugin_version=self.plugin_version) self.settings = settings self.current_project_id: Optional[str] = None self.processings = set() - self.processings_history = ProcessingHistory() # local storage for active processings list - self.processing_fetch_timer = QTimer(self.dlg) - self.processing_fetch_timer.setInterval(self.config.PROCESSING_TABLE_REFRESH_INTERVAL * 1000) + self.processings_history = None # ProcessingHistory() - local storage for active processings list + self.processing_fetch_timer = QTimer(dlg) + self.processing_fetch_timer.setInterval(timer_interval) + self.deleting_processings = None def set_current_project(self, project_id: str): """ @@ -238,3 +247,58 @@ def disable_processing_start(self, response: QNetworkReply): reason = self.tr('Processing cost is not available:\n{message}').format(message=message) self.view.disable_processing_start(reason, clear_area=False) + def delete_processings(self) -> None: + """Delete one or more processings from the server. + + Asks for confirmation in a pop-up dialog. Multiple processings can be selected. + Is called by clicking the deleteProcessings ('Delete') button. + """ + # Pause refreshing processings table to avoid conflicts + self.processing_fetch_timer.stop() + selected_ids = self.selected_processing_ids() + # Ask for confirmation if there are selected rows + if selected_ids and self.alert( + self.tr('Delete selected processings?'), QMessageBox.Question + ): + self.deleting_processings = {id_: None for id_ in selected_ids} + for id_ in selected_ids: + self.api.delete_processing(processing_id=id_, + callback=self.delete_processings_callback, + error_handler=self.delete_processings_error_handler, + callback_kwargs={"processing_id": id_}, + error_handler_kwargs={"processing_id": id_}) + + def _finalize_processing_delete(self): + self.view.delete_processings_from_table([key for key, value in self.deleting_processings.items() if value]) + # todo: save and report error responses? + self.report_http_error(self.tr(f"Failed to remove processings {[key for key, value in self.deleting_processings.items() if value is False]} ")) + self.processing_fetch_timer.start() + self.deleting_processings = None + + + def delete_processings_callback(self, + _: QNetworkReply, + processing_id: str) -> None: + """Delete processings from the table after they've been deleted from the server. + + :param id_: ID of the deleted processing. + """ + self.deleting_processings[processing_id] = True + if any(status is None for status in self.deleting_processings.values()): + pass + else: + self._finalize_processing_delete() + + def delete_processings_error_handler(self, + _: QNetworkReply, + processing_id: str) -> None: + """Error handler for processing deletion request. + + :param response: The HTTP response. + """ + self.deleting_processings[processing_id] = False + if any(status is None for status in self.deleting_processings.values()): + pass + else: + self._finalize_processing_delete() + diff --git a/mapflow/functional/view/processing_view.py b/mapflow/functional/view/processing_view.py index d9543ab0..7f8ec00e 100644 --- a/mapflow/functional/view/processing_view.py +++ b/mapflow/functional/view/processing_view.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import Qt, QCoreApplication from PyQt5.QtWidgets import QAbstractItemView, QTableWidgetItem from PyQt5.QtGui import QColor -from mapflow.functional.view.main_dialog import MainDialog +from ...dialogs.main_dialog import MainDialog from ...schema.processing import ProcessingDTO, ProcessingUIParams from ...config import config @@ -139,3 +139,9 @@ def update_processing_name(self, processing_id: str, new_name: str) -> bool: # Processing ID not found in table return False + + def delete_processings_from_table(self, processing_ids): + rows = [self.dlg.processingsTable.findItems(id_, Qt.MatchExactly)[0].row() for id_ in processing_ids] + rows.sort(reverse=True) + for row in rows: + self.dlg.processingsTable.removeRow(row) diff --git a/mapflow/functional/view/project_view.py b/mapflow/functional/view/project_view.py index 45999993..765dd195 100644 --- a/mapflow/functional/view/project_view.py +++ b/mapflow/functional/view/project_view.py @@ -63,7 +63,7 @@ def setup_projects_table(self, projects: dict[str, MapflowProject]): self.dlg.projectsTable.setRowCount(len(projects)) self.dlg.projectsTable.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.dlg.projectsTable.setSelectionBehavior(QAbstractItemView.SelectRows) - for row, project in enumerate(projects): + for row, project in enumerate(projects.values()): id_item = QTableWidgetItem() id_item.setData(Qt.DisplayRole, project.id) self.dlg.projectsTable.setItem(row, 0, id_item) diff --git a/mapflow/mapflow.py b/mapflow/mapflow.py index 0e3f6e25..d2d08d2e 100644 --- a/mapflow/mapflow.py +++ b/mapflow/mapflow.py @@ -48,7 +48,6 @@ ImagerySearchProvider, MyImageryProvider, ProviderInterface) -from mapflow.schema.workflow_def import WorkflowDef from .errors import (ProcessingInputDataMissing, BadProcessingInput, PluginError, @@ -70,7 +69,8 @@ ProviderReturnSchema, ImageCatalogRequestSchema, ImageCatalogResponseSchema, - PostProcessingSchemaV2) + PostProcessingSchemaV2, + WorkflowDef) from .schema.catalog import PreviewType, ProductType from .schema.project import MapflowProject, UserRole @@ -197,13 +197,27 @@ def __init__(self, iface) -> None: temp_dir=self.temp_dir ) - self.data_catalog_service = DataCatalogService(self.http, self.server, self.dlg, self.iface, self.result_loader, self.plugin_version, self.temp_dir) + self.data_catalog_service = DataCatalogService(self.http, + self.server, + self.dlg, + self.iface, + self.result_loader, + self.plugin_version, + self.temp_dir) self.data_catalog_controller = DataCatalogController(self.dlg, self.data_catalog_service) self.project_service = ProjectService(self.http, self.server, self.settings, self.dlg) self.project_service.projectsUpdated.connect(self.update_projects) - self.processing_service = ProcessingService(self.http, self.server) + self.processing_service = ProcessingService(self.http, + self.server, + self.dlg, + self.iface, + self.result_loader, + self.plugin_version, + self.temp_dir, + self.settings, + self.config.PROCESSING_TABLE_REFRESH_INTERVAL * 1000) # load providers from settings errors = [] try: @@ -244,7 +258,7 @@ def __init__(self, iface) -> None: self.project.layersAdded.connect(self.monitor_polygon_layer_feature_selection) # Processings self.dlg.processingsTable.cellDoubleClicked.connect(self.load_results) - self.dlg.deleteProcessings.clicked.connect(self.delete_processings) + self.dlg.deleteProcessings.clicked.connect(self.processing_service.delete_processings) self.dlg.filterProcessings.textChanged.connect(self.dlg.filter_processings_table) # Processings ratings self.dlg.processingsTable.itemSelectionChanged.connect(self.enable_feedback) @@ -1819,44 +1833,6 @@ def calculate_processing_cost_callback(self, response: QNetworkReply): self.dlg.processingProblemsLabel.setText(self.tr("Processsing cost: {cost} credits").format(cost=response_data)) self.dlg.startProcessing.setEnabled(True) - def delete_processings(self) -> None: - """Delete one or more processings from the server. - - Asks for confirmation in a pop-up dialog. Multiple processings can be selected. - Is called by clicking the deleteProcessings ('Delete') button. - """ - # Pause refreshing processings table to avoid conflicts - self.processing_fetch_timer.stop() - selected_ids = self.selected_processing_ids() - # Ask for confirmation if there are selected rows - if selected_ids and self.alert( - self.tr('Delete selected processings?'), QMessageBox.Question - ): - for id_ in selected_ids: - self.http.delete( - url=f'{self.server}/processings/{id_}', - callback=self.delete_processings_callback, - callback_kwargs={'id_': id_}, - error_handler=self.delete_processings_error_handler - ) - - def delete_processings_callback(self, _: QNetworkReply, id_: str) -> None: - """Delete processings from the table after they've been deleted from the server. - - :param id_: ID of the deleted processing. - """ - row = self.dlg.processingsTable.findItems(id_, Qt.MatchExactly)[0].row() - self.dlg.processingsTable.removeRow(row) - self.processing_fetch_timer.start() - - def delete_processings_error_handler(self, - response: QNetworkReply) -> None: - """Error handler for processing deletion request. - - :param response: The HTTP response. - """ - self.report_http_error(response, self.tr("Error deleting a processing")) - def check_processing_ui(self, allow_empty_name=False): processing_name = self.dlg.processingName.text() diff --git a/mapflow/schema/__init__.py b/mapflow/schema/__init__.py index a3bcf82b..b8282696 100644 --- a/mapflow/schema/__init__.py +++ b/mapflow/schema/__init__.py @@ -13,3 +13,4 @@ ImagerySearchSchema, UserDefinedParams) from .provider import ProviderReturnSchema +from .workflow_def import WorkflowDef, BlockConfig diff --git a/mapflow/schema/project.py b/mapflow/schema/project.py index 41f0ff7e..5bb9cb6c 100644 --- a/mapflow/schema/project.py +++ b/mapflow/schema/project.py @@ -4,7 +4,7 @@ from typing import Optional, List, Dict from .base import Serializable, SkipDataClass -from mapflow.schema.workflow_def import WorkflowDef +from .workflow_def import WorkflowDef from ..config import Config From bc12a4d363e23d95bd741f3bd7cd2b335066db96 Mon Sep 17 00:00:00 2001 From: Alexey Trekin Date: Tue, 2 Dec 2025 21:35:12 +0500 Subject: [PATCH 4/4] WIP big refactor: app_context class; processing and project service/controller/view; processing history full refactoring --- mapflow/dialogs/main_dialog.py | 13 +- mapflow/dialogs/processing_dialog.py | 5 +- mapflow/entity/processing.py | 191 ---- mapflow/entity/status.py | 39 +- mapflow/errors/errors.py | 3 + mapflow/functional/api/processing_api.py | 23 +- mapflow/functional/api/project_api.py | 14 +- mapflow/functional/app_context.py | 74 ++ .../controller/processing_controller.py | 156 ++- .../controller/project_controller.py | 0 mapflow/functional/helpers.py | 2 +- mapflow/functional/layer_utils.py | 74 +- mapflow/functional/result_loader.py | 21 - mapflow/functional/service/alert_service.py | 81 ++ .../service/area_calculator_service.py | 163 +++ .../functional/service/processing_service.py | 254 ++--- mapflow/functional/service/project_service.py | 208 +++- mapflow/functional/view/processing_view.py | 98 +- mapflow/functional/view/project_view.py | 30 +- mapflow/http.py | 13 +- mapflow/mapflow.py | 974 +++++------------- mapflow/schema/__init__.py | 10 +- mapflow/schema/base.py | 6 +- mapflow/{entity => schema}/billing.py | 0 mapflow/schema/layer.py | 2 +- mapflow/schema/processing.py | 62 +- mapflow/schema/processing_history.py | 100 ++ mapflow/schema/project.py | 23 +- mapflow/schema/status.py | 128 +++ mapflow/schema/workflow_def.py | 2 +- 30 files changed, 1533 insertions(+), 1236 deletions(-) delete mode 100644 mapflow/entity/processing.py create mode 100644 mapflow/functional/app_context.py create mode 100644 mapflow/functional/controller/project_controller.py delete mode 100644 mapflow/functional/result_loader.py create mode 100644 mapflow/functional/service/alert_service.py create mode 100644 mapflow/functional/service/area_calculator_service.py rename mapflow/{entity => schema}/billing.py (100%) create mode 100644 mapflow/schema/processing_history.py create mode 100644 mapflow/schema/status.py diff --git a/mapflow/dialogs/main_dialog.py b/mapflow/dialogs/main_dialog.py index 01eb5f73..180b74f0 100644 --- a/mapflow/dialogs/main_dialog.py +++ b/mapflow/dialogs/main_dialog.py @@ -1,22 +1,19 @@ import sys from pathlib import Path from typing import Iterable, Optional, List -from datetime import datetime from PyQt5 import uic from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QPalette -from PyQt5.QtWidgets import (QWidget, QPushButton, QCheckBox, QTableWidgetItem, QStackedLayout, QLabel, QToolButton, - QAction, QMenu, QAbstractItemView, QHeaderView, QVBoxLayout, QButtonGroup, QTableWidget) +from PyQt5.QtWidgets import (QWidget, QPushButton, QCheckBox, QTableWidgetItem, QStackedLayout, QLabel, QToolButton, + QAction, QMenu, QAbstractItemView) from qgis.core import QgsMapLayerProxyModel, QgsSettings from . import icons from ..config import config, ConfigColumns -from ..entity.billing import BillingType +from ..schema import BillingType, UserRole from ..entity.provider import ProviderInterface from ..functional import helpers -from ..schema.project import MapflowProject, UserRole -from ..schema.catalog import ProductType ui_path = Path(__file__).parent/'static'/'ui' @@ -240,8 +237,8 @@ def setup_for_billing(self, billing_type: BillingType): """ set the UI elements according to user's billing type """ - credits_used = billing_type == billing_type.credits - balance_visible = billing_type != billing_type.none + credits_used = billing_type == BillingType.credits + balance_visible = billing_type != BillingType.none self.topUpBalanceButton.setVisible(credits_used) self.labelCoins_1.setVisible(False) # credits_used diff --git a/mapflow/dialogs/processing_dialog.py b/mapflow/dialogs/processing_dialog.py index ea04fe72..20353c86 100644 --- a/mapflow/dialogs/processing_dialog.py +++ b/mapflow/dialogs/processing_dialog.py @@ -3,9 +3,8 @@ from PyQt5 import uic from PyQt5.QtWidgets import QWidget, QDialogButtonBox -from ..entity.processing import Processing from .icons import plugin_icon -from ..schema.processing import UpdateProcessingSchema +from ..schema.processing import UpdateProcessingSchema, ProcessingDTO ui_path = Path(__file__).parent/'static'/'ui' @@ -27,7 +26,7 @@ def on_name_change(self): self.ok.setEnabled(True) self.ok.setToolTip("") - def setup(self, processing: Processing): + def setup(self, processing: ProcessingDTO): if not processing: raise TypeError("Can edit only existing processing!") self.setWindowTitle(self.tr("Edit processing {}").format(processing.name)) diff --git a/mapflow/entity/processing.py b/mapflow/entity/processing.py deleted file mode 100644 index 1875b887..00000000 --- a/mapflow/entity/processing.py +++ /dev/null @@ -1,191 +0,0 @@ -import sys -from datetime import datetime, timedelta -from typing import List, Dict, Optional, Tuple - -from .status import ProcessingStatus, ProcessingReviewStatus -from ..errors import ErrorMessage -from ..schema.processing import BlockOption, ProcessingParams - - -class Processing: - def __init__(self, - id_, - name, - status, - workflow_def, - aoi_area, - cost, - created, - percent_completed, - raster_layer, - vector_layer, - errors=None, - review_status=None, - in_review_until=None, - params: Optional[ProcessingParams] = None, - blocks: Optional[List[BlockOption]] = None, - description: Optional[str] = None, - **kwargs): - self.id_ = id_ - self.name = name - self.status = ProcessingStatus(status) - self.workflow_def = workflow_def - self.aoi_area = aoi_area - self.cost = int(cost) - self.created = created.astimezone() - self.percent_completed = int(percent_completed) - self.errors = errors - self.raster_layer = raster_layer - self.vector_layer = vector_layer - self.review_status = ProcessingReviewStatus(review_status) - self.in_review_until = in_review_until - self.params = params - self.blocks = blocks - self.description = description - - @classmethod - def from_response(cls, processing): - id_ = processing['id'] - name = processing['name'] - status = processing['status'] - description = processing.get("description") or None - workflow_def = processing['workflowDef']['name'] - aoi_area = round(processing['aoiArea'] / 10 ** 6, 2) - - if sys.version_info.minor < 7: # python 3.6 doesn't understand 'Z' as UTC - created = processing['created'].replace('Z', '+0000') - else: - created = processing['created'] - created = datetime.strptime(created, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() - percent_completed = processing['percentCompleted'] - messages = processing.get('messages', []) - errors = [ErrorMessage.from_response(message) for message in messages] - raster_layer = processing['rasterLayer'] - vector_layer = processing['vectorLayer'] - if processing.get('reviewStatus'): - 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() - else: - in_review_until = None - else: - review_status = in_review_until = None - cost = processing.get('cost', 0) - params = ProcessingParams.from_dict(processing.get("params")) - blocks = [BlockOption.from_dict(block) for block in processing.get("blocks", [])] - return cls(id_, - name, - status, - workflow_def, - aoi_area, - cost, - created, - percent_completed, - raster_layer, - vector_layer, - errors, - review_status, - in_review_until, - params, - blocks, - description) - - @property - def is_new(self): - now = datetime.now().astimezone() - one_day = timedelta(1) - return now - self.created < one_day - - @property - 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() - one_day = timedelta(1) - return self.in_review_until - now < one_day - - def error_message(self, raw=False): - if not self.errors: - return "" - return "\n".join([error.to_str(raw=raw) for error in self.errors]) - - def asdict(self): - return { - 'id': self.id_, - 'name': self.name, - 'status': self.status_with_review, - 'workflowDef': self.workflow_def, - 'aoiArea': self.aoi_area, - 'cost': self.cost, - 'percentCompleted': self.percent_completed, - 'errors': self.errors, - # Serialize datetime and drop seconds for brevity - 'created': self.created.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 "", - "description": self.description - } - - @property - def status_with_review(self): - """ - Review status is set instead of status if applicable, that is - when the status is OK and review_status is set (not None) - """ - if self.status.is_ok and not self.review_status.is_none: - return self.review_status.display_value - else: - return self.status.display_value - - -def parse_processings_request(response: list) -> List[Processing]: - return [Processing.from_response(resp) for resp in response] - - -class ProcessingHistory: - """ - History of the processings, including failed and finished processings, that are stored in settings - """ - def __init__(self, - failed: Optional[List[str]] = None, - finished: Optional[List[str]] = None): - self.failed = failed or [] - self.finished = finished or [] - - def asdict(self): - return { - 'failed': [id_ for id_ in self.failed], - 'finished': [id_ for id_ in self.finished] - } - - def update(self, - failed: Optional[List[Processing]] = None, - finished: Optional[List[Processing]] = None): - self.failed = [processing.id_ for processing in failed] - self.failed = [processing.id_ for processing in finished] - - @classmethod - def from_settings(cls, settings: Dict[str, List[str]]): - return cls(failed=settings.get('failed', []), finished=settings.get('finished', [])) - - -def updated_processings(processings: List[Processing], - history: ProcessingHistory) -> Tuple[List[Processing], List[Processing], ProcessingHistory]: - failed = [] - finished = [] - failed_ids = [] - finished_ids = [] - for processing in processings: - if processing.status.is_failed: - failed_ids.append(processing.id_) - if processing.id_ not in history.failed: - failed.append(processing) - # Find recently finished processings and alert the user - elif processing.percent_completed == 100: - finished_ids.append(processing.id_) - if processing.id_ not in history.finished: - finished.append(processing) - - return failed, finished, ProcessingHistory(failed_ids, finished_ids) diff --git a/mapflow/entity/status.py b/mapflow/entity/status.py index 7d90271d..32dc07b9 100644 --- a/mapflow/entity/status.py +++ b/mapflow/entity/status.py @@ -1,7 +1,12 @@ +from dataclasses import dataclass +from datetime import datetime from enum import Enum +from typing import Optional from PyQt5.QtCore import QObject +from ..schema.base import Serializable, SkipDataClass + class ProcessingStatusDict(QObject): def __init__(self): @@ -72,8 +77,12 @@ def is_cancelled(self): def is_awaiting(self): return self == ProcessingStatus.awaiting + @property + def is_terminal(self): + return self.is_ok or self.is_failed or self.is_refunded or self.is_cancelled + -class ProcessingReviewStatus(NamedEnum): +class ProcessingReviewStatusEnum(NamedEnum): none = None in_review = 'IN_REVIEW' not_accepted = 'NOT_ACCEPTED' @@ -84,14 +93,36 @@ def __init__(self, value): super().__init__(value) self.value_map = ProcessingReviewStatusDict().value_map + +@dataclass +class ProcessingReviewStatus(Serializable, SkipDataClass): + reviewStatus: Optional[ProcessingReviewStatusEnum] = None + inReviewUntil: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Optional[dict]): + """Handle None input by returning instance with defaults.""" + if data is None: + return cls() + return super().from_dict(data) + + def __post_init__(self): + if self.inReviewUntil: + self.inReviewUntil = datetime.strptime(self.inReviewUntil, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + self.reviewStatus = ProcessingReviewStatusEnum(self.reviewStatus) + @property def is_in_review(self): - return self == ProcessingReviewStatus.in_review + return self.reviewStatus == ProcessingReviewStatusEnum.in_review @property def is_not_accepted(self): - return self == ProcessingReviewStatus.not_accepted + return self.reviewStatus == ProcessingReviewStatusEnum.not_accepted @property def is_none(self): - return self == ProcessingReviewStatus.none \ No newline at end of file + return self.reviewStatus == ProcessingReviewStatusEnum.none + + @property + def is_accepted(self): + return self.reviewStatus == ProcessingReviewStatusEnum.accepted diff --git a/mapflow/errors/errors.py b/mapflow/errors/errors.py index 80ae050f..e1fc99f8 100644 --- a/mapflow/errors/errors.py +++ b/mapflow/errors/errors.py @@ -52,3 +52,6 @@ def to_str(self, raw=False): '\n Contact us to resolve the issue! help@geoalert.io').format(exception=str(e), code=self.code) return message + + def __reduce__(self): + return (self.__class__, (self.code, self.parameters, self.message)) diff --git a/mapflow/functional/api/processing_api.py b/mapflow/functional/api/processing_api.py index d22615f4..57b613a1 100644 --- a/mapflow/functional/api/processing_api.py +++ b/mapflow/functional/api/processing_api.py @@ -19,23 +19,19 @@ class ProcessingApi(QObject): def __init__(self, http: Http, - server: str, dlg: MainDialog, iface, - result_loader, - plugin_version): + result_loader): super().__init__() - self.server = server self.http = http self.iface = iface self.dlg = dlg self.result_loader = result_loader - self.plugin_version = plugin_version # project CRUD def create_processing(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable) -> None: self.http.post( - url=f'{self.server}/processings/v2', + path="processings/v2", callback=callback, error_handler=error_handler, use_default_error_handler=False, @@ -43,7 +39,7 @@ def create_processing(self, data: PostProcessingSchema, callback: Callable, erro ) def update_processing(self, processing_id: Union[UUID, str], processing: UpdateProcessingSchema, callback: Callable, error_handler: Callable): - self.http.put(url=f"{self.server}/processings/{processing_id}", + self.http.put(path=f"processings/{processing_id}", body=processing.as_json().encode(), headers={}, callback=callback, @@ -55,7 +51,7 @@ def delete_processing(self, processing_id: Union[UUID, str], error_handler: Callable, callback_kwargs: dict, error_handler_kwargs: dict) -> None: - self.http.delete(url=f"{self.server}/processings/{processing_id}", + self.http.delete(path="processings/{processing_id}", callback = callback, callback_kwargs = callback_kwargs, use_default_error_handler=False, @@ -64,14 +60,21 @@ def delete_processing(self, processing_id: Union[UUID, str], timeout=5) def get_processing(self, processing_id: Union[UUID, str], callback: Callable) -> None: - self.http.get(url=f"{self.server}/processings/{processing_id}/v2", + self.http.get(path=f"processings/{processing_id}/v2", callback=callback, use_default_error_handler=True, timeout=5) + def get_processings(self, project_id: Union[UUID, str], callback: Callable): + self.http.get(path=f"projects/{project_id}/processings/v2", + callback=callback, + use_default_error_handler=False, + timeout=5) + + def get_cost(self, data: PostProcessingSchema, callback: Callable, error_handler: Callable): self.http.post( - url=f"{self.server}/processing/cost/v2", + path="processing/cost/v2", callback=callback, body=data.as_json().encode(), use_default_error_handler=False, diff --git a/mapflow/functional/api/project_api.py b/mapflow/functional/api/project_api.py index fb157d00..e957c365 100644 --- a/mapflow/functional/api/project_api.py +++ b/mapflow/functional/api/project_api.py @@ -9,14 +9,12 @@ class ProjectApi(QObject): def __init__(self, - http: Http, - server: str): + http: Http): super().__init__() - self.server = server self.http = http def create_project(self, project: CreateProjectSchema, callback: Callable): - self.http.post(url=f"{self.server}/projects", + self.http.post(path="projects", body=project.as_json().encode(), headers={}, callback=callback, @@ -24,14 +22,14 @@ def create_project(self, project: CreateProjectSchema, callback: Callable): timeout=5) def delete_project(self, project_id, callback: Callable): - self.http.delete(url=f"{self.server}/projects/{project_id}", + self.http.delete(path=f"projects/{project_id}", headers={}, callback=callback, use_default_error_handler=True, timeout=5) def update_project(self, project_id, project: UpdateProjectSchema, callback: Callable): - self.http.put(url=f"{self.server}/projects/{project_id}", + self.http.put(path=f"projects/{project_id}", body=project.as_json().encode(), headers={}, callback=callback, @@ -39,7 +37,7 @@ def update_project(self, project_id, project: UpdateProjectSchema, callback: Cal timeout=5) def get_project(self, project_id, callback: Callable, error_handler: Callable): - self.http.get(url=f"{self.server}/projects/{project_id}", + self.http.get(path=f"projects/{project_id}", headers={}, callback=callback, use_default_error_handler= False, @@ -49,7 +47,7 @@ def get_project(self, project_id, callback: Callable, error_handler: Callable): def get_projects(self, request_body: ProjectsRequest, callback: Callable): - self.http.post(url=f"{self.server}/projects/page", + self.http.post(path="projects/page", headers={}, body=request_body.as_json().encode(), callback=callback, diff --git a/mapflow/functional/app_context.py b/mapflow/functional/app_context.py new file mode 100644 index 00000000..aab0539f --- /dev/null +++ b/mapflow/functional/app_context.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Optional, Any + +from qgis.core import QgsGeometry, QgsVectorLayer, QgsProject +from ..schema.project import UserRole + +if TYPE_CHECKING: + from ..schema.project import MapflowProject + from ..schema.workflow_def import WorkflowDef + from ..entity.billing import BillingType + from ..entity.provider import ProviderInterface + + +@dataclass +class AppContext: + """ + Shared application state accessible by all services. + Represents current session state - not persisted. + """ + + # === Infrastructure === + server: str = "" + project: Optional[QgsProject] = None + plugin_name: str = "" + plugin_version: str = "" + temp_dir: Optional[str] = None + + # === Project & Processing Selection === + project_id: Optional[str] = None + current_project: Optional["MapflowProject"] = None + user_role: Optional["UserRole"] = UserRole.owner + selected_processing_ids: List[str] = field(default_factory=list) + + # === AOI State === + aoi: Optional[QgsGeometry] = None + aoi_size: Optional[float] = None + aoi_layers: List[QgsVectorLayer] = field(default_factory=list) + + # === User/Account State === + is_admin: bool = False + logged_in: bool = False + username: str = "" + password: str = "" + + # === Billing & Limits === + billing_type: Optional["BillingType"] = None + remaining_limit: float = 0.0 + remaining_credits: float = 0.0 + aoi_area_limit: float = 0.0 + max_aois_per_processing: int = 1 + review_workflow_enabled: bool = False + + # === Imagery Search State === + search_provider: Optional["ProviderInterface"] = None + metadata_aoi: Optional[QgsGeometry] = None + metadata_layer: Optional[QgsVectorLayer] = None + search_footprints: Dict[str, Any] = field(default_factory=dict) + search_page_offset: int = 0 + + # === Preview State === + preview_dict: Dict[str, Any] = field(default_factory=dict) + + @property + def workflow_defs(self): + if self.current_project: + return self.current_project.workflowDefs + else: + return None + + def get_workflow_def(self, wd_name): + if not self.workflow_defs: + return None + else: + return self.workflow_defs.get(wd_name) diff --git a/mapflow/functional/controller/processing_controller.py b/mapflow/functional/controller/processing_controller.py index bae028b2..902577ec 100644 --- a/mapflow/functional/controller/processing_controller.py +++ b/mapflow/functional/controller/processing_controller.py @@ -1,20 +1,158 @@ -from PyQt5.QtCore import QObject, QTimer +from typing import Optional +from PyQt5.QtCore import QObject, QSettings +from PyQt5.QtWidgets import QMessageBox + +from ..app_context import AppContext from ..service.processing_service import ProcessingService from ..service.project_service import ProjectService -from ...dialogs.main_dialog import MainDialog +from ...dialogs import CreateProjectDialog, UpdateProjectDialog, MainDialog, UpdateProcessingDialog -class ProcessingController(QObject): - def __init__(self, dlg: MainDialog, processing_service: ProcessingService, project_service: ProjectService): +class ProjectProcessingController(QObject): + """ + Controller that coordinates navigation and interactions between + Projects and Processings views. + + Responsibilities: + - Wire UI events to service methods + - Handle navigation between projects and processings views + - Connect signals between services + """ + + def __init__(self, dlg: MainDialog, + processing_service: ProcessingService, + project_service: ProjectService, + settings: QSettings, + app_context: AppContext): super().__init__() self.dlg = dlg self.processing_service = processing_service self.project_service = project_service - self.view = self.processing_service.view + self.settings = settings + self.app_context = app_context + + self._setup_processing_bindings() + self._setup_project_bindings() + self._setup_navigation() - # Connect UI actions + self.project_connection = None + + def _setup_processing_bindings(self): + """Processing-specific UI connections.""" self.dlg.startProcessing.clicked.connect(self.processing_service.start_processing) - self.processing_service.processing_fetch_timer.timeout.connect(self.processing_service.get_processings) + self.dlg.processing_update_action.triggered.connect(self.update_processing) + self.processing_service.processing_fetch_timer.timeout.connect( + self.processing_service.get_processings + ) + + def _setup_project_bindings(self): + """Project-specific UI connections.""" + # Project service already sets up its own pagination/filter bindings in __init__ + # Projects + self.dlg.createProject.clicked.connect(self.create_project) + self.dlg.deleteProject.clicked.connect(self.delete_project) + self.dlg.updateProject.clicked.connect(self.update_project) + self.project_service.projectsUpdated.connect(self.project_service.update_projects) + self.project_service.projectsFiltered.connect(self.connect_projects) + + def _setup_navigation(self): + """Navigation between projects and processings views.""" + # Connect UI navigation buttons + self.dlg.switchProjectsButton.clicked.connect(lambda: self.show_projects(open_saved_page=True)) + self.dlg.switchProcessingsButton.clicked.connect(lambda: self.show_processings(save_page=True)) + self.dlg.projectsTable.doubleClicked.connect(self._on_project_double_clicked) + + def _on_project_double_clicked(self, index): + """Handle double-click on project row to navigate to processings.""" + project_id = self.dlg.projectsTable.item(index.row(), 0).text() + self.app_context.current_project = self.project_service.projects.get(project_id) + self.show_processings(save_page=True) + + def show_processings(self, save_page: bool = False): + """ + Navigate to processings view for current/specified project. - # Connect project service signals to processing service slots - self.project_service.projectChanged.connect(self.processing_service.set_current_project) + Args: + save_page: If True, save current projects page state to settings + project_id: The project ID to show processings for. If None, uses current project. + """ + if not self.app_context.project_id: + return + + # Save current projects page state before switching + if save_page: + sort_by, sort_order = self.project_service.view.sort_projects() + projects_page = { + 'offset': self.project_service.projects_page_offset, + 'sort_by': sort_by, + 'sort_order': sort_order, + 'filter': self.project_service.view.projects_filter + } + self.settings.setValue('projectsPage', projects_page) + # Load processing history + self.processing_service.load_processing_history() + # Switch view + self.project_service.view.switch_to_processings() + + # Setup processings table for the project + self.processing_service.setup_processings_table() + + def update_processing(self): + processing = self.processing_service.selected_processing() + if not processing: + return + dialog = UpdateProcessingDialog(self.dlg) + dialog.accepted.connect(lambda: self.processing_service.update_processing(processing.id, + dialog.processing())) + dialog.setup(processing) + dialog.deleteLater() + + # ==== PROJECTS ==== # + def show_projects(self, open_saved_page: bool = False): + """ + Navigate to projects view. + + Args: + open_saved_page: If True, restore previously saved page state from settings + """ + # Stop processing polling when leaving processings view + self.processing_service.processing_fetch_timer.stop() + + # Fetch projects (handles saved page restoration internally) + self.project_service.get_projects(open_saved_page) + + # Switch view + self.project_service.view.switch_to_projects() + + def create_project(self): + dialog = CreateProjectDialog(self.dlg) + dialog.accepted.connect(lambda: self.project_service.create_project(dialog.project())) + dialog.setup() + dialog.deleteLater() + + def update_project(self): + dialog = UpdateProjectDialog(self.dlg) + dialog.accepted.connect(lambda: self.project_service.update_project(self.app_context.current_project.id, + dialog.project())) + dialog.setup(self.app_context.current_project) + dialog.deleteLater() + + def delete_project(self): + if self.alert(self.tr('Do you really want to remove project {}? ' + 'This action cannot be undone, all processings will be lost!').format( + self.app_context.current_project.name), + icon=QMessageBox.Question): + # Unload current project as we are deleting it + to_delete = self.app_context.project_id + self.app_context.project_id = None + self.app_context.current_project = None + self.project_service.delete_project(to_delete) + + def connect_projects(self): + """ + Reset connection between project table selection and project change + """ + if self.project_connection is not None: + self.dlg.projectsTable.itemSelectionChanged.disconnect(self.project_connection) + self.project_connection = None + self.project_connection = self.dlg.projectsTable.itemSelectionChanged.connect(self.project_service.on_project_change) diff --git a/mapflow/functional/controller/project_controller.py b/mapflow/functional/controller/project_controller.py new file mode 100644 index 00000000..e69de29b diff --git a/mapflow/functional/helpers.py b/mapflow/functional/helpers.py index 1816b6df..5cd5beb5 100644 --- a/mapflow/functional/helpers.py +++ b/mapflow/functional/helpers.py @@ -10,7 +10,7 @@ ) from ..config import config -from ..entity.billing import BillingType +from ..schema.billing import BillingType from ..schema.project import UserRole PROJECT = QgsProject.instance() diff --git a/mapflow/functional/layer_utils.py b/mapflow/functional/layer_utils.py index 1508f22e..4f847d01 100644 --- a/mapflow/functional/layer_utils.py +++ b/mapflow/functional/layer_utils.py @@ -25,10 +25,12 @@ QgsCoordinateTransform ) -from .geometry import clip_aoi_to_image_extent, clip_aoi_to_catalog_extent +from .app_context import AppContext +from .geometry import clip_aoi_to_catalog_extent from .helpers import WGS84, to_wgs84, WGS84_ELLIPSOID from ..dialogs.error_message_widget import ErrorMessageWidget from ..schema.catalog import AoiResponseSchema +from ..schema.processing import ProcessingDTO from ..styles import get_style_name @@ -250,21 +252,17 @@ def footprint_to_extent(footprint: dict) -> QgsRectangle: # Layer management for results class ResultsLoader(QObject): - def __init__(self, iface, maindialog, http, server, project, settings, plugin_name, temp_dir): + def __init__(self, iface, maindialog, http, settings, context: AppContext): super().__init__() + self.context = context + self.iface = iface self.message_bar = iface.messageBar() self.dlg = maindialog self.http = http - self.iface = iface - self.server = server - self.project = project - self.layer_tree_root = self.project.layerTreeRoot() + self.settings = settings # By default, plugin adds layers to a group unless user explicitly deletes it self.add_layers_to_group = True self.layer_group = None - self.settings = settings - self.plugin_name = plugin_name - self.temp_dir = temp_dir # ======= General layer management ====== # @@ -279,30 +277,32 @@ def add_layer(self, layer: Optional[QgsMapLayer], order=0) -> None: """ if not layer: return - self.layer_group = self.layer_tree_root.findGroup(self.settings.value('layerGroup')) + self.layer_group = self.context.project.layerTreeRoot().findGroup(self.settings.value('layerGroup')) if self.add_layers_to_group: if not self.layer_group: # сreate a layer group - self.layer_group = self.layer_tree_root.insertGroup(0, self.plugin_name) + self.layer_group = self.context.project.layerTreeRoot().insertGroup(0, self.context.plugin_name) # A bug fix, - gotta collapse first to be able to expand it # Or else it'll ignore the setExpanded(True) calls self.layer_group.setExpanded(False) - self.settings.setValue('layerGroup', self.plugin_name) + self.settings.setValue('layerGroup', self.context.plugin_name) # If the group has been deleted, assume user wants to add layers to root, memorize it self.layer_group.destroyed.connect(lambda: setattr(self, 'add_layers_to_group', False)) # Let user rename the group, memorize the new name self.layer_group.nameChanged.connect(lambda _, name: self.settings.setValue('layerGroup', name)) # To be added to group, layer has to be added to project first - self.project.addMapLayer(layer, addToLegend=False) + self.context.project.addMapLayer(layer, addToLegend=False) # Explcitly add layer to the position 0 (default value) or else it adds it to bottom self.layer_group.insertLayer(order, layer) self.layer_group.setExpanded(True) else: # assume user opted to not use a group, add layers as usual - self.project.addMapLayer(layer) + self.context.project.addMapLayer(layer) - def add_preview_layer(self, preview_layer, preview_dict): + def add_preview_layer(self, preview_layer): + """Add a preview layer, using preview_dict from context to track layers.""" + preview_dict = self.context.preview_dict # Delete layer from dictionary if it was deleted from layer tree for url, id in preview_dict.copy().items(): - if id not in self.project.mapLayers() and id != preview_layer.id(): + if id not in self.context.project.mapLayers() and id != preview_layer.id(): del preview_dict[url] # Revove the old layer if its url matches current one and its in the dictionary url = preview_layer.dataProvider().dataSourceUri() @@ -328,18 +328,18 @@ def add_preview_layer(self, preview_layer, preview_dict): # ======= Load as tile layers ====== # def load_result_tiles(self, processing): - raster_tilejson = processing.raster_layer.get("tileJsonUrl", None) - vector_tilejson = processing.vector_layer.get("tileJsonUrl", None) - raster_layer = generate_raster_layer(processing.raster_layer.get("tileUrl", None), + raster_tilejson = processing.rasterLayer.tileJsonUrl + vector_tilejson = processing.vectorLayer.tileJsonUrl + raster_layer = generate_raster_layer(processing.rasterLayer.tileJsonUrl, name=f"{processing.name} raster") - vector_layer = generate_vector_layer(processing.vector_layer.get("tileUrl", None), + vector_layer = generate_vector_layer(processing.vectorLayer.tileJsonUrl, name=processing.name) - vector_layer.loadNamedStyle(get_style_name(processing.workflow_def, vector_layer)) + vector_layer.loadNamedStyle(get_style_name(processing.workflowDef.name, vector_layer)) self.request_layer_extent(tilejson_uri=raster_tilejson, layer=raster_layer, next_layers = [vector_layer], next_tilejson_uris = [vector_tilejson], - processing_id = processing.id_ + processing_id = processing.id ) def request_layer_extent(self, @@ -430,7 +430,7 @@ def download_results_file(self, pid) -> None: return self.dlg.saveOptionsButton.setEnabled(False) self.http.get( - url=f'{self.server}/processings/{pid}/result', + url=f'{self.context.server}/processings/{pid}/result', callback=self.download_results_file_callback, callback_kwargs={'path': path}, use_default_error_handler=False, @@ -442,10 +442,10 @@ def download_aoi_file(self, pid) -> None: """ Download area of interest and save to a geojson file """ - path = Path(self.temp_dir)/f'{pid}_aoi.geojson' + path = Path(self.context.temp_dir)/f'{pid}_aoi.geojson' self.dlg.saveOptionsButton.setEnabled(False) self.http.get( - url=f'{self.server}/processings/{pid}/aois', + url=f'{self.context.server}/processings/{pid}/aois', callback=self.download_aoi_file_callback, callback_kwargs={'path': path}, use_default_error_handler=True, @@ -500,7 +500,7 @@ def download_results(self, processing) -> None: """ self.dlg.processingsTable.setEnabled(False) self.http.get( - url=f'{self.server}/processings/{processing.id_}/result', + url=f'{self.context.server}/processings/{processing.id}/result', callback=self.download_results_callback, callback_kwargs={'processing': processing}, use_default_error_handler=False, @@ -508,7 +508,7 @@ def download_results(self, processing) -> None: timeout=300 ) - def download_results_callback(self, response: QNetworkReply, processing) -> None: + def download_results_callback(self, response: QNetworkReply, processing: ProcessingDTO) -> None: """Display processing results upon their successful fetch. :param response: The HTTP response. @@ -516,17 +516,17 @@ def download_results_callback(self, response: QNetworkReply, processing) -> None """ self.dlg.processingsTable.setEnabled(True) # Avoid overwriting existing files by adding (n) to their names - output_path = Path(self.dlg.outputDirectory.text(), processing.id_).with_suffix(".gpkg") + output_path = Path(self.dlg.outputDirectory.text(), processing.id).with_suffix(".gpkg") if output_path.exists(): count = 1 - while output_path.with_stem(processing.id_ + f"_{count}").exists(): + while output_path.with_stem(processing.id + f"_{count}").exists(): count += 1 - output_path = output_path.with_stem(processing.id_ + f"_{count}") - transform = self.project.transformContext() + output_path = output_path.with_stem(processing.id + f"_{count}") + transform = self.context.project.transformContext() # Layer creation options for QGIS 3.10.3+ write_options = QgsVectorFileWriter.SaveVectorOptions() write_options.layerOptions = ['fid=id'] - with open(Path(self.temp_dir, os.urandom(32).hex()), mode='wb+') as f: + with open(Path(self.context.temp_dir, os.urandom(32).hex()), mode='wb+') as f: response_data = response.readAll().data() f.write(response_data) layer = QgsVectorLayer(f.name, '', 'ogr') @@ -549,9 +549,9 @@ def download_results_callback(self, response: QNetworkReply, processing) -> None output_path = output_path.with_suffix(".geojson") if output_path.exists(): count = 1 - while output_path.with_stem(processing.id_ + f"_{count}").exists(): + while output_path.with_stem(processing.id + f"_{count}").exists(): count += 1 - output_path = output_path.with_stem(processing.id_ + f"_{count}") + output_path = output_path.with_stem(processing.id + f"_{count}") try: with open(str(output_path), mode='wb+') as f: f.write(response_data) @@ -560,10 +560,10 @@ def download_results_callback(self, response: QNetworkReply, processing) -> None return # Load the results into QGIS results_layer = QgsVectorLayer(str(output_path), processing.name, 'ogr') - results_layer.loadNamedStyle(get_style_name(processing.workflow_def, layer)) + results_layer.loadNamedStyle(get_style_name(processing.workflowDef.name, layer)) # Add the source raster (COG) if it has been created - raster_url = processing.raster_layer.get('tileUrl') - tile_json_url = processing.raster_layer.get("tileJsonUrl") + raster_url = processing.rasterLayer.tileUrl + tile_json_url = processing.rasterLayer.tileJsonUrl if raster_url: params = { 'type': 'xyz', diff --git a/mapflow/functional/result_loader.py b/mapflow/functional/result_loader.py deleted file mode 100644 index 81845954..00000000 --- a/mapflow/functional/result_loader.py +++ /dev/null @@ -1,21 +0,0 @@ -from ..entity.processing import Processing - - -class ResultLoader: - def __init__(self, processing: Processing, http): - self.processing = processing - self.raster_tilejson = None - self.vector_tilejson = None - self.raster_layer = None - self.vector_layer = None - self.http = http - - def __call__(self): - """ Entrypoint """ - self.get_raster_tilejson() - - def get_raster_tilejson(self): - pass - - def get_vector_tilejson(self, response): - pass diff --git a/mapflow/functional/service/alert_service.py b/mapflow/functional/service/alert_service.py new file mode 100644 index 00000000..fa6224c3 --- /dev/null +++ b/mapflow/functional/service/alert_service.py @@ -0,0 +1,81 @@ +from typing import Optional +from PyQt5.QtWidgets import QApplication, QMessageBox +from PyQt5.QtCore import Qt, QObject + + +class AlertService(QObject): + """Singleton service for displaying alerts and notifications.""" + + _instance: Optional['AlertService'] = None + _initialized: bool = False + + def __new__(cls, plugin_name: str = None): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, plugin_name: str = None): + if AlertService._initialized: + return + super().__init__() + self._plugin_name = plugin_name or "Mapflow" + AlertService._initialized = True + + @classmethod + def instance(cls) -> 'AlertService': + """Get the singleton instance. Must be initialized first.""" + if cls._instance is None: + raise RuntimeError("AlertService not initialized. Call AlertService(plugin_name) first.") + return cls._instance + + @property + def plugin_name(self) -> str: + return self._plugin_name + + def alert(self, message: str, icon: QMessageBox.Icon = QMessageBox.Critical, blocking: bool = True) -> bool: + """Display a minimalistic modal dialog with some info or a question. + + :param message: A text to display + :param icon: Info/Warning/Critical/Question + :param blocking: Opened as modal - code below will only be executed when the alert is closed + :return: True if user clicked OK (for Question dialogs), False otherwise + """ + box = QMessageBox(icon, self._plugin_name, message, parent=QApplication.activeWindow()) + box.setTextFormat(Qt.RichText) + if icon == QMessageBox.Question: + box.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok) + return box.exec() == QMessageBox.Ok if blocking else box.open() + + def info(self, message: str, blocking: bool = True) -> bool: + """Display an info message.""" + return self.alert(message, QMessageBox.Information, blocking) + + def warning(self, message: str, blocking: bool = True) -> bool: + """Display a warning message.""" + return self.alert(message, QMessageBox.Warning, blocking) + + def error(self, message: str, blocking: bool = True) -> bool: + """Display an error message.""" + return self.alert(message, QMessageBox.Critical, blocking) + + def confirm(self, message: str) -> bool: + """Display a confirmation dialog. Returns True if user confirms.""" + return self.alert(message, QMessageBox.Question, blocking=True) + + +# Convenience functions for direct import +def alert(message: str, icon: QMessageBox.Icon = QMessageBox.Critical, blocking: bool = True) -> bool: + """Display an alert using the singleton AlertService.""" + return AlertService.instance().alert(message, icon, blocking) + +def alert_info(message: str, blocking: bool = True) -> bool: + return AlertService.instance().info(message, blocking) + +def alert_warning(message: str, blocking: bool = True) -> bool: + return AlertService.instance().warning(message, blocking) + +def alert_error(message: str, blocking: bool = True) -> bool: + return AlertService.instance().error(message, blocking) + +def alert_confirm(message: str) -> bool: + return AlertService.instance().confirm(message) diff --git a/mapflow/functional/service/area_calculator_service.py b/mapflow/functional/service/area_calculator_service.py new file mode 100644 index 00000000..d8548f2b --- /dev/null +++ b/mapflow/functional/service/area_calculator_service.py @@ -0,0 +1,163 @@ +from typing import Union, List +from PyQt5.QtCore import QObject +from qgis.core import QgsVectorLayer, QgsWkbTypes, QgsGeometry, QgsProject, QgsFeature, QgsCoordinateReferenceSystem +from ..app_context import AppContext +from .. import layer_utils +from ...dialogs import MainDialog +from ...entity.provider import MyImageryProvider + +class AreaCalculatorService(QObject): + def __init__(self, + app_context: AppContext, + qgs_project: QgsProject, + dlg: MainDialog): + self.app_context = app_context + self.qgs_project = qgs_project + self.dlg = dlg + + def get_aoi_area_polygon_layer(self, layer: Union[QgsVectorLayer, None]) -> None: + if not layer or layer.featureCount() == 0: + 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: + reason = self.tr('Set AOI to start processing') + self.dlg.disable_processing_start(reason, clear_area=True) + self.app_context.aoi = self.app_context.aoi_size = None + return + + features = list(layer.getSelectedFeatures()) or list(layer.getFeatures()) + if QgsWkbTypes.flatType(layer.wkbType()) == QgsWkbTypes.Polygon: + geoms_count = len(features) + elif QgsWkbTypes.flatType(layer.wkbType()) == QgsWkbTypes.MultiPolygon: + geoms_count = layer_utils.count_polygons_in_layer(features) + else: # type of layer is not supported + # (but it shouldn't be the case, because point and line layers will not appear in AOI-combo, + # and collections are devided by QGIS into separate layers with different types) + raise ValueError("Only polygon and multipolyon layers supported for this operation") + if self.app_context.max_aois_per_processing >= geoms_count: + if len(features) == 1: + aoi = features[0].geometry() + else: + aoi = QgsGeometry.collectGeometry([feature.geometry() for feature in features]) + self.calculate_aoi_area(aoi, layer.crs()) + return aoi + else: # self.app_context.max_aois_per_processing < number of polygons (as features and as parts of multipolygons): + 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: + reason = self.tr('AOI must contain not more than {} polygons').format(self.app_context.max_aois_per_processing) + self.dlg.disable_processing_start(reason, clear_area=True) + self.app_context.aoi = self.app_context.aoi_size = None + + def calculate_aoi_area_polygon_layer(self, layer: Union[QgsVectorLayer, None]) -> None: + """Get the AOI size total when polygon another layer is chosen, + current layer's selection is changed or the layer's features are modified. + + :param layer: The current polygon layer + """ + self.get_aoi_area_polygon_layer(layer) + provider = self.app_context.provider + if isinstance(provider, MyImageryProvider): + self.calculate_aoi_area_catalog() + + def calculate_aoi_area_use_image_extent(self) -> None: + """Get the AOI size when the Use image extent checkbox is toggled. + + :param use_image_extent: The current state of the checkbox + """ + provider = self.providers[self.dlg.providerIndex()] + if isinstance(provider, MyImageryProvider): + self.calculate_aoi_area_catalog() + else: + self.calculate_aoi_area_polygon_layer(self.dlg.polygonCombo.currentLayer()) + + def calculate_aoi_area_catalog(self) -> None: + """Get the AOI size when a new mosaic or image in 'My imagery' is selected. + """ + # If different provider is chosen, set it to My imagery + self.data_catalog_service.set_catalog_provider(self.providers) + image = self.data_catalog_service.selected_image() + mosaic = self.data_catalog_service.selected_mosaic() + if image or mosaic: + self.use_imagery_extent.setEnabled(True) + if image: + catalog_aoi = QgsGeometry().fromWkt(image.footprint) + self.use_imagery_extent.setText(self.tr("Use extent of '{name}'").format(name=image.filename)) + else: + catalog_aoi = QgsGeometry().fromWkt(mosaic.footprint) + self.use_imagery_extent.setText(self.tr("Use extent of '{name}'").format(name=mosaic.name)) + aoi = layer_utils.get_catalog_aoi(catalog_aoi=catalog_aoi, + selected_aoi=self.app_context.aoi) + else: + aoi = self.get_aoi_area_polygon_layer(self.dlg.polygonCombo.currentLayer()) + self.use_imagery_extent.setText(self.tr("Use imagery extent")) + self.use_imagery_extent.setEnabled(False) + if not self.app_context.aoi: # other error message is already shown + pass + elif not aoi: # error after intersection + self.dlg.disable_processing_start(reason=self.tr("Selected AOI does not intersect the selected imagery"), + clear_area=True) + return + # Don't recalculate AOI if first selected mosaic/image didn't change + selected_mosaics = self.dlg.mosaicTable.selectedIndexes() + selected_images = self.dlg.imageTable.selectedIndexes() + if len(selected_mosaics) > 1 and self.dlg.selected_mosaic_cell == selected_mosaics[0] \ + or len(selected_images) > 1 and self.dlg.selected_image_cell == selected_images[0]: + return + self.calculate_aoi_area(aoi, helpers.WGS84) + + def calculate_aoi_area_selection(self, _: List[QgsFeature]) -> None: + """Get the AOI size when the selection changed on a polygon layer. + + :param _: A list of currently selected features + """ + layer = self.dlg.polygonCombo.currentLayer() + if layer == self.iface.activeLayer(): + self.calculate_aoi_area_polygon_layer(layer) + + def calculate_aoi_area_layer_edited(self) -> None: + """Get the AOI size when a feature is added or remove from a layer.""" + layer = self.sender() + if layer == self.dlg.polygonCombo.currentLayer(): + self.calculate_aoi_area_polygon_layer(layer) + + def calculate_aoi_area(self, aoi: QgsGeometry, crs: QgsCoordinateReferenceSystem) -> None: + """Display the AOI size in sq.km. + This is the only place where app_context.aoi is changed! This is important because it is the place where we + send request to update processing cost! + :param aoi: the processing area. + :param crs: the CRS of the processing area. + """ + if crs != helpers.WGS84: + aoi = helpers.to_wgs84(aoi, crs) + + self.app_context.aoi = aoi # save for reuse in processing creation or metadata requests + # fetch UI data + provider_index = self.dlg.providerIndex() + selected_images = self.dlg.metadataTable.selectedItems() + if selected_images: + rows = list(set(image.row() for image in selected_images)) + local_image_indices = [int(self.dlg.metadataTable.item(row, self.config.LOCAL_INDEX_COLUMN).text()) + for row in rows] + else: + local_image_indices = [] + # This is AOI with respect to selected Maxar images and raster image extent + try: + real_aoi = self.get_aoi(provider_index=provider_index, + local_image_indices=local_image_indices, + selected_aoi=self.app_context.aoi) + except ImageIdRequired: + # AOI is OK, but image ID is not selected, + # in this case we should use selected AOI without cut by AOI + real_aoi = self.app_context.aoi + except Exception as e: + # Could not calculate AOI size + real_aoi = QgsGeometry() + try: + self.app_context.aoi_size = layer_utils.calculate_aoi_area(real_aoi, + self.app_context.project.transformContext()) + except Exception as e: + self.app_context.aoi_size = 0 + + self.dlg.labelAoiArea.setText(self.tr('Area: {:.2f} sq.km').format(self.app_context.aoi_size)) + self.processing_service.update_processing_cost() diff --git a/mapflow/functional/service/processing_service.py b/mapflow/functional/service/processing_service.py index bc3a7adb..2405853f 100644 --- a/mapflow/functional/service/processing_service.py +++ b/mapflow/functional/service/processing_service.py @@ -1,51 +1,15 @@ import json from uuid import UUID -from dataclasses import dataclass, field -from typing import Set, Dict, Optional -from PyQt5.QtCore import QObject, pyqtSignal, Qt, pyqtSlot, QTimer -from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest +from typing import Dict, Optional, List +from PyQt5.QtCore import QObject, QTimer +from PyQt5.QtNetwork import QNetworkReply from PyQt5.QtWidgets import QMessageBox from ...dialogs.main_dialog import MainDialog from ...http import Http, api_message_parser from ..view.processing_view import ProcessingView from ..api.processing_api import ProcessingApi -from ...schema.processing import ProcessingDTO, UpdateProcessingSchema -from ...entity.status import ProcessingStatus -from ...entity.billing import BillingType -from ...entity.provider import MyImageryProvider, ImagerySearchProvider - -@dataclass -class ProcessingHistory: - """ - History of the processings for a specific project, including failed and finished processings, - that are stored in settings - """ - project_id: Optional[UUID] - failed: Set[UUID] = field(default_factory=set) - finished: Set[UUID] = field(default_factory=set) - other: Dict[UUID, ProcessingStatus] = field(default_factory=dict) - - def to_settings(self, settings): - settings.setValue(f"{self.project_id}", json.dumps({"failed": list(self.failed), "finished": list(self.finished)})) - - @classmethod - def from_settings(cls, settings, project_id: UUID): - data = json.loads(settings.value(f"{project_id}")) - return cls(project_id, set(data["failed"]), set(data["finished"])) - - def is_finished(self, processing_id: UUID): - return processing_id in self.finished - - def is_failed(self, processing_id: UUID): - return processing_id in self.failed - - def add(self, processing_id: UUID, status: ProcessingStatus): - if status.is_failed: - self.failed.add(processing_id) - elif status.is_ok: - self.finished.add(processing_id) - else: - self.other[processing_id] = status +from ...schema import ProcessingDTO, UpdateProcessingSchema, ProcessingStatus, BillingType, ProcessingHistory +from ..app_context import AppContext class ProcessingService(QObject): """ @@ -54,70 +18,57 @@ class ProcessingService(QObject): def __init__(self, http: Http, - server: str, dlg: MainDialog, iface, result_loader, - plugin_version, - temp_dir, + app_context: AppContext, settings, timer_interval): super().__init__() self.http = http - self.server = server self.iface = iface self.result_loader = result_loader - self.plugin_version = plugin_version - self.temp_dir = temp_dir + self.app_context = app_context + self.settings = settings self.view = ProcessingView(dlg=dlg) self.api = ProcessingApi(http=http, - server=server, dlg=dlg, iface=iface, - result_loader=self.result_loader, - plugin_version=self.plugin_version) - self.settings = settings + result_loader=self.result_loader) - self.current_project_id: Optional[str] = None self.processings = set() self.processings_history = None # ProcessingHistory() - local storage for active processings list self.processing_fetch_timer = QTimer(dlg) self.processing_fetch_timer.setInterval(timer_interval) self.deleting_processings = None + self.processing_cost = 0 - def set_current_project(self, project_id: str): + def load_processing_history(self): """ - Set the current project ID and optionally refresh processings. - This slot is connected to ProjectService.projectChanged signal. - - Args: - project_id: The ID of the project to set as current + Loads processing history to set up the notifications after the processings are fetched """ - if not project_id: - self.current_project_id = None - return - - self.current_project_id = project_id # Update processing history context for the new project - if self.current_project_id: + if self.app_context.current_project: try: self.processings_history = ProcessingHistory.from_settings( - self.settings, UUID(self.current_project_id) + self.settings, UUID(self.app_context.current_project.id) ) + print(f"Read processings history: {len(self.processings_history.processing_statuses)}") except (ValueError, KeyError): # If no history exists for this project, create new one - self.processings_history = ProcessingHistory(project_id=UUID(self.current_project_id)) - + self.processings_history = ProcessingHistory(project_id=UUID(self.app_context.current_project.id)) + print(f"Failed to read processings history: {len(self.processings_history.processing_statuses)}") + # Optionally refresh processings for the new project # self.get_processings() # Uncomment when this method is implemented - + # ================ CREATE ====================== # def validate_params(self, ui_start_params): pass def start_processing(self): """ - if self.project_id != 'default': + if self.project_id != 'default': request_body.projectId = self.project_id """ @@ -128,7 +79,8 @@ def start_processing(self): provider = self.get_data_provider(ui_start_params) source_params = provider.source_params() # gather all the other logic - self.http.post() + self.api.create_processing() + def start_processing_callback(self, response: QNetworkReply) -> None: """Display a success message and clear the processing name field.""" @@ -151,12 +103,17 @@ def start_processing_error_handler(self): self.processing_fetch_timer.start() self.alert(self.tr("Failed to start processing"), QMessageBox.Warning) + # ============= REQUEST ================= # + def setup_processings_table(self): + if not self.app_context.current_project: + return + self.view.set_table_loading() + self.get_processings() + self.processing_fetch_timer.start() + def get_processings(self): - project_id = None - # todo: get real project id. - # - signal from ProjectService? - # - initialize ProjcessingService with a ProjectService? - self.api.get_processings(project_id=project_id, + # Fetch processings at startup and start the timer to keep fetching them afterwards + self.api.get_processings(project_id=self.app_context.current_project.id, callback=self.get_processings_callback) def get_processings_callback(self, response: QNetworkReply): @@ -166,9 +123,7 @@ def get_processings_callback(self, response: QNetworkReply): """ response_data = json.loads(response.readAll().data()) processings = [ProcessingDTO.from_dict(entry) for entry in response_data] - if all(not (p.status.is_in_progress or p.status.is_awaiting) - and p.review_status.is_not_accepted - for p in processings): + if all(p.is_final_state for p in processings): # We do not re-fetch the processings, if nothing is going to change. # What can change from server-side: processing can finish if IN_PROGRESS or AWAITING # or review can be accepted if NOT_ACCEPTED. @@ -177,9 +132,64 @@ def get_processings_callback(self, response: QNetworkReply): self.processings = {processing.id: processing for processing in processings} self.update_local_processings(processings) - def save_processing(self, new_processing): - # add new processing status to settings - self.settings.setValue("", "") + def update_local_processings(self, processings: List[ProcessingDTO]): + """ + Update local processing history and notify user of status changes. + + Args: + processings: List of ProcessingDTO from server + """ + if not self.processings_history: + return + + # Convert list to dict for history update + processings_dict = {p.id: p for p in processings} + + # Update history and get report of terminal status changes + print(f"In update_local_processings: {len(self.processings_history.processing_statuses)}") + terminal_changes = self.processings_history.update(processings_dict, self.settings) + + # Notify user of newly completed/failed processings + self._notify_status_changes(terminal_changes, processings_dict) + + # Update the view + self.view.update_processing_table(processings) + + def _notify_status_changes(self, + terminal_changes: Dict[str, List[UUID]], + processings: Dict[UUID, ProcessingDTO]) -> None: + """ + Notify user about processing status changes. + + Args: + terminal_changes: Dict of status -> list of processing IDs + processings: Dict of all current processings for name lookup + """ + # Notify about completed processings + finished_ids = terminal_changes.get(ProcessingStatus.ok.value, []) + for pid in finished_ids: + processing = processings.get(pid) + if processing: + self.iface.messageBar().pushSuccess( + self.tr("Processing completed"), + self.tr("Processing '{name}' has finished successfully").format(name=processing.name) + ) + + # Notify about failed processings + failed_ids = terminal_changes.get(ProcessingStatus.failed.value, []) + for pid in failed_ids: + processing = processings.get(pid) + if processing: + self.iface.messageBar().pushWarning( + self.tr("Processing failed"), + self.tr("Processing '{name}' has failed").format(name=processing.name) + ) + + def save_processing(self, new_processing: ProcessingDTO): + """Add new processing status to history and persist to settings.""" + if self.processings_history: + self.processings_history.add(new_processing.id, new_processing.status) + self.processings_history.to_settings(self.settings) def update_processing(self, processing_id: UUID, processing: UpdateProcessingSchema): self.api.update_processing(processing_id=processing_id, processing=processing, callback=self.update_processing_callback) @@ -190,46 +200,42 @@ def update_processing_callback(self, response: QNetworkReply): self.view.update_processing_name(processing_id=processing.id, new_name=processing.name) # Processing cost - def update_processing_cost(self, aoi, workflow_defs): + def update_processing_cost(self): + """Update the processing cost based on current AOI and workflow. + + Uses app_context for: aoi, workflow_defs, user_role, billing_type + """ + aoi = self.app_context.aoi + workflow_defs = self.app_context.workflow_defs + user_role = self.app_context.user_role + if not aoi: # Here the button must already be disabled, and the warning text set - if self.dlg.startProcessing.isEnabled(): - if not self.user_role.can_start_processing: - reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) + if self.view.dlg.startProcessing.isEnabled(): + if not user_role.can_start_processing: + reason = self.tr('Not enough rights to start processing in a shared project ({})').format(user_role.value) else: reason = self.tr("Set AOI to start processing") self.view.disable_processing_start(reason, clear_area=False) - elif not self.workflow_defs: - self.dlg.disable_processing_start(reason=self.tr("Error! Models are not initialized.\n" + elif not workflow_defs: + self.view.disable_processing_start(reason=self.tr("Error! Models are not initialized.\n" "Please, make sure you have selected a project"), clear_area=True) - elif self.billing_type != BillingType.credits: - self.dlg.startProcessing.setEnabled(True) - self.dlg.processingProblemsLabel.clear() - request_body, error = self.create_processing_request(allow_empty_name=True) - else: # self.billing_type == BillingType.credits: f - provider = self.providers[self.dlg.providerIndex()] - request_body, error = self.create_processing_request(allow_empty_name=True) - if not request_body: - self.dlg.disable_processing_start(self.tr("Processing cost is not available:\n" - "{error}").format(error=error)) - elif isinstance(provider, ImagerySearchProvider) and \ - not self.dlg.metadataTable.selectionModel().hasSelection(): - self.dlg.disable_processing_start(self.tr("This provider requires image ID. " - "Use search tab to find imagery for you requirements, " - "and select image in the table.")) - elif isinstance(provider, MyImageryProvider) and\ - not self.dlg.mosaicTable.selectionModel().hasSelection(): - self.dlg.disable_processing_start(reason=self.tr('Choose imagery to start processing')) - else: - if self.user_role.can_start_processing: - self.http.post( - url=f"{self.server}/processing/cost/v2", - callback=self.calculate_processing_cost_callback, - body=request_body.as_json().encode(), - use_default_error_handler=False, - error_handler=self.clear_processing_cost - ) + elif self.app_context.billing_type != BillingType.credits: + self.view.dlg.startProcessing.setEnabled(True) + self.view.dlg.processingProblemsLabel.clear() + else: # billing_type == BillingType.credits + # TODO: This method needs access to providers and create_processing_request + # which are currently in Mapflow class. Consider: + # 1. Moving provider logic to a ProviderService + # 2. Passing a callback for request creation + # 3. Moving this entire cost calculation to Mapflow + pass + + def calculate_processing_cost_callback(self, response: QNetworkReply): + self.processing_cost = int(response.readAll().data().decode()) + self.view.set_processing_cost(self.processing_cost) + def disable_processing_start(self, response: QNetworkReply): """ @@ -241,8 +247,8 @@ 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) - if not self.user_role.can_start_processing: - reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) + 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: reason = self.tr('Processing cost is not available:\n{message}').format(message=message) self.view.disable_processing_start(reason, clear_area=False) @@ -275,7 +281,6 @@ def _finalize_processing_delete(self): self.processing_fetch_timer.start() self.deleting_processings = None - def delete_processings_callback(self, _: QNetworkReply, processing_id: str) -> None: @@ -302,3 +307,18 @@ def delete_processings_error_handler(self, else: self._finalize_processing_delete() + def stop(self): + self.processing_fetch_timer.stop() + self.processing_fetch_timer.deleteLater() + + def selected_processings(self, limit=None) -> List[ProcessingDTO]: + pids = self.view.selected_processing_ids(limit=limit) + # limit None will give full selection + selected_processings = [self.processings[pid] for pid in filter(lambda pid: pid in pids, self.processings)] + return selected_processings + + def selected_processing(self) -> Optional[ProcessingDTO]: + first = self.selected_processings(limit=1) + if not first: + return None + return first[0] diff --git a/mapflow/functional/service/project_service.py b/mapflow/functional/service/project_service.py index 53ff570c..345367ff 100644 --- a/mapflow/functional/service/project_service.py +++ b/mapflow/functional/service/project_service.py @@ -1,32 +1,36 @@ import json -from typing import Optional, Callable +from typing import Optional, Callable, List -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtCore import QObject, pyqtSignal, Qt from PyQt5.QtNetwork import QNetworkReply from PyQt5.QtWidgets import QAbstractItemView - -from ...dialogs.main_dialog import MainDialog +from qgis.core import QgsSettings +from .. import helpers +from ..app_context import AppContext +from ...dialogs import MainDialog from ...config import Config -from ...schema.project import CreateProjectSchema, UpdateProjectSchema, MapflowProject, ProjectsRequest, ProjectsResult, ProjectSortBy, ProjectSortOrder +from ...schema.project import CreateProjectSchema, UpdateProjectSchema, MapflowProject, ProjectsRequest, ProjectsResult, \ + ProjectSortBy, ProjectSortOrder, UserRole from ..api.project_api import ProjectApi from ..view.project_view import ProjectView class ProjectService(QObject): projectsUpdated = pyqtSignal() - projectChanged = pyqtSignal(str) # Emits project_id when project selection changes + projectsFiltered = pyqtSignal() - def __init__(self, http, server, settings, dlg: MainDialog): + def __init__(self, http, app_context: AppContext, settings: QgsSettings, dlg: MainDialog, config: Config, area_calc_fn): super().__init__() self.http = http - self.server = server self.settings = settings + self.app_context = app_context self.dlg = dlg - self.api = ProjectApi(self.http, self.server) + self.config = config + self.api = ProjectApi(self.http) self.view = ProjectView(self.dlg) - self.projects_data = {} - self.projects = [] - self.project_id = None + self.projects_data = None + self.projects = {} + self.app_context.project_id = None self.projects_page_limit = Config.PROJECTS_PAGE_LIMIT self.projects_page_offset = 0 # Connections @@ -34,14 +38,23 @@ def __init__(self, http, server, settings, dlg: MainDialog): self.dlg.projectsPreviousPageButton.clicked.connect(self.show_projects_previous_page) self.dlg.filterProjects.textEdited.connect(self.get_filtered_projects) self.dlg.sortProjectsCombo.activated.connect(self.get_projects) - + # TODO: move area calculation to area_calculator_service + self.calculate_aoi_area_use_image_extent = area_calc_fn + + def set_current_project(self, project: MapflowProject): + self.app_context.project_id = project.id + self.app_context.current_project = project + if project.shareProject: + self.app_context.user_role = project.shareProject.get_user_role(self.app_context.username) + else: + self.app_context.user_role = UserRole.owner + def create_project(self, project: CreateProjectSchema): self.api.create_project(project, self.create_project_callback) def create_project_callback(self, response: QNetworkReply): project = MapflowProject.from_dict(json.loads(response.readAll().data())) - self.project_id = project.id - self.projectChanged.emit(str(project.id)) + self.set_current_project(project) self.get_projects() def delete_project(self, project_id): @@ -59,13 +72,35 @@ def update_project(self, project_id, project: UpdateProjectSchema): def update_project_callback(self, response: QNetworkReply): project = MapflowProject.from_dict(json.loads(response.readAll().data())) - self.project_id = project.id - self.projectChanged.emit(str(project.id)) + self.set_current_project(project) self.get_projects() def get_project(self, project_id, callback: Callable, error_handler: Callable): self.api.get_project(project_id, callback, error_handler) - + + def get_project_callback(self, response: QNetworkReply): + self.app_context.current_project = MapflowProject.from_dict(json.loads(response.readAll().data())) + if self.app_context.current_project: + self.app_context.project_id = self.app_context.current_project.id + elided_name = self.dlg.currentProjectLabel.fontMetrics().elidedText( + self.app_context.current_project.name, + Qt.ElideRight, + self.dlg.currentProjectLabel.width() - 50) + self.dlg.currentProjectLabel.setText(self.tr("Project: {}").format(elided_name)) + self.dlg.currentProjectLabel.adjustSize() + self.get_project_sharing() + self.project_service.setup_project_change_rights() + self.settings.setValue("project_id", self.app_context.project_id) + self.view.setup_workflow_defs(self.app_context.current_project.workflowDefs) + # Manually toggle function to avoid race condition + # TODO: Can we avoid this? Calling the function from here is ugly + self.calculate_aoi_area_use_image_extent() + + def get_project_error_handler(self, response: QNetworkReply): + self.default_error_handler(response) + # Switch to projects table if couldn't get current project + self.project_processing_controller.show_projects() + def get_projects(self, open_saved_page: Optional[bool] = False): """Get projects depending on page parameters (offset, sorting, filtering). @@ -125,9 +160,9 @@ def get_projects(self, open_saved_page: Optional[bool] = False): def get_projects_callback(self, response: QNetworkReply): self.projects_data = ProjectsResult.from_dict(json.loads(response.readAll().data())) - self.projects = [MapflowProject.from_dict(project) for project in self.projects_data.results] + self.projects = {project.id: project for project in self.projects_data.results} self.dlg.projectsTable.setSortingEnabled(False) # temporary disable sorting by clicking the header - self.view.setup_projects_table(self.projects) + self.view.setup_projects_table(self.projects_data.results) # En(dis)able page controls based on total, limit and offset if self.projects_data.total > self.projects_page_limit: quotient, remainder = divmod(self.projects_data.total, self.projects_page_limit) @@ -143,7 +178,7 @@ def get_projects_callback(self, response: QNetworkReply): self.projectsUpdated.emit() self.dlg.projectsTable.clearSelection() self.dlg.projectsTable.setSelectionMode(QAbstractItemView.SingleSelection) - self.select_project(self.project_id) + self.select_project(self.app_context.project_id) def select_project(self, project_id): self.view.select_project(project_id) @@ -155,41 +190,6 @@ def show_projects_next_page(self): def show_projects_previous_page(self): self.projects_page_offset += -self.projects_page_limit self.get_projects() - - def switch_to_projects(self, open_saved_page: Optional[bool] = False): - """Get projects and switch from processings to projects table in stacked widget. - - Allows to open saved projects page even after reload. - But we don't need to do that when e.g. we are switching to a different projects page. - - :param open_saved_page: A boolean that determines if we should get projects page from the settings or not. - """ - self.get_projects(open_saved_page) - self.view.switch_to_projects() - - def switch_to_processings(self, - save_page: Optional[bool] = False, - project_id: Optional[int] = None): - """Switch from projects to processings table in stacked widget. - - Allows to remember current projects page before switching (to later reopen it even after reload). - But we only want to remember it if user chose a project (not when switching if no id was saved). - - :param save_page: A boolean that determines if we should save projects page parameters to settings or not. - """ - if save_page: - # Save current offset, sorting and filter - sort_by, sort_order = self.view.sort_projects() - projects_filter = self.dlg.filterProjects.text() - projects_page = {'offset' : self.projects_page_offset, - 'sort_by' : sort_by, - 'sort_order' : sort_order, - 'filter': projects_filter} - self.settings.setValue('projectsPage', projects_page) - self.view.switch_to_processings() - self.project_id = project_id - if project_id: - self.projectChanged.emit(str(project_id)) def get_filtered_projects(self): """Get projects, resetting filtered offset value. @@ -198,5 +198,99 @@ def get_filtered_projects(self): So each time offset resets to 0 to show 1st page of newly filtered response. """ self.projects_page_offset = 0 - self.project_id = None + self.app_context.project_id = None self.get_projects() + + # ========== Projects ========== # + + def on_project_change(self): + selected_id = self.dlg.selected_project_id() + if selected_id is not None and selected_id == self.app_context.project_id and self.app_context.workflow_defs: + # we look at workflow defs because if they are NOT initialized, it means that the project + # is not initialized yet (at plugin's startup) and we still need to set it up + # otherwise, if the WDs are set, we assume that the project hasn't changed and skip further setup + return + if selected_id is None: + self.app_context.current_project = self.app_context.project_id = None + self.settings.setValue("project_id", None) + self.setup_project_change_rights() + self.dlg.setWindowTitle(helpers.generate_plugin_header(self.app_context.plugin_name, + env=self.config.MAPFLOW_ENV)) + self.dlg.switchProcessingsButton.setEnabled(False) + else: + self.dlg.switchProcessingsButton.setEnabled(True) + # Find project in projects/page and set as current + self.app_context.project_id = selected_id + for pid, project in self.projects.items(): + if selected_id == pid: + self.app_context.current_project = project + elided_name = self.dlg.currentProjectLabel.fontMetrics().elidedText( + self.app_context.current_project.name, + Qt.ElideRight, + self.dlg.currentProjectLabel.width() - 50) + self.dlg.currentProjectLabel.setText(self.tr("Project: {}").format(elided_name)) + if self.app_context.current_project: + self.get_project_sharing() + self.view.setup_workflow_defs(self.app_context.current_project.workflowDefs, + self.config.DEFAULT_MODEL) + self.setup_project_change_rights() + self.settings.setValue("project_id", self.app_context.project_id) + + # Manually toggle function to avoid race condition + # TODO: Can we avoid this? Calling the function from here is ugly + self.calculate_aoi_area_use_image_extent() + + + def setup_project_change_rights(self): + project_editable = True + if not self.app_context.current_project: + project_editable = False + reason = self.tr("No project selected") + elif self.app_context.current_project.isDefault: + reason = self.tr("You can't remove or modify default project") + project_editable = False + elif not self.app_context.user_role.can_delete_rename_project: + reason = self.tr('Not enough rights to delete or update shared project ({})').format( + self.app_context.user_role.value) + else: + reason = "" + self.dlg.enable_project_change(reason, + project_editable and self.app_context.user_role.can_delete_rename_project) + + def update_projects(self): + if not self.projects: + self.view.clear_projects_table() + self.filter_projects(self.view.projects_filter) + if self.app_context.project_id: + self.project_service.select_project(self.app_context.project_id) + + def filter_projects(self, name_filter): + if not name_filter: + filtered_projects = self.projects + else: + filtered_projects = {pid: p for pid, p in self.projects.items() if name_filter.lower() in p.name.lower()} + if self.app_context.project_id in self.projects \ + and self.app_context.project_id not in filtered_projects: + # We maintain the current project in the combo even if it not found to prevent over-requesting + # until it is changed explicitly + filtered_projects.update({self.app_context.project_id: self.projects[self.app_context.project_id]}) + self.projectsFiltered.emit() + + def get_project_sharing(self): + if not self.app_context.current_project: + return + if self.app_context.current_project.shareProject: + # Get user role, if project is shared + self.app_context.user_role = self.app_context.current_project.shareProject.get_user_role(self.app_context.username) + project_owner = self.app_context.current_project.shareProject.owners[0].email + # Disable buttons + self.dlg.enable_shared_project(self.app_context.user_role) + else: + self.app_context.user_role = UserRole.owner + project_owner = self.app_context.username + # Specify new main window header + self.dlg.setWindowTitle(helpers.generate_plugin_header(self.app_context.plugin_name, + env=self.config.MAPFLOW_ENV, + project_name=self.app_context.current_project.name, + user_role=self.app_context.user_role, + project_owner=project_owner)) diff --git a/mapflow/functional/view/processing_view.py b/mapflow/functional/view/processing_view.py index 7f8ec00e..90296ade 100644 --- a/mapflow/functional/view/processing_view.py +++ b/mapflow/functional/view/processing_view.py @@ -1,11 +1,12 @@ -from typing import List +from typing import List, Optional from uuid import UUID from PyQt5.QtCore import Qt, QCoreApplication -from PyQt5.QtWidgets import QAbstractItemView, QTableWidgetItem +from PyQt5.QtWidgets import QAbstractItemView, QTableWidgetItem, QMessageBox from PyQt5.QtGui import QColor from ...dialogs.main_dialog import MainDialog from ...schema.processing import ProcessingDTO, ProcessingUIParams from ...config import config +from ..service.alert_service import alert class ProcessingView: """ @@ -38,7 +39,7 @@ def read_processing_start_params(self): name = self.dlg.processingName or None, wd_name = self.dlg.modelCombo.currentText(), data_source_index = self.dlg.providerIndex(), - + # todo: add other params ) def clear_processing_name(self, name): @@ -53,7 +54,7 @@ def disable_processing_start(self, reason: str, clear_area: bool): def create_table_items(self, processing: ProcessingDTO): table_items = [] set_color = False - processing_dict = processing.as_dict() + processing_dict = processing.as_processing_table_dict() if processing.status.is_ok and processing.review_expires: # setting color for close review set_color = True @@ -63,11 +64,11 @@ def create_table_items(self, processing: ProcessingDTO): table_item.setData(Qt.DisplayRole, processing_dict[attr]) if processing.status.is_failed: table_item.setToolTip(processing.error_message(raw=config.SHOW_RAW_ERROR)) - elif processing.in_review_until: + 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.in_review_until.strftime('%Y-%m-%d %H:%M') if processing.in_review_until else "")) + processing.reviewUntil.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." )) @@ -76,10 +77,10 @@ def create_table_items(self, processing: ProcessingDTO): table_items.append(table_item) return table_items - def update_processing_table(self, processings: dict[UUID, ProcessingDTO]): + def update_processing_table(self, processings: List[ProcessingDTO]): # UPDATE THE TABLE # Memorize the selection to restore it after table update - selected_processings = self.dlg.selected_processing_ids() + selected_processings = self.selected_processing_ids() # Explicitly clear selection since resetting row count won't do it self.dlg.processingsTable.clearSelection() # Temporarily enable multi selection so that selectRow won't clear previous selection @@ -88,7 +89,7 @@ def update_processing_table(self, processings: dict[UUID, ProcessingDTO]): self.dlg.processingsTable.setSortingEnabled(False) self.dlg.processingsTable.setRowCount(len(processings)) # Fill out the table - for row, proc in enumerate(processings.values()): + for row, proc in enumerate(processings): table_items = self.create_table_items(processing=proc) for col, item in enumerate(table_items): self.dlg.processingsTable.setItem(row, col, item) @@ -140,8 +141,87 @@ def update_processing_name(self, processing_id: str, new_name: str) -> bool: # Processing ID not found in table return False + def set_table_loading(self): + table_item = QTableWidgetItem(self.tr("Loading...")) + table_item.setToolTip(self.tr('Fetching your processings from server, please wait')) + self.dlg.processingsTable.setRowCount(1) + self.dlg.processingsTable.setItem(0, 0, table_item) + for column in range(1, self.dlg.processingsTable.columnCount()): + empty_item = QTableWidgetItem("") + self.dlg.processingsTable.setItem(0, column, empty_item) + + def delete_processings_from_table(self, processing_ids): rows = [self.dlg.processingsTable.findItems(id_, Qt.MatchExactly)[0].row() for id_ in processing_ids] rows.sort(reverse=True) for row in rows: self.dlg.processingsTable.removeRow(row) + + def set_processing_cost(self, cost: int): + self.dlg.processingProblemsLabel.setPalette(self.dlg.default_palette) + self.dlg.processingProblemsLabel.setText(self.tr("Processsing cost: {cost} credits").format(cost=cost)) + self.dlg.startProcessing.setEnabled(True) + + def alert_failed_processings(self, failed_processings): + if not failed_processings: + return + # this means that some of processings have failed since last update and the limit must have been returned + if len(failed_processings) == 1: + proc = failed_processings[0] + alert( + proc.name + + self.tr(' failed with error:\n') + proc.error_message(self.config.SHOW_RAW_ERROR), + QMessageBox.Critical, + blocking=False) + elif 1 < len(failed_processings) < 10: + # If there are more than one failed processing, we will not + alert(self.tr('{} processings failed: \n {} \n ' + 'See tooltip over the processings table' + ' for error details').format(len(failed_processings), + '\n'.join((proc.name for proc in failed_processings))), + QMessageBox.Critical, + blocking=False) + else: # >= 10 + alert(self.tr( + '{} processings failed: \n ' + 'See tooltip over the processings table for error details').format(len(failed_processings)), + QMessageBox.Critical, + blocking=False) + + def alert_finished_processings(self, finished_processings): + if not finished_processings: + return + if len(finished_processings) == 1: + # Print error message from first failed processing + proc = finished_processings[0] + alert( + proc.name + + self.tr(' finished. Double-click it in the table to download the results.'), + QMessageBox.Information, + blocking=False # don't repeat if user doesn't close the alert + ) + elif 1 < len(finished_processings) < 10: + # If there are more than one failed processing, we will not + alert(self.tr( + '{} processings finished: \n {} \n ' + 'Double-click it in the table ' + 'to download the results').format(len(finished_processings), + '\n'.join((proc.name for proc in finished_processings))), + QMessageBox.Information, + blocking=False) + else: # >= 10 + alert(self.tr( + '{} processings finished. \n ' + 'Double-click it in the table to download the results').format(len(finished_processings)), + QMessageBox.Information, + blocking=False) + + def selected_processing_ids(self, limit=None): + # add unique selected rows + selected_rows = list(set(index.row() for index in self.dlg.processingsTable.selectionModel().selectedIndexes())) + if not selected_rows: + return [] + pids = [self.dlg.processingsTable.item(row, + config.PROCESSING_TABLE_ID_COLUMN_INDEX).text() + for row in selected_rows[:limit]] + return pids diff --git a/mapflow/functional/view/project_view.py b/mapflow/functional/view/project_view.py index 765dd195..a4fc0502 100644 --- a/mapflow/functional/view/project_view.py +++ b/mapflow/functional/view/project_view.py @@ -1,9 +1,12 @@ +from typing import List + from PyQt5.QtCore import QObject, Qt from PyQt5.QtWidgets import QWidget, QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView from ...dialogs.main_dialog import MainDialog from ...dialogs import icons from ...config import ConfigColumns +from ...schema import WorkflowDef from ...schema.project import MapflowProject, ProjectSortBy, ProjectSortOrder @@ -54,7 +57,7 @@ def enable_projects_pages(self, enable: bool = False): self.dlg.projectsNextPageButton.setEnabled(enable) self.dlg.projectsPreviousPageButton.setEnabled(enable) - def setup_projects_table(self, projects: dict[str, MapflowProject]): + def setup_projects_table(self, projects: List[MapflowProject]): if not projects: return # First column is ID, hidden; second is name @@ -63,7 +66,7 @@ def setup_projects_table(self, projects: dict[str, MapflowProject]): self.dlg.projectsTable.setRowCount(len(projects)) self.dlg.projectsTable.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.dlg.projectsTable.setSelectionBehavior(QAbstractItemView.SelectRows) - for row, project in enumerate(projects.values()): + for row, project in enumerate(projects): id_item = QTableWidgetItem() id_item.setData(Qt.DisplayRole, project.id) self.dlg.projectsTable.setItem(row, 0, id_item) @@ -96,6 +99,16 @@ def setup_projects_table(self, projects: dict[str, MapflowProject]): self.dlg.projectsTable.setSelectionMode(QAbstractItemView.SingleSelection) self.dlg.projectsTable.setSortingEnabled(True) # enable sorting by header click + def clear_projects_table(self): + self.dlg.projectsTable.clear() + # Add a row with an error message to projects table + table_item = QTableWidgetItem(self.tr("No project that meets specified criteria was found")) + self.dlg.projectsTable.setRowCount(1) + self.dlg.projectsTable.setColumnCount(2) + self.dlg.projectsTable.setItem(0, 1, table_item) + self.dlg.projectsTable.setHorizontalHeaderLabels(["ID", self.tr("Project")]) + return + def select_project(self, project_id): try: item = self.dlg.projectsTable.findItems(project_id, Qt.MatchExactly)[0] @@ -131,3 +144,16 @@ def sort_projects(self): else: # Z-A, Newest first, Updated recently sort_order = ProjectSortOrder.descending return sort_by.value, sort_order.value + + def setup_workflow_defs(self, + workflow_defs: dict[str, WorkflowDef], + default_model_name, + ): + self.dlg.modelCombo.clear() + self.dlg.modelCombo.addItems(wd.name for wd in workflow_defs.values()) + self.dlg.modelCombo.setCurrentText(default_model_name) + self.dlg.modelCombo.activated.emit(self.dlg.modelCombo.currentIndex()) # ?? + + @property + def projects_filter(self): + return self.dlg.filterProjects.text() \ No newline at end of file diff --git a/mapflow/http.py b/mapflow/http.py index 56e580fc..7a530994 100644 --- a/mapflow/http.py +++ b/mapflow/http.py @@ -14,12 +14,14 @@ class Http(QObject): """""" def __init__(self, + server: str, plugin_version: str, default_error_handler: Callable) -> None: """ oauth_id is defined if we are using oauth2 configuration """ self.oauth_id = None + self.server = server self.plugin_version = plugin_version self._basic_auth = b'' self._oauth = None @@ -120,7 +122,8 @@ def authorize(self, request: QNetworkRequest, auth: Optional[bytes] = None): def send_request( self, method: Callable, - url: str, + url: Optional[str] = None, + path: Optional[str] = None, headers: dict = None, auth: bytes = None, callback: Callable = None, @@ -132,6 +135,14 @@ def send_request( body: Union[QHttpMultiPart, bytes] = None ) -> QNetworkReply: """Send an actual request.""" + if url is not None and path is not None: + raise ValueError("Only one of url/path can be specified") + elif url is None and path is None: + raise ValueError("url or path must be specified") + elif path is not None: + # relative path that is bound to the self.server, allowes to NOT repeat the server loaction + url = f"{self.server}/{path.lstrip('/')}" + request = QNetworkRequest(QUrl(url)) if isinstance(body, bytes): request.setHeader(QNetworkRequest.ContentTypeHeader, 'application/json') diff --git a/mapflow/mapflow.py b/mapflow/mapflow.py index d2d08d2e..25be40b8 100644 --- a/mapflow/mapflow.py +++ b/mapflow/mapflow.py @@ -27,18 +27,15 @@ MapflowLoginDialog, ErrorMessageWidget, ProviderDialog, - ReviewDialog, - CreateProjectDialog, - UpdateProjectDialog, - UpdateProcessingDialog, + ReviewDialog ) from .dialogs.confirm_processing_start_dialog import ConfirmProcessingStartDialog from .dialogs.processing_details_dialog import ProcessingDetailsDialog from .dialogs.icons import plugin_icon +from .functional.app_context import AppContext from .functional.controller.data_catalog_controller import DataCatalogController +from .functional.controller.processing_controller import ProjectProcessingController from .config import Config, ConfigColumns -from .entity.billing import BillingType -from .entity.processing import parse_processings_request, Processing, ProcessingHistory, updated_processings from .entity.provider import (UsersProvider, MaxarProvider, ProvidersList, @@ -58,9 +55,9 @@ from .functional.auth import get_auth_id from .functional.geometry import clip_aoi_to_image_extent from .functional.service import ProcessingService, ProjectService, DataCatalogService +from .functional.service.alert_service import alert, AlertService from .http import (Http, get_error_report_body, - data_catalog_message_parser, securewatch_message_parser, api_message_parser, ) @@ -70,9 +67,12 @@ ImageCatalogRequestSchema, ImageCatalogResponseSchema, PostProcessingSchemaV2, - WorkflowDef) -from .schema.catalog import PreviewType, ProductType -from .schema.project import MapflowProject, UserRole + WorkflowDef, + BillingType, + PreviewType, + ProductType, + MapflowProject, + UserRole) class Mapflow(QObject): @@ -84,35 +84,17 @@ def __init__(self, iface) -> None: :param iface: an instance of the QGIS interface. """ # init configs - self.search_footprints = dict() self.config = Config() + self.app_context = AppContext() + # Set max_aois_per_processing from config as default + self.app_context.max_aois_per_processing = self.config.MAX_AOIS_PER_PROCESSING # init empty params - self.max_aois_per_processing = self.config.MAX_AOIS_PER_PROCESSING - self.aoi_size = None - self.aoi = None - self.metadata_aoi = None - self.metadata_layer = None - self.search_provider = None - self.is_admin = False - self.aoi_area_limit = 0.0 - self.username = self.password = '' self.version_ok = True - self.remaining_limit = 0 - self.remaining_credits = 0 - self.billing_type = BillingType.area - self.review_workflow_enabled = False - self.processing_cost = 0 # Save refs to key variables used throughout the plugin self.iface = iface self.main_window = self.iface.mainWindow() - self.workflow_defs = {} - self.current_project = None - self.user_role = UserRole.owner - self.aoi_layers = [] - self.preview_dict = {} self.project_connection = None super().__init__(self.main_window) - self.project = QgsProject.instance() self.message_bar = self.iface.messageBar() self.plugin_dir = os.path.dirname(__file__) self.plugin_name = self.config.PLUGIN_NAME # aliased here to be overloaded in submodules @@ -120,9 +102,13 @@ def __init__(self, iface) -> None: self.settings = QgsSettings() # Get the server environment to connect to (for admins) self.server = self.config.SERVER - self.layer_tree_root = self.project.layerTreeRoot() - # Set up authentication flags - self.logged_in = False + + # Populate infrastructure in app_context + self.app_context.server = self.server + self.app_context.project = QgsProject.instance() + self.app_context.plugin_name = self.plugin_name + + self.layer_tree_root = self.app_context.project.layerTreeRoot() # Init toolbar and toolbar buttons self.toolbar = self.iface.addToolBar(self.plugin_name) self.toolbar.setObjectName(self.plugin_name) @@ -143,15 +129,8 @@ def __init__(self, iface) -> None: if self.settings.value('processings') is None: self.settings.setValue('processings', {}) # Set projectsfrom settings if it was opened before - self.project_id = self.settings.value("project_id") - self.projects = {} + self.app_contextproject_id = self.settings.value("project_id") # Store user's current processing - self.processing_history = ProcessingHistory.from_settings( - self.settings.value('processings', {}) - .get(self.config.MAPFLOW_ENV, {}) - .get(self.username, {}) - .get(self.project_id, {})) - self.processings = [] # Imagery search pagination self.search_page_offset = 0 self.search_page_limit = self.config.SEARCH_RESULTS_PAGE_LIMIT @@ -172,12 +151,14 @@ def __init__(self, iface) -> None: # todo: Move to Maindialog metadata_parser = ConfigParser() metadata_parser.read(os.path.join(self.plugin_dir, 'metadata.txt')) - self.plugin_version = metadata_parser.get('general', 'version') + self.app_context.plugin_version = metadata_parser.get('general', 'version') self.dlg.help.setText( - self.dlg.help.text().replace('Mapflow', f'{self.plugin_name} {self.plugin_version}', 1) + self.dlg.help.text().replace('Mapflow', f'{self.plugin_name} {self.app_context.plugin_version}', 1) ) # Initialize HTTP request sender - self.http = Http(self.plugin_version, self.default_error_handler) + self.http = Http(self.app_context.server, + self.app_context.plugin_version, + self.default_error_handler) self.calculator = QgsDistanceArea() # RESTORE LATEST FIELD VALUES & OTHER ELEMENTS STATE self.dlg.outputDirectory.setText(self.settings.value('outputDir')) @@ -185,39 +166,46 @@ def __init__(self, iface) -> None: # Setup temporary directory from setting or skip for now self.temp_dir = None self.setup_tempdir() + self.app_context.temp_dir = self.temp_dir # Initialize services + # Initialize AlertService singleton + AlertService(self.plugin_name) self.result_loader = layer_utils.ResultsLoader(iface=self.iface, maindialog=self.dlg, http=self.http, - server=self.server, - project=self.project, settings=self.settings, - plugin_name=self.plugin_name, - temp_dir=self.temp_dir + context=self.app_context ) - self.data_catalog_service = DataCatalogService(self.http, - self.server, - self.dlg, - self.iface, - self.result_loader, - self.plugin_version, - self.temp_dir) + self.data_catalog_service = DataCatalogService(http=self.http, + server=self.server, + dlg=self.dlg, + iface=self.iface, + result_loader=self.result_loader, + plugin_version=self.app_context.plugin_version, + temp_dir=self.temp_dir) self.data_catalog_controller = DataCatalogController(self.dlg, self.data_catalog_service) - self.project_service = ProjectService(self.http, self.server, self.settings, self.dlg) - self.project_service.projectsUpdated.connect(self.update_projects) - - self.processing_service = ProcessingService(self.http, - self.server, - self.dlg, - self.iface, - self.result_loader, - self.plugin_version, - self.temp_dir, - self.settings, - self.config.PROCESSING_TABLE_REFRESH_INTERVAL * 1000) + self.project_service = ProjectService(http=self.http, + app_context=self.app_context, + settings=self.settings, + dlg=self.dlg, + config=self.config, + area_calc_fn=self.calculate_aoi_area_use_image_extent) + + self.processing_service = ProcessingService(http=self.http, + dlg=self.dlg, + iface=self.iface, + result_loader=self.result_loader, + app_context=self.app_context, + settings=self.settings, + timer_interval=self.config.PROCESSING_TABLE_REFRESH_INTERVAL * 1000) + self.project_processing_controller = ProjectProcessingController(self.dlg, + self.processing_service, + self.project_service, + self.settings, + self.app_context) # load providers from settings errors = [] try: @@ -252,10 +240,10 @@ def __init__(self, iface) -> None: self.dlg.mosaicTable.itemSelectionChanged.connect(self.calculate_aoi_area_catalog) self.dlg.imageTable.itemSelectionChanged.connect(self.calculate_aoi_area_catalog) self.monitor_polygon_layer_feature_selection([ - self.project.mapLayer(layer_id) for layer_id in self.project.mapLayers(validOnly=True) + self.app_context.project.mapLayer(layer_id) for layer_id in self.app_context.project.mapLayers(validOnly=True) ]) - self.project.layersAdded.connect(self.setup_layers_context_menu) - self.project.layersAdded.connect(self.monitor_polygon_layer_feature_selection) + self.app_context.project.layersAdded.connect(self.setup_layers_context_menu) + self.app_context.project.layersAdded.connect(self.monitor_polygon_layer_feature_selection) # Processings self.dlg.processingsTable.cellDoubleClicked.connect(self.load_results) self.dlg.deleteProcessings.clicked.connect(self.processing_service.delete_processings) @@ -284,14 +272,6 @@ def __init__(self, iface) -> None: self.dlg.editProvider.clicked.connect(self.edit_provider) self.dlg.removeProvider.clicked.connect(self.remove_provider) - # Projects - self.dlg.createProject.clicked.connect(self.create_project) - self.dlg.deleteProject.clicked.connect(self.delete_project) - self.dlg.updateProject.clicked.connect(self.update_project) - self.dlg.projectsTable.cellDoubleClicked.connect(lambda: self.show_processings(save_page=True)) - self.dlg.switchProcessingsButton.clicked.connect(lambda: self.show_processings(save_page=True)) - self.dlg.switchProjectsButton.clicked.connect(lambda: self.show_projects(open_saved_page=True)) - # Maxar self.config_search_columns = ConfigColumns() self.meta_table_layer_connection = self.dlg.metadataTable.itemSelectionChanged.connect( @@ -304,12 +284,6 @@ def __init__(self, iface) -> None: 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) - # Poll processings - self.processing_fetch_timer = QTimer(self.dlg) - self.processing_fetch_timer.setInterval(self.config.PROCESSING_TABLE_REFRESH_INTERVAL * 1000) - self.processing_fetch_timer.timeout.connect( - lambda: self.processing_service.get_processings(project_id=self.project_id, - callback=self.get_processings_callback)) # Poll user status to get limits self.user_status_update_timer = QTimer(self.dlg) self.user_status_update_timer.setInterval(self.config.USER_STATUS_UPDATE_INTERVAL * 1000) @@ -366,8 +340,8 @@ def setup_layers_context_menu(self, layers: List[QgsMapLayer]): def add_to_layers(self, layer=None): if not layer: layer = self.iface.layerTreeView().currentLayer() - if layer not in self.aoi_layers: - self.aoi_layers.append(layer) + if layer not in self.app_context.aoi_layers: + self.app_context.aoi_layers.append(layer) self.iface.addCustomActionForLayer(self.remove_layer_action, layer) self.dlg.polygonCombo.setExceptedLayerList(self.filter_aoi_layers()) self.dlg.polygonCombo.setLayer(layer) @@ -376,7 +350,7 @@ def remove_from_layers(self, layer=None): if not layer: layer = self.iface.layerTreeView().currentLayer() try: - self.aoi_layers.remove(layer) + self.app_context.aoi_layers.remove(layer) except ValueError: pass # it can be easly already removed as I can't remove action from contextmenu of a single layer @@ -401,27 +375,6 @@ def first_status_request(self): use_default_error_handler=False ) self.data_catalog_service.get_user_limit() - - def get_project_callback(self, response: QNetworkReply): - self.current_project = MapflowProject.from_dict(json.loads(response.readAll().data())) - if self.current_project: - self.project_id = self.current_project.id - elided_name = self.dlg.currentProjectLabel.fontMetrics().elidedText(self.current_project.name, - Qt.ElideRight, - self.dlg.currentProjectLabel.width() - 50) - self.dlg.currentProjectLabel.setText(self.tr("Project: {}").format(elided_name)) - self.dlg.currentProjectLabel.adjustSize() - self.get_project_sharing(self.current_project) - self.setup_project_change_rights() - self.settings.setValue("project_id", self.project_id) - self.setup_workflow_defs(self.current_project.workflowDefs) - # Manually toggle function to avoid race condition - self.calculate_aoi_area_use_image_extent() - - def get_project_error_handler(self, response: QNetworkReply): - self.default_error_handler(response) - # Switch to projects table if couldn't get current project - self.project_service.switch_to_projects() def setup_add_layer_menu(self): self.add_layer_menu.addAction(self.draw_aoi) @@ -437,13 +390,12 @@ 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.processing_update_action.triggered.connect(self.update_processing) self.dlg.saveOptionsButton.setMenu(self.dlg.options_menu) def create_aoi_layer_from_map(self, action: QAction): aoi_geometry = helpers.to_wgs84( QgsGeometry.fromRect(self.iface.mapCanvas().extent()), - self.project.crs() + self.app_context.project.crs() ) aoi_layer = QgsVectorLayer('Polygon?crs=epsg:4326', f'AOI_{self.aoi_layer_counter}', @@ -499,49 +451,48 @@ def filter_aoi_layers(self): if self.dlg.useAllVectorLayers.isChecked(): # We exclude search metadata layers from AOI layers list because they are big, crowded # and lead to topology errors - if self.search_provider: - return [layer for layer in self.project.mapLayers().values() - if self.search_provider.name + ' metadata' == layer.name()] + if self.app_context.search_provider: + return [layer for layer in self.app_context.project.mapLayers().values() + if self.app_context.search_provider.name + ' metadata' == layer.name()] else: return [] else: - return [layer for layer in self.project.mapLayers().values() if layer not in self.aoi_layers] + return [layer for layer in self.app_context.project.mapLayers().values() if layer not in self.app_context.aoi_layers] def on_options_change(self): wd_name = self.dlg.modelCombo.currentText() - wd = self.workflow_defs.get(wd_name) + wd = self.app_context.get_workflow_def(wd_name) if not wd: return enabled_blocks = self.dlg.enabled_blocks() self.dlg.show_wd_price(wd_price=wd.get_price(enable_blocks=enabled_blocks), wd_description=wd.description, - display_price=self.billing_type == BillingType.credits) + display_price=self.app_context.billing_type == BillingType.credits) self.save_options_settings(wd, enabled_blocks) - if self.billing_type == BillingType.credits: - self.update_processing_cost() + if self.app_context.billing_type == BillingType.credits: + self.processing_service.update_processing_cost() def on_model_change(self, - index: Optional[int] = None, - update_cost: Optional[bool] = True) -> None: + index: Optional[int] = None) -> None: wd_name = self.dlg.modelCombo.currentText() - wd = self.workflow_defs.get(wd_name) + wd = self.app_context.get_workflow_def(wd_name) self.set_available_imagery_sources(wd_name) if not wd: return self.show_wd_options(wd) self.dlg.show_wd_price(wd_price=wd.get_price(enable_blocks=self.dlg.enabled_blocks()), wd_description=wd.description, - display_price=self.billing_type == BillingType.credits) - if self.billing_type == BillingType.credits and \ - update_cost == True: # is False only when sent from setup_workflow_defs (to avoid same requests) - self.update_processing_cost() + display_price=self.app_context.billing_type == BillingType.credits) + if self.app_context.billing_type == BillingType.credits: + # todo: here was a toggle to not call if from setup_workflow_defs, but maybe not so important? + self.processing_service.update_processing_cost() def show_wd_options(self, wd: WorkflowDef): self.dlg.clear_model_options() for block in wd.optional_blocks: self.dlg.add_model_option(block.displayName, checked=bool(self.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.user_role.can_start_processing) + self.dlg.enable_model_options(self.app_context.user_role.can_start_processing) def save_options_settings(self, wd: WorkflowDef, enabled_blocks: List[bool]): enabled_blocks_dict = wd.get_enabled_blocks(enabled_blocks) @@ -566,7 +517,7 @@ def set_available_imagery_sources(self, wd: str) -> None: def filter_metadata(self, *_, min_intersection=None, max_cloud_cover=None) -> None: """Filter out the metadata table and layer every time user changes a filter.""" try: - crs = self.metadata_layer.crs() + crs = self.app_context.metadata_layer.crs() except (RuntimeError, AttributeError): # no metadata layer return if max_cloud_cover is None: @@ -575,13 +526,13 @@ def filter_metadata(self, *_, min_intersection=None, max_cloud_cover=None) -> No min_intersection = self.dlg.minIntersection.value() from_ = self.dlg.metadataFrom.date() to = self.dlg.metadataTo.date() - aoi = helpers.from_wgs84(self.metadata_aoi, crs) + aoi = helpers.from_wgs84(self.app_context.metadata_aoi, crs) if not aoi: if self.dlg.polygonCombo.currentLayer(): geom = layer_utils.collect_geometry_from_layer(self.dlg.polygonCombo.currentLayer()) aoi = helpers.from_wgs84(geom, crs) self.calculator.setEllipsoid(crs.ellipsoidAcronym()) - self.calculator.setSourceCrs(crs, self.project.transformContext()) + self.calculator.setSourceCrs(crs, self.app_context.project.transformContext()) min_intersection_size = self.calculator.measureArea(aoi) * (min_intersection / 100) aoi = QgsGeometry.createGeometryEngine(aoi.constGet()) aoi.prepareGeometry() @@ -594,9 +545,9 @@ def filter_metadata(self, *_, min_intersection=None, max_cloud_cover=None) -> No id_column_index = self.config.MAXAR_ID_COLUMN_INDEX datetime_column_index = self.config.MAXAR_DATETIME_COLUMN_INDEX cloud_cover_column_index = self.config.MAXAR_CLOUD_COLUMN_INDEX - self.metadata_layer.setSubsetString('') # clear any existing filters + self.app_context.metadata_layer.setSubsetString('') # clear any existing filters filtered_ids = [] - for feature in self.metadata_layer.getFeatures(): + for feature in self.app_context.metadata_layer.getFeatures(): area = self.calculator.measureArea(QgsGeometry(aoi.intersection(feature.geometry().constGet()))) try: acquisition_date = feature.attribute("acquisitionDate").date() @@ -619,7 +570,7 @@ def filter_metadata(self, *_, min_intersection=None, max_cloud_cover=None) -> No filter_ = f'id not in (' + ', '.join((f"'{id_}'" for id_ in filtered_ids)) + ')' else: filter_ = '' - self.metadata_layer.setSubsetString(filter_) + self.app_context.metadata_layer.setSubsetString(filter_) # Show/hide table rows for row in range(self.dlg.metadataTable.rowCount()): id_ = self.dlg.metadataTable.item(row, id_column_index).data(Qt.DisplayRole) @@ -673,96 +624,13 @@ def on_zoom_change(self): self.settings.setValue('zoom', str(self.dlg.zoomCombo.currentText())) else: self.settings.setValue('zoom', None) - self.update_processing_cost() - - def setup_workflow_defs(self, workflow_defs: List[WorkflowDef]): - self.workflow_defs = {wd.name: wd for wd in workflow_defs} - self.dlg.modelCombo.clear() - # We skip SENTINEL WDs if sentinel is not enabled (normally, it should be not) - # wds along with ids in the format: {'model_name': 'workflow_def_id'} - self.dlg.modelCombo.addItems(name for name in self.workflow_defs - if self.config.ENABLE_SENTINEL or self.config.SENTINEL_WD_NAME_PATTERN not in name) - self.dlg.modelCombo.setCurrentText(self.config.DEFAULT_MODEL) - self.on_model_change(update_cost=False) # is already updated when calculating aoi + self.processing_service.update_processing_cost() def save_dialog_state(self): """Memorize dialog element sizes & positioning to allow user to customize the look.""" # Save main dialog size & position self.settings.setValue('mainDialogState', self.dlg.saveGeometry()) - # ========== Projects ========== # - - def on_project_change(self): - selected_id = self.dlg.selected_project_id() - if selected_id is not None and selected_id == self.project_id and self.workflow_defs: - # we look at workflow defs because if they are NOT initialized, it means that the project - # is not initialized yet (at plugin's startup) and we still need to set it up - # otherwise, if the WDs are set, we assume that the project hasn't changed and skip further setup - return - if selected_id is None: - self.current_project = self.project_id = None - self.settings.setValue("project_id", None) - self.setup_project_change_rights() - self.dlg.setWindowTitle(helpers.generate_plugin_header(self.plugin_name, - env=self.config.MAPFLOW_ENV)) - self.dlg.switchProcessingsButton.setEnabled(False) - else: - self.dlg.switchProcessingsButton.setEnabled(True) - # Find project in projects/page and set as current - self.project_id = selected_id - for pid, project in self.projects.items(): - if selected_id == pid: - self.current_project = project - elided_name = self.dlg.currentProjectLabel.fontMetrics().elidedText(self.current_project.name, - Qt.ElideRight, - self.dlg.currentProjectLabel.width() - 50) - self.dlg.currentProjectLabel.setText(self.tr("Project: {}").format(elided_name)) - if self.current_project: - self.get_project_sharing(self.current_project) - self.setup_workflow_defs(self.current_project.workflowDefs) - self.setup_project_change_rights() - self.settings.setValue("project_id", self.project_id) - - # Manually toggle function to avoid race condition - self.calculate_aoi_area_use_image_extent() - - def setup_project_change_rights(self): - project_editable = True - if not self.current_project: - project_editable = False - reason = self.tr("No project selected") - elif self.current_project.isDefault: - reason = self.tr("You can't remove or modify default project") - project_editable = False - elif not self.user_role.can_delete_rename_project: - reason = self.tr('Not enough rights to delete or update shared project ({})').format(self.user_role.value) - else: - reason = "" - self.dlg.enable_project_change(reason, project_editable and self.user_role.can_delete_rename_project) - - def create_project(self): - dialog = CreateProjectDialog(self.dlg) - dialog.accepted.connect(lambda: self.project_service.create_project(dialog.project())) - dialog.setup() - dialog.deleteLater() - - def update_project(self): - dialog = UpdateProjectDialog(self.dlg) - dialog.accepted.connect(lambda: self.project_service.update_project(self.current_project.id, - dialog.project())) - dialog.setup(self.current_project) - dialog.deleteLater() - - def delete_project(self): - if self.alert(self.tr('Do you really want to remove project {}? ' - 'This action cannot be undone, all processings will be lost!').format(self.current_project.name), - icon=QMessageBox.Question): - # Unload current project as we are deleting it - to_delete = self.project_id - self.project_id = None - self.current_project = None - self.project_service.delete_project(to_delete) - # ========= Providers ============ # def remove_provider(self) -> None: """Delete a web tile provider from the list of registered providers. @@ -869,7 +737,7 @@ def toggle_imagery_search(self, return # No need to re-set imagery search if the provider is not set, # or if search provider did not change - if isinstance(self.search_provider, SentinelProvider): + if isinstance(self.app_context.search_provider, SentinelProvider): columns = self.config.SENTINEL_ATTRIBUTES hidden_columns = (len(columns) - 1,) sort_by = self.config.SENTINEL_DATETIME_COLUMN_INDEX @@ -877,7 +745,7 @@ def toggle_imagery_search(self, image_id_tooltip = self.tr( 'If you already know which {provider_name} image you want to process,\n' 'simply paste its ID here. Otherwise, search suitable images in the catalog below.' - ).format(provider_name=self.search_provider.name) + ).format(provider_name=self.app_context.search_provider.name) image_id_placeholder = self.tr('e.g. S2B_OPER_MSI_L1C_TL_VGS4_20220209T091044_A025744_T36SXA_N04_00') geoms = None else: # any non-sentinel provider: setup table as for ImagerySearch provider @@ -889,21 +757,21 @@ def toggle_imagery_search(self, image_id_tooltip = self.tr( 'If you already know which {provider_name} image you want to process,\n' 'simply paste its ID here. Otherwise, search suitable images in the catalog below.' - ).format(provider_name=self.search_provider.name) + ).format(provider_name=self.app_context.search_provider.name) image_id_placeholder = self.tr('e.g. a3b154c40cc74f3b934c0ffc9b34ecd1') # If we have searched with current provider previously, we want to restore the search results as it were # We store the results in a temp folder, separate file for each provider - geoms = self.search_provider.load_search_layer(self.temp_dir) + geoms = self.app_context.search_provider.load_search_layer(self.temp_dir) if geoms: self.display_metadata_geojson_layer( - os.path.join(self.temp_dir, self.search_provider.metadata_layer_name), - f"{self.search_provider.name} metadata") + os.path.join(self.temp_dir, self.app_context.search_provider.metadata_layer_name), + f"{self.app_context.search_provider.name} metadata") else: self.clear_metadata() # override max zoom for proxy maxar provider - self.dlg.setup_imagery_search(provider=self.search_provider, + self.dlg.setup_imagery_search(provider=self.app_context.search_provider, columns=columns, hidden_columns=hidden_columns, sort_by=sort_by, @@ -965,8 +833,8 @@ def replace_search_provider(self, provider: ProviderInterface): if not provider_supports_search: provider = self.imagery_search_provider # we need to deselect table to be able to use the non-search provider - if provider != self.search_provider: - self.search_provider = provider + if provider != self.app_context.search_provider: + self.app_context.search_provider = provider provider_changed = True return provider_changed @@ -996,8 +864,8 @@ def get_metadata(self, _: Optional[bool] = False, offset: Optional[int] = 0) -> more_button.deleteLater() provider = self.providers[self.dlg.providerIndex()] # Check if the AOI is defined - if self.aoi: - aoi = self.aoi + if self.app_context.aoi: + aoi = self.app_context.aoi else: self.alert(self.tr('Please, select a valid area of interest')) return @@ -1039,14 +907,14 @@ def get_metadata(self, _: Optional[bool] = False, offset: Optional[int] = 0) -> def clear_metadata(self): try: - self.project.removeMapLayer(self.metadata_layer) + self.app_context.project.removeMapLayer(self.app_context.metadata_layer) except (AttributeError, RuntimeError): # metadata layer has been deleted pass self.dlg.metadataTable.clearContents() self.dlg.metadataTable.setRowCount(0) #provider = self.providers[self.dlg.providerIndex()] - self.search_provider.clear_saved_search(self.temp_dir) + self.app_context.search_provider.clear_saved_search(self.temp_dir) def request_mapflow_metadata(self, aoi: QgsGeometry, @@ -1065,7 +933,7 @@ def request_mapflow_metadata(self, search_providers: Optional[List[str]] = None): if not self.check_if_output_directory_is_selected(): return # only when outputDirectory is empty AND user closed selection dialog - self.metadata_aoi = aoi + self.app_context.metadata_aoi = aoi request_payload = ImageCatalogRequestSchema(aoi=json.loads(aoi.asJson()), acquisitionDateFrom=from_, acquisitionDateTo=to, @@ -1099,17 +967,17 @@ def request_mapflow_metadata_error_handler(self, response: QNetworkReply): def display_metadata_geojson_layer(self, filename, layer_name): try: - self.project.removeMapLayer(self.metadata_layer) + self.app_context.project.removeMapLayer(self.app_context.metadata_layer) except (AttributeError, RuntimeError): # metadata layer has been deleted pass - self.metadata_layer = QgsVectorLayer(filename, layer_name, 'ogr') - self.project.addMapLayer(self.metadata_layer) - self.metadata_layer.loadNamedStyle(os.path.join(self.plugin_dir, 'static', 'styles', 'metadata.qml')) - self.meta_layer_table_connection = self.metadata_layer.selectionChanged.connect( + self.app_context.metadata_layer = QgsVectorLayer(filename, layer_name, 'ogr') + self.app_context.project.addMapLayer(self.app_context.metadata_layer) + self.app_context.metadata_layer.loadNamedStyle(os.path.join(self.plugin_dir, 'static', 'styles', 'metadata.qml')) + self.meta_layer_table_connection = self.app_context.metadata_layer.selectionChanged.connect( self.sync_layer_selection_with_table) - self.search_footprints = { + self.app_context.search_footprints = { feature['local_index']: feature - for feature in self.metadata_layer.getFeatures() + for feature in self.app_context.metadata_layer.getFeatures() } def request_mapflow_metadata_callback(self, response: QNetworkReply, @@ -1168,11 +1036,11 @@ def request_skywatch_metadata( min_intersection: int, ) -> None: """Sumbit a request to SkyWatch to get metadata.""" - self.metadata_aoi = aoi + self.app_context.metadata_aoi = aoi callback_kwargs = {'max_cloud_cover': max_cloud_cover, 'min_intersection': min_intersection} # Check if the AOI is too large self.calculator.setEllipsoid(helpers.WGS84_ELLIPSOID) - self.calculator.setSourceCrs(helpers.WGS84, self.project.transformContext()) + self.calculator.setSourceCrs(helpers.WGS84, self.app_context.project.transformContext()) aoi_bbox = aoi.boundingBox() aoi_bbox_geom = QgsGeometry.fromRect(aoi_bbox) # Check the area @@ -1238,11 +1106,11 @@ def request_skywatch_metadata_callback( self.sentinel_metadata_coords = {} # Delete previous search try: - self.project.removeMapLayer(self.metadata_layer) + self.app_context.project.removeMapLayer(self.app_context.metadata_layer) except (AttributeError, RuntimeError): # metadata layer has been deleted pass # Prepare a layer - self.metadata_layer = QgsVectorLayer( + self.app_context.metadata_layer = QgsVectorLayer( 'polygon?crs=epsg:4326&index=yes&' + '&'.join(f'field={name}:{type_}' for name, type_ in { 'id': 'string', @@ -1253,8 +1121,8 @@ def request_skywatch_metadata_callback( constants.SENTINEL_OPTION_NAME + ' metadata', 'memory' ) - self.metadata_layer.loadNamedStyle(os.path.join(self.plugin_dir, 'static', 'styles', 'metadata.qml')) - self.meta_layer_table_connection = self.metadata_layer.selectionChanged.connect( + self.app_context.metadata_layer.loadNamedStyle(os.path.join(self.plugin_dir, 'static', 'styles', 'metadata.qml')) + self.meta_layer_table_connection = self.app_context.metadata_layer.selectionChanged.connect( self.sync_layer_selection_with_table) # Poll processings metadata_fetch_timer = QTimer(self.dlg) @@ -1363,9 +1231,9 @@ def fetch_skywatch_metadata_callback( json.dump(metadata, file) metadata_layer = QgsVectorLayer(output_file_name, '', 'ogr') # Add the new features to the displayed metadata layer - self.metadata_layer.dataProvider().addFeatures(metadata_layer.getFeatures()) + self.app_context.metadata_layer.dataProvider().addFeatures(metadata_layer.getFeatures()) if timer: # first page - self.result_loader.add_layer(self.metadata_layer) + self.result_loader.add_layer(self.app_context.metadata_layer) current_row_count = self.dlg.metadataTable.rowCount() self.dlg.metadataTable.setRowCount(current_row_count + metadata_layer.featureCount()) self.dlg.metadataTable.setSortingEnabled(False) @@ -1384,7 +1252,7 @@ def fetch_skywatch_metadata_callback( next_page_start_index = response['pagination']['cursor']['next'] except TypeError: # {"data": [], "pagination": None} try: - self.project.removeMapLayer(self.metadata_layer) + self.app_context.project.removeMapLayer(self.app_context.metadata_layer) except (AttributeError, RuntimeError): # metadata layer has been deleted pass self.alert( @@ -1441,7 +1309,7 @@ def get_maxar_metadata( min_intersection: int ) -> None: """Get SecureWatch image metadata.""" - self.metadata_aoi = aoi + self.app_context.metadata_aoi = aoi callback_kwargs = { 'provider': provider, 'min_intersection': min_intersection, @@ -1546,7 +1414,7 @@ def sync_table_selection_with_image_id_and_layer(self) -> None: selected_rows = [cell.row() for cell in selected_cells] local_indices = [self.dlg.metadataTable.item(row, local_index_column).text() for row in selected_rows] try: - self.metadata_layer.selectionChanged.disconnect(self.meta_layer_table_connection) + self.app_context.metadata_layer.selectionChanged.disconnect(self.meta_layer_table_connection) # disconnect to prevent loop of signals except (RuntimeError, AttributeError): # metadata layer was removed or not initialized @@ -1554,15 +1422,15 @@ def sync_table_selection_with_image_id_and_layer(self) -> None: self.replace_search_provider_index() try: - self.metadata_layer.selectByExpression(f"{key} in {tuple(local_indices)}") + self.app_context.metadata_layer.selectByExpression(f"{key} in {tuple(local_indices)}") except RuntimeError: # layer has been deleted pass except Exception as e: - self.meta_layer_table_connection = self.metadata_layer.selectionChanged.connect( + self.meta_layer_table_connection = self.app_context.metadata_layer.selectionChanged.connect( self.sync_layer_selection_with_table) raise e self.calculate_aoi_area_polygon_layer(self.dlg.polygonCombo.currentLayer()) - self.meta_layer_table_connection = self.metadata_layer.selectionChanged.connect( + self.meta_layer_table_connection = self.app_context.metadata_layer.selectionChanged.connect( self.sync_layer_selection_with_table) def sync_layer_selection_with_table(self, selected_ids: List[int]) -> None: @@ -1589,7 +1457,7 @@ def sync_layer_selection_with_table(self, selected_ids: List[int]) -> None: return found_items = [] for selected_id in selected_ids: - selected_local_index = self.metadata_layer.getFeature(selected_id)[key] + selected_local_index = self.app_context.metadata_layer.getFeature(selected_id)[key] for item in self.dlg.metadataTable.findItems(str(selected_local_index), Qt.MatchExactly): if item.column() == id_column_index: found_items.append(item) @@ -1641,12 +1509,12 @@ def sync_image_id_with_table_and_layer(self, image_id: str) -> None: def get_aoi_area_polygon_layer(self, layer: Union[QgsVectorLayer, None]) -> None: if not layer or layer.featureCount() == 0: - if not self.user_role.can_start_processing: - reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) + 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: reason = self.tr('Set AOI to start processing') self.dlg.disable_processing_start(reason, clear_area=True) - self.aoi = self.aoi_size = None + self.app_context.aoi = self.app_context.aoi_size = None return features = list(layer.getSelectedFeatures()) or list(layer.getFeatures()) @@ -1658,20 +1526,20 @@ def get_aoi_area_polygon_layer(self, layer: Union[QgsVectorLayer, None]) -> None # (but it shouldn't be the case, because point and line layers will not appear in AOI-combo, # and collections are devided by QGIS into separate layers with different types) raise ValueError("Only polygon and multipolyon layers supported for this operation") - if self.max_aois_per_processing >= geoms_count: + if self.app_context.max_aois_per_processing >= geoms_count: if len(features) == 1: aoi = features[0].geometry() else: aoi = QgsGeometry.collectGeometry([feature.geometry() for feature in features]) self.calculate_aoi_area(aoi, layer.crs()) return aoi - else: # self.max_aois_per_processing < number of polygons (as features and as parts of multipolygons): - if not self.user_role.can_start_processing: - reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) + else: # self.app_context.max_aois_per_processing < number of polygons (as features and as parts of multipolygons): + 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: - reason = self.tr('AOI must contain not more than {} polygons').format(self.max_aois_per_processing) + reason = self.tr('AOI must contain not more than {} polygons').format(self.app_context.max_aois_per_processing) self.dlg.disable_processing_start(reason, clear_area=True) - self.aoi = self.aoi_size = None + self.app_context.aoi = self.app_context.aoi_size = None def calculate_aoi_area_polygon_layer(self, layer: Union[QgsVectorLayer, None]) -> None: """Get the AOI size total when polygon another layer is chosen, @@ -1711,12 +1579,12 @@ def calculate_aoi_area_catalog(self) -> None: catalog_aoi = QgsGeometry().fromWkt(mosaic.footprint) self.use_imagery_extent.setText(self.tr("Use extent of '{name}'").format(name=mosaic.name)) aoi = layer_utils.get_catalog_aoi(catalog_aoi=catalog_aoi, - selected_aoi=self.aoi) + selected_aoi=self.app_context.aoi) else: aoi = self.get_aoi_area_polygon_layer(self.dlg.polygonCombo.currentLayer()) self.use_imagery_extent.setText(self.tr("Use imagery extent")) self.use_imagery_extent.setEnabled(False) - if not self.aoi: # other error message is already shown + if not self.app_context.aoi: # other error message is already shown pass elif not aoi: # error after intersection self.dlg.disable_processing_start(reason=self.tr("Selected AOI does not intersect the selected imagery"), @@ -1747,7 +1615,7 @@ def calculate_aoi_area_layer_edited(self) -> None: def calculate_aoi_area(self, aoi: QgsGeometry, crs: QgsCoordinateReferenceSystem) -> None: """Display the AOI size in sq.km. - This is the only place where self.aoi is changed! This is important because it is the place where we + This is the only place where app_context.aoi is changed! This is important because it is the place where we send request to update processing cost! :param aoi: the processing area. :param crs: the CRS of the processing area. @@ -1755,7 +1623,7 @@ def calculate_aoi_area(self, aoi: QgsGeometry, crs: QgsCoordinateReferenceSystem if crs != helpers.WGS84: aoi = helpers.to_wgs84(aoi, crs) - self.aoi = aoi # save for reuse in processing creation or metadata requests + self.app_context.aoi = aoi # save for reuse in processing creation or metadata requests # fetch UI data provider_index = self.dlg.providerIndex() selected_images = self.dlg.metadataTable.selectedItems() @@ -1769,91 +1637,43 @@ def calculate_aoi_area(self, aoi: QgsGeometry, crs: QgsCoordinateReferenceSystem try: real_aoi = self.get_aoi(provider_index=provider_index, local_image_indices=local_image_indices, - selected_aoi=self.aoi) + selected_aoi=self.app_context.aoi) except ImageIdRequired: # AOI is OK, but image ID is not selected, # in this case we should use selected AOI without cut by AOI - real_aoi = self.aoi + real_aoi = self.app_context.aoi except Exception as e: # Could not calculate AOI size real_aoi = QgsGeometry() try: - self.aoi_size = layer_utils.calculate_aoi_area(real_aoi, self.project.transformContext()) + self.app_context.aoi_size = layer_utils.calculate_aoi_area(real_aoi, self.app_context.project.transformContext()) except Exception as e: - self.aoi_size = 0 + self.app_context.aoi_size = 0 - self.dlg.labelAoiArea.setText(self.tr('Area: {:.2f} sq.km').format(self.aoi_size)) - self.update_processing_cost() - - def update_processing_cost(self): - if not self.aoi: - # Here the button must already be disabled, and the warning text set - if self.dlg.startProcessing.isEnabled(): - if not self.user_role.can_start_processing: - reason = self.tr('Not enough rights to start processing in a shared project ({})').format(self.user_role.value) - else: - reason = self.tr("Set AOI to start processing") - self.dlg.disable_processing_start(reason, clear_area=False) - elif not self.workflow_defs: - self.dlg.disable_processing_start(reason=self.tr("Error! Models are not initialized.\n" - "Please, make sure you have selected a project"), - clear_area=True) - elif self.billing_type != BillingType.credits: - self.dlg.startProcessing.setEnabled(True) - self.dlg.processingProblemsLabel.clear() - request_body, error = self.create_processing_request(allow_empty_name=True) - else: # self.billing_type == BillingType.credits: f - provider = self.providers[self.dlg.providerIndex()] - request_body, error = self.create_processing_request(allow_empty_name=True) - if not request_body: - self.dlg.disable_processing_start(self.tr("Processing cost is not available:\n" - "{error}").format(error=error)) - elif isinstance(provider, ImagerySearchProvider) and\ - not self.dlg.metadataTable.selectionModel().hasSelection(): - self.dlg.disable_processing_start(self.tr("This provider requires image ID. " - "Use search tab to find imagery for you requirements, " - "and select image in the table.")) - elif isinstance(provider, MyImageryProvider) and\ - not self.dlg.mosaicTable.selectionModel().hasSelection(): - self.dlg.disable_processing_start(reason=self.tr('Choose imagery to start processing')) - else: - if self.user_role.can_start_processing: - self.http.post( - url=f"{self.server}/processing/cost/v2", - callback=self.calculate_processing_cost_callback, - body=request_body.as_json().encode(), - use_default_error_handler=False, - error_handler=self.clear_processing_cost - ) - - def calculate_processing_cost_callback(self, response: QNetworkReply): - response_data = response.readAll().data().decode() - self.processing_cost = int(response_data) - self.dlg.processingProblemsLabel.setPalette(self.dlg.default_palette) - self.dlg.processingProblemsLabel.setText(self.tr("Processsing cost: {cost} credits").format(cost=response_data)) - self.dlg.startProcessing.setEnabled(True) + self.dlg.labelAoiArea.setText(self.tr('Area: {:.2f} sq.km').format(self.app_context.aoi_size)) + self.processing_service.update_processing_cost() def check_processing_ui(self, allow_empty_name=False): processing_name = self.dlg.processingName.text() if not processing_name and not allow_empty_name: raise ProcessingInputDataMissing(self.tr('Please, specify a name for your processing')) - if not self.aoi: + if not self.app_context.aoi: if self.dlg.polygonCombo.currentLayer(): raise BadProcessingInput(self.tr('Processing area layer is corrupted or has invalid projection')) else: raise BadProcessingInput(self.tr('Please, select a valid area of interest')) - if self.aoi_area_limit < self.aoi_size: + if self.app_context.aoi_area_limit < self.app_context.aoi_size: raise BadProcessingInput(self.tr( 'Up to {} sq km can be processed at a time. ' - 'Try splitting your area(s) into several processings.').format(self.aoi_area_limit)) + 'Try splitting your area(s) into several processings.').format(self.app_context.aoi_area_limit)) return True def crop_aoi_with_maxar_image_footprint(self, aoi: QgsFeature, local_image_indices: List[int]): - extents = [self.search_footprints[local_image_index] for local_image_index in local_image_indices] + extents = [self.app_context.search_footprints[local_image_index] for local_image_index in local_image_indices] try: clipped_aoi_features = clip_aoi_to_image_extent(aoi, extents) aoi = QgsGeometry.fromWkt('GEOMETRYCOLLECTION()') @@ -1871,7 +1691,7 @@ def get_processing_params(self, provider_name: Optional[str] = None): provider = self.providers[provider_index] meta = {'source-app': 'qgis', - 'version': self.plugin_version, + 'version': self.app_context.plugin_version, 'source': provider.name.lower()} if not provider: raise PluginError(self.tr('Providers are not initialized')) @@ -1929,7 +1749,7 @@ def create_processing_request(self, allow_empty_name: bool = False) -> Tuple[Optional[PostProcessingSchema], str]: processing_name = self.dlg.processingName.text() wd_name = self.dlg.modelCombo.currentText() - wd = self.workflow_defs.get(wd_name) + wd = self.app_context.get_workflow_def(wd_name) provider_index = self.dlg.providerIndex() provider = self.providers[provider_index] s3_uri = self.get_s3_uri(provider) @@ -1985,7 +1805,7 @@ def create_processing_request(self, aoi = self.get_aoi(provider_index=provider_index, local_image_indices=local_image_indices, - selected_aoi=self.aoi) + selected_aoi=self.app_context.aoi) except AoiNotIntersectsImage: return None, self.tr("Selected AOI does not intersect the selected imagery") except ImageIdRequired: @@ -1993,7 +1813,7 @@ def create_processing_request(self, "and select image in the table.") except PluginError as e: return None, str(e) - project_id = self.current_project.id + project_id = self.app_context.current_project.id processing_params = PostProcessingSchemaV2( name=processing_name, description=None, @@ -2016,11 +1836,11 @@ def create_processing(self) -> None: if not processing_params: self.alert(error, icon=QMessageBox.Warning) return - if not helpers.check_processing_limit(billing_type=self.billing_type, - remaining_limit=self.remaining_limit, - remaining_credits=self.remaining_credits, - aoi_size=self.aoi_size, - processing_cost=self.processing_cost): + if not helpers.check_processing_limit(billing_type=self.app_context.billing_type, + remaining_limit=self.app_context.remaining_limit, + remaining_credits=self.app_context.remaining_credits, + aoi_size=self.app_context.aoi_size, + processing_cost=self.processing_service.processing_cost): self.alert(self.tr('Processing limit exceeded. ' 'Visit "Mapflow" ' 'to top up your balance'), @@ -2031,7 +1851,7 @@ def start_processing(): self.message_bar.pushInfo(self.plugin_name, self.tr('Starting the processing...')) try: self.dlg.startProcessing.setEnabled(False) - self.post_processing(processing_params) + self.processing_service.start_processing(processing_params) except Exception as e: self.alert(self.tr("Could not launch processing! Error: {}.").format(str(e))) # Show processing start confirmation dialog if checkbox is checked @@ -2046,8 +1866,8 @@ def set_start_confirmation(): dialog.checkBox.toggled.connect(set_start_confirmation) dialog.accepted.connect(start_processing) # Fill dialog with parameters - if self.billing_type==BillingType.credits: - price = self.tr("{cost} credits").format(cost=self.processing_cost) + if self.app_context.billing_type==BillingType.credits: + price = self.tr("{cost} credits").format(cost=self.processing_service.processing_cost) else: price = None provider = self.providers[self.dlg.providerIndex()] @@ -2076,7 +1896,7 @@ def set_start_confirmation(): price=price, provider=provider_text, zoom=zoom, - area=str(round(self.aoi_size, 2))+self.tr(" sq.km"), + area=str(round(self.app_context.aoi_size, 2))+self.tr(" sq.km"), model=self.dlg.modelCombo.currentText(), blocks=[self.dlg.modelOptionsLayout.itemAt(i).widget() for i in range(self.dlg.modelOptionsLayout.count())]) @@ -2086,32 +1906,13 @@ def set_start_confirmation(): start_processing() return - def upload_tif_callback(self, - response: QNetworkReply, - processing_params: PostProcessingSchema) -> None: - """Start processing upon a successful GeoTIFF upload. - - :param response: The HTTP response. - :param processing_params: A dictionary with the processing parameters. - """ - processing_params.params.url = json.loads(response.readAll().data())['url'] - self.post_processing(processing_params) - - def upload_tif_error_handler(self, response: QNetworkReply) -> None: - """Error handler for GeoTIFF upload request, made for data-catalog API - - """ - self.report_http_error(response=response, - title=self.tr("We couldn't upload your GeoTIFF"), - error_message_parser=data_catalog_message_parser) - def post_processing(self, request_body: PostProcessingSchema) -> None: """Submit a processing to Mapflow. :param request_body: Processing parameters. """ - if self.project_id != 'default': - request_body.projectId = self.project_id + if self.app_context.project_id != 'default': + request_body.projectId = self.app_context.project_id self.http.post( url=f'{self.server}/processings/v2', callback=self.post_processing_callback, @@ -2121,20 +1922,6 @@ def post_processing(self, request_body: PostProcessingSchema) -> None: body=request_body.as_json().encode() ) - def post_processing_callback(self, _: QNetworkReply, processing_name: str) -> None: - """Display a success message and clear the processing name field.""" - self.alert( - self.tr("Success! We'll notify you when the processing has finished."), - QMessageBox.Information - ) - if self.dlg.processingName.text() == processing_name: - self.dlg.processingName.clear() - self.processing_fetch_timer.start() # start monitoring - # Do an extra fetch immediately - self.processing_service.get_processings(project_id=self.project_id, - callback=self.get_processings_callback) - self.dlg.startProcessing.setEnabled(True) - def post_processing_error_handler(self, response: QNetworkReply) -> None: """Error handler for processing creation requests. @@ -2153,7 +1940,7 @@ def post_processing_error_handler(self, response: QNetworkReply) -> None: else: error_summary, email_body = get_error_report_body(response=response, response_body=response_body, - plugin_version=self.plugin_version, + plugin_version=self.app_context.plugin_version, error_message_parser=api_message_parser) ErrorMessageWidget(parent=QApplication.activeWindow(), text= error_summary, @@ -2174,40 +1961,40 @@ def set_processing_limit(self, response: QNetworkReply, response_data = json.loads(response.readAll().data()) if self.plugin_name != 'Mapflow': # In custom plugins, we don't show the remaining limit and do not check it for the processing - self.billing_type = BillingType.none + self.app_context.billing_type = BillingType.none else: # get billing type, by default it is area - self.billing_type = BillingType(response_data.get('billingType', 'AREA').upper()) + self.app_context.billing_type = BillingType(response_data.get('billingType', 'AREA').upper()) # get limits - self.remaining_limit = int(response_data.get('remainingArea', 0)) / 1e6 # convert into sq.km - self.remaining_credits = int(response_data.get('remainingCredits', 0)) - self.max_aois_per_processing = int(response_data.get("maxAoisPerProcessing", + self.app_context.remaining_limit = int(response_data.get('remainingArea', 0)) / 1e6 # convert into sq.km + self.app_context.remaining_credits = int(response_data.get('remainingCredits', 0)) + self.app_context.max_aois_per_processing = int(response_data.get("maxAoisPerProcessing", self.config.MAX_AOIS_PER_PROCESSING)) - if self.billing_type == BillingType.credits: - balance_str = self.tr("Your balance: {} credits").format(self.remaining_credits) - elif self.billing_type == BillingType.area: # area - balance_str = self.tr('Remaining limit: {:.2f} sq.km').format(self.remaining_limit) + if self.app_context.billing_type == BillingType.credits: + balance_str = self.tr("Your balance: {} credits").format(self.app_context.remaining_credits) + elif self.app_context.billing_type == BillingType.area: # area + balance_str = self.tr('Remaining limit: {:.2f} sq.km').format(self.app_context.remaining_limit) else: # BillingType.none balance_str = '' - self.review_workflow_enabled = response_data.get('reviewWorkflowEnabled', False) + self.app_context.review_workflow_enabled = response_data.get('reviewWorkflowEnabled', False) self.dlg.balanceLabel.setText(balance_str) if app_startup_request: - self.update_processing_cost() + self.processing_service.update_processing_cost() self.app_startup_user_update_timer.stop() - self.dlg.setup_for_billing(self.billing_type) - self.dlg.setup_for_review(self.review_workflow_enabled) + self.dlg.setup_for_billing(self.app_context.billing_type) + self.dlg.setup_for_review(self.app_context.review_workflow_enabled) self.dlg.modelCombo.activated.emit(self.dlg.modelCombo.currentIndex()) self.setup_providers(response_data.get("dataProviders") or []) self.setup_search_providers(response_data.get("searchDataProviders") or []) self.on_provider_change() # Open processings or projects table - if self.current_project: - self.show_processings() + if self.app_context.current_project: + self.project_processing_controller.show_processings() else: - self.show_projects() - self.setup_project_change_rights() + self.project_processing_controller.show_projects() + self.project_service.setup_project_change_rights() def setup_providers(self, providers_data): self.imagery_search_provider_instance = ImagerySearchProvider(proxy=self.server) @@ -2290,7 +2077,7 @@ def preview_catalog(self, image_id): footprint = self.metadata_footprint(feature=feature) url = feature.attribute('previewUrl') preview_type = feature.attribute('previewType') - self.iface.mapCanvas().zoomToSelected(self.metadata_layer) + self.iface.mapCanvas().zoomToSelected(self.app_context.metadata_layer) self.iface.mapCanvas().refresh() if not preview_type: self.alert(self.tr("Selected imagery has no preview")) @@ -2343,7 +2130,7 @@ def display_png_preview(self, preview.FlushCache() layer = QgsRasterLayer(f.name, f"{image_id} preview", 'gdal') layer.setExtent(extent) - self.project.addMapLayer(layer) + self.app_context.project.addMapLayer(layer) def display_png_preview_gcp(self, response: QNetworkReply, @@ -2402,7 +2189,7 @@ def display_png_preview_gcp(self, else: for band in range(layer.bandCount()): layer.dataProvider().setNoDataValue(band, 0) - self.project.addMapLayer(layer) + self.app_context.project.addMapLayer(layer) def preview_png_error_handler(self, response: QNetworkReply): self.report_http_error(response, self.tr("Could not display preview")) @@ -2477,7 +2264,7 @@ def metadata_feature(self, image_id): if not image_id: return None try: # Get the image extent to set the correct extent on the raster layer - return next(self.metadata_layer.getFeatures(f"id = '{image_id}'")) + return next(self.app_context.metadata_layer.getFeatures(f"id = '{image_id}'")) except (RuntimeError, AttributeError, StopIteration): # layer doesn't exist or has been deleted, or empty return None @@ -2495,7 +2282,7 @@ def preview_xyz(self, provider, image_id): # Add OSM instaed of preview, if it is unavailable (for Mapbox) osm = constants.OSM layer = QgsRasterLayer(osm, 'OpenStreetMap', 'wms') - self.result_loader.add_preview_layer(preview_layer=layer, preview_dict=self.preview_dict) + self.result_loader.add_preview_layer(preview_layer=layer) return except Exception as e: self.alert(str(e), QMessageBox.Warning) @@ -2514,7 +2301,7 @@ def preview_xyz(self, provider, image_id): extent = self.metadata_extent(image_id) if extent: layer.setExtent(extent) - self.result_loader.add_preview_layer(preview_layer=layer, preview_dict=self.preview_dict) + self.result_loader.add_preview_layer(preview_layer=layer) else: self.alert(self.tr("We couldn't load a preview for this image")) @@ -2554,10 +2341,10 @@ def preview_or_search(self, provider) -> None: def update_processing_current_rating(self) -> None: # reset labels: - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return - pid = processing.id_ + pid = processing.id p_name = processing.name self.dlg.set_processing_rating_labels(processing_name=p_name) @@ -2568,7 +2355,6 @@ def update_processing_current_rating(self) -> None: def update_processing_current_rating_callback(self, response: QNetworkReply) -> None: response_data = json.loads(response.readAll().data()) - processing = Processing.from_response(response_data) p_name = response_data.get('name') rating_json = response_data.get('rating') if not rating_json: @@ -2579,33 +2365,11 @@ def update_processing_current_rating_callback(self, response: QNetworkReply) -> current_rating=rating, current_feedback=feedback) - def selected_processing_ids(self, limit=None): - # add unique selected rows - selected_rows = list(set(index.row() for index in self.dlg.processingsTable.selectionModel().selectedIndexes())) - if not selected_rows: - return [] - pids = [self.dlg.processingsTable.item(row, - self.config.PROCESSING_TABLE_ID_COLUMN_INDEX).text() - for row in selected_rows[:limit]] - return pids - - def selected_processings(self, limit=None) -> List[Processing]: - pids = self.selected_processing_ids(limit=limit) - # limit None will give full selection - selected_processings = [p for p in filter(lambda p: p.id_ in pids, self.processings)] - return selected_processings - - def selected_processing(self) -> Optional[Processing]: - first = self.selected_processings(limit=1) - if not first: - return None - return first[0] - def submit_processing_rating(self) -> None: - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return - pid = processing.id_ + pid = processing.id if not processing.status.is_ok: self.alert(self.tr('Only finished processings can be rated')) return @@ -2626,14 +2390,14 @@ def submit_processing_rating(self) -> None: ) def accept_processing(self): - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return - pid = processing.id_ + pid = processing.id if not processing.status.is_ok: self.alert(self.tr('Only finished processings can be rated')) return - elif not processing.review_status.is_in_review: + elif not processing.reviewStatus.is_in_review: self.alert(self.tr("Processing must be in `Review required` status")) return self.http.put( @@ -2645,17 +2409,16 @@ def review_processing_callback(self, response: QNetworkReply): # Clear successfully uploaded review self.review_dialog.reviewComment.setText("") self.processing_fetch_timer.start() - self.processing_service.get_processings(project_id=self.project_id, - callback=self.get_processings_callback) + self.processing_service.get_processings() def show_review_dialog(self): - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return if not processing.status.is_ok: self.alert(self.tr('Only finished processings can be rated')) return - elif not processing.review_status.is_in_review: + elif not processing.reviewStatus.is_in_review: self.alert(self.tr("Processing must be in `Review required` status")) return self.review_dialog.setup(processing) @@ -2665,7 +2428,7 @@ def submit_review(self): body = {"comment": self.review_dialog.reviewComment.toPlainText(), "features": layer_utils.export_as_geojson(self.review_dialog.reviewLayerCombo.currentLayer())} self.http.put( - url=f'{self.server}/processings/{self.review_dialog.processing.id_}/rejection', + url=f'{self.server}/processings/{self.review_dialog.processing.id}/rejection', body=json.dumps(body).encode(), callback=self.review_processing_callback ) @@ -2693,18 +2456,18 @@ def enable_review_submit(self, status_ok: bool) -> None: def enable_rating_submit(self, status_ok: bool) -> None: rating_selected = 5 >= self.dlg.ratingComboBox.currentIndex() > 0 - if not self.user_role.can_delete_rename_review_processing: - reason = self.tr('Not enough rights to rate processing in a shared project ({})').format(self.user_role.value) + if not self.app_context.user_role.can_delete_rename_review_processing: + reason = self.tr('Not enough rights to rate processing in a shared project ({})').format(self.app_context.user_role.value) elif not status_ok: - if not self.selected_processing(): + if not self.processing_service.selected_processing(): reason = self.tr('Please select processing') else: reason = self.tr("Only correctly finished processings (status OK) can be rated") - elif not rating_selected and self.user_role.can_delete_rename_review_processing: + elif not rating_selected and self.app_context.user_role.can_delete_rename_review_processing: reason = self.tr("Please select rating to submit") else: reason = "" - self.dlg.enable_rating(can_interact=(status_ok and self.user_role.can_delete_rename_review_processing), + self.dlg.enable_rating(can_interact=(status_ok and self.app_context.user_role.can_delete_rename_review_processing), can_send=rating_selected, reason=reason) @@ -2713,24 +2476,24 @@ def enable_feedback(self) -> None: By feedback we mean either rating (1-5 stars + message) for regular users or review for users which have review workflow enabled """ - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: - if self.review_workflow_enabled: + if self.app_context.review_workflow_enabled: self.enable_review_submit(False) else: self.enable_rating_submit(False) return - if self.review_workflow_enabled: - self.enable_review_submit(processing.status.is_ok and processing.review_status.is_in_review) + if self.app_context.review_workflow_enabled: + self.enable_review_submit(processing.status.is_ok and processing.reviewStatus.is_in_review) else: self.enable_rating_submit(processing.status.is_ok) # =================== Results management ==================== # def load_results(self): - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return - if processing.id_ not in self.processing_history.finished: + if not processing.status.is_ok: self.alert(self.tr("Only the results of correctly finished processing can be loaded")) return @@ -2746,194 +2509,25 @@ def download_results_file(self) -> None: Download result and save directly to a geojson file It is the most reliable way to get results, applicable if everything else failed """ - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return - if processing.id_ not in self.processing_history.finished: + if not processing.status.is_ok: self.alert(self.tr("Only the results of correctly finished processing can be loaded")) return - self.result_loader.download_results_file(pid=processing.id_) + self.result_loader.download_results_file(pid=processing.id) def download_aoi_file(self) -> None: """ Download area of interest and save to a geojson file """ - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return - self.result_loader.download_aoi_file(pid=processing.id_) + self.result_loader.download_aoi_file(pid=processing.id) def alert(self, message: str, icon: QMessageBox.Icon = QMessageBox.Critical, blocking=True) -> None: - """Display a minimalistic modal dialog with some info or a question. - - :param message: A text to display - :param icon: Info/Warning/Critical/Question - :param blocking: Opened as modal - code below will only be executed when the alert is closed - """ - box = QMessageBox(icon, self.plugin_name, message, parent=QApplication.activeWindow()) - box.setTextFormat(Qt.RichText) - if icon == QMessageBox.Question: # by default, only OK is added - box.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok) - return box.exec() == QMessageBox.Ok if blocking else box.open() - - def get_processings_callback(self, response: QNetworkReply, caller=None) -> None: - """Update the processing table and user limit. - - :param response: The HTTP response. - """ - response_data = json.loads(response.readAll().data()) - processings = parse_processings_request(response_data) - if all(not (p.status.is_in_progress or p.status.is_awaiting) - and p.review_status.is_not_accepted - for p in processings): - # We do not re-fetch the processings, if nothing is going to change. - # What can change from server-side: processing can finish if IN_PROGRESS or AWAITING - # or review can be accepted if NOT_ACCEPTED. - # Any other processings can change only from client-side - self.processing_fetch_timer.stop() - env = self.config.MAPFLOW_ENV - processing_history = self.settings.value('processings') - self.processing_history = ProcessingHistory.from_settings( - processing_history.get(env, {}) - .get(self.username, {}) - .get(self.project_id, {})) - # get updated processings (newly failed and newly finished) and updated user processing history - failed_processings, finished_processings, self.processing_history = updated_processings(processings, - self.processing_history) - - # update processing limit of user - self.update_processing_limit() - self.alert_failed_processings(failed_processings) - self.alert_finished_processings(finished_processings) - self.update_processing_table(processings) - self.processings = processings - try: # use try-except bc this will only error once - processing_history[env][self.username][self.project_id] = self.processing_history.asdict() - except KeyError: # history for the current env hasn't been initialized yet - try: - processing_history[env][self.username] = {self.project_id: self.processing_history.asdict()} - except KeyError: - processing_history[env] = {self.username: {self.project_id: self.processing_history.asdict()}} - self.settings.setValue('processings', processing_history) - - def show_processings(self, save_page: Optional[bool] = False): - """Get processings and switch to processings table in stacked widget. - - :param save_page: A boolean that determines if we should save projects page parameters to settings (if - user chose a project) or not (switching if no id was saved). - """ - if not self.project_id: - return - self.setup_processings_table() - self.project_service.switch_to_processings(save_page, self.project_id) - - def show_projects(self, open_saved_page: Optional[bool] = False): - """Get projects and switch from processings to projects table in stacked widget. - - Allows to open saved projects page even after reload. - But we don't need to do that when e.g. we are switching to a different projects page. - - :param open_saved_page: A boolean that determines if we should get projects page from the settings (e.g. when - switching from processings table) or not (e.g. when showing next projects page). - """ - self.processing_fetch_timer.stop() - self.project_service.switch_to_projects(open_saved_page) - - def alert_failed_processings(self, failed_processings): - if not failed_processings: - return - # this means that some of processings have failed since last update and the limit must have been returned - if len(failed_processings) == 1: - proc = failed_processings[0] - self.alert( - proc.name + - self.tr(' failed with error:\n') + proc.error_message(self.config.SHOW_RAW_ERROR), - QMessageBox.Critical, - blocking=False) - elif 1 < len(failed_processings) < 10: - # If there are more than one failed processing, we will not - self.alert(self.tr('{} processings failed: \n {} \n ' - 'See tooltip over the processings table' - ' for error details').format(len(failed_processings), - '\n'.join((proc.name for proc in failed_processings))), - QMessageBox.Critical, - blocking=False) - else: # >= 10 - self.alert(self.tr( - '{} processings failed: \n ' - 'See tooltip over the processings table for error details').format(len(failed_processings)), - QMessageBox.Critical, - blocking=False) - - def alert_finished_processings(self, finished_processings): - if not finished_processings: - return - if len(finished_processings) == 1: - # Print error message from first failed processing - proc = finished_processings[0] - self.alert( - proc.name + - self.tr(' finished. Double-click it in the table to download the results.'), - QMessageBox.Information, - blocking=False # don't repeat if user doesn't close the alert - ) - elif 1 < len(finished_processings) < 10: - # If there are more than one failed processing, we will not - self.alert(self.tr( - '{} processings finished: \n {} \n ' - 'Double-click it in the table ' - 'to download the results').format(len(finished_processings), - '\n'.join((proc.name for proc in finished_processings))), - QMessageBox.Information, - blocking=False) - else: # >= 10 - self.alert(self.tr( - '{} processings finished. \n ' - 'Double-click it in the table to download the results').format(len(finished_processings)), - QMessageBox.Information, - blocking=False) - - def update_processing_table(self, processings: List[Processing]): - # UPDATE THE TABLE - # Memorize the selection to restore it after table update - selected_processings = self.selected_processing_ids() - # Explicitly clear selection since resetting row count won't do it - self.dlg.processingsTable.clearSelection() - # Temporarily enable multi selection so that selectRow won't clear previous selection - self.dlg.processingsTable.setSelectionMode(QAbstractItemView.MultiSelection) - # Row insertion triggers sorting -> row indexes shift -> duplicate rows, so turn sorting off - self.dlg.processingsTable.setSortingEnabled(False) - self.dlg.processingsTable.setRowCount(len(processings)) - # Fill out the table - for row, proc in enumerate(processings): - processing_dict = proc.asdict() - set_color = False - if proc.status.is_ok and proc.review_expires: - # setting color for close review - set_color = True - color = QColor(255, 220, 200) - for col, attr in enumerate(self.config.PROCESSING_TABLE_COLUMNS): - table_item = QTableWidgetItem() - table_item.setData(Qt.DisplayRole, processing_dict[attr]) - if proc.status.is_failed: - table_item.setToolTip(proc.error_message(raw=self.config.SHOW_RAW_ERROR)) - elif proc.in_review_until: - table_item.setToolTip(self.tr("Please review or accept this processing until {}." - " Double click to add results" - " to the map").format( - proc.in_review_until.strftime('%Y-%m-%d %H:%M') if proc.in_review_until else "")) - elif proc.status.is_ok: - table_item.setToolTip(self.tr("Double click to add results to the map." - )) - if set_color: - table_item.setBackground(color) - self.dlg.processingsTable.setItem(row, col, table_item) - if proc.id_ in selected_processings: - self.dlg.processingsTable.selectRow(row) - self.dlg.processingsTable.setSortingEnabled(True) - # Restore extended selection and filtering - self.dlg.processingsTable.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.dlg.filter_processings_table(self.dlg.filterProcessings.text()) + alert(message, icon, blocking) def initGui(self) -> None: """Create the menu entries and toolbar icons inside the QGIS GUI. @@ -2951,7 +2545,7 @@ def initGui(self) -> None: plugin_button = QAction(self.plugin_icon, self.plugin_name, self.main_window) plugin_button.triggered.connect(self.main) self.toolbar.addAction(plugin_button) - self.project.readProject.connect(self.set_layer_group) + self.app_context.project.readProject.connect(self.set_layer_group) self.dlg.processingsTable.sortByColumn(self.config.PROCESSING_TABLE_SORT_COLUMN_INDEX, Qt.DescendingOrder) def set_layer_group(self) -> None: @@ -2965,8 +2559,7 @@ def set_layer_group(self) -> None: def unload(self) -> None: """Remove the plugin icon & toolbar from QGIS GUI.""" - self.processing_fetch_timer.stop() - self.processing_fetch_timer.deleteLater() + self.processing_service.stop() self.user_status_update_timer.stop() self.iface.removeCustomActionForLayerType(self.add_layer_action) self.iface.removeCustomActionForLayerType(self.remove_layer_action) @@ -3026,9 +2619,9 @@ def login_basic(self, token) -> None: self.settings.setValue('token', token) # keep login/password from token try: - self.username, self.password = b64decode(token).decode().split(':') + self.app_context.username, self.app_context.password = b64decode(token).decode().split(':') except: - self.username = self.password = '' + self.app_context.username = self.app_context.password = '' self.dlg_login.show() self.alert(self.tr('Wrong token. ' 'Visit "mapflow.ai" ' @@ -3049,7 +2642,7 @@ def logout(self) -> None: self.settings.setValue('token', '') self.processing_fetch_timer.stop() self.user_status_update_timer.stop() - self.logged_in = False + self.app_context.logged_in = False self.http.logout() self.dlg.close() # self.dlg_login = self.set_up_login_dialog() # recreate the login dialog @@ -3069,7 +2662,7 @@ def default_error_handler(self, parser = api_message_parser if 'mapflow' in response.request().url().authority() else securewatch_message_parser if error == QNetworkReply.AuthenticationRequiredError: # invalid/empty credentials # Prevent deadlocks - if self.logged_in: # token re-issued during a plugin session + if self.app_context.logged_in: # token re-issued during a plugin session self.logout() elif self.settings.value('token'): # env changed w/out logging out (admin) self.alert(self.tr('Wrong token. ' @@ -3107,11 +2700,11 @@ def default_error_handler(self, self.report_http_error(response, self.tr('Proxy error. Please, check your proxy settings.')) return True elif error == QNetworkReply.ContentAccessDenied: - if not self.user_role.can_delete_rename_project: + if not self.app_context.user_role.can_delete_rename_project: self.report_http_error(response, self.tr("Not enough rights for this action\n"+ - "in a shared project '{project_name}' ({user_role})").format(project_name=self.current_project.name, - user_role=self.user_role.value), + "in a shared project '{project_name}' ({user_role})").format(project_name=self.app_context.current_project.name, + user_role=self.app_context.user_role.value), error_message_parser=parser) else: self.report_http_error(response, @@ -3136,29 +2729,13 @@ def report_http_error(self, response_body = response.readAll().data().decode() error_summary, email_body = get_error_report_body(response=response, response_body=response_body, - plugin_version=self.plugin_version, + plugin_version=self.app_context.plugin_version, error_message_parser=error_message_parser) ErrorMessageWidget(parent=QApplication.activeWindow(), text= error_summary, title=title, email_body=email_body).show() - def setup_processings_table(self): - if not self.project_id: - return - table_item = QTableWidgetItem("Loading...") - table_item.setToolTip('Fetching your processings from server, please wait') - self.dlg.processingsTable.setRowCount(1) - self.dlg.processingsTable.setItem(0, 0, table_item) - for column in range(1, self.dlg.processingsTable.columnCount()): - empty_item = QTableWidgetItem("") - self.dlg.processingsTable.setItem(0, column, empty_item) - # Fetch processings at startup and start the timer to keep fetching them afterwards - self.http.get(url=f'{self.server}/projects/{self.project_id}/processings/v2', - callback=self.get_processings_callback, - callback_kwargs={"caller": f"setup_table_{self.project_id}"}, - use_default_error_handler=False) - self.processing_fetch_timer.start() def find_project(self, projects: List[MapflowProject], project_id: str): # first, try to find by ID @@ -3194,62 +2771,31 @@ def log_in_callback(self, response: QNetworkReply) -> None: default_project = MapflowProject.from_dict(response) self.update_processing_limit() - self.aoi_area_limit = userinfo['aoiAreaLimit'] * 1e-6 + self.app_context.aoi_area_limit = userinfo['aoiAreaLimit'] * 1e-6 # We have different behavior for admin as he has access to all processings self.is_admin = userinfo.get("role") == "ADMIN" self.dlg.restoreGeometry(self.settings.value('mainDialogState', b'')) # Authenticate and keep user logged in - self.logged_in = True + self.app_context.logged_in = True self.dlg_login.close() # Get all projects & setup processings table (see callback) if self.is_admin: - self.project_id = Config.PROJECT_ID - self.setup_workflow_defs(default_project.workflowDefs) - self.setup_processings_table() + self.app_context.project_id = Config.PROJECT_ID + self.project_service.setup_workflow_defs(default_project.workflowDefs) + self.processing_service.setup_processings_table() else: - if self.project_id: - self.project_service.get_project(self.project_id, self.get_project_callback, self.get_project_error_handler) + if self.app_context.project_id: + self.project_service.get_project(self.app_context.project_id, + self.get_project_callback, + self.get_project_error_handler) self.data_catalog_service.get_mosaics() - self.dlg.setup_for_billing(self.billing_type) + self.dlg.setup_for_billing(self.app_context.billing_type) self.dlg.show() self.user_status_update_timer.start() self.app_startup_user_update_timer.start() - def update_projects(self): - self.projects = {pr.id: pr for pr in self.project_service.projects} - if not self.projects: - self.dlg.projectsTable.clear() - # Add a row with an error message to projects table - table_item = QTableWidgetItem(self.tr("No project that meets specified criteria was found")) - self.dlg.projectsTable.setRowCount(1) - self.dlg.projectsTable.setColumnCount(2) - self.dlg.projectsTable.setItem(0, 1, table_item) - self.dlg.projectsTable.setHorizontalHeaderLabels(["ID", self.tr("Project")]) - return - self.filter_projects(self.dlg.filterProjects.text()) - if self.project_id: - self.project_service.select_project(self.project_id) - - def connect_projects(self): - if self.project_connection is not None: - self.dlg.projectsTable.itemSelectionChanged.disconnect(self.project_connection) - self.project_connection = None - self.project_connection = self.dlg.projectsTable.itemSelectionChanged.connect(self.on_project_change) - - def filter_projects(self, name_filter): - if not name_filter: - filtered_projects = self.projects - else: - filtered_projects = {pid: p for pid, p in self.projects.items() if name_filter.lower() in p.name.lower()} - if self.project_id in self.projects \ - and self.project_id not in filtered_projects: - # We maintain the current project in the combo even if it not found to prevent over-requesting - # until it is changed explicitly - filtered_projects.update({self.project_id: self.projects[self.project_id]}) - self.connect_projects() - def check_plugin_version_callback(self, response: QNetworkReply) -> None: """Inspect the plugin version backend expects and show a warning if it is incompatible w/ the plugin. @@ -3261,9 +2807,9 @@ def check_plugin_version_callback(self, response: QNetworkReply) -> None: """ server_version = response.readAll().data().decode('utf-8') - latest_reported_version = self.settings.value('latest_reported_version', self.plugin_version) + latest_reported_version = self.settings.value('latest_reported_version', self.app_context.plugin_version) - force_upgrade, recommend_upgrade = helpers.check_version(local_version=self.plugin_version, + force_upgrade, recommend_upgrade = helpers.check_version(local_version=self.app_context.plugin_version, server_version=server_version, latest_reported_version=latest_reported_version) if force_upgrade: @@ -3271,7 +2817,7 @@ def check_plugin_version_callback(self, response: QNetworkReply) -> None: "The server requires version {server_version}, your plugin is {local_version}\n" "Go to Plugins -> Manage and Install Plugins -> Upgradable").format( server_version=server_version, - local_version=self.plugin_version, + local_version=self.app_context.plugin_version, icon=QMessageBox.Warning)) self.version_ok = False self.dlg.close() @@ -3281,7 +2827,7 @@ def check_plugin_version_callback(self, response: QNetworkReply) -> None: "We recommend you to upgrade to get all the latest features\n" "Go to Plugins -> Manage and Install Plugins -> Upgradable").format( server_version=server_version, - local_version=self.plugin_version, + local_version=self.app_context.plugin_version, icon=QMessageBox.Information)) # saving the requested version to not bother the user next time, if he decides not to upgrade self.settings.setValue('latest_reported_version', server_version) @@ -3292,7 +2838,7 @@ def check_plugin_version_callback(self, response: QNetworkReply) -> None: self.version_ok = True def show_details(self): - processing = self.selected_processing() + processing = self.processing_service.selected_processing() if not processing: return error = None @@ -3305,46 +2851,6 @@ def show_details(self): dialog.setup(processing, error or None) dialog.deleteLater() - def update_processing(self): - processing = self.selected_processing() - if not processing: - return - dialog = UpdateProcessingDialog(self.dlg) - dialog.accepted.connect(lambda: self.processing_service.update_processing(processing.id_, - dialog.processing())) - dialog.setup(processing) - dialog.deleteLater() - - def create_project(self): - dialog = CreateProjectDialog(self.dlg) - dialog.accepted.connect(lambda: self.project_service.create_project(dialog.project())) - dialog.setup() - dialog.deleteLater() - - def get_project_sharing(self, project): - if not project: - return - if project.shareProject: - # Get user role, if project is shared - users = project.shareProject.users - for user in users: - if user.email == self.username: - self.user_role = UserRole(user.role) - # Get project owner - owners = project.shareProject.owners - for owner in owners: - if owner.email == self.username: - self.user_role = UserRole.owner - project_owner = owners[0].email - # Disable buttons - self.dlg.enable_shared_project(self.user_role) - # Specify new main window header - self.dlg.setWindowTitle(helpers.generate_plugin_header(self.plugin_name, - env=self.config.MAPFLOW_ENV, - project_name=project.name, - user_role=self.user_role, - project_owner=project_owner)) - def get_local_image_indices(self, selected_images): try: rows = list(set(image.row() for image in selected_images)) @@ -3356,12 +2862,12 @@ def get_local_image_indices(self, selected_images): def get_search_providers(self, local_image_indices): try: - provider_names = [self.search_footprints[local_image_index].attribute("providerName") + provider_names = [self.app_context.search_footprints[local_image_index].attribute("providerName") for local_image_index in local_image_indices] except KeyError: provider_names = [] try: - product_types = [self.search_footprints[local_image_index].attribute("productType") + product_types = [self.app_context.search_footprints[local_image_index].attribute("productType") for local_image_index in local_image_indices] except KeyError: product_types = [] @@ -3396,7 +2902,7 @@ def get_zoom(self, provider, local_image_indices, product_types): if isinstance(provider, ImagerySearchProvider): if local_image_indices: try: - zooms = [self.search_footprints[local_image_index].attribute("zoom") + zooms = [self.app_context.search_footprints[local_image_index].attribute("zoom") for local_image_index in local_image_indices] except KeyError: zooms = [] @@ -3485,7 +2991,7 @@ def main(self) -> None: self.dlg.close() return - if self.logged_in: + if self.app_context.logged_in: # with any auth method self.dlg.show() self.dlg.raise_() diff --git a/mapflow/schema/__init__.py b/mapflow/schema/__init__.py index b8282696..c20e3ff5 100644 --- a/mapflow/schema/__init__.py +++ b/mapflow/schema/__init__.py @@ -1,5 +1,5 @@ from .base import SkipDataClass -from .catalog import ImageCatalogRequestSchema, ImageCatalogResponseSchema +from .catalog import ImageCatalogRequestSchema, ImageCatalogResponseSchema, PreviewType, ProductType from .processing import (PostSourceSchema, PostProviderSchema, PostProcessingSchema, @@ -11,6 +11,12 @@ MyImagerySchema, ImagerySearchParams, ImagerySearchSchema, - UserDefinedParams) + UserDefinedParams, + ProcessingDTO, + UpdateProcessingSchema) from .provider import ProviderReturnSchema from .workflow_def import WorkflowDef, BlockConfig +from .status import ProcessingStatus, ProcessingReviewStatus +from .billing import BillingType +from .project import MapflowProject, UserRole +from .processing_history import ProcessingHistory diff --git a/mapflow/schema/base.py b/mapflow/schema/base.py index 1a0445f0..aaf4893c 100644 --- a/mapflow/schema/base.py +++ b/mapflow/schema/base.py @@ -2,6 +2,7 @@ import json from dataclasses import dataclass, fields from datetime import datetime +from typing import Optional @dataclass @@ -12,8 +13,11 @@ class SkipDataClass: This is abstract class and will do nothing, as it has no fields """ + @classmethod - def from_dict(cls, params_dict: dict): + def from_dict(cls, params_dict: Optional[dict]): + if not params_dict: + return None clsf = [f.name for f in fields(cls)] return cls(**{k: v for k, v in params_dict.items() if k in clsf}) diff --git a/mapflow/entity/billing.py b/mapflow/schema/billing.py similarity index 100% rename from mapflow/entity/billing.py rename to mapflow/schema/billing.py diff --git a/mapflow/schema/layer.py b/mapflow/schema/layer.py index 6f131130..dd473c61 100644 --- a/mapflow/schema/layer.py +++ b/mapflow/schema/layer.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from mapflow.schema import SkipDataClass +from .base import SkipDataClass @dataclass diff --git a/mapflow/schema/processing.py b/mapflow/schema/processing.py index c3069180..989735a3 100644 --- a/mapflow/schema/processing.py +++ b/mapflow/schema/processing.py @@ -3,12 +3,13 @@ from datetime import datetime, timedelta from typing import Optional, Mapping, Any, Union, Iterable, List from uuid import UUID + from .base import SkipDataClass, Serializable +from .status import ProcessingStatus, ProcessingReviewStatus +from .layer import RasterLayer, VectorLayer +from .workflow_def import WorkflowDef from ..entity.provider.provider import SourceType -from ..entity.status import ProcessingStatus, ProcessingReviewStatus from ..errors import ErrorMessage -from ..schema.layer import RasterLayer, VectorLayer -from .workflow_def import WorkflowDef @dataclass class PostSourceSchema(Serializable, SkipDataClass): @@ -114,7 +115,9 @@ class ProcessingParams(Serializable, SkipDataClass): UserDefinedParams] @classmethod - def from_dict(cls, params_dict: dict): + def from_dict(cls, params_dict: Optional[dict]): + if not params_dict: + return None clsf = [f.name for f in fields(cls)] processing_params = cls(**{k: v for k, v in params_dict.items() if k in clsf}) source_params = processing_params.sourceParams @@ -167,32 +170,63 @@ class ProcessingDTO(Serializable, SkipDataClass): rasterLayer: RasterLayer vectorLayer: VectorLayer messages: list[ErrorMessage] - - percent_completed: int - review_status: ProcessingReviewStatus - in_review_until: datetime params: ProcessingParams blocks: List[BlockOption] + percentCompleted: Optional[int] = None + reviewStatus: Optional[ProcessingReviewStatus] = None + def __post_init__(self): - self.review_status = ProcessingReviewStatus(self.review_status) self.status = ProcessingStatus(self.status) self.created = datetime.strptime(self.created, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() 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) self.messages = [ErrorMessage.from_response(message) for message in self.messages] + self.rasterLayer = RasterLayer.from_dict(self.rasterLayer) + self.vectorLayer = VectorLayer.from_dict(self.vectorLayer) + if self.reviewStatus is None: + self.reviewStatus = ProcessingReviewStatus() + else: + self.reviewStatus = ProcessingReviewStatus.from_dict(self.reviewStatus) @property def review_expires(self): - if not isinstance(self.in_review_until, datetime)\ - or not self.review_status.is_in_review: + if not isinstance(self.reviewStatus.inReviewUntil, datetime)\ + or not self.reviewStatus.is_in_review: return False now = datetime.now().astimezone() one_day = timedelta(1) - return self.in_review_until - now < one_day + return self.reviewStatus.inReviewUntil - now < one_day + + @property + def reviewUntil(self): + """ + backwards compatibility + """ + return self.reviewStatus.inReviewUntil + + @property + def is_final_state(self): + """ + means that the processing is reached final state and can't change it without user interaction + """ + return self.status.is_terminal and not self.reviewStatus.is_not_accepted def error_message(self, raw=False): - if not self.errors: + if not self.messages: return "" - return "\n".join([error.to_str(raw=raw) for error in self.errors]) + return "\n".join([error.to_str(raw=raw) for error in self.messages]) + + def as_processing_table_dict(self): + return { + "name": self.name, + "workflowDef": self.workflowDef.name, + "status": self.status.value, + "percentCompleted": self.percentCompleted, + "aoiArea": self.aoiArea/1000000, + "cost": self.cost, + "created": self.created.strftime('%Y-%m-%d %H:%M'), + "reviewUntil": self.reviewUntil, + "id": self.id + } diff --git a/mapflow/schema/processing_history.py b/mapflow/schema/processing_history.py new file mode 100644 index 00000000..1b6fe7af --- /dev/null +++ b/mapflow/schema/processing_history.py @@ -0,0 +1,100 @@ +import json +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Optional, Dict, List, Set +from uuid import UUID + +from .status import ProcessingStatus +from .processing import ProcessingDTO + + +@dataclass +class ProcessingHistory: + """ + History of the processings for a specific project, including failed and finished processings, + that are stored in settings + + As project IDs are unique UUIDs, we can skip all other sections and just save/load by this key + """ + project_id: Optional[UUID] = None + processing_statuses: Dict[UUID, ProcessingStatus] = field(default_factory=dict) + + def to_settings(self, settings): + settings.setValue(f"processing_history_{self.project_id}", json.dumps({ + str(id_): status.value + for id_, status in self.processing_statuses.items() + if status.is_terminal + })) + + @classmethod + def from_settings(cls, settings, project_id: UUID): + data = settings.value(f"processing_history_{project_id}") + if not data: + return cls(project_id=project_id, processing_statuses={}) + parsed = json.loads(data) + return cls( + project_id=project_id, + processing_statuses={id_: ProcessingStatus(status) for id_, status in parsed.items()} + ) + + def is_finished(self, processing_id: UUID) -> bool: + return self.processing_statuses.get(processing_id) == ProcessingStatus.ok + + def is_failed(self, processing_id: UUID) -> bool: + return self.processing_statuses.get(processing_id) == ProcessingStatus.failed + + def add(self, processing_id: UUID, status: ProcessingStatus): + self.processing_statuses[processing_id] = status + + def update(self, processings: Dict[UUID, ProcessingDTO], settings) -> Dict[str, List[UUID]]: + """ + Update processing statuses from current processings. + + - Updates current statuses in memory + - Detects changes to terminal statuses (OK, FAILED, etc.) + - Persists to settings if terminal status changes occurred + - Returns report of newly terminal processings for notifications + + Args: + processings: Dict mapping processing ID to ProcessingDTO + settings: QgsSettings instance for persistence + + Returns: + Dict with status names as keys and lists of processing IDs that + newly entered that terminal status. Example: + {"OK": [uuid1, uuid2], "FAILED": [uuid3]} + """ + terminal_changes = defaultdict(list) + for processing_id, processing in processings.items(): + # in case it's UUID + processing_id = str(processing_id) + old_status = self.processing_statuses.get(processing_id) + new_status = processing.status + + # Skip if status unchanged + if old_status == new_status: + continue + + # Update in-memory status + self.processing_statuses[processing_id] = new_status + + # Check if this is a NEW terminal status (wasn't terminal before) + if new_status.is_terminal: + # Only report if it wasn't already in a terminal state + status_name = new_status.value + terminal_changes[status_name].append(processing_id) + + # Persist to settings only if terminal statuses changed + if terminal_changes: + print(f"Saving to settings: {len(self.processing_statuses)}") + self.to_settings(settings) + + return terminal_changes + + def cleanup_missing(self, current_ids: Set[UUID]) -> None: + """Remove entries for processings that no longer exist in the project""" + self.processing_statuses = { + pid: status + for pid, status in self.processing_statuses.items() + if pid in current_ids + } diff --git a/mapflow/schema/project.py b/mapflow/schema/project.py index 5bb9cb6c..61ff8a61 100644 --- a/mapflow/schema/project.py +++ b/mapflow/schema/project.py @@ -37,6 +37,15 @@ def __post_init__(self): if self.users: self.users = [ShareProjectUser.from_dict(item) for item in self.users] + def get_user_role(self, email): + for owner in self.owners: + if owner.email == email: + return UserRole.owner + for user in self.users: + if user.email == email: + return UserRole(user.role) + + @dataclass class MapflowProjectInfo(SkipDataClass): id: str @@ -49,8 +58,8 @@ class MapflowProject(SkipDataClass): name: str isDefault: bool description: Optional[str] - workflowDefs: Optional[List[dict]] = None - shareProject: Optional[Dict[str, ShareProject]] = None + workflowDefs: Optional[dict] = None + shareProject: Optional[ShareProject] = None updated: Optional[datetime] = None created: Optional[datetime] = None processingCounts: Optional[Dict[str, int]] = None @@ -58,14 +67,14 @@ class MapflowProject(SkipDataClass): def __post_init__(self): if self.workflowDefs: - self.workflowDefs = [WorkflowDef.from_dict(item) for item in self.workflowDefs] + self.workflowDefs = {item['id']: WorkflowDef.from_dict(item) for item in self.workflowDefs} else: - self.workflowDefs = [] + self.workflowDefs = {} if self.shareProject: self.shareProject = ShareProject.from_dict(self.shareProject) else: - self.shareProject = [] + 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")) @@ -110,3 +119,7 @@ class ProjectsResult(SkipDataClass): results: Optional[List[MapflowProject]] = None total: int = 0 count: int = None + + def __post_init__(self): + if self.results: + self.results = [MapflowProject.from_dict(proj) for proj in self.results] diff --git a/mapflow/schema/status.py b/mapflow/schema/status.py new file mode 100644 index 00000000..9850ff22 --- /dev/null +++ b/mapflow/schema/status.py @@ -0,0 +1,128 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Optional + +from PyQt5.QtCore import QObject + +from .base import Serializable, SkipDataClass + + +class ProcessingStatusDict(QObject): + def __init__(self): + super().__init__() + self.value_map = {None: None, + 'OK': self.tr("Ok"), + 'IN_PROGRESS': self.tr("In progress"), + 'FAILED': self.tr("Failed"), + 'REFUNDED': self.tr("Refunded"), + 'CANCELLED': self.tr("Cancelled"), + 'AWAITING': self.tr("Awaiting")} + + +class ProcessingReviewStatusDict(QObject): + def __init__(self): + super().__init__() + self.value_map = {None: None, + 'IN_REVIEW': self.tr("Review required"), + 'NOT_ACCEPTED': self.tr("In review"), + 'REFUNDED': self.tr("Refunded"), + 'ACCEPTED': self.tr("Ok")} + + +class NamedEnum(Enum): + def __init__(self, value): + super().__init__() + self.value_map = {} + + @property + def display_value(self): + return self.value_map.get(self.value, self.value) + + +class ProcessingStatus(NamedEnum): + none = None + ok = 'OK' + in_progress = 'IN_PROGRESS' + failed = 'FAILED' + refunded = 'REFUNDED' + cancelled = 'CANCELLED' + awaiting = 'AWAITING' + + def __init__(self, value): + super().__init__(value) + self.value_map = ProcessingStatusDict().value_map + + @property + def is_ok(self): + return self == ProcessingStatus.ok + + @property + def is_in_progress(self): + return self == ProcessingStatus.in_progress + + @property + def is_failed(self): + return self == ProcessingStatus.failed + + @property + def is_refunded(self): + return self == ProcessingStatus.refunded + + @property + def is_cancelled(self): + return self == ProcessingStatus.cancelled + + @property + def is_awaiting(self): + return self == ProcessingStatus.awaiting + + @property + def is_terminal(self): + return self.is_ok or self.is_failed or self.is_refunded or self.is_cancelled + + +class ProcessingReviewStatusEnum(NamedEnum): + none = None + in_review = 'IN_REVIEW' + not_accepted = 'NOT_ACCEPTED' + refunded = 'REFUNDED' + accepted = 'ACCEPTED' + + def __init__(self, value): + super().__init__(value) + self.value_map = ProcessingReviewStatusDict().value_map + + +@dataclass +class ProcessingReviewStatus(Serializable, SkipDataClass): + reviewStatus: Optional[ProcessingReviewStatusEnum] = None + inReviewUntil: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Optional[dict]): + """Handle None input by returning instance with defaults.""" + if data is None: + return cls() + return super().from_dict(data) + + def __post_init__(self): + if self.inReviewUntil: + self.inReviewUntil = datetime.strptime(self.inReviewUntil, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + self.reviewStatus = ProcessingReviewStatusEnum(self.reviewStatus) + + @property + def is_in_review(self): + return self.reviewStatus == ProcessingReviewStatusEnum.in_review + + @property + def is_not_accepted(self): + return self.reviewStatus == ProcessingReviewStatusEnum.not_accepted + + @property + def is_none(self): + return self.reviewStatus == ProcessingReviewStatusEnum.none + + @property + def is_accepted(self): + return self.reviewStatus == ProcessingReviewStatusEnum.accepted diff --git a/mapflow/schema/workflow_def.py b/mapflow/schema/workflow_def.py index 16a8648d..31685d94 100644 --- a/mapflow/schema/workflow_def.py +++ b/mapflow/schema/workflow_def.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional, List -from mapflow.schema import SkipDataClass +from .base import SkipDataClass @dataclass