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):