Coverage for functions \ flipdare \ util \ time_util.py: 78%

271 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13from dataclasses import dataclass 

14import pytz 

15from datetime import UTC, datetime, timedelta, tzinfo 

16from google.cloud.firestore_v1 import SERVER_TIMESTAMP as FIRESTORE_SERVER_TIMESTAMP 

17from google.cloud.firestore_v1.transforms import Sentinel 

18from flipdare.app_types import DatabaseTimeType 

19 

20__all__ = ["TypesenseTime", "FirestoreTime", "TimeUtil"] 

21 

22 

23@dataclass 

24class DateRange: 

25 from_date: datetime 

26 to_date: datetime 

27 

28 

29class TimeUtil: 

30 

31 def __init__(self) -> None: 

32 pass 

33 

34 # ---------------------------------------------------------------------------------------------- 

35 # TIMEs 

36 # ---------------------------------------------------------------------------------------------- 

37 

38 @staticmethod 

39 def get_current_utc_dt() -> datetime: 

40 """Get the current UTC time.""" 

41 return datetime.now(UTC) 

42 

43 @staticmethod 

44 def get_current_utc_timestamp() -> int: 

45 """Get the current UTC timestamp in seconds since epoch.""" 

46 return int(datetime.now(UTC).timestamp()) 

47 

48 @staticmethod 

49 def get_current_utc_float_time() -> float: 

50 """Get the current UTC time as a float timestamp (seconds since epoch).""" 

51 return float(datetime.now(UTC).timestamp()) 

52 

53 # ---------------------------------------------------------------------------------------------- 

54 # Modifiers 

55 # ---------------------------------------------------------------------------------------------- 

56 

57 @staticmethod 

58 def epoch_to_user_dt(epoch: float, user_tz_str: str | None = None) -> datetime: 

59 """Convert a float timestamp (seconds since epoch) to a datetime in user's timezone.""" 

60 utc_dt = TimeUtil.epoch_to_utc_dt(epoch) 

61 if user_tz_str is None: 

62 return utc_dt 

63 

64 user_tz = TimeUtil._get_user_tz(user_tz_str) 

65 return utc_dt.astimezone(user_tz) 

66 

67 @staticmethod 

68 def simple_epoch_to_utc_dt(epoch: int) -> datetime: 

69 """Convert a simple integer timestamp (seconds since epoch) to a UTC datetime.""" 

70 return datetime.fromtimestamp(epoch, tz=UTC) 

71 

72 @staticmethod 

73 def epoch_to_utc_dt(epoch: float) -> datetime: 

74 """Convert a float timestamp (seconds since epoch) to a UTC datetime.""" 

75 return datetime.fromtimestamp(epoch, tz=UTC) 

76 

77 @staticmethod 

78 def short_to_utc_dt(label: str) -> datetime: 

79 return datetime.strptime(label, "%d_%B_%Y").replace(tzinfo=UTC) 

80 

81 @staticmethod 

82 def dt_to_epoch(dt: datetime) -> int: 

83 """Convert a datetime to an integer timestamp (seconds since epoch).""" 

84 if dt.tzinfo is None: 

85 dt = dt.replace(tzinfo=UTC) 

86 return int(dt.timestamp()) 

87 

88 @staticmethod 

89 def dt_to_simple_epoch(dt: datetime) -> int: 

90 """Convert a datetime to a simple integer timestamp (seconds since epoch).""" 

91 if dt.tzinfo is None: 

92 dt = dt.replace(tzinfo=UTC) 

93 return int(dt.timestamp()) 

94 

95 # ---------------------------------------------------------------------------------------------- 

96 # PAST Times 

97 # ---------------------------------------------------------------------------------------------- 

98 

99 @staticmethod 

100 def get_start_of_day_utc() -> datetime: 

101 """Get the start of the current day in UTC.""" 

102 now = datetime.now(UTC) 

103 return datetime(year=now.year, month=now.month, day=now.day, tzinfo=UTC) 

104 

105 @staticmethod 

106 def get_utc_time_days_ago(days: int) -> datetime: 

107 """Get the UTC time a certain number of days ago.""" 

108 return datetime.now(UTC) - timedelta(days=days) 

109 

110 @staticmethod 

111 def get_utc_time_days_ago_from(from_when: datetime, days: int) -> datetime: 

112 """Get the UTC time a certain number of days ago from a specific datetime.""" 

113 return from_when - timedelta(days=days) 

114 

115 @staticmethod 

116 def get_utc_time_hours_ago(hours: int) -> datetime: 

117 """Get the UTC time a certain number of hours ago.""" 

118 return datetime.now(UTC) - timedelta(hours=hours) 

119 

120 @staticmethod 

121 def get_utc_time_minutes_ago(minutes: int) -> datetime: 

122 """Get the UTC time a certain number of minutes ago.""" 

123 return datetime.now(UTC) - timedelta(minutes=minutes) 

124 

125 @staticmethod 

126 def get_utc_time_future_hours(from_when: datetime, hours: int) -> datetime: 

127 """Get the UTC time a certain number of hours in the future from a specific datetime.""" 

128 return from_when + timedelta(hours=hours) 

129 

130 @staticmethod 

131 def get_utc_time_future_days_now(days: int) -> datetime: 

132 """Get the UTC time a certain number of days in the future.""" 

133 return datetime.now(UTC) + timedelta(days=days) 

134 

135 @staticmethod 

136 def get_utc_time_future_days(from_when: datetime, days: int) -> datetime: 

137 """Get the UTC time a certain number of days in the future.""" 

138 return from_when + timedelta(days=days) 

139 

140 # ---------------------------------------------------------------------------------------------- 

141 # Times as Strings 

142 # ---------------------------------------------------------------------------------------------- 

143 

144 @staticmethod 

145 def formatted_now() -> str: 

146 now = TimeUtil.get_current_utc_dt() 

147 return TimeUtil.formatted_dt(now) 

148 

149 @staticmethod 

150 def formatted_now_utc() -> str: 

151 """Get the current UTC time as an ISO formatted string.""" 

152 return TimeUtil.formatted_dt(datetime.now(UTC)) 

153 

154 @staticmethod 

155 def formatted_dt(dt: datetime) -> str: 

156 """Format a datetime object to a string.""" 

157 return dt.strftime("%Y-%m-%d %H:%M:%S UTC") 

158 

159 @staticmethod 

160 def formatted_seconds(seconds: int) -> str: 

161 """Format seconds into H:M:S string.""" 

162 hrs, rem = divmod(seconds, 3600) 

163 mins, secs = divmod(rem, 60) 

164 return f"{hrs}h {mins}m {secs}s" 

165 

166 @staticmethod 

167 def formatted_epoch(epoch: float) -> str: 

168 """Format a float timestamp (seconds since epoch) to a string.""" 

169 dt = TimeUtil.epoch_to_utc_dt(epoch) 

170 return TimeUtil.formatted_dt(dt) 

171 

172 @staticmethod 

173 def formatted_short(day: datetime) -> str: 

174 """Format a datetime object to a simple string format (e.g., '1_January_2024').""" 

175 return day.strftime("%#d_%B_%Y") 

176 

177 # ---------------------------------------------------------------------------------------------- 

178 # USER strings 

179 # ---------------------------------------------------------------------------------------------- 

180 

181 @staticmethod 

182 def formatted_user(dt: datetime, user_tz_str: str | None = None) -> str: 

183 """Format a datetime object to a string in user's timezone.""" 

184 from flipdare.app_log import LOG 

185 

186 user_tz = TimeUtil._get_user_tz(user_tz_str) 

187 try: 

188 user_dt = dt.astimezone(user_tz) 

189 return user_dt.strftime("%Y-%m-%d %H:%M:%S") 

190 except Exception as e: 

191 LOG().error(f"Error formatting datetime for user timezone {user_tz_str}: {e}") 

192 return TimeUtil.formatted_dt(dt) 

193 

194 @staticmethod 

195 def formatted_user_accurate(dt: datetime, user_tz_str: str | None = None) -> str: 

196 """Format a datetime object to a string in user's timezone with important format.""" 

197 from flipdare.app_log import LOG 

198 

199 if user_tz_str is None: 

200 return dt.strftime("%#d %B %Y at %#I:%M %p UTC") 

201 try: 

202 user_tz = pytz.timezone(user_tz_str) 

203 user_dt = dt.astimezone(user_tz) 

204 return user_dt.strftime("%#d %B %Y at %#I:%M %p") 

205 except Exception as e: 

206 LOG().error(f"Error formatting datetime for user timezone {user_tz_str}: {e}") 

207 return TimeUtil.formatted_dt(dt) 

208 

209 @staticmethod 

210 def formatted_user_day(dt: datetime, user_tz_str: str | None = None) -> str: 

211 """Format a datetime object to a string in user's timezone.""" 

212 from flipdare.app_log import LOG 

213 

214 if user_tz_str is None: 

215 return dt.strftime("%#d %B %Y") 

216 try: 

217 user_tz = pytz.timezone(user_tz_str) 

218 user_dt = dt.astimezone(user_tz) 

219 return user_dt.strftime("%#d %B %Y") 

220 except Exception as e: 

221 LOG().error(f"Error formatting datetime for user timezone {user_tz_str}: {e}") 

222 return TimeUtil.formatted_dt(dt) 

223 

224 # ---------------------------------------------------------------------------------------------- 

225 # TIME Ranges 

226 # ---------------------------------------------------------------------------------------------- 

227 

228 @staticmethod 

229 def last_week_epoch() -> tuple[float, float]: 

230 # 1. Start of 'this' week (Monday at 00:00:00) 

231 now = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) 

232 days_since_monday = now.weekday() 

233 this_monday_start = now - timedelta(days=days_since_monday) 

234 

235 # 2. Last week's boundaries 

236 last_monday_start = this_monday_start - timedelta(days=7) 

237 last_sunday_end = this_monday_start # This Monday 00:00 is the end of last Sunday 

238 

239 return last_monday_start.timestamp(), last_sunday_end.timestamp() 

240 

241 # ---------------------------------------------------------------------------------------------- 

242 # Operations 

243 # ---------------------------------------------------------------------------------------------- 

244 

245 @staticmethod 

246 def get_date_range( 

247 days: int, start: datetime | None = None, reverse: bool = False 

248 ) -> list[DateRange]: 

249 """Get a list of datetime ranges for the past number of days.""" 

250 if start is None: 

251 start = TimeUtil.get_start_of_day_utc() 

252 ranges = [ 

253 DateRange(start - timedelta(days=i + 1), start - timedelta(days=i)) 

254 for i in range(days) 

255 ] 

256 if reverse: 

257 ranges.reverse() 

258 return ranges 

259 

260 @staticmethod 

261 def duration_in_seconds(start: datetime, end: datetime) -> int: 

262 """Calculate the duration in seconds between two datetime objects.""" 

263 if start.tzinfo is None: 

264 start = start.replace(tzinfo=UTC) 

265 if end.tzinfo is None: 

266 end = end.replace(tzinfo=UTC) 

267 return int((end - start).total_seconds()) 

268 

269 @staticmethod 

270 def is_older_than( 

271 dt: datetime, 

272 seconds: int | None = None, 

273 minutes: int | None = None, 

274 hours: int | None = None, 

275 days: int | None = None, 

276 ) -> bool: 

277 """Check if a datetime is older than a certain number of seconds from now.""" 

278 if minutes is not None: 

279 seconds = minutes * 60 

280 if hours is not None: 

281 seconds = hours * 3600 

282 if days is not None: 

283 seconds = days * 86400 

284 if seconds is None: 

285 raise ValueError("Either seconds, minutes, hours, or days must be provided.") 

286 

287 cutoff = datetime.now(UTC) - timedelta(seconds=seconds) 

288 return dt < cutoff 

289 

290 @staticmethod 

291 def is_newer_than( 

292 dt: datetime, 

293 seconds: int | None = None, 

294 minutes: int | None = None, 

295 hours: int | None = None, 

296 ) -> bool: 

297 """Check if a datetime is newer than a certain number of seconds from now.""" 

298 if minutes is not None: 

299 seconds = minutes * 60 

300 if hours is not None: 

301 seconds = hours * 3600 

302 if seconds is None: 

303 raise ValueError("Either seconds, minutes, or hours must be provided.") 

304 cutoff = datetime.now(UTC) - timedelta(seconds=seconds) 

305 return dt > cutoff 

306 

307 # ---------------------------------------------------------------------------------------------- 

308 # Helpers 

309 # ---------------------------------------------------------------------------------------------- 

310 

311 @staticmethod 

312 def _get_user_tz(user_tz_str: str | None = None) -> pytz.BaseTzInfo | tzinfo: 

313 if user_tz_str is None: 

314 return UTC 

315 

316 from flipdare.app_log import LOG 

317 

318 try: 

319 return pytz.timezone(user_tz_str) 

320 except Exception as e: 

321 LOG().error(f"Error getting timezone for user timezone string {user_tz_str}: {e}") 

322 return UTC 

323 

324 

325class TypesenseTime: 

326 

327 @staticmethod 

328 def server_timestamp() -> int: 

329 return TimeUtil.get_current_utc_timestamp() 

330 

331 @staticmethod 

332 def from_any(timestamp: int | Sentinel | datetime | str | None) -> int: 

333 """Convert Firestore UTC time to integer timestamp (seconds since epoch).""" 

334 if isinstance(timestamp, int): 

335 return timestamp 

336 

337 if isinstance(timestamp, Sentinel): 

338 return TypesenseTime.server_timestamp() 

339 

340 firestore_time = FirestoreTime.from_firestore(timestamp) 

341 

342 if firestore_time is None: 

343 return TimeUtil.get_current_utc_timestamp() 

344 if firestore_time.tzinfo is None: 

345 firestore_time = firestore_time.replace(tzinfo=UTC) 

346 return int(firestore_time.timestamp()) 

347 

348 @staticmethod 

349 def from_firestore(timestamp: DatabaseTimeType) -> int: 

350 """Convert Firestore UTC time to integer timestamp (seconds since epoch).""" 

351 if isinstance(timestamp, Sentinel): 

352 return TypesenseTime.server_timestamp() 

353 

354 firestore_time = FirestoreTime.from_firestore(timestamp) 

355 

356 if firestore_time is None: 

357 return TimeUtil.get_current_utc_timestamp() 

358 if firestore_time.tzinfo is None: 

359 firestore_time = firestore_time.replace(tzinfo=UTC) 

360 return int(firestore_time.timestamp()) 

361 

362 @staticmethod 

363 def to_utc_datetime(timestamp: int | None = None) -> datetime | None: 

364 """Convert integer timestamp (seconds since epoch) to UTC datetime.""" 

365 if timestamp is None: 

366 return None 

367 return datetime.fromtimestamp(timestamp, tz=UTC) 

368 

369 

370class FirestoreTime: 

371 

372 @staticmethod 

373 def server_timestamp() -> Sentinel: 

374 return FIRESTORE_SERVER_TIMESTAMP 

375 

376 @staticmethod 

377 def age_in_days(timestamp: DatabaseTimeType) -> int: 

378 """Calculate the age of a Firestore timestamp from now in days.""" 

379 dt = FirestoreTime.from_firestore(timestamp) 

380 if dt is None: 

381 return 0 

382 

383 return (datetime.now(UTC) - dt).days 

384 

385 @staticmethod 

386 def formatted(timestamp: DatabaseTimeType) -> str: 

387 """Format Firestore timestamp to string.""" 

388 dt = FirestoreTime.from_firestore(timestamp) 

389 if dt is None: 

390 # most likely a new object with a sentinel.. 

391 # special case, since not stored in db, just set to current server time 

392 dt = TimeUtil.get_current_utc_dt() 

393 

394 return dt.strftime("%Y-%m-%d %H:%M:%S UTC") 

395 

396 @staticmethod 

397 def to_firestore(timestamp: DatabaseTimeType) -> Sentinel | datetime | None: 

398 """ 

399 Convert various timestamp formats to Firestore timestamp format. 

400 Note, this will be in UTC time. 

401 """ 

402 from flipdare.app_log import LOG 

403 

404 if timestamp is None: 

405 return FirestoreTime.server_timestamp() 

406 

407 try: 

408 # Sentinel check 

409 if isinstance(timestamp, Sentinel): 

410 return timestamp 

411 

412 # Datetime check (handles DatetimeWithNanoseconds too) 

413 if isinstance(timestamp, datetime): 

414 # Convert to UTC if timezone-aware, add UTC if naive 

415 return ( 

416 timestamp.astimezone(UTC) 

417 if timestamp.tzinfo 

418 else timestamp.replace(tzinfo=UTC) 

419 ) 

420 

421 # If it's a string, fromisoformat handles 'Z' and offsets automatically in 3.11+ 

422 # if isinstance(timestamp, str): 

423 return datetime.fromisoformat(timestamp) 

424 

425 except Exception as error: 

426 LOG().error(f"Error converting timestamp {timestamp}: {error}") 

427 

428 return None 

429 

430 @staticmethod 

431 def internal_str(timestamp: DatabaseTimeType) -> str: 

432 """ 

433 Convert Firestore timestamp to Python datetime. 

434 Note, this will be in UTC time 

435 """ 

436 timestamp = FirestoreTime.from_firestore(timestamp) 

437 if timestamp is None: 

438 return "N/A" 

439 return TimeUtil.formatted_dt(timestamp) 

440 

441 @staticmethod 

442 def from_firestore(timestamp: DatabaseTimeType) -> datetime | None: 

443 """Convert Firestore timestamp to a standardized UTC datetime.""" 

444 match timestamp: 

445 case None: 

446 return None 

447 

448 # Sentinel check - ensure this is handled before attribute checks 

449 case obj if "Sentinel" in str(type(obj)): 

450 return None 

451 

452 case datetime() as dt: 

453 # DatetimeWithNanoseconds is caught here as it's a datetime subclass. 

454 # .astimezone(timezone.utc) is the safest method in 3.13. 

455 return dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC) 

456 

457 case str() as ts_str: 

458 try: 

459 # 3.11+ handles 'Z' and ISO formats natively 

460 dt = datetime.fromisoformat(ts_str.removesuffix(" UTC")) 

461 # If naive (no tzinfo), assume it's already UTC; if aware, convert to UTC 

462 return dt.replace(tzinfo=UTC) if not dt.tzinfo else dt.astimezone(UTC) 

463 except ValueError: 

464 return None 

465 

466 # 2. Use getattr to safely check for attributes on unknown objects. 

467 # This will only be reached if the object is NOT a datetime or str. 

468 case obj if (s := getattr(obj, "seconds", None)) is not None and ( 

469 ns := getattr(obj, "nanoseconds", None) 

470 ) is not None: 

471 return datetime.fromtimestamp(s + ns / 1e9, tz=UTC) 

472 

473 case _: 

474 return None