Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions mapflow/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
from PyQt5.QtCore import QCoreApplication
from qgis.core import QgsSettings


SEARCH_CAPTURE_TIMEZONE = 'UTC'

@dataclass
class ConfigColumns():
def __init__(self):
Expand All @@ -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',
Expand Down Expand Up @@ -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

Expand Down
17 changes: 9 additions & 8 deletions mapflow/entity/processing.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions mapflow/entity/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions mapflow/functional/service/processing_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<b>{template.name}</b><br/>"
f"<b>Status:</b> {template.status}<br/>"
f"<b>Created:</b> {template.createdAt.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Active Until:</b> {template.activeUntil.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Created:</b> {local_created_at.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Active Until:</b> {local_active_until.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Active:</b> {'Yes' if template.isActive else 'No'}<br/>"
f"<b>Archived:</b> {'Yes' if template.isArchived else 'No'}<br/>"
f"<b>New Images:</b> {template.newImagesCount or 0}<br/>"
Expand Down
13 changes: 8 additions & 5 deletions mapflow/functional/view/data_catalog_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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('<b>Name</b>: {filename}\
<br><b>Uploaded</b></br>: {date} at {time}\
<br><b>Size</b></br>: {file_size}\
Expand All @@ -254,8 +256,8 @@ def full_image_info(self, image: ImageReturnSchema):
<br><b>Height</br></b>: {height} pixels\
<br><b>Pixel size</br></b>: {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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion mapflow/functional/view/processing_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
))
Expand Down
6 changes: 4 additions & 2 deletions mapflow/mapflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<b>{template.name}</b><br/>"
f"<b>Status:</b> {template.status}<br/>"
f"<b>Created:</b> {template.createdAt.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Active Until:</b> {template.activeUntil.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Created:</b> {local_created_at.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Active Until:</b> {local_active_until.strftime('%Y-%m-%d %H:%M')}<br/>"
f"<b>Linked processings:</b> {linked_count}<br/>"
f"<b>New images:</b> {new_images}"
)
Expand Down
18 changes: 17 additions & 1 deletion mapflow/schema/base.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)}")
4 changes: 2 additions & 2 deletions mapflow/schema/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions mapflow/schema/data_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand All @@ -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)


Expand Down Expand Up @@ -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)
21 changes: 6 additions & 15 deletions mapflow/schema/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 3 additions & 3 deletions mapflow/schema/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions mapflow/schema/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading