from typing import List, Optional from .dates_add_days import days_in_month, parse_iso_date from .form_date_parts_types import DateParts, DatePartsCheck, DateRules MONTH_NAMES = ( "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ) PARTS = ("day", "month", "year") # ASCII whitespace only, so all three languages trim the same characters # (str.strip() with no argument would also take Unicode spaces). _SPACE = " \t\n\r\f\v" def _is_digits(value: str) -> bool: # ASCII digits only: str.isdigit() accepts "٢", which is not a day in the # other two languages. return value != "" and all("0" <= ch <= "9" for ch in value) def _small_number(value: str, largest: int) -> Optional[int]: # A day or month number: one or two digits. if not _is_digits(value) or len(value) > 2: return None n = int(value) return n if 1 <= n <= largest else None def _ascii_lower(value: str) -> str: return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in value) def _month_number(value: str) -> Optional[int]: # GOV.UK asks services to accept month names in full or short ("jan"), in # any case; "sept" is common enough to accept too. numeric = _small_number(value, 12) if numeric is not None: return numeric lower = _ascii_lower(value) if lower == "sept": return 9 for i, full in enumerate(MONTH_NAMES): name = _ascii_lower(full) if lower == name or lower == name[:3]: return i + 1 return None def _lower_first(label: str) -> str: # "Date of birth" starts a sentence; after "Enter" it is "date of birth". # A label that starts with an acronym ("UK arrival date") is left alone. def upper(i: int) -> bool: return i < len(label) and "A" <= label[i] <= "Z" if upper(0) and not upper(1): return _ascii_lower(label[0]) + label[1:] return label def _long_date(iso: str) -> str: d = parse_iso_date(iso) return "%d %s %d" % (d.day, MONTH_NAMES[d.month - 1], d.year) def _invalid(message: str, fields: List[str]) -> DatePartsCheck: return DatePartsCheck(valid=False, date=None, message=message, fields=fields) def validate_date_parts(parts: DateParts, label: str, rules: DateRules) -> DatePartsCheck: """A date typed into three boxes, checked the way the GOV.UK Design System's date input asks: missing or incomplete information first, then a date that cannot be real, then the service's own rules, each with GOV.UK's wording.""" name = label.strip(_SPACE) if name == "": raise ValueError('a date needs a label, such as "Date of birth", to write its error messages') # Rules are the caller's, so a bad one fails loudly whatever was typed. timing = rules.timing if rules.timing is not None else "any" if timing != "any" and rules.today is None: raise ValueError("rules.today is needed to check that a date is %s" % timing.replace("-", " ")) for bound in (rules.today, rules.not_before, rules.not_after): if bound is not None: parse_iso_date(bound) not_before, not_after = rules.not_before, rules.not_after if not_before is not None and not_after is not None and not_before > not_after: raise ValueError( "rules.notBefore (%s) must not be after rules.notAfter (%s)" % (not_before, not_after) ) typed = { "day": parts.day.strip(_SPACE), "month": parts.month.strip(_SPACE), "year": parts.year.strip(_SPACE), } year = typed["year"] missing = [p for p in PARTS if typed[p] == ""] if len(missing) == 3: return _invalid("Enter %s" % _lower_first(name), list(PARTS)) if missing: what = missing[0] if len(missing) == 1 else "%s and %s" % (missing[0], missing[1]) return _invalid("%s must include a %s" % (name, what), missing) if _is_digits(year) and len(year) != 4: return _invalid("Year must include 4 numbers", ["year"]) d = _small_number(typed["day"], 31) m = _month_number(typed["month"]) y = int(year) if _is_digits(year) and int(year) >= 1 else None found = {"day": d, "month": m, "year": y} wrong = [p for p in PARTS if found[p] is None] if wrong: return _invalid("%s must be a real date" % name, wrong if len(wrong) == 1 else list(PARTS)) assert d is not None and m is not None and y is not None # 31 April or 29 February 2023: the day is what does not exist. if d > days_in_month(y, m): return _invalid("%s must be a real date" % name, ["day"]) iso = "%s-%02d-%02d" % (year, m, d) today = rules.today if rules.today is not None else "" if timing == "past" and iso >= today: return _invalid("%s must be in the past" % name, list(PARTS)) if timing == "past-or-today" and iso > today: return _invalid("%s must be today or in the past" % name, list(PARTS)) if timing == "future" and iso <= today: return _invalid("%s must be in the future" % name, list(PARTS)) if timing == "future-or-today" and iso < today: return _invalid("%s must be today or in the future" % name, list(PARTS)) if not_before is not None and not_after is not None: if iso < not_before or iso > not_after: return _invalid( "%s must be between %s and %s" % (name, _long_date(not_before), _long_date(not_after)), list(PARTS), ) elif not_before is not None and iso < not_before: return _invalid("%s must be the same as or after %s" % (name, _long_date(not_before)), list(PARTS)) elif not_after is not None and iso > not_after: return _invalid("%s must be the same as or before %s" % (name, _long_date(not_after)), list(PARTS)) return DatePartsCheck(valid=True, date=iso, message=None, fields=[])