Coverage for jutil/validators.py: 95%
376 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 16:40 -0500
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 16:40 -0500
1import random
2import re
3import unicodedata
4from datetime import date
5from decimal import Decimal
6from random import randint
7from typing import Tuple, Optional, Any
8from django.core.exceptions import ValidationError
9from django.utils.timezone import now
10from django.utils.translation import gettext as _
11from jutil.bank_const_iban import IBAN_LENGTH_BY_COUNTRY
13# Country-specific bank constants (abc-order):
14from jutil.bank_const_be import BE_BIC_BY_ACCOUNT_NUMBER, BE_BANK_NAME_BY_BIC
15from jutil.bank_const_dk import DK_BANK_CLEARING_MAP
16from jutil.bank_const_fi import FI_BIC_BY_ACCOUNT_NUMBER, FI_BANK_NAME_BY_BIC
17from jutil.bank_const_se import SE_BANK_CLEARING_LIST
19EMAIL_VALIDATOR = re.compile(r"[a-zA-Z0-9\._-]+@[a-zA-Z0-9\._-]+\.[a-zA-Z]+")
20PHONE_FILTER = re.compile(r"[^+0-9]")
21PHONE_VALIDATOR = re.compile(r"\+?\d{6,}")
22PASSPORT_FILTER = re.compile(r"[^-A-Z0-9]")
23STRIP_NON_NUMBERS = re.compile(r"[^0-9]")
24STRIP_NON_ALPHANUMERIC = re.compile(r"[^0-9A-Za-z]")
25VARIABLE_NAME = re.compile(r"[^0-9A-Za-z_]")
26STRIP_WHITESPACE = re.compile(r"\s+")
27STRIP_PREFIX_ZEROS = re.compile(r"^0+")
28IBAN_FILTER = re.compile(r"[^A-Z0-9]")
29DIGIT_FILTER = re.compile(r"[^0-9]")
32def phone_filter(v: str) -> str:
33 return PHONE_FILTER.sub("", str(v)) if v else ""
36def phone_validator(v0: str):
37 v = phone_filter(v0)
38 if not v or not PHONE_VALIDATOR.fullmatch(v):
39 v_str = _("Missing value") if v is None else str(v0)
40 raise ValidationError(_("Invalid phone number") + " ({})".format(v_str), code="invalid_phone")
43def phone_sanitizer(v: str) -> str:
44 v = phone_filter(v)
45 if not v or not PHONE_VALIDATOR.fullmatch(v):
46 return ""
47 return v
50def email_filter(v: str) -> str:
51 return str(v).lower().strip() if v else ""
54def email_validator(v: str):
55 if not is_email(v):
56 v_str = _("Missing value") if not v else str(v)
57 raise ValidationError(_("Invalid email") + " ({})".format(v_str), code="invalid_email")
60def email_sanitizer(v: str) -> str:
61 v = email_filter(v)
62 if not v or not EMAIL_VALIDATOR.fullmatch(v):
63 return ""
64 return v
67def passport_filter(v: str) -> str:
68 return PASSPORT_FILTER.sub("", str(v).upper()) if v else ""
71def passport_validator(v0: str):
72 v = passport_filter(v0)
73 if not v or len(v) < 5:
74 v_str = _("Missing value") if v is None else str(v0)
75 raise ValidationError(_("Invalid passport number") + " ({})".format(v_str), code="invalid_passport")
78def passport_sanitizer(v: str):
79 v = passport_filter(v)
80 if not v or len(v) < 5:
81 return ""
82 return v
85def country_code_filter(v: str) -> str:
86 return v.strip().upper()
89def bic_filter(v: str) -> str:
90 return v.strip().upper()
93def country_code_validator(v0: str):
94 """
95 Accepts both ISO-2 and ISO-3 formats.
96 :param v: str
97 :return: None
98 """
99 v = country_code_filter(v0)
100 if not (2 <= len(v) <= 3):
101 v_str = _("Missing value") if v is None else str(v0)
102 raise ValidationError(_("Invalid country code") + " ({})".format(v_str), code="invalid_country_code")
105def country_code_sanitizer(v: str) -> str:
106 v = country_code_filter(v)
107 return v if 2 <= len(v) <= 3 else ""
110def bic_sanitizer(v: str) -> str:
111 v = bic_filter(v)
112 return v if 8 <= len(v) <= 11 else ""
115def variable_name_sanitizer(v: str) -> str:
116 v = VARIABLE_NAME.sub("", ascii_filter(v).replace(" ", "_"))
117 if v and v[0].isdigit():
118 v = "_" + v
119 return v
122def ascii_filter(v: str) -> str:
123 """
124 Replaces Unicode accent characters with plain ASCII.
125 For example remove_accents('HELÉN') == 'HELEN'.
126 :param v: str
127 :return: str
128 """
129 return unicodedata.normalize("NFKD", v).encode("ASCII", "ignore").decode()
132def digit_filter(v: str) -> str:
133 return DIGIT_FILTER.sub("", str(v)) if v else ""
136def iban_filter(v: str) -> str:
137 return IBAN_FILTER.sub("", str(v).upper()) if v else ""
140def iban_filter_readable(acct) -> str:
141 acct = iban_filter(acct)
142 if acct:
143 i = 0
144 j = 4
145 out = ""
146 nlen = len(acct)
147 while i < nlen:
148 if out:
149 out += " "
150 out += acct[i:j]
151 i = j
152 j += 4
153 return out
154 return acct
157def bic_validator(v0: str):
158 """
159 Validates bank BIC/SWIFT code (8-11 characters).
160 :param v: str
161 :return: None
162 """
163 v = bic_filter(v0)
164 if not (8 <= len(v) <= 11):
165 v_str = _("Missing value") if v is None else str(v0)
166 raise ValidationError(_("Invalid bank BIC/SWIFT code") + " ({})".format(v_str), code="invalid_bic")
169def iban_validator(v0: str):
170 """
171 Validates IBAN format bank account number.
172 :param v: str
173 :return: None
174 """
175 # validate prefix and length
176 v = iban_filter(v0)
177 if not v:
178 raise ValidationError(_("Invalid IBAN account number") + " ({})".format(_("Missing value")), code="invalid_iban")
179 country = v[:2].upper()
180 if country not in IBAN_LENGTH_BY_COUNTRY:
181 raise ValidationError(_("Invalid country code") + " ({})".format(country), code="invalid_country_code")
182 iban_len = IBAN_LENGTH_BY_COUNTRY[country]
183 if iban_len != len(v):
184 raise ValidationError(_("Invalid IBAN account number") + " ({})".format(v0), code="invalid_iban")
186 # validate IBAN numeric part
187 if iban_len <= 26: # very long IBANs are unsupported by the numeric part validation
188 digits = "0123456789"
189 num = ""
190 for ch in v[4:] + v[0:4]:
191 if ch not in digits:
192 ch = str(ord(ch) - ord("A") + 10)
193 num += ch
194 x = Decimal(num) % Decimal(97)
195 if x != Decimal(1):
196 raise ValidationError(_("Invalid IBAN account number") + " ({})".format(v0), code="invalid_iban")
199def iban_generator(country_code: str = "") -> str:
200 """
201 Generates IBAN format bank account number (for testing).
202 :param country_code: 2-character country code (optional)
203 :return: str
204 """
205 # pick random country code if not set (with max IBAN length 27)
206 if not country_code:
207 country_code = random.choice(list(filter(lambda cc: IBAN_LENGTH_BY_COUNTRY[cc] <= 26, IBAN_LENGTH_BY_COUNTRY.keys()))) # nosec
208 if country_code not in IBAN_LENGTH_BY_COUNTRY:
209 raise ValidationError(_("Invalid country code") + " ({})".format(country_code), code="invalid_country_code")
210 nlen = IBAN_LENGTH_BY_COUNTRY[country_code]
211 if nlen > 26:
212 raise ValidationError(_("IBAN checksum generation does not support >26 character IBANs"), code="invalid_iban")
214 # generate BBAN part
215 digits = "0123456789"
216 bban = "".join([random.choice(digits) for n in range(nlen - 4)]) # nosec
218 # generate valid IBAN numeric part
219 # (probably not the most efficient way to do this but write a better one if you need faster...)
220 num0 = ""
221 for ch in bban + country_code:
222 if ch not in digits:
223 ch = str(ord(ch) - ord("A") + 10)
224 num0 += ch
225 for checksum in range(1, 100): 225 ↛ 237line 225 didn't jump to line 237, because the loop on line 225 didn't complete
226 num = num0
227 checksum_str = "{:02}".format(checksum)
228 for ch in checksum_str:
229 if ch not in digits: 229 ↛ 230line 229 didn't jump to line 230, because the condition on line 229 was never true
230 ch = str(ord(ch) - ord("A") + 10)
231 num += ch
232 # print(num, '/', 97, 'nlen', nlen)
233 x = Decimal(num) % Decimal(97)
234 if x == Decimal(1):
235 return country_code + checksum_str + bban
237 raise ValidationError(_("Invalid IBAN account number"), code="invalid_iban") # should not get here
240def validate_country_iban(v0: str, country: str):
241 v = iban_filter(v0)
242 if v[0:2] != country:
243 raise ValidationError(_("Invalid IBAN account number") + " ({})".format(v0), code="invalid_iban")
244 iban_validator(v)
247def iban_bank_info(v: str) -> Tuple[str, str]:
248 """
249 Returns BIC code and bank name from IBAN number.
250 :param v: IBAN account number
251 :return: (BIC code, bank name) or ('', '') if not found / unsupported country
252 """
253 v = iban_filter(v)
254 prefix = v[:2]
255 func_name = prefix.lower() + "_iban_bank_info" # e.g. fi_iban_bank_info, be_iban_bank_info
256 func = globals().get(func_name)
257 if func is not None:
258 return func(v)
259 return "", ""
262def iban_bic(v: str) -> str:
263 """
264 Returns BIC code from IBAN number.
265 :param v: IBAN account number
266 :return: BIC code or '' if not found
267 """
268 info = iban_bank_info(v)
269 return info[0] if info else ""
272def calculate_age(born: date, today: Optional[date] = None) -> int:
273 if not today:
274 today = now().date()
275 return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
278def filter_country_company_org_id(country_code: str, v: str):
279 if country_code == "FI":
280 return fi_company_org_id_filter(v)
281 return PASSPORT_FILTER.sub("", v)
284def validate_country_company_org_id(country_code: str, v: str):
285 if country_code == "FI":
286 fi_company_org_id_validator(v)
289def is_int(v: Any) -> bool:
290 """
291 Returns True if value is int or can be converted to int.
292 :param v: Any
293 :return: bool
294 """
295 try:
296 return str(int(v)) == str(v)
297 except Exception:
298 return False
301def is_iban(v: str) -> bool:
302 """
303 Returns True if account number is valid IBAN format.
304 :param v: str
305 :return: bool
306 """
307 try:
308 iban_validator(v)
309 return True
310 except ValidationError:
311 return False
314def is_email(v: str) -> bool:
315 """
316 Returns True if v is email address.
317 :param v: str
318 :return: bool
319 """
320 v = email_filter(v)
321 return bool(v and EMAIL_VALIDATOR.fullmatch(v))
324# ============================================================================
325# Country specific functions (countries in alphabetical order)
326# ============================================================================
329# ----------------------------------------------------------------------------
330# Belgium
331# ----------------------------------------------------------------------------
334def be_iban_validator(v: str):
335 validate_country_iban(v, "BE")
338def be_iban_bank_info(v: str) -> Tuple[str, str]:
339 """
340 Returns BIC code and bank name from BE IBAN number.
341 :param v: IBAN account number
342 :return: (BIC code, bank name) or ('', '') if not found
343 """
344 v = iban_filter(v)
345 bic = BE_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None)
346 return (bic, BE_BANK_NAME_BY_BIC[bic]) if bic is not None else ("", "")
349# ----------------------------------------------------------------------------
350# Denmark
351# ----------------------------------------------------------------------------
354def dk_iban_validator(v: str):
355 validate_country_iban(v, "DK")
358def dk_clearing_code_bank_name(v: str) -> str:
359 v = iban_filter(v)
360 if v.startswith("DK"):
361 v = v[4:]
362 return DK_BANK_CLEARING_MAP.get(v[:4], "")
365def dk_iban_bank_info(v: str) -> Tuple[str, str]:
366 """
367 Returns empty string (BIC not available) and bank name from DK IBAN number.
368 DK5000400440116243
369 :param v: IBAN account number
370 :return: ('', bank name) or ('', '') if not found
371 """
372 return "", dk_clearing_code_bank_name(v)
375# ----------------------------------------------------------------------------
376# Estonia
377# ----------------------------------------------------------------------------
380def ee_iban_validator(v: str):
381 validate_country_iban(v, "EE")
384# ----------------------------------------------------------------------------
385# Finland
386# ----------------------------------------------------------------------------
388FI_SSN_FILTER = re.compile(r"[^0-9A-Z+-]")
389FI_SSN_VALIDATOR = re.compile(r"^\d{6}[+-A]\d{3}[\d\w]$")
390FI_COMPANY_ORG_ID_FILTER = re.compile(r"[^0-9]")
393def fi_payment_reference_number(num: str):
394 """
395 Appends Finland reference number checksum to existing number.
396 :param num: At least 3 digits
397 :return: Number plus checksum
398 """
399 assert isinstance(num, str)
400 v = STRIP_WHITESPACE.sub("", num)
401 if digit_filter(v) != v:
402 raise ValidationError(_("Invalid payment reference: {}").format(num))
403 v = v.lstrip("0")
404 if len(v) < 3:
405 raise ValidationError(_("Invalid payment reference: {}").format(num))
406 weights = [7, 3, 1]
407 weighed_sum = 0
408 vlen = len(v)
409 for j in range(vlen):
410 weighed_sum += int(v[vlen - 1 - j]) * weights[j % 3]
411 return v + str((10 - (weighed_sum % 10)) % 10)
414def fi_payment_reference_validator(v: str):
415 v = STRIP_WHITESPACE.sub("", v)
416 if fi_payment_reference_number(v[:-1]) != v:
417 raise ValidationError(_("Invalid payment reference: {}").format(v))
420def iso_payment_reference_validator(v: str):
421 """
422 Validates ISO reference number checksum.
423 :param v: Reference number
424 """
425 num = ""
426 v = STRIP_WHITESPACE.sub("", v)
427 v = STRIP_PREFIX_ZEROS.sub("", v)
428 for ch in v[4:] + v[0:4]:
429 x = ord(ch)
430 if ord("0") <= x <= ord("9"):
431 num += ch
432 else:
433 x -= 55
434 if x < 10 or x > 35:
435 raise ValidationError(_("Invalid payment reference: {}").format(v))
436 num += str(x)
437 res = Decimal(num) % Decimal("97")
438 if res != Decimal("1"): 438 ↛ 439line 438 didn't jump to line 439, because the condition on line 438 was never true
439 raise ValidationError(_("Invalid payment reference: {}").format(v))
442def fi_iban_validator(v: str):
443 validate_country_iban(v, "FI")
446def fi_iban_bank_info(v: str) -> Tuple[str, str]:
447 """
448 Returns BIC code and bank name from FI IBAN number.
449 :param v: IBAN account number
450 :return: (BIC code, bank name) or ('', '') if not found
451 """
452 v = iban_filter(v)
453 bic = FI_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None)
454 name = FI_BANK_NAME_BY_BIC.get(bic, "") if bic is not None else ""
455 return bic or "", name
458def fi_ssn_filter(v: str) -> str:
459 return FI_SSN_FILTER.sub("", v.upper())
462def fi_company_org_id_filter(v: str) -> str:
463 v = FI_COMPANY_ORG_ID_FILTER.sub("", v)
464 return v[:-1] + "-" + v[-1:] if len(v) >= 2 else ""
467def fi_company_org_id_validator(v0: str):
468 v = re.sub(r"\s+", "", v0)
469 prefix = v[:2] # retain prefix: either numeric or FI is ok
470 v = fi_company_org_id_filter(v)
471 if v[:2] == prefix:
472 prefix = "FI"
473 if v[-2:-1] != "-" or prefix != "FI":
474 raise ValidationError(_("Invalid company organization ID") + " ({})".format(v0), code="invalid_company_org_id")
475 v = v.replace("-", "", 1)
476 if len(v) != 8: 476 ↛ 477line 476 didn't jump to line 477, because the condition on line 476 was never true
477 raise ValidationError(_("Invalid company organization ID") + " ({})".format(v0), code="invalid_company_org_id")
478 multipliers = (7, 9, 10, 5, 8, 4, 2)
479 x = 0
480 for i, m in enumerate(multipliers):
481 x += int(v[i]) * m
482 remainder = divmod(x, 11)[1]
483 if remainder == 1: 483 ↛ 484line 483 didn't jump to line 484, because the condition on line 483 was never true
484 raise ValidationError(_("Invalid company organization ID") + " ({})".format(v0), code="invalid_company_org_id")
485 if remainder >= 2:
486 check_digit = str(11 - remainder)
487 if check_digit != v[-1:]:
488 raise ValidationError(_("Invalid company organization ID") + " ({})".format(v0), code="invalid_company_org_id")
491def fi_company_org_id_generator() -> str:
492 remainder = 1
493 v = ""
494 while remainder < 2:
495 v = str(randint(11111111, 99999999)) # nosec
496 multipliers = (7, 9, 10, 5, 8, 4, 2)
497 x = 0
498 for i, m in enumerate(multipliers):
499 x += int(v[i]) * m
500 remainder = divmod(x, 11)[1]
501 check_digit = str(11 - remainder)
502 return v[:-1] + "-" + check_digit
505def fi_ssn_validator(v0: str):
506 v = fi_ssn_filter(v0)
507 if not FI_SSN_VALIDATOR.fullmatch(v):
508 raise ValidationError(_("Invalid personal identification number") + " ({})".format(v0), code="invalid_ssn")
509 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
510 digits = {
511 10: "A",
512 11: "B",
513 12: "C",
514 13: "D",
515 14: "E",
516 15: "F",
517 16: "H",
518 17: "J",
519 18: "K",
520 19: "L",
521 20: "M",
522 21: "N",
523 22: "P",
524 23: "R",
525 24: "S",
526 25: "T",
527 26: "U",
528 27: "V",
529 28: "W",
530 29: "X",
531 30: "Y",
532 }
533 ch = digits.get(d, str(d))
534 if ch != v[-1:]:
535 raise ValidationError(_("Invalid personal identification number") + " ({})".format(v0), code="invalid_ssn")
538def fi_ssn_generator(min_year: int = 1920, max_year: int = 1999):
539 if not (1800 <= min_year < 2100):
540 raise ValidationError(_("Unsupported year") + " ({})".format(min_year))
541 if not (1800 <= max_year < 2100): 541 ↛ 542line 541 didn't jump to line 542, because the condition on line 541 was never true
542 raise ValidationError(_("Unsupported year") + " ({})".format(max_year))
544 day = randint(1, 28) # nosec
545 month = randint(1, 12) # nosec
546 year = randint(min_year, max_year) # nosec
547 suffix = randint(100, 999) # nosec
548 sep = "-"
549 if year < 1900:
550 sep = "+"
551 year2 = year - 1800
552 elif year >= 2000:
553 sep = "A"
554 year2 = year - 2000
555 else:
556 year2 = year - 1900
557 v = "{:02}{:02}{:02}{}{}".format(day, month, year2, sep, suffix)
558 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
559 digits = {
560 10: "A",
561 11: "B",
562 12: "C",
563 13: "D",
564 14: "E",
565 15: "F",
566 16: "H",
567 17: "J",
568 18: "K",
569 19: "L",
570 20: "M",
571 21: "N",
572 22: "P",
573 23: "R",
574 24: "S",
575 25: "T",
576 26: "U",
577 27: "V",
578 28: "W",
579 29: "X",
580 30: "Y",
581 }
582 ch = digits.get(d, str(d))
583 return v + ch
586def fi_ssn_birthday(v: str) -> date:
587 v = fi_ssn_filter(v)
588 fi_ssn_validator(v)
589 sep = v[6] # 231298-965X
590 year = int(v[4:6])
591 month = int(v[2:4])
592 day = int(v[0:2])
593 if sep == "+": # 1800
594 year += 1800
595 elif sep == "-":
596 year += 1900
597 elif sep == "A": 597 ↛ 599line 597 didn't jump to line 599, because the condition on line 597 was never false
598 year += 2000
599 return date(year, month, day)
602def fi_ssn_age(ssn: str, today: Optional[date] = None) -> int:
603 return calculate_age(fi_ssn_birthday(ssn), today)
606# ----------------------------------------------------------------------------
607# Sweden
608# ----------------------------------------------------------------------------
610SE_SSN_FILTER = re.compile(r"[^-0-9]")
611SE_SSN_VALIDATOR = re.compile(r"^\d{6}[-]\d{3}[\d]$")
614def se_iban_bank_info(v: str) -> Tuple[str, str]:
615 """
616 Returns BIC code and bank name from SE IBAN number.
617 :param v: IBAN account number
618 :return: (BIC code, bank name) or ('', '') if not found
619 """
620 bank_name = se_clearing_code_bank_info(v)[0]
621 return "", bank_name
624def se_iban_validator(v: str):
625 validate_country_iban(v, "SE")
628def se_ssn_filter(v: str) -> str:
629 return SE_SSN_FILTER.sub("", v.upper())
632def se_ssn_validator(v0: str):
633 v = se_ssn_filter(v0)
634 if not SE_SSN_VALIDATOR.fullmatch(v): 634 ↛ 635line 634 didn't jump to line 635, because the condition on line 634 was never true
635 raise ValidationError(_("Invalid personal identification number") + " ({})".format(v0), code="invalid_ssn")
636 v = STRIP_NON_NUMBERS.sub("", v)
637 dsum = 0
638 for i in range(9):
639 x = int(v[i])
640 if i & 1 == 0:
641 x += x
642 # print('summing', v[i], 'as', x)
643 xsum = x % 10 + int(x / 10) % 10
644 # print(v[i], 'xsum', xsum)
645 dsum += xsum
646 # print('sum', dsum)
647 rem = dsum % 10
648 # print('rem', rem)
649 checksum = 10 - rem
650 if checksum == 10:
651 checksum = 0
652 # print('checksum', checksum)
653 if int(v[-1:]) != checksum:
654 raise ValidationError(_("Invalid personal identification number") + " ({})".format(v0), code="invalid_ssn")
657def se_clearing_code_bank_info(account_number: str) -> Tuple[str, Optional[int]]:
658 """
659 Returns Sweden bank info by clearing code.
660 :param account_number: Swedish account number with clearing code as prefix
661 :return: (Bank name, account digit count) or ("", None) if not found
662 """
663 v = iban_filter(account_number)
664 if v.startswith("SE"):
665 v = v[4:]
666 clearing = v[:4]
667 for name, begin, end, acc_digits in SE_BANK_CLEARING_LIST: 667 ↛ 670line 667 didn't jump to line 670, because the loop on line 667 didn't complete
668 if begin <= clearing <= end:
669 return name, acc_digits
670 return "", None