From 8b4effc4859e7014f05ebf7b3f69674b18c9b301 Mon Sep 17 00:00:00 2001 From: Alexey Trekin Date: Tue, 5 May 2026 13:26:00 +0500 Subject: [PATCH] use client-side timezone for display, UTC for internal storage of time. Search imagery capture time is left at UTC --- mapflow/config.py | 9 ++++++-- mapflow/entity/processing.py | 17 ++++++++------- mapflow/entity/status.py | 4 ++-- .../functional/service/processing_service.py | 7 +++++-- mapflow/functional/view/data_catalog_view.py | 13 +++++++----- mapflow/functional/view/processing_view.py | 2 +- mapflow/mapflow.py | 6 ++++-- mapflow/schema/base.py | 18 +++++++++++++++- mapflow/schema/catalog.py | 4 ++-- mapflow/schema/data_catalog.py | 8 +++---- mapflow/schema/processing.py | 21 ++++++------------- mapflow/schema/project.py | 6 +++--- mapflow/schema/status.py | 4 ++-- tests/test_data_catalog.py | 3 ++- tests/test_processing_templates.py | 11 ++++++++++ 15 files changed, 83 insertions(+), 50 deletions(-) diff --git a/mapflow/config.py b/mapflow/config.py index e7a0dbb3..e522bac7 100644 --- a/mapflow/config.py +++ b/mapflow/config.py @@ -4,6 +4,9 @@ from PyQt5.QtCore import QCoreApplication from qgis.core import QgsSettings + +SEARCH_CAPTURE_TIMEZONE = 'UTC' + @dataclass class ConfigColumns(): def __init__(self): @@ -15,7 +18,7 @@ def __init__(self): QCoreApplication.translate('Config', 'Band Order'): 'colorBandOrder', QCoreApplication.translate('Config', 'Cloud %'): 'cloudCover', QCoreApplication.translate('Config', 'Off Nadir') + f' \N{DEGREE SIGN}': 'offNadirAngle', - QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=time.localtime().tm_zone): 'acquisitionDate', + QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=SEARCH_CAPTURE_TIMEZONE): 'acquisitionDate', QCoreApplication.translate('Config', 'Zoom level'): 'zoom', QCoreApplication.translate('Config', 'Spatial Resolution, m'): 'pixelResolution', QCoreApplication.translate('Config', 'Image ID'): 'id', @@ -80,7 +83,9 @@ class Config: PPRVIEW_INDEX_COLUMN = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('preview') NAME_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('providerName') ZOOM_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.values()).index('zoom') - MAXAR_DATETIME_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index(QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=TIMEZONE)) + MAXAR_DATETIME_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index( + QCoreApplication.translate('Config', 'Date & Time') + ' ({t})'.format(t=SEARCH_CAPTURE_TIMEZONE) + ) MAXAR_CLOUD_COLUMN_INDEX = tuple(ConfigColumns().METADATA_TABLE_ATTRIBUTES.keys()).index(QCoreApplication.translate('Config', 'Cloud %')) MAXAR_MAX_FREE_ZOOM = 12 diff --git a/mapflow/entity/processing.py b/mapflow/entity/processing.py index 16d35b6d..098cefe6 100644 --- a/mapflow/entity/processing.py +++ b/mapflow/entity/processing.py @@ -1,10 +1,11 @@ import sys -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import List, Dict, Optional, Tuple from .status import ProcessingStatus, ProcessingReviewStatus from ..errors import ErrorMessage from ..schema.processing import BlockOption, ProcessingParams +from ..schema.base import parse_api_datetime_utc class Processing: @@ -34,7 +35,7 @@ def __init__(self, self.workflow_def = workflow_def self.aoi_area = aoi_area self.cost = int(cost) - self.created = created.astimezone() + self.created = parse_api_datetime_utc(created) self.percent_completed = int(percent_completed) self.errors = errors self.raster_layer = raster_layer @@ -61,7 +62,7 @@ def from_response(cls, processing): created = processing['created'].replace('Z', '+0000') else: created = processing['created'] - created = datetime.strptime(created, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + created = parse_api_datetime_utc(created) percent_completed = processing['percentCompleted'] messages = processing.get('messages', []) errors = [ErrorMessage.from_response(message) for message in messages] @@ -71,7 +72,7 @@ def from_response(cls, processing): review_status = processing.get('reviewStatus', {}).get('reviewStatus') in_review_until_str = processing.get('reviewStatus', {}).get('inReviewUntil') if in_review_until_str: - in_review_until = datetime.strptime(in_review_until_str, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + in_review_until = parse_api_datetime_utc(in_review_until_str) else: in_review_until = None else: @@ -100,7 +101,7 @@ def from_response(cls, processing): @property def is_new(self): - now = datetime.now().astimezone() + now = datetime.now(timezone.utc) one_day = timedelta(1) return now - self.created < one_day @@ -109,7 +110,7 @@ def review_expires(self): if not isinstance(self.in_review_until, datetime)\ or not self.review_status.is_in_review: return False - now = datetime.now().astimezone() + now = datetime.now(timezone.utc) one_day = timedelta(1) return self.in_review_until - now < one_day @@ -129,9 +130,9 @@ def asdict(self): 'percentCompleted': self.percent_completed, 'errors': self.errors, # Serialize datetime and drop seconds for brevity - 'created': self.created.strftime('%Y-%m-%d %H:%M'), + 'created': self.created.astimezone().strftime('%Y-%m-%d %H:%M'), 'rasterLayer': self.raster_layer, - 'reviewUntil': self.in_review_until.strftime('%Y-%m-%d %H:%M') if self.in_review_until else "", + 'reviewUntil': self.in_review_until.astimezone().strftime('%Y-%m-%d %H:%M') if self.in_review_until else "", 'description': self.description, 'meta': self.meta } diff --git a/mapflow/entity/status.py b/mapflow/entity/status.py index a3b9d8f2..95873455 100644 --- a/mapflow/entity/status.py +++ b/mapflow/entity/status.py @@ -5,7 +5,7 @@ from PyQt5.QtCore import QObject -from ..schema.base import Serializable, SkipDataClass +from ..schema.base import Serializable, SkipDataClass, parse_api_datetime_utc class ProcessingStatusDict(QObject): @@ -118,7 +118,7 @@ def from_dict(cls, data: Optional[dict]): def __post_init__(self): if self.inReviewUntil: - self.inReviewUntil = datetime.strptime(self.inReviewUntil, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + self.inReviewUntil = parse_api_datetime_utc(self.inReviewUntil) self.reviewStatus = ProcessingReviewStatusEnum(self.reviewStatus) @property diff --git a/mapflow/functional/service/processing_service.py b/mapflow/functional/service/processing_service.py index 59404f2e..0a62f61d 100644 --- a/mapflow/functional/service/processing_service.py +++ b/mapflow/functional/service/processing_service.py @@ -730,14 +730,17 @@ def open_template_details(self): template = self.selected_template() if not template: return + + local_created_at = template.createdAt.astimezone() + local_active_until = template.activeUntil.astimezone() # Show template details in a message box for now # TODO: Create a proper template details dialog details = ( f"{template.name}
" f"Status: {template.status}
" - f"Created: {template.createdAt.strftime('%Y-%m-%d %H:%M')}
" - f"Active Until: {template.activeUntil.strftime('%Y-%m-%d %H:%M')}
" + f"Created: {local_created_at.strftime('%Y-%m-%d %H:%M')}
" + f"Active Until: {local_active_until.strftime('%Y-%m-%d %H:%M')}
" f"Active: {'Yes' if template.isActive else 'No'}
" f"Archived: {'Yes' if template.isArchived else 'No'}
" f"New Images: {template.newImagesCount or 0}
" diff --git a/mapflow/functional/view/data_catalog_view.py b/mapflow/functional/view/data_catalog_view.py index d147f081..3aa000dc 100644 --- a/mapflow/functional/view/data_catalog_view.py +++ b/mapflow/functional/view/data_catalog_view.py @@ -194,8 +194,9 @@ def display_mosaic_info(self, mosaic: MosaicReturnSchema, images: list[ImageRetu pixel_size=pixel_size, crs=images[0].meta_data.crs, count=images[0].meta_data.count) + local_created_at = mosaic.created_at.astimezone() text += self.tr("Created: {date} at {time} \nTags: {tags}" - ).format(date=mosaic.created_at.date(), time=mosaic.created_at.strftime('%H:%M'), + ).format(date=local_created_at.date(), time=local_created_at.strftime('%H:%M'), tags=tags_str) self.dlg.catalogInfo.setText(text) self.display_image_number(0, len(images)) @@ -245,6 +246,7 @@ def full_image_info(self, image: ImageReturnSchema): # Otherwise - CRS is most likely projected else: pixel_size = round(pixel_size, 2) # meters or other units + local_uploaded_at = image.uploaded_at.astimezone() message = self.tr('Name: {filename}\
Uploaded
: {date} at {time}\
Size
: {file_size}\ @@ -254,8 +256,8 @@ def full_image_info(self, image: ImageReturnSchema):
Height
: {height} pixels\
Pixel size
: {pixel_size}'\ ).format(filename=image.filename, - date=image.uploaded_at.date(), - time=image.uploaded_at.strftime('%H:%M'), + date=local_uploaded_at.date(), + time=local_uploaded_at.strftime('%H:%M'), file_size=get_readable_size(image.file_size), crs=image.meta_data.crs, bands=image.meta_data.count, @@ -423,13 +425,14 @@ def show_image_info(self, image: ImageReturnSchema): # Otherwise - CRS is most likely projected else: pixel_size = round(pixel_size, 2) # meters or other units + local_uploaded_at = image.uploaded_at.astimezone() self.dlg.catalogInfo.setText(self.tr("Uploaded: {date} at {time} \n" "File size: {size} \n" "Pixel size: {pixel_size} \n" "CRS: {crs} \n" "Bands: {count}" - ).format(date=image.uploaded_at.date(), - time=image.uploaded_at.strftime('%H:%M'), + ).format(date=local_uploaded_at.date(), + time=local_uploaded_at.strftime('%H:%M'), size=get_readable_size(image.file_size), pixel_size=pixel_size, crs=image.meta_data.crs, diff --git a/mapflow/functional/view/processing_view.py b/mapflow/functional/view/processing_view.py index 74816a1c..2512c46e 100644 --- a/mapflow/functional/view/processing_view.py +++ b/mapflow/functional/view/processing_view.py @@ -205,7 +205,7 @@ def create_table_items(self, processing: Union[ProcessingDTO, ProcessingTemplate table_item.setToolTip(self.tr("Please review or accept this processing until {}." " Double click to add results" " to the map").format( - processing.reviewUntil.strftime('%Y-%m-%d %H:%M') if processing.reviewUntil else "")) + processing.reviewUntil.astimezone().strftime('%Y-%m-%d %H:%M') if processing.reviewUntil else "")) elif processing.status.is_ok: table_item.setToolTip(self.tr("Double click to add results to the map." )) diff --git a/mapflow/mapflow.py b/mapflow/mapflow.py index 69584172..954fb9bd 100644 --- a/mapflow/mapflow.py +++ b/mapflow/mapflow.py @@ -587,12 +587,14 @@ def show_template_details_and_navigation(self, template): linked_processings = self._linked_processings_from_template(template) linked_count = len(linked_processings) new_images = template.newImagesCount or 0 + local_created_at = template.createdAt.astimezone() + local_active_until = template.activeUntil.astimezone() details = ( f"{template.name}
" f"Status: {template.status}
" - f"Created: {template.createdAt.strftime('%Y-%m-%d %H:%M')}
" - f"Active Until: {template.activeUntil.strftime('%Y-%m-%d %H:%M')}
" + f"Created: {local_created_at.strftime('%Y-%m-%d %H:%M')}
" + f"Active Until: {local_active_until.strftime('%Y-%m-%d %H:%M')}
" f"Linked processings: {linked_count}
" f"New images: {new_images}" ) diff --git a/mapflow/schema/base.py b/mapflow/schema/base.py index aaf4893c..723a6e5f 100644 --- a/mapflow/schema/base.py +++ b/mapflow/schema/base.py @@ -1,7 +1,7 @@ import dataclasses import json from dataclasses import dataclass, fields -from datetime import datetime +from datetime import datetime, timezone from typing import Optional @@ -44,3 +44,19 @@ def as_dict(self, skip_none=True): def as_json(self, skip_none=True): return json.dumps(self.as_dict(skip_none=skip_none)) + + +def parse_api_datetime_utc(value: Optional[object]) -> Optional[datetime]: + """Parse API datetime preserving UTC semantics for internal state.""" + if value is None or value == "": + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + if isinstance(value, str): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + raise TypeError(f"Unsupported datetime value type: {type(value)}") diff --git a/mapflow/schema/catalog.py b/mapflow/schema/catalog.py index 79e77e69..27b8bb12 100644 --- a/mapflow/schema/catalog.py +++ b/mapflow/schema/catalog.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Optional, Mapping, Any, Union, List -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc from ..config import Config @@ -77,7 +77,7 @@ class ImageSchema(Serializable, SkipDataClass): def __post_init__(self): if isinstance(self.acquisitionDate, str): - self.acquisitionDate = datetime.fromisoformat(self.acquisitionDate.replace("Z", "+00:00")) + self.acquisitionDate = parse_api_datetime_utc(self.acquisitionDate) elif not isinstance(self.acquisitionDate, datetime): raise TypeError("Acquisition date must be either datetime or ISO-formatted str") self.cloudCover = self.cloudCover diff --git a/mapflow/schema/data_catalog.py b/mapflow/schema/data_catalog.py index 56c29444..ecf72e74 100644 --- a/mapflow/schema/data_catalog.py +++ b/mapflow/schema/data_catalog.py @@ -4,7 +4,7 @@ from typing import Sequence, Union, Optional, List from dataclasses import dataclass -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc from .layer import RasterLayer @@ -41,7 +41,7 @@ class MosaicCreateReturnSchema(SkipDataClass): tags: Union[Sequence[str], None] = () def __post_init__(self): - self.created_at = datetime.fromisoformat(self.created_at.replace("Z", "+00:00")) + self.created_at = parse_api_datetime_utc(self.created_at) @dataclass class MosaicReturnSchema(SkipDataClass): @@ -54,7 +54,7 @@ class MosaicReturnSchema(SkipDataClass): tags: Union[Sequence[str], None] = () def __post_init__(self): - self.created_at = datetime.fromisoformat(self.created_at.replace("Z", "+00:00")) + self.created_at = parse_api_datetime_utc(self.created_at) self.rasterLayer = RasterLayer.from_dict(self.rasterLayer) @@ -87,5 +87,5 @@ class ImageReturnSchema(SkipDataClass): available_for_download: bool = True def __post_init__(self): - self.uploaded_at = datetime.fromisoformat(self.uploaded_at.replace("Z", "+00:00")) + self.uploaded_at = parse_api_datetime_utc(self.uploaded_at) self.meta_data = ImageMetadataSchema.from_dict(self.meta_data) diff --git a/mapflow/schema/processing.py b/mapflow/schema/processing.py index 461304ce..2c87efde 100644 --- a/mapflow/schema/processing.py +++ b/mapflow/schema/processing.py @@ -2,11 +2,11 @@ from qgis.core import QgsVectorLayer from dataclasses import dataclass, fields -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional, Mapping, Any, Union, Iterable, List from uuid import UUID -from .base import SkipDataClass, Serializable +from .base import SkipDataClass, Serializable, parse_api_datetime_utc from .status import ProcessingStatus, ProcessingReviewStatus from .layer import RasterLayer, VectorLayer from .workflow_def import WorkflowDef @@ -155,16 +155,7 @@ class PostProcessingSchemaV2(Serializable): def _parse_iso_datetime(dt_str: str) -> datetime: """Parse ISO 8601 datetime strings with or without microseconds.""" - if not dt_str: - return None - # Handle both formats: with and without microseconds - # e.g., "2025-09-26T06:25:55.820336Z" or "2026-03-15T17:00:00Z" - try: - # Try format with microseconds first - return datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S.%fZ').replace(tzinfo=None).astimezone() - except ValueError: - # Fall back to format without microseconds - return datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=None).astimezone() + return parse_api_datetime_utc(dt_str) @dataclass @@ -281,7 +272,7 @@ def as_processing_table_dict(self): "percentCompleted": "N/A", "aoiArea": self.aoi_area, "cost": None, - "created": self.createdAt.strftime('%Y-%m-%d %H:%M'), + "created": self.createdAt.astimezone().strftime('%Y-%m-%d %H:%M'), "reviewUntil": None, "id": self.id, } @@ -374,7 +365,7 @@ def review_expires(self): if not isinstance(self.reviewStatus.inReviewUntil, datetime)\ or not self.reviewStatus.is_in_review: return False - now = datetime.now().astimezone() + now = datetime.now(timezone.utc) one_day = timedelta(1) return self.reviewStatus.inReviewUntil - now < one_day @@ -407,7 +398,7 @@ def as_processing_table_dict(self): "percentCompleted": self.percentCompleted, "aoiArea": self.aoiArea/1000000, "cost": self.cost, - "created": self.created.strftime('%Y-%m-%d %H:%M'), + "created": self.created.astimezone().strftime('%Y-%m-%d %H:%M'), "reviewUntil": self.reviewUntil, "id": self.id } diff --git a/mapflow/schema/project.py b/mapflow/schema/project.py index 989b9c6f..1b3401f6 100644 --- a/mapflow/schema/project.py +++ b/mapflow/schema/project.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Optional, List, Dict -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc from .workflow_def import WorkflowDef from ..config import Config @@ -78,8 +78,8 @@ def __post_init__(self): else: self.shareProject = None if self.created and self.updated: - self.created = datetime.fromisoformat(self.created.replace("Z", "+00:00")) - self.updated = datetime.fromisoformat(self.updated.replace("Z", "+00:00")) + self.created = parse_api_datetime_utc(self.created) + self.updated = parse_api_datetime_utc(self.updated) class UserRole(str, Enum): readonly = "readonly" diff --git a/mapflow/schema/status.py b/mapflow/schema/status.py index 86e41bd4..08bea75e 100644 --- a/mapflow/schema/status.py +++ b/mapflow/schema/status.py @@ -5,7 +5,7 @@ from PyQt5.QtCore import QObject -from .base import Serializable, SkipDataClass +from .base import Serializable, SkipDataClass, parse_api_datetime_utc class ProcessingStatusDict(QObject): @@ -114,7 +114,7 @@ def from_dict(cls, data: Optional[dict]): def __post_init__(self): if self.inReviewUntil: - self.inReviewUntil = datetime.strptime(self.inReviewUntil, '%Y-%m-%dT%H:%M:%S.%f%z').astimezone() + self.inReviewUntil = parse_api_datetime_utc(self.inReviewUntil) self.reviewStatus = ProcessingReviewStatusEnum(self.reviewStatus) @property diff --git a/tests/test_data_catalog.py b/tests/test_data_catalog.py index fdf497e7..b8b91d0c 100644 --- a/tests/test_data_catalog.py +++ b/tests/test_data_catalog.py @@ -4,7 +4,7 @@ """ import json from unittest.mock import MagicMock, patch -from datetime import datetime +from datetime import datetime, timedelta import pytest @@ -68,6 +68,7 @@ def test_uploaded_at_parsed(self): data = _image_data() image = ImageReturnSchema.from_dict(data) assert isinstance(image.uploaded_at, datetime) + assert image.uploaded_at.utcoffset() == timedelta(0) def test_meta_data_parsed(self): """meta_data dict is parsed into ImageMetadataSchema.""" diff --git a/tests/test_processing_templates.py b/tests/test_processing_templates.py index 60713e55..e61fdb21 100644 --- a/tests/test_processing_templates.py +++ b/tests/test_processing_templates.py @@ -1,5 +1,6 @@ import json from unittest.mock import MagicMock +from datetime import timedelta import pytest @@ -50,6 +51,11 @@ def test_template_dto_from_dict(self): assert dto.isArchived is False assert dto.maxAoiIntersectionPercent == 85.5 + def test_template_datetimes_kept_in_utc(self): + dto = ProcessingTemplateDTO.from_dict(_template_payload()) + assert dto.createdAt.utcoffset() == timedelta(0) + assert dto.activeUntil.utcoffset() == timedelta(0) + def test_template_details_from_dict(self): details = ProcessingTemplateDetails.from_dict({ "template": _template_payload(), @@ -159,6 +165,11 @@ def test_template_table_mapping(self, payload_updates, expected_status): assert table_row["status"] == expected_status assert table_row["aoiArea"] is None + def test_template_table_created_is_localized_for_display(self): + dto = ProcessingTemplateDTO.from_dict(_template_payload()) + table_row = dto.as_processing_table_dict() + assert table_row["created"] == dto.createdAt.astimezone().strftime("%Y-%m-%d %H:%M") + class TestTemplateApi: def setup_method(self):