83 lines
2.9 KiB
Python
83 lines
2.9 KiB
Python
"""Parsing a date out of a request body, strictly.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
Two plugins carried an identical private `_parse_date` that swallowed the
|
|
error:
|
|
|
|
try:
|
|
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
So an unparseable date and a deliberately cleared one produced the same
|
|
result, and the write went ahead. `PUT /api/warranty/<id>` with enddate
|
|
'not-a-date' answered 200 "Warranty updated" and stored NULL - the warranty
|
|
then vanished from every expiry surface, silently, on the strength of a typo.
|
|
The measuring-tool calibration date did the same thing and dropped the tool out
|
|
of the calibration report.
|
|
|
|
The rule here: an EXPLICIT empty value clears the field, and anything that is
|
|
not a date is refused. Those are different intents and they get different
|
|
answers. Callers turn `DateValueError` into a 400 naming the field, so the
|
|
person who mistyped it finds out at the point of the mistake rather than at the
|
|
next audit.
|
|
|
|
Accepted forms are deliberately narrow - 'YYYY-MM-DD', and the ISO-8601
|
|
datetime forms whose leading ten characters are a date, since that is what the
|
|
frontend's date inputs and every vendor API in the tree emit. Anything looser
|
|
is a guess about what somebody meant.
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
|
|
|
|
class DateValueError(ValueError):
|
|
"""A supplied value was neither empty nor a date this accepts.
|
|
|
|
Carries the field name so the error message can say which one, which is the
|
|
whole point of refusing rather than nulling.
|
|
"""
|
|
|
|
def __init__(self, field, value):
|
|
self.field = field
|
|
self.value = value
|
|
super().__init__(
|
|
'%s must be a date as YYYY-MM-DD (got %r)' % (field, value))
|
|
|
|
|
|
def parse_date(value, field='date'):
|
|
"""Return a date, or None for an explicitly empty value.
|
|
|
|
Raises DateValueError for anything else. None and '' mean "clear it";
|
|
they are the only values that produce None.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value.date()
|
|
if isinstance(value, date):
|
|
return value
|
|
if not isinstance(value, str):
|
|
# A number or a list is not a mistyped date, it is the wrong type.
|
|
raise DateValueError(field, value)
|
|
text = value.strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
return datetime.strptime(text[:10], '%Y-%m-%d').date()
|
|
except ValueError:
|
|
raise DateValueError(field, value)
|
|
|
|
|
|
def parse_date_fields(data, fields):
|
|
"""Parse several date fields out of a payload at once.
|
|
|
|
Only keys PRESENT in the payload are returned, so a caller can tell "not
|
|
mentioned" (leave it alone) from "sent as empty" (clear it) - a distinction
|
|
a plain `data.get(...)` throws away, and the reason the alias-clearing bug
|
|
existed on the PC form.
|
|
"""
|
|
return {field: parse_date(data[field], field)
|
|
for field in fields if field in data}
|