Remediate the 2026-09-03 feature-test findings
This commit is contained in:
82
shopdb/utils/datevalues.py
Normal file
82
shopdb/utils/datevalues.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user