Coverage for jutil/validators.py : 96%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import random
2import re
3import unicodedata
4from datetime import date
5from decimal import Decimal
6from random import randint
7from typing import Tuple, Optional
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
20EMAIL_VALIDATOR = re.compile(r"[a-zA-Z0-9\._-]+@[a-zA-Z0-9\._-]+\.[a-zA-Z]+")
21PHONE_FILTER = re.compile(r"[^+0-9]")
22PHONE_VALIDATOR = re.compile(r"\+?\d{6,}")
23PASSPORT_FILTER = re.compile(r"[^-A-Z0-9]")
24STRIP_NON_NUMBERS = re.compile(r"[^0-9]")
25STRIP_NON_ALPHANUMERIC = re.compile(r"[^0-9A-Za-z]")
26STRIP_WHITESPACE = re.compile(r"\s+")
27IBAN_FILTER = re.compile(r"[^A-Z0-9]")
28DIGIT_FILTER = re.compile(r"[^0-9]")
31def phone_filter(v: str) -> str:
32 return PHONE_FILTER.sub("", str(v)) if v else ""
35def phone_validator(v: str):
36 v = phone_filter(v)
37 if not v or not PHONE_VALIDATOR.fullmatch(v):
38 v_str = _("Missing value") if v is None else str(v)
39 raise ValidationError(_("Invalid phone number") + ": {}".format(v_str), code="invalid_phone")
42def phone_sanitizer(v: str) -> str:
43 v = phone_filter(v)
44 if not v or not PHONE_VALIDATOR.fullmatch(v):
45 return ""
46 return v
49def email_filter(v: str) -> str:
50 return str(v).lower().strip() if v else ""
53def email_validator(v: str):
54 v = email_filter(v)
55 if not v or not EMAIL_VALIDATOR.fullmatch(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(v: str):
72 v = passport_filter(v)
73 if not v or len(v) < 5:
74 v_str = _("Missing value") if v is None else str(v)
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(v: str):
94 """
95 Accepts both ISO-2 and ISO-3 formats.
96 :param v: str
97 :return: None
98 """
99 v = country_code_filter(v)
100 if not (2 <= len(v) <= 3):
101 v_str = _("Missing value") if v is None else str(v)
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 ascii_filter(v: str) -> str:
116 """
117 Replaces Unicode accent characters with plain ASCII.
118 For example remove_accents('HELÉN') == 'HELEN'.
119 :param v: str
120 :return: str
121 """
122 return unicodedata.normalize("NFKD", v).encode("ASCII", "ignore").decode()
125def digit_filter(v: str) -> str:
126 return DIGIT_FILTER.sub("", str(v)) if v else ""
129def iban_filter(v: str) -> str:
130 return IBAN_FILTER.sub("", str(v).upper()) if v else ""
133def iban_filter_readable(acct) -> str:
134 acct = iban_filter(acct)
135 if acct:
136 i = 0
137 j = 4
138 out = ""
139 nlen = len(acct)
140 while i < nlen:
141 if out:
142 out += " "
143 out += acct[i:j]
144 i = j
145 j += 4
146 return out
147 return acct
150def bic_validator(v: str):
151 """
152 Validates bank BIC/SWIFT code (8-11 characters).
153 :param v: str
154 :return: None
155 """
156 v = bic_filter(v)
157 if not (8 <= len(v) <= 11):
158 v_str = _("Missing value") if v is None else str(v)
159 raise ValidationError(_("Invalid bank BIC/SWIFT code") + ": {}".format(v_str), code="invalid_bic")
162def iban_validator(v: str):
163 """
164 Validates IBAN format bank account number.
165 :param v: str
166 :return: None
167 """
168 # validate prefix and length
169 v = iban_filter(v)
170 if not v:
171 raise ValidationError(_("Invalid IBAN account number") + ": {}".format(_("Missing value")), code="invalid_iban")
172 country = v[:2].upper()
173 if country not in IBAN_LENGTH_BY_COUNTRY:
174 raise ValidationError(_("Invalid country code") + ": {}".format(country), code="invalid_country_code")
175 iban_len = IBAN_LENGTH_BY_COUNTRY[country]
176 if iban_len != len(v):
177 raise ValidationError(_("Invalid IBAN account number") + ": {}".format(v), code="invalid_iban")
179 # validate IBAN numeric part
180 if iban_len <= 26: # very long IBANs are unsupported by the numeric part validation
181 digits = "0123456789"
182 num = ""
183 for ch in v[4:] + v[0:4]:
184 if ch not in digits:
185 ch = str(ord(ch) - ord("A") + 10)
186 num += ch
187 x = Decimal(num) % Decimal(97)
188 if x != Decimal(1):
189 raise ValidationError(_("Invalid IBAN account number") + ": {}".format(v), code="invalid_iban")
192def iban_generator(country_code: str = "") -> str:
193 """
194 Generates IBAN format bank account number (for testing).
195 :param country_code: 2-character country code (optional)
196 :return: str
197 """
198 # pick random country code if not set (with max IBAN length 27)
199 if not country_code:
200 country_code = random.choice( # nosec
201 list(filter(lambda cc: IBAN_LENGTH_BY_COUNTRY[cc] <= 26, IBAN_LENGTH_BY_COUNTRY.keys()))
202 )
203 if country_code not in IBAN_LENGTH_BY_COUNTRY:
204 raise ValidationError(_("Invalid country code") + ": {}".format(country_code), code="invalid_country_code")
205 nlen = IBAN_LENGTH_BY_COUNTRY[country_code]
206 if nlen > 26:
207 raise ValidationError(_("IBAN checksum generation does not support >26 character IBANs"), code="invalid_iban")
209 # generate BBAN part
210 digits = "0123456789"
211 bban = "".join([random.choice(digits) for n in range(nlen - 4)]) # nosec
213 # generate valid IBAN numeric part
214 # (probably not the most efficient way to do this but write a better one if you need faster...)
215 num0 = ""
216 for ch in bban + country_code:
217 if ch not in digits:
218 ch = str(ord(ch) - ord("A") + 10)
219 num0 += ch
220 for checksum in range(1, 100): 220 ↛ 232line 220 didn't jump to line 232, because the loop on line 220 didn't complete
221 num = num0
222 checksum_str = "{:02}".format(checksum)
223 for ch in checksum_str:
224 if ch not in digits: 224 ↛ 225line 224 didn't jump to line 225, because the condition on line 224 was never true
225 ch = str(ord(ch) - ord("A") + 10)
226 num += ch
227 # print(num, '/', 97, 'nlen', nlen)
228 x = Decimal(num) % Decimal(97)
229 if x == Decimal(1):
230 return country_code + checksum_str + bban
232 raise ValidationError(_("Invalid IBAN account number"), code="invalid_iban") # should not get here
235def validate_country_iban(v: str, country: str):
236 v = iban_filter(v)
237 if v[0:2] != country:
238 raise ValidationError(_("Invalid IBAN account number") + " ({}.2): {}".format(country, v), code="invalid_iban")
239 iban_validator(v)
242def iban_bank_info(v: str) -> Tuple[str, str]:
243 """
244 Returns BIC code and bank name from IBAN number.
245 :param v: IBAN account number
246 :return: (BIC code, bank name) or ('', '') if not found / unsupported country
247 """
248 v = iban_filter(v)
249 prefix = v[:2]
250 func_name = prefix.lower() + "_iban_bank_info" # e.g. fi_iban_bank_info, be_iban_bank_info
251 func = globals().get(func_name)
252 if func is not None:
253 return func(v)
254 return "", ""
257def iban_bic(v: str) -> str:
258 """
259 Returns BIC code from IBAN number.
260 :param v: IBAN account number
261 :return: BIC code or '' if not found
262 """
263 info = iban_bank_info(v)
264 return info[0] if info else ""
267def calculate_age(born: date, today: Optional[date] = None) -> int:
268 if not today:
269 today = now().date()
270 return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
273def filter_country_company_org_id(country_code: str, v: str):
274 if country_code == "FI":
275 return fi_company_org_id_filter(v)
276 return PASSPORT_FILTER.sub("", v)
279def validate_country_company_org_id(country_code: str, v: str):
280 if country_code == "FI":
281 fi_company_org_id_validator(v)
284# ============================================================================
285# Country specific functions (countries in alphabetical order)
286# ============================================================================
289# ----------------------------------------------------------------------------
290# Belgium
291# ----------------------------------------------------------------------------
294def be_iban_validator(v: str):
295 validate_country_iban(v, "BE")
298def be_iban_bank_info(v: str) -> Tuple[str, str]:
299 """
300 Returns BIC code and bank name from BE IBAN number.
301 :param v: IBAN account number
302 :return: (BIC code, bank name) or ('', '') if not found
303 """
304 v = iban_filter(v)
305 bic = BE_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None)
306 return (bic, BE_BANK_NAME_BY_BIC[bic]) if bic is not None else ("", "")
309# ----------------------------------------------------------------------------
310# Denmark
311# ----------------------------------------------------------------------------
314def dk_iban_validator(v: str):
315 validate_country_iban(v, "DK")
318def dk_clearing_code_bank_name(v: str) -> str:
319 v = iban_filter(v)
320 if v.startswith("DK"):
321 v = v[4:]
322 return DK_BANK_CLEARING_MAP.get(v[:4], "")
325def dk_iban_bank_info(v: str) -> Tuple[str, str]:
326 """
327 Returns empty string (BIC not available) and bank name from DK IBAN number.
328 DK5000400440116243
329 :param v: IBAN account number
330 :return: ('', bank name) or ('', '') if not found
331 """
332 return "", dk_clearing_code_bank_name(v)
335# ----------------------------------------------------------------------------
336# Estonia
337# ----------------------------------------------------------------------------
340def ee_iban_validator(v: str):
341 validate_country_iban(v, "EE")
344# ----------------------------------------------------------------------------
345# Finland
346# ----------------------------------------------------------------------------
348FI_SSN_FILTER = re.compile(r"[^0-9A-Z+-]")
349FI_SSN_VALIDATOR = re.compile(r"^\d{6}[+-A]\d{3}[\d\w]$")
350FI_COMPANY_ORG_ID_FILTER = re.compile(r"[^0-9]")
353def fi_payment_reference_number(num: str):
354 """
355 Appends Finland reference number checksum to existing number.
356 :param num: At least 3 digits
357 :return: Number plus checksum
358 """
359 assert isinstance(num, str)
360 v = STRIP_WHITESPACE.sub("", num)
361 if digit_filter(v) != v:
362 raise ValidationError(_("Invalid payment reference: {}").format(num))
363 v = v.lstrip("0")
364 if len(v) < 3:
365 raise ValidationError(_("Invalid payment reference: {}").format(num))
366 weights = [7, 3, 1]
367 weighed_sum = 0
368 vlen = len(v)
369 for j in range(vlen):
370 weighed_sum += int(v[vlen - 1 - j]) * weights[j % 3]
371 return v + str((10 - (weighed_sum % 10)) % 10)
374def fi_payment_reference_validator(v: str):
375 v = STRIP_WHITESPACE.sub("", v)
376 if fi_payment_reference_number(v[:-1]) != v:
377 raise ValidationError(_("Invalid payment reference: {}").format(v))
380def iso_payment_reference_validator(v: str):
381 """
382 Validates ISO reference number checksum.
383 :param v: Reference number
384 """
385 num = ""
386 v = STRIP_WHITESPACE.sub("", v)
387 for ch in v[4:] + v[0:4]:
388 x = ord(ch)
389 if ord("0") <= x <= ord("9"):
390 num += ch
391 else:
392 x -= 55
393 if x < 10 or x > 35:
394 raise ValidationError(_("Invalid payment reference: {}").format(v))
395 num += str(x)
396 res = Decimal(num) % Decimal("97")
397 if res != Decimal("1"): 397 ↛ 398line 397 didn't jump to line 398, because the condition on line 397 was never true
398 raise ValidationError(_("Invalid payment reference: {}").format(v))
401def fi_iban_validator(v: str):
402 validate_country_iban(v, "FI")
405def fi_iban_bank_info(v: str) -> Tuple[str, str]:
406 """
407 Returns BIC code and bank name from FI IBAN number.
408 :param v: IBAN account number
409 :return: (BIC code, bank name) or ('', '') if not found
410 """
411 v = iban_filter(v)
412 bic = FI_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None)
413 name = FI_BANK_NAME_BY_BIC.get(bic, "") if bic is not None else ""
414 return bic or "", name
417def fi_ssn_filter(v: str) -> str:
418 return FI_SSN_FILTER.sub("", v.upper())
421def fi_company_org_id_filter(v: str) -> str:
422 v = FI_COMPANY_ORG_ID_FILTER.sub("", v)
423 return v[:-1] + "-" + v[-1:] if len(v) >= 2 else ""
426def fi_company_org_id_validator(v0: str):
427 prefix = re.sub(r"\s+", "", v0)[:2] # retain prefix: either numeric or FI is ok
428 v = fi_company_org_id_filter(v0)
429 if v[:2] == prefix:
430 prefix = "FI"
431 if v[-2:-1] != "-" or prefix != "FI":
432 raise ValidationError(
433 _("Invalid company organization ID") + " (FI.1): {}".format(v0), code="invalid_company_org_id"
434 )
435 v = v.replace("-", "", 1)
436 if len(v) != 8: 436 ↛ 437line 436 didn't jump to line 437, because the condition on line 436 was never true
437 raise ValidationError(
438 _("Invalid company organization ID") + " (FI.2): {}".format(v0), code="invalid_company_org_id"
439 )
440 multipliers = (7, 9, 10, 5, 8, 4, 2)
441 x = 0
442 for i, m in enumerate(multipliers):
443 x += int(v[i]) * m
444 remainder = divmod(x, 11)[1]
445 if remainder == 1: 445 ↛ 446line 445 didn't jump to line 446, because the condition on line 445 was never true
446 raise ValidationError(
447 _("Invalid company organization ID") + " (FI.3): {}".format(v0), code="invalid_company_org_id"
448 )
449 if remainder >= 2:
450 check_digit = str(11 - remainder)
451 if check_digit != v[-1:]:
452 raise ValidationError(
453 _("Invalid company organization ID") + " (FI.4): {}".format(v0), code="invalid_company_org_id"
454 )
457def fi_company_org_id_generator() -> str:
458 remainder = 1
459 v = ""
460 while remainder < 2:
461 v = str(randint(11111111, 99999999)) # nosec
462 multipliers = (7, 9, 10, 5, 8, 4, 2)
463 x = 0
464 for i, m in enumerate(multipliers):
465 x += int(v[i]) * m
466 remainder = divmod(x, 11)[1]
467 check_digit = str(11 - remainder)
468 return v[:-1] + "-" + check_digit
471def fi_ssn_validator(v: str):
472 v = fi_ssn_filter(v)
473 if not FI_SSN_VALIDATOR.fullmatch(v):
474 raise ValidationError(_("Invalid personal identification number") + " (FI.1): {}".format(v), code="invalid_ssn")
475 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
476 digits = {
477 10: "A",
478 11: "B",
479 12: "C",
480 13: "D",
481 14: "E",
482 15: "F",
483 16: "H",
484 17: "J",
485 18: "K",
486 19: "L",
487 20: "M",
488 21: "N",
489 22: "P",
490 23: "R",
491 24: "S",
492 25: "T",
493 26: "U",
494 27: "V",
495 28: "W",
496 29: "X",
497 30: "Y",
498 }
499 ch = digits.get(d, str(d))
500 if ch != v[-1:]:
501 raise ValidationError(_("Invalid personal identification number") + " (FI.2): {}".format(v), code="invalid_ssn")
504def fi_ssn_generator(min_year: int = 1920, max_year: int = 1999):
505 if not (1800 <= min_year < 2100):
506 raise ValidationError(_("Unsupported year") + ": {}".format(min_year))
507 if not (1800 <= max_year < 2100): 507 ↛ 508line 507 didn't jump to line 508, because the condition on line 507 was never true
508 raise ValidationError(_("Unsupported year") + ": {}".format(max_year))
510 day = randint(1, 28) # nosec
511 month = randint(1, 12) # nosec
512 year = randint(min_year, max_year) # nosec
513 suffix = randint(100, 999) # nosec
514 sep = "-"
515 if year < 1900:
516 sep = "+"
517 year2 = year - 1800
518 elif year >= 2000:
519 sep = "A"
520 year2 = year - 2000
521 else:
522 year2 = year - 1900
523 v = "{:02}{:02}{:02}{}{}".format(day, month, year2, sep, suffix)
524 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
525 digits = {
526 10: "A",
527 11: "B",
528 12: "C",
529 13: "D",
530 14: "E",
531 15: "F",
532 16: "H",
533 17: "J",
534 18: "K",
535 19: "L",
536 20: "M",
537 21: "N",
538 22: "P",
539 23: "R",
540 24: "S",
541 25: "T",
542 26: "U",
543 27: "V",
544 28: "W",
545 29: "X",
546 30: "Y",
547 }
548 ch = digits.get(d, str(d))
549 return v + ch
552def fi_ssn_birthday(v: str) -> date:
553 v = fi_ssn_filter(v)
554 fi_ssn_validator(v)
555 sep = v[6] # 231298-965X
556 year = int(v[4:6])
557 month = int(v[2:4])
558 day = int(v[0:2])
559 if sep == "+": # 1800
560 year += 1800
561 elif sep == "-":
562 year += 1900
563 elif sep == "A": 563 ↛ 565line 563 didn't jump to line 565, because the condition on line 563 was never false
564 year += 2000
565 return date(year, month, day)
568def fi_ssn_age(ssn: str, today: Optional[date] = None) -> int:
569 return calculate_age(fi_ssn_birthday(ssn), today)
572# ----------------------------------------------------------------------------
573# Sweden
574# ----------------------------------------------------------------------------
576SE_SSN_FILTER = re.compile(r"[^-0-9]")
577SE_SSN_VALIDATOR = re.compile(r"^\d{6}[-]\d{3}[\d]$")
580def se_iban_validator(v: str):
581 validate_country_iban(v, "SE")
584def se_ssn_filter(v: str) -> str:
585 return SE_SSN_FILTER.sub("", v.upper())
588def se_ssn_validator(v: str):
589 v = se_ssn_filter(v)
590 if not SE_SSN_VALIDATOR.fullmatch(v): 590 ↛ 591line 590 didn't jump to line 591, because the condition on line 590 was never true
591 raise ValidationError(_("Invalid personal identification number") + " (SE.1): {}".format(v), code="invalid_ssn")
592 v = STRIP_NON_NUMBERS.sub("", v)
593 dsum = 0
594 for i in range(9):
595 x = int(v[i])
596 if i & 1 == 0:
597 x += x
598 # print('summing', v[i], 'as', x)
599 xsum = x % 10 + int(x / 10) % 10
600 # print(v[i], 'xsum', xsum)
601 dsum += xsum
602 # print('sum', dsum)
603 rem = dsum % 10
604 # print('rem', rem)
605 checksum = 10 - rem
606 if checksum == 10:
607 checksum = 0
608 # print('checksum', checksum)
609 if int(v[-1:]) != checksum:
610 raise ValidationError(_("Invalid personal identification number") + " (SE.2): {}".format(v), code="invalid_ssn")
613def se_clearing_code_bank_info(account_number: str) -> Tuple[str, Optional[int]]:
614 """
615 Returns Sweden bank info by clearning code.
616 :param account_number: Swedish account number with clearing code as prefix
617 :return: (Bank name, account digit count) or ('', None) if not found
618 """
619 v = digit_filter(account_number)
620 clearing = v[:4]
621 for name, begin, end, acc_digits in SE_BANK_CLEARING_LIST: 621 ↛ 624line 621 didn't jump to line 624, because the loop on line 621 didn't complete
622 if begin <= clearing <= end:
623 return name, acc_digits
624 return "", None