Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/date.py: 40%

158 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +0000

1import datetime 

2import logging 

3import pytz 

4import typing as t 

5import tzlocal 

6import warnings 

7 

8from viur.core import conf, current, db, i18n 

9from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity 

10from viur.core.utils import utcNow 

11 

12 

13class DateBone(BaseBone): 

14 """ 

15 DateBone is a bone that can handle date and/or time information. It can store date and time information 

16 separately, as well as localize the time based on the user's timezone. 

17 

18 :param bool creationMagic: Use the current time as value when creating an entity; ignoring this bone if the 

19 entity gets updated. 

20 :param bool updateMagic: Use the current time whenever this entity is saved. 

21 :param bool date: If True, the bone will contain date information. 

22 :param time: If True, the bone will contain time information. 

23 :param localize: If True, the user's timezone is assumed for input and output. This is only valid if both 'date' 

24 and 'time' are set to True. By default, UTC time is used. 

25 """ 

26 # FIXME: the class has no parameters; merge with __init__ 

27 type = "date" 

28 

29 def __init__( 

30 self, 

31 *, 

32 date: bool = True, 

33 localize: bool = None, 

34 naive: bool = False, 

35 time: bool = True, 

36 

37 # deprecated: 

38 creationMagic: bool = False, 

39 updateMagic: bool = False, 

40 **kwargs 

41 ): 

42 """ 

43 Initializes a new DateBone. 

44 

45 :param creationMagic: Use the current time as value when creating an entity; ignoring this bone if the 

46 entity gets updated. 

47 :param updateMagic: Use the current time whenever this entity is saved. 

48 :param date: Should this bone contain a date-information? 

49 :param time: Should this bone contain time information? 

50 :param localize: Assume users timezone for in and output? Only valid if this bone 

51 contains date and time-information! Per default, UTC time is used. 

52 :param naive: Use naive datetime for this bone, the default is aware. 

53 """ 

54 super().__init__(**kwargs) 

55 

56 # Either date or time must be set 

57 if not (date or time): 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true

58 raise ValueError("Attempt to create an empty DateBone! Set date or time to True!") 

59 

60 # Localize-flag only possible with date and time 

61 if localize and not (date and time): 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true

62 raise ValueError("Localization is only possible with date and time!") 

63 # Default localize all DateBones, if not explicitly defined 

64 elif localize is None and not naive: 64 ↛ 67line 64 didn't jump to line 67 because the condition on line 64 was always true

65 localize = date and time 

66 

67 if naive and localize: 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true

68 raise ValueError("Localize and naive is not possible!") 

69 

70 # Magic is only possible in non-multiple bones and why ever only on readonly bones... 

71 # FIXME: VIUR4 remove any magical things and finally start to write robust and relisient software... 

72 if creationMagic or updateMagic: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 _depmsg = "'creationMagic/updateMagic' is deprecated; Use 'compute'-features instead!" 

74 logging.warning(_depmsg) 

75 warnings.warn(_depmsg, DeprecationWarning, stacklevel=2) 

76 

77 if self.multiple: 

78 raise ValueError("Cannot be multiple and have a creation/update-magic set!") 

79 

80 self.readonly = True # todo: why??? 

81 

82 self.creationMagic = creationMagic # FIXME: VIUR4 remove this 

83 self.updateMagic = updateMagic # FIXME: VIUR4 remove this 

84 self.date = date 

85 self.time = time 

86 self.localize = localize 

87 self.naive = naive 

88 

89 def singleValueFromClient(self, value, skel, bone_name, client_data): 

90 """ 

91 Reads a value from the client. If the value is valid for this bone, it stores the value and returns None. 

92 Otherwise, the previous value is left unchanged, and an error message is returned. 

93 The value is assumed to be in the local time zone only if both self.date and self.time are set to True and 

94 self.localize is True. 

95 **Value is valid if, when converted into String, it complies following formats:** 

96 is digit (may include one '-') and valid POSIX timestamp: converted from timestamp; 

97 assumes UTC timezone 

98 is digit (may include one '-') and NOT valid POSIX timestamp and not date and time: interpreted as 

99 seconds after epoch 

100 'now': current time 

101 'nowX', where X converted into String is added as seconds to current time 

102 '%H:%M:%S' if not date and time 

103 '%M:%S' if not date and time 

104 '%S' if not date and time 

105 '%Y-%m-%d %H:%M:%S' (ISO date format) 

106 '%Y-%m-%d %H:%M' (ISO date format) 

107 '%Y-%m-%d' (ISO date format) 

108 '%m/%d/%Y %H:%M:%S' (US date-format) 

109 '%m/%d/%Y %H:%M' (US date-format) 

110 '%m/%d/%Y' (US date-format) 

111 '%d.%m.%Y %H:%M:%S' (EU date-format) 

112 '%d.%m.%Y %H:%M' (EU date-format) 

113 '%d.%m.%Y' (EU date-format) 

114 

115 The resulting year must be >= 1900. 

116 

117 :param bone_name: Our name in the skeleton 

118 :param client_data: *User-supplied* request-data, has to be of valid format 

119 :returns: tuple[datetime or None, [Errors] or None] 

120 """ 

121 time_zone = self.guessTimeZone() 

122 value = str(value) # always enforce value to be a str 

123 

124 if value.replace("-", "", 1).replace(".", "", 1).isdigit(): 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 if int(value) < -1 * (2 ** 30) or int(value) > (2 ** 31) - 2: 

126 value = None 

127 else: 

128 value = datetime.datetime.fromtimestamp(float(value), tz=time_zone).replace(microsecond=0) 

129 

130 elif not self.date and self.time: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true

131 try: 

132 value = datetime.datetime.fromisoformat(value) 

133 

134 except ValueError: 

135 try: 

136 if value.count(":") > 1: 

137 (hour, minute, second) = [int(x.strip()) for x in value.split(":")] 

138 value = datetime.datetime( 

139 year=1970, 

140 month=1, 

141 day=1, 

142 hour=hour, 

143 minute=minute, 

144 second=second, 

145 tzinfo=time_zone, 

146 ) 

147 elif value.count(":") > 0: 

148 (hour, minute) = [int(x.strip()) for x in value.split(":")] 

149 value = datetime.datetime(year=1970, month=1, day=1, hour=hour, minute=minute, tzinfo=time_zone) 

150 elif value.replace("-", "", 1).isdigit(): 

151 value = datetime.datetime(year=1970, month=1, day=1, second=int(value), tzinfo=time_zone) 

152 else: 

153 value = None 

154 

155 except ValueError: 

156 value = None 

157 

158 elif value.lower().startswith("now"): 

159 now = datetime.datetime.now(time_zone) 

160 if len(value) > 4: 

161 try: 

162 now += datetime.timedelta(seconds=int(value[3:])) 

163 except ValueError: 

164 now = None 

165 

166 value = now 

167 

168 else: 

169 # try to parse ISO-formatted date string 

170 try: 

171 value = datetime.datetime.fromisoformat(value) 

172 except ValueError: 

173 # otherwise, test against several format strings 

174 for fmt in ( 174 ↛ 191line 174 didn't jump to line 191 because the loop on line 174 didn't complete

175 "%Y-%m-%d %H:%M:%S", 

176 "%m/%d/%Y %H:%M:%S", 

177 "%d.%m.%Y %H:%M:%S", 

178 "%Y-%m-%d %H:%M", 

179 "%m/%d/%Y %H:%M", 

180 "%d.%m.%Y %H:%M", 

181 "%Y-%m-%d", 

182 "%m/%d/%Y", 

183 "%d.%m.%Y", 

184 ): 

185 try: 

186 value = datetime.datetime.strptime(value, fmt) 

187 break 

188 except ValueError: 

189 continue 

190 else: 

191 value = None 

192 

193 if not value: 

194 return self.getEmptyValue(), [ 

195 ReadFromClientError(ReadFromClientErrorSeverity.Invalid) 

196 ] 

197 

198 if value.tzinfo and self.naive: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 return self.getEmptyValue(), [ 

200 ReadFromClientError( 

201 ReadFromClientErrorSeverity.Invalid, 

202 i18n.translate("core.bones.error.datetimenaive", "Datetime must be naive") 

203 ) 

204 ] 

205 

206 if not value.tzinfo and not self.naive: 

207 value = time_zone.localize(value) 

208 

209 # remove microseconds 

210 # TODO: might become configurable 

211 value = value.replace(microsecond=0) 

212 

213 if err := self.isInvalid(value): 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)] 

215 

216 return value, None 

217 

218 def isInvalid(self, value): 

219 """ 

220 Validates the input value to ensure that the year is greater than or equal to 1900. If the year is less 

221 than 1900, it returns an error message. Otherwise, it calls the superclass's isInvalid method to perform 

222 any additional validations. 

223 

224 This check is important because the strftime function, which is used to format dates in Python, will 

225 break if the year is less than 1900. 

226 

227 :param datetime value: The input value to be validated, expected to be a datetime object. 

228 

229 :returns: An error message if the year is less than 1900, otherwise the result of calling 

230 the superclass's isInvalid method. 

231 :rtype: str or None 

232 """ 

233 if isinstance(value, datetime.datetime): 233 ↛ 237line 233 didn't jump to line 237 because the condition on line 233 was always true

234 if value.year < 1900: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 return "Year must be >= 1900" 

236 

237 return super().isInvalid(value) 

238 

239 def guessTimeZone(self): 

240 """ 

241 Tries to guess the user's time zone based on request headers. If the time zone cannot be guessed, it 

242 falls back to using the UTC time zone. The guessed time zone is then cached for future use during the 

243 current request. 

244 

245 :returns: The guessed time zone for the user or a default time zone (UTC) if the time zone cannot be guessed. 

246 :rtype: pytz timezone object 

247 """ 

248 if self.naive: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 return None 

250 if not (self.date and self.time and self.localize): 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true

251 return pytz.utc 

252 

253 if conf.instance.is_dev_server: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 return pytz.timezone(tzlocal.get_localzone_name()) 

255 

256 timeZone = pytz.utc # Default fallback 

257 currReqData = current.request_data.get() 

258 

259 try: 

260 # Check the local cache first 

261 if "timeZone" in currReqData: 261 ↛ anywhereline 261 didn't jump anywhere: it always raised an exception.

262 return currReqData["timeZone"] 

263 headers = current.request.get().request.headers 

264 if "X-Appengine-Country" in headers: 

265 country = headers["X-Appengine-Country"] 

266 else: # Maybe local development Server - no way to guess it here 

267 return timeZone 

268 tzList = pytz.country_timezones[country] 

269 except: # Non-User generated request (deferred call; task queue etc), or no pytz 

270 return timeZone 

271 if len(tzList) == 1: # Fine - the country has exactly one timezone 

272 timeZone = pytz.timezone(tzList[0]) 

273 elif country.lower() == "us": # Fallback for the US 

274 timeZone = pytz.timezone("EST") 

275 elif country.lower() == "de": # For some freaking reason Germany is listed with two timezones 

276 timeZone = pytz.timezone("Europe/Berlin") 

277 elif country.lower() == "au": 

278 timeZone = pytz.timezone("Australia/Canberra") # Equivalent to NSW/Sydney :) 

279 else: # The user is in a Country which has more than one timezone 

280 pass 

281 currReqData["timeZone"] = timeZone # Cache the result 

282 return timeZone 

283 

284 def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool): 

285 """ 

286 Prepares a single value for storage by removing any unwanted parts of the datetime object, such as 

287 microseconds or adjusting the date and time components depending on the configuration of the dateBone. 

288 The method also ensures that the datetime object is timezone aware. 

289 

290 :param datetime value: The input datetime value to be serialized. 

291 :param SkeletonInstance skel: The instance of the skeleton that contains this bone. 

292 :param str name: The name of the bone in the skeleton. 

293 :param bool parentIndexed: A boolean indicating if the parent bone is indexed. 

294 :returns: The serialized datetime value with unwanted parts removed and timezone-aware. 

295 :rtype: datetime 

296 """ 

297 if value: 

298 # Crop unwanted values to zero 

299 value = value.replace(microsecond=0) 

300 if not self.time: 

301 value = value.replace(hour=0, minute=0, second=0) 

302 elif not self.date: 

303 value = value.replace(year=1970, month=1, day=1) 

304 if self.naive: 

305 value = value.replace(tzinfo=datetime.timezone.utc) 

306 # We should always deal with timezone aware datetimes 

307 assert value.tzinfo, f"Encountered a naive Datetime object in {name} - refusing to save." 

308 return value 

309 

310 def singleValueUnserialize(self, value): 

311 """ 

312 Converts the serialized datetime value back to its original form. If the datetime object is timezone aware, 

313 it adjusts the timezone based on the configuration of the dateBone. 

314 

315 :param datetime value: The input serialized datetime value to be unserialized. 

316 :returns: The unserialized datetime value with the appropriate timezone applied or None if the input 

317 value is not a valid datetime object. 

318 :rtype: datetime or None 

319 """ 

320 if isinstance(value, datetime.datetime): 

321 # Serialized value is timezone aware. 

322 if self.naive: 

323 value = value.replace(tzinfo=None) 

324 return value 

325 else: 

326 # If local timezone is needed, set here, else force UTC. 

327 time_zone = self.guessTimeZone() 

328 return value.astimezone(time_zone) 

329 else: 

330 # We got garbage from the datastore 

331 return None 

332 

333 def buildDBFilter(self, 

334 name: str, 

335 skel: 'viur.core.skeleton.SkeletonInstance', 

336 dbFilter: db.Query, 

337 rawFilter: dict, 

338 prefix: t.Optional[str] = None) -> db.Query: 

339 """ 

340 Constructs a datastore filter for date and/or time values based on the given raw filter. It parses the 

341 raw filter and, if successful, applies it to the datastore query. 

342 

343 :param str name: The name of the dateBone in the skeleton. 

344 :param SkeletonInstance skel: The skeleton instance containing the dateBone. 

345 :param db.Query dbFilter: The datastore query to which the filter will be applied. 

346 :param Dict rawFilter: The raw filter dictionary containing the filter values. 

347 :param Optional[str] prefix: An optional prefix to use for the filter key, defaults to None. 

348 :returns: The datastore query with the constructed filter applied. 

349 :rtype: db.Query 

350 """ 

351 for key in [x for x in rawFilter.keys() if x.startswith(name)]: 

352 resDict = {} 

353 if not self.fromClient(resDict, key, rawFilter): # Parsing succeeded 

354 super().buildDBFilter(name, skel, dbFilter, {key: resDict[key]}, prefix=prefix) 

355 

356 return dbFilter 

357 

358 def performMagic(self, valuesCache, name, isAdd): 

359 """ 

360 Automatically sets the current date and/or time for a dateBone when a new entry is created or an 

361 existing entry is updated, depending on the configuration of creationMagic and updateMagic. 

362 

363 :param dict valuesCache: The cache of values to be stored in the datastore. 

364 :param str name: The name of the dateBone in the skeleton. 

365 :param bool isAdd: A flag indicating whether the operation is adding a new entry (True) or updating an 

366 existing one (False). 

367 """ 

368 if (self.creationMagic and isAdd) or self.updateMagic: 

369 if self.naive: 

370 valuesCache[name] = utcNow().replace(microsecond=0, tzinfo=None) 

371 else: 

372 valuesCache[name] = utcNow().replace(microsecond=0).astimezone(self.guessTimeZone()) 

373 

374 def structure(self) -> dict: 

375 return super().structure() | { 

376 "date": self.date, 

377 "time": self.time, 

378 "naive": self.naive 

379 } 

380 

381 def _atomic_dump(self, value): 

382 if not value: 

383 return None 

384 if not isinstance(value, datetime.datetime): 

385 raise ValueError("Expecting datetime object") 

386 

387 return value.isoformat()