diff --git a/api/src/api/routes/site.py b/api/src/api/routes/site.py index 3ac5171b13811dd3ddd0e35065e82662e2d8912b..928da6c155d260a3bc22875de6677b3d0d56eccc 100644 --- a/api/src/api/routes/site.py +++ b/api/src/api/routes/site.py @@ -69,8 +69,8 @@ def api_route_is_running(): def _db_execute_get_homepage(session: SessionDb, is_mod: bool): - upcoming_start: date = date.today() - upcoming_end: date = upcoming_start + timedelta(days=7) + upcoming_start: datetime = get_standard_datetime_now().replace(hour=0, minute=0, second=0, microsecond=0) + upcoming_end: datetime = upcoming_start + timedelta(days=7) upcoming_lectures = session.scalars( Lecture.select({ diff --git a/common_py/src/videoag_common/api_object/fields/basic_fields.py b/common_py/src/videoag_common/api_object/fields/basic_fields.py index 675d33d00f752fbbfe34878b1213ad4d2a277013..6df057b56b7c825d600d5c2b0fc8823dc8fc7c40 100644 --- a/common_py/src/videoag_common/api_object/fields/basic_fields.py +++ b/common_py/src/videoag_common/api_object/fields/basic_fields.py @@ -169,8 +169,8 @@ class ApiDatetimeField[_O: "ApiObject"](ApiSimpleColumnField[_O]): def post_init_check(self, context: FieldContext): super().post_init_check(context) - if not isinstance(self._column.type, sql.types.DateTime): # pragma: no cover - raise TypeError(f"SQL type for datetime field must be datetime, but is '{self._column.type}'") + if not isinstance(self._column.type, UTCTimestamp): # pragma: no cover + raise TypeError(f"SQL type for datetime field must be UTCTimestamp (our custom type), but is '{self._column.type}'") @property def may_be_none_allowed_for_config_fields(self) -> bool: diff --git a/common_py/src/videoag_common/database/__init__.py b/common_py/src/videoag_common/database/__init__.py index a97d49858ce23545114f0977df0397dd54351f69..a65007e6524b942ce9deba88f39fc912519ef707 100644 --- a/common_py/src/videoag_common/database/__init__.py +++ b/common_py/src/videoag_common/database/__init__.py @@ -19,3 +19,6 @@ from .database import ( TransactionConflictError, Database, ) +from .utc_timestamp import ( + UTCTimestamp +) diff --git a/common_py/src/videoag_common/database/drift_detector.py b/common_py/src/videoag_common/database/drift_detector.py index 8d5becc64e858049402a7442e0fa0ce9063c100f..72963a913f9f3ec1c77677f337087ce16222e01a 100644 --- a/common_py/src/videoag_common/database/drift_detector.py +++ b/common_py/src/videoag_common/database/drift_detector.py @@ -10,6 +10,8 @@ from sqlalchemy.sql.base import _NoneName, ReadOnlyColumnCollection from sqlalchemy.sql.schema import ColumnCollectionConstraint, ForeignKey from sqlalchemy.dialects import postgresql as postgresql +from .utc_timestamp import UTCTimestamp + # # This file attempts its best to detect any difference between the schema in the python files and the actual database. @@ -264,6 +266,9 @@ def _check_types_equal(actual: types.TypeEngine, schema: types.TypeEngine) -> bo if type(schema) is types.TIMESTAMP: return isinstance(actual, types.TIMESTAMP) + if type(schema) is UTCTimestamp: + return isinstance(actual, types.TIMESTAMP) + if isinstance(schema, types.Enum): if not isinstance(actual, types.Enum): return False diff --git a/common_py/src/videoag_common/database/utc_timestamp.py b/common_py/src/videoag_common/database/utc_timestamp.py new file mode 100644 index 0000000000000000000000000000000000000000..da05bf1221cc02a7871f851aefe4586e27fb2a36 --- /dev/null +++ b/common_py/src/videoag_common/database/utc_timestamp.py @@ -0,0 +1,27 @@ +import datetime +# noinspection PyPep8Naming +from datetime import datetime as Datetime +import sqlalchemy as sql + + +class UTCTimestamp(sql.TypeDecorator[Datetime]): + impl = sql.TIMESTAMP() + cache_ok = True + + @property + def python_type(self) -> type[Datetime]: + return Datetime + + def process_bind_param(self, value: Datetime | None, dialect: sql.Dialect) -> Datetime | None: + if value is None: + return None + if not value.tzinfo: + raise ValueError(f"Missing timezone in datetime: {value}") + return value.astimezone(datetime.timezone.utc) + + def process_result_value(self, value: Datetime | None, dialect: sql.Dialect) -> Datetime | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.UTC) + return value diff --git a/common_py/src/videoag_common/miscellaneous/util.py b/common_py/src/videoag_common/miscellaneous/util.py index 4ab8d61e667a21e7c663f5f40a6ba3e48fb128a2..8cb2a82933cebf3e18180f7926cce5c985208f8a 100644 --- a/common_py/src/videoag_common/miscellaneous/util.py +++ b/common_py/src/videoag_common/miscellaneous/util.py @@ -35,10 +35,17 @@ def pad_string(val: str, padding: str, length: int): return (padding * math.ceil( (length - len(val)) / len(padding) )) + val -# Python doesn't have a formatter for milliseconds -# Don't just remove last three numbers with microseconds. See here: https://stackoverflow.com/a/35643540 def format_standard_datetime(dt: Datetime): - return dt.astimezone(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%S.") + pad_string(str(dt.microsecond // 1000), "0", 3) + "Z" + def zero_pad(val: int, length: int): + return pad_string(str(val), "0", length) + + # Python's strftime can't handle milliseconds and is unreliably regarding the length (e.g. sometimes microseconds or + # years are not padded to full length) https://stackoverflow.com/a/35643540 + # So we just format it ourselves + dt = dt.astimezone(datetime.UTC) + return (f"{zero_pad(dt.year, 4)}-{zero_pad(dt.month, 2)}-{zero_pad(dt.day, 2)}" + f"T{zero_pad(dt.hour, 2)}:{zero_pad(dt.minute, 2)}:{zero_pad(dt.second, 2)}" + f".{zero_pad(dt.microsecond // 1000, 3)}Z") def parse_standard_datetime(val: str): diff --git a/common_py/src/videoag_common/objects/changelog.py b/common_py/src/videoag_common/objects/changelog.py index 2eb3e33a78637e9d41d9d9e4dad4542b6b47abdd..8b44281019545c21d2b0fc28f5b17c3a1dc6f90b 100644 --- a/common_py/src/videoag_common/objects/changelog.py +++ b/common_py/src/videoag_common/objects/changelog.py @@ -23,7 +23,7 @@ class ChangelogEntry(ApiObject, Base): } change_time: Mapped[datetime] = api_mapped( - mapped_column(TIMESTAMP(), nullable=False, index=True, server_default=sql.text("CURRENT_TIMESTAMP")), + mapped_column(UTCTimestamp(), nullable=False, index=True, server_default=sql.text("CURRENT_TIMESTAMP")), ApiDatetimeField( include_in_data=True ) diff --git a/common_py/src/videoag_common/objects/course.py b/common_py/src/videoag_common/objects/course.py index 82ae8c9c34f7f391d900c45da55efdeaff56701f..72a3af04602d8dc1dbb59e8be6c7d6615b3659c3 100644 --- a/common_py/src/videoag_common/objects/course.py +++ b/common_py/src/videoag_common/objects/course.py @@ -108,7 +108,7 @@ class Lecture(DeletableApiObject, VisibilityApiObject, ApiViewPermissionsObject, ) ) time: Mapped[datetime] = api_mapped( - mapped_column(TIMESTAMP(), nullable=False, index=True), + mapped_column(UTCTimestamp(), nullable=False, index=True), ApiDatetimeField( include_in_config=True, config_directly_modifiable=True, include_in_data=True @@ -154,7 +154,7 @@ class Lecture(DeletableApiObject, VisibilityApiObject, ApiViewPermissionsObject, ) ) publish_time: Mapped[datetime] = api_mapped( - mapped_column(TIMESTAMP(), nullable=True, index=True), + mapped_column(UTCTimestamp(), nullable=True, index=True), ApiDatetimeField( include_in_data=True, data_only_mod=True ) diff --git a/common_py/src/videoag_common/objects/job.py b/common_py/src/videoag_common/objects/job.py index 80d60d5909cf77180471afc4f66edd455ecf9a67..89e77cda6df772ac3540ef8b4be209c97c53e4a1 100644 --- a/common_py/src/videoag_common/objects/job.py +++ b/common_py/src/videoag_common/objects/job.py @@ -73,19 +73,19 @@ class Job(ApiObject, Base): ) ) creation_time: Mapped[datetime] = api_mapped( - mapped_column(sql.DateTime(), nullable=False, server_default=sql.text("CURRENT_TIMESTAMP")), + mapped_column(UTCTimestamp(), nullable=False, server_default=sql.text("CURRENT_TIMESTAMP")), ApiDatetimeField( include_in_data=True ) ) run_start_time: Mapped[datetime] = api_mapped( - mapped_column(sql.DateTime(), nullable=True), + mapped_column(UTCTimestamp(), nullable=True), ApiDatetimeField( include_in_data=True ) ) run_end_time: Mapped[datetime] = api_mapped( - mapped_column(sql.DateTime(), nullable=True), + mapped_column(UTCTimestamp(), nullable=True), ApiDatetimeField( include_in_data=True ) diff --git a/common_py/src/videoag_common/objects/medium.py b/common_py/src/videoag_common/objects/medium.py index 52a539c0db255e26111b4fd00a428f9706392c49..c8de5dcabd76371d0ba67c9fd9d512dea024c8ff 100644 --- a/common_py/src/videoag_common/objects/medium.py +++ b/common_py/src/videoag_common/objects/medium.py @@ -74,7 +74,7 @@ class SorterFile(DeletableApiObject, Base): ) ) file_modification_time: Mapped[datetime] = api_mapped( - mapped_column(sql.DateTime, nullable=False), + mapped_column(UTCTimestamp, nullable=False), ApiDatetimeField( include_in_data=True ) @@ -101,7 +101,7 @@ class SorterFile(DeletableApiObject, Base): ) ) update_time: Mapped[datetime] = api_mapped( - mapped_column(sql.DateTime, nullable=False), + mapped_column(UTCTimestamp, nullable=False), ApiDatetimeField( include_in_data=True ) diff --git a/common_py/src/videoag_common/objects/site.py b/common_py/src/videoag_common/objects/site.py index 0d0e420036ee57a4c39fcc34de533a642000b85e..39d65386f87f149ac050d7be017f17bc0ead3253 100644 --- a/common_py/src/videoag_common/objects/site.py +++ b/common_py/src/videoag_common/objects/site.py @@ -49,14 +49,14 @@ class Announcement(DeletableApiObject, VisibilityApiObject, Base): ) ) publish_time: Mapped[datetime] = api_mapped( - mapped_column(TIMESTAMP(), nullable=True), + mapped_column(UTCTimestamp(), nullable=True), ApiDatetimeField( include_in_config=True, include_in_data=True, data_only_mod=True ) ) expiration_time: Mapped[datetime] = api_mapped( - mapped_column(TIMESTAMP(), nullable=True), + mapped_column(UTCTimestamp(), nullable=True), ApiDatetimeField( include_in_config=True, include_in_data=True, data_only_mod=True diff --git a/common_py/src/videoag_common/objects/stats.py b/common_py/src/videoag_common/objects/stats.py index 566fbfe4a639617a9c1c6eb17f4d2d7eb6cddd38..7488074f4fac2c35ca17da3a49ca212873492171 100755 --- a/common_py/src/videoag_common/objects/stats.py +++ b/common_py/src/videoag_common/objects/stats.py @@ -12,7 +12,7 @@ from videoag_common.miscellaneous import JsonSerializableEnum class PublishMediumWatchLogEntry(Base): watch_id: Mapped[str] = mapped_column(String(length=64, collation=STRING_COLLATION), nullable=False, primary_key=True) - timestamp: Mapped[datetime] = mapped_column(TIMESTAMP(), nullable=False, primary_key=True, + timestamp: Mapped[datetime] = mapped_column(UTCTimestamp(), nullable=False, primary_key=True, server_default=sql.text("CURRENT_TIMESTAMP")) # No foreign key to improve performance. Values are validated when aggregating publish_medium_id: Mapped[int] = mapped_column(nullable=False) diff --git a/common_py/src/videoag_common/objects/user.py b/common_py/src/videoag_common/objects/user.py index 53b2ec0c8d9bfd6ad0c058be87529d5a9dd2a8fa..3aec3bf5967a950fc5582d08cb5f35161a0cfa05 100644 --- a/common_py/src/videoag_common/objects/user.py +++ b/common_py/src/videoag_common/objects/user.py @@ -36,7 +36,7 @@ class User(ApiObject, Base): ) ) - last_login: Mapped[datetime] = mapped_column(TIMESTAMP(), nullable=True) + last_login: Mapped[datetime] = mapped_column(UTCTimestamp(), nullable=True) enable_mail_notifications: Mapped[bool] = mapped_column(nullable=False, default=True) notify_new_video: Mapped[bool] = mapped_column(nullable=False, default=True) diff --git a/job_controller/jobs/source_file_sorter/job.py b/job_controller/jobs/source_file_sorter/job.py index 0cdc7e047c903f94c562c48130ebaa66f34f9fe8..f4b221ed8fdabfd6f0ef2816e2142c5a5839f5ef 100644 --- a/job_controller/jobs/source_file_sorter/job.py +++ b/job_controller/jobs/source_file_sorter/job.py @@ -2,7 +2,7 @@ import hashlib import logging import shutil import time -from datetime import date, datetime +from datetime import date, datetime, UTC from pathlib import Path import videoag_common @@ -40,7 +40,7 @@ def _check_file( to_sort_file_db_paths: list[str], file: Path ): - file_mod_time = datetime.fromtimestamp(file.lstat().st_mtime) + file_mod_time = datetime.fromtimestamp(file.lstat().st_mtime, tz=UTC) seconds_since_modification = time.time() - file.lstat().st_mtime error_message = None diff --git a/job_controller/src/job_controller/executor_api/job_executor_api.py b/job_controller/src/job_controller/executor_api/job_executor_api.py index 96d83c3b13c60e277b6b852dfc6c881d80ce9fe9..370a5c12d19c7737aa1e4de7312194b83fbd8934 100644 --- a/job_controller/src/job_controller/executor_api/job_executor_api.py +++ b/job_controller/src/job_controller/executor_api/job_executor_api.py @@ -17,7 +17,9 @@ class JobExecutionInfo(ABC): @abstractmethod def get_start_time(self) -> datetime or None: """ - Returns the time when the job execution started or None if the execution has not started yet + Returns the time when the job execution started or None if the execution has not started yet. + + Always with UTC timezone """ pass @@ -25,6 +27,8 @@ class JobExecutionInfo(ABC): def get_finish_time(self) -> datetime or None: """ Only call this if is_success() did not return None. May be None only if the job failed + + Always with UTC timezone """ pass diff --git a/job_controller/src/job_controller/executor_api/kubernetes_api.py b/job_controller/src/job_controller/executor_api/kubernetes_api.py index 1e62f010300227eff023ba3b665614ad30114ece..dd8b7ece6a17e8ddad0caddac1812abb87e7eb54 100644 --- a/job_controller/src/job_controller/executor_api/kubernetes_api.py +++ b/job_controller/src/job_controller/executor_api/kubernetes_api.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, UTC import kubernetes as k8s from kubernetes.client import ApiException @@ -54,11 +54,15 @@ class K8sJobInfo(JobExecutionInfo): def get_start_time(self) -> datetime or None: start_time = self._k8s_job.status.start_time assert start_time is None or isinstance(start_time, datetime) + if start_time is not None: + start_time = start_time.astimezone(UTC) return start_time - def get_finish_time(self) -> datetime: + def get_finish_time(self) -> datetime | None: finish_time = self._k8s_job.status.completion_time assert finish_time is None or isinstance(finish_time, datetime) + if finish_time is not None: + finish_time = finish_time.astimezone(UTC) return finish_time