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

163 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +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: Deprecated, use `compute` instead. Use the current time as value when 

46 creating an entity; ignoring this bone if the entity gets updated. 

47 :param updateMagic: Deprecated, use `compute` instead. Use the current time whenever this 

48 entity is saved. 

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

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

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

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

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

54 """ 

55 super().__init__(**kwargs) 

56 

57 # Either date or time must be set 

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

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

60 

61 # Localize-flag only possible with date and time 

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

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

64 # Default localize all DateBones, if not explicitly defined 

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

66 localize = date and time 

67 

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

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

70 

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

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

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

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

75 logging.warning(_depmsg) 

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

77 

78 if self.multiple: 

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

80 

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, only if date and time 

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

102 only if date and time 

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

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

105 '%S' if not date and time 

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

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

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

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

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

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

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

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

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

115 

116 The resulting year must be >= 1900. 

117 

118 :param bone_name: Our name in the skeleton 

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

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

121 """ 

122 time_zone = self.guessTimeZone() 

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

124 

125 if value.replace("-", "", 1).replace(".", "", 1).isdigit(): 

126 # The test above only strips one "-" and one "." from anywhere in the string, so it also 

127 # passes for values float() cannot read at all ("1-2", "12-", "1.2-3"). 

128 try: 

129 timestamp = float(value) 

130 except ValueError: 

131 timestamp = None 

132 

133 if timestamp is None or not -1 * (2 ** 30) <= timestamp <= (2 ** 31) - 2: 

134 value = None 

135 else: 

136 value = datetime.datetime.fromtimestamp(timestamp, tz=time_zone).replace(microsecond=0) 

137 

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

139 # must be checked before the time-only branch below, so that "now" is answered here 

140 # instead of silently falling through into the time parser 

141 if not self.time or not self.date: 

142 value = None 

143 else: 

144 now = datetime.datetime.now(time_zone) 

145 if offset := value[3:]: 

146 try: 

147 now += datetime.timedelta(seconds=int(offset)) 

148 except ValueError: 

149 now = None 

150 

151 value = now 

152 

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

154 try: 

155 value = datetime.datetime.fromisoformat(value) 

156 

157 except ValueError: 

158 try: 

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

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

161 value = datetime.datetime( 

162 year=1970, 

163 month=1, 

164 day=1, 

165 hour=hour, 

166 minute=minute, 

167 second=second, 

168 tzinfo=time_zone, 

169 ) 

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

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

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

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

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

175 else: 

176 value = None 

177 

178 except ValueError: 

179 value = None 

180 

181 else: 

182 # try to parse ISO-formatted date string 

183 try: 

184 value = datetime.datetime.fromisoformat(value) 

185 except ValueError: 

186 # otherwise, test against several format strings 

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

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

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

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

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

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

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

194 "%Y-%m-%d", 

195 "%m/%d/%Y", 

196 "%d.%m.%Y", 

197 ): 

198 try: 

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

200 break 

201 except ValueError: 

202 continue 

203 else: 

204 value = None 

205 

206 if not value: 

207 return self.getEmptyValue(), [ 

208 ReadFromClientError(ReadFromClientErrorSeverity.Invalid) 

209 ] 

210 

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

212 return self.getEmptyValue(), [ 

213 ReadFromClientError( 

214 ReadFromClientErrorSeverity.Invalid, 

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

216 ) 

217 ] 

218 

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

220 value = time_zone.localize(value) 

221 

222 # remove microseconds 

223 # TODO: might become configurable 

224 value = value.replace(microsecond=0) 

225 

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

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

228 

229 return value, None 

230 

231 def isInvalid(self, value): 

232 """ 

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

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

235 any additional validations. 

236 

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

238 break if the year is less than 1900. 

239 

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

241 

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

243 the superclass's isInvalid method. 

244 :rtype: str or None 

245 """ 

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

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

248 return "Year must be >= 1900" 

249 

250 return super().isInvalid(value) 

251 

252 def guessTimeZone(self): 

253 """ 

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

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

256 current request. 

257 

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

259 :rtype: pytz timezone object 

260 """ 

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

262 return None 

263 if not (self.date and self.time and self.localize): 

264 return pytz.utc 

265 

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

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

268 

269 timeZone = pytz.utc # Default fallback 

270 currReqData = current.request_data.get() 

271 

272 try: 

273 # Check the local cache first 

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

275 return currReqData["timeZone"] 

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

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

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

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

280 return timeZone 

281 tzList = pytz.country_timezones[country] 

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

283 return timeZone 

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

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

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

287 timeZone = pytz.timezone("EST") 

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

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

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

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

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

293 pass 

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

295 return timeZone 

296 

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

298 """ 

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

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

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

302 

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

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

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

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

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

308 :rtype: datetime 

309 """ 

310 if value: 310 ↛ 321line 310 didn't jump to line 321 because the condition on line 310 was always true

311 # Crop unwanted values to zero 

312 value = value.replace(microsecond=0) 

313 if not self.time: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

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

315 elif not self.date: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

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

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

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

319 # We should always deal with timezone aware datetimes 

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

321 return value 

322 

323 def singleValueUnserialize(self, value): 

324 """ 

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

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

327 

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

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

330 value is not a valid datetime object. 

331 :rtype: datetime or None 

332 """ 

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

334 # Serialized value is timezone aware. 

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

336 value = value.replace(tzinfo=None) 

337 return value 

338 else: 

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

340 time_zone = self.guessTimeZone() 

341 return value.astimezone(time_zone) 

342 else: 

343 # We got garbage from the datastore 

344 return None 

345 

346 def buildDBFilter(self, 

347 name: str, 

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

349 dbFilter: db.Query, 

350 rawFilter: dict, 

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

352 """ 

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

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

355 

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

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

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

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

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

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

362 :rtype: db.Query 

363 """ 

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

365 resDict = {} 

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

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

368 

369 return dbFilter 

370 

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

372 """ 

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

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

375 

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

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

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

379 existing one (False). 

380 """ 

381 if (self.creationMagic and isAdd) or self.updateMagic: 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true

382 if self.naive: 

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

384 else: 

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

386 

387 def structure(self) -> dict: 

388 return super().structure() | { 

389 "date": self.date, 

390 "time": self.time, 

391 "naive": self.naive 

392 } 

393 

394 def _atomic_dump(self, value): 

395 if not value: 

396 return None 

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

398 raise ValueError("Expecting datetime object") 

399 

400 return value.isoformat()