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
12# Country-specific bank constants (abc-order):
13from jutil.bank_const_be import BE_BIC_BY_ACCOUNT_NUMBER, BE_BANK_NAME_BY_BIC
14from jutil.bank_const_dk import DK_BANK_CLEARING_MAP
15from jutil.bank_const_fi import FI_BIC_BY_ACCOUNT_NUMBER, FI_BANK_NAME_BY_BIC
16from 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]')
25STRIP_WHITESPACE = re.compile(r'\s+')
26IBAN_FILTER = re.compile(r'[^A-Z0-9]')
27DIGIT_FILTER = re.compile(r'[^0-9]')
30def phone_filter(v: str) -> str:
31 return PHONE_FILTER.sub('', str(v)) if v else ''
34def phone_validator(v: str):
35 v = phone_filter(v)
36 if not v or not PHONE_VALIDATOR.fullmatch(v):
37 v_str = _('Missing value') if v is None else str(v)
38 raise ValidationError(_('Invalid phone number') + ': {}'.format(v_str), code='invalid_phone')
41def phone_sanitizer(v: str) -> str:
42 v = phone_filter(v)
43 if not v or not PHONE_VALIDATOR.fullmatch(v):
44 return ''
45 return v
48def email_filter(v: str) -> str:
49 return str(v).lower().strip() if v else ''
52def email_validator(v: str):
53 v = email_filter(v)
54 if not v or not EMAIL_VALIDATOR.fullmatch(v):
55 v_str = _('Missing value') if not v else str(v)
56 raise ValidationError(_('Invalid email') + ': {}'.format(v_str), code='invalid_email')
59def email_sanitizer(v: str) -> str:
60 v = email_filter(v)
61 if not v or not EMAIL_VALIDATOR.fullmatch(v):
62 return ''
63 return v
66def passport_filter(v: str) -> str:
67 return PASSPORT_FILTER.sub('', str(v).upper()) if v else ''
70def passport_validator(v: str):
71 v = passport_filter(v)
72 if not v or len(v) < 5:
73 v_str = _('Missing value') if v is None else str(v)
74 raise ValidationError(_('Invalid passport number') + ': {}'.format(v_str), code='invalid_passport')
77def passport_sanitizer(v: str):
78 v = passport_filter(v)
79 if not v or len(v) < 5:
80 return ''
81 return v
84def country_code_filter(v: str) -> str:
85 return v.strip().upper()
88def bic_filter(v: str) -> str:
89 return v.strip().upper()
92def country_code_validator(v: str):
93 """
94 Accepts both ISO-2 and ISO-3 formats.
95 :param v: str
96 :return: None
97 """
98 v = country_code_filter(v)
99 if not (2 <= len(v) <= 3):
100 v_str = _('Missing value') if v is None else str(v)
101 raise ValidationError(_('Invalid country code') + ': {}'.format(v_str), code='invalid_country_code')
104def country_code_sanitizer(v: str) -> str:
105 v = country_code_filter(v)
106 return v if 2 <= len(v) <= 3 else ''
109def bic_sanitizer(v: str) -> str:
110 v = bic_filter(v)
111 return v if 8 <= len(v) <= 11 else ''
114def ascii_filter(v: str) -> str:
115 """
116 Replaces Unicode accent characters with plain ASCII.
117 For example remove_accents('HELÉN') == 'HELEN'.
118 :param v: str
119 :return: str
120 """
121 return unicodedata.normalize('NFKD', v).encode('ASCII', 'ignore').decode()
124def digit_filter(v: str) -> str:
125 return DIGIT_FILTER.sub('', str(v)) if v else ''
128def iban_filter(v: str) -> str:
129 return IBAN_FILTER.sub('', str(v).upper()) if v else ''
132def iban_filter_readable(acct) -> str:
133 acct = iban_filter(acct)
134 if acct:
135 i = 0
136 j = 4
137 out = ''
138 nlen = len(acct)
139 while i < nlen:
140 if out:
141 out += ' '
142 out += acct[i:j]
143 i = j
144 j += 4
145 return out
146 return acct
149def bic_validator(v: str):
150 """
151 Validates bank BIC/SWIFT code (8-11 characters).
152 :param v: str
153 :return: None
154 """
155 v = bic_filter(v)
156 if not (8 <= len(v) <= 11):
157 v_str = _('Missing value') if v is None else str(v)
158 raise ValidationError(_('Invalid bank BIC/SWIFT code') + ': {}'.format(v_str), code='invalid_bic')
161def iban_validator(v: str):
162 """
163 Validates IBAN format bank account number.
164 :param v: str
165 :return: None
166 """
167 # validate prefix and length
168 v = iban_filter(v)
169 if not v:
170 raise ValidationError(_('Invalid IBAN account number') + ': {}'.format(_('Missing value')), code='invalid_iban')
171 country = v[:2].upper()
172 if country not in IBAN_LENGTH_BY_COUNTRY:
173 raise ValidationError(_('Invalid country code') + ': {}'.format(country), code='invalid_country_code')
174 iban_len = IBAN_LENGTH_BY_COUNTRY[country]
175 if iban_len != len(v):
176 raise ValidationError(_('Invalid IBAN account number') + ': {}'.format(v), code='invalid_iban')
178 # validate IBAN numeric part
179 if iban_len <= 26: # very long IBANs are unsupported by the numeric part validation
180 digits = '0123456789'
181 num = ''
182 for ch in v[4:] + v[0:4]:
183 if ch not in digits:
184 ch = str(ord(ch) - ord('A') + 10)
185 num += ch
186 x = Decimal(num) % Decimal(97)
187 if x != Decimal(1):
188 raise ValidationError(_('Invalid IBAN account number') + ': {}'.format(v), code='invalid_iban')
191def iban_generator(country_code: str = '') -> str:
192 """
193 Generates IBAN format bank account number (for testing).
194 :param country_code: 2-character country code (optional)
195 :return: str
196 """
197 # pick random country code if not set (with max IBAN length 27)
198 if not country_code:
199 country_code = random.choice( # nosec
200 list(filter(lambda cc: IBAN_LENGTH_BY_COUNTRY[cc] <= 26, IBAN_LENGTH_BY_COUNTRY.keys())))
201 if country_code not in IBAN_LENGTH_BY_COUNTRY:
202 raise ValidationError(_('Invalid country code') + ': {}'.format(country_code), code='invalid_country_code')
203 nlen = IBAN_LENGTH_BY_COUNTRY[country_code]
204 if nlen > 26:
205 raise ValidationError(_('IBAN checksum generation does not support >26 character IBANs'), code='invalid_iban')
207 # generate BBAN part
208 digits = '0123456789'
209 bban = ''.join([random.choice(digits) for n in range(nlen - 4)]) # nosec
211 # generate valid IBAN numeric part
212 # (probably not the most efficient way to do this but write a better one if you need faster...)
213 num0 = ''
214 for ch in bban + country_code:
215 if ch not in digits:
216 ch = str(ord(ch) - ord('A') + 10)
217 num0 += ch
218 for checksum in range(1, 100): 218 ↛ 230line 218 didn't jump to line 230, because the loop on line 218 didn't complete
219 num = num0
220 checksum_str = '{:02}'.format(checksum)
221 for ch in checksum_str:
222 if ch not in digits: 222 ↛ 223line 222 didn't jump to line 223, because the condition on line 222 was never true
223 ch = str(ord(ch) - ord('A') + 10)
224 num += ch
225 # print(num, '/', 97, 'nlen', nlen)
226 x = Decimal(num) % Decimal(97)
227 if x == Decimal(1):
228 return country_code + checksum_str + bban
230 raise ValidationError(_('Invalid IBAN account number'), code='invalid_iban') # should not get here
233def validate_country_iban(v: str, country: str):
234 v = iban_filter(v)
235 if v[0:2] != country:
236 raise ValidationError(_('Invalid IBAN account number') + ' ({}.2): {}'.format(country, v), code='invalid_iban')
237 iban_validator(v)
240def iban_bank_info(v: str) -> Tuple[str, str]:
241 """
242 Returns BIC code and bank name from IBAN number.
243 :param v: IBAN account number
244 :return: (BIC code, bank name) or ('', '') if not found / unsupported country
245 """
246 v = iban_filter(v)
247 prefix = v[:2]
248 func_name = prefix.lower() + '_iban_bank_info' # e.g. fi_iban_bank_info, be_iban_bank_info
249 func = globals().get(func_name)
250 if func is not None:
251 return func(v)
252 return '', ''
255def iban_bic(v: str) -> str:
256 """
257 Returns BIC code from IBAN number.
258 :param v: IBAN account number
259 :return: BIC code or '' if not found
260 """
261 info = iban_bank_info(v)
262 return info[0] if info else ''
265def calculate_age(born: date, today: Optional[date] = None) -> int:
266 if not today:
267 today = now().date()
268 return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
271def filter_country_company_org_id(country_code: str, v: str):
272 if country_code == 'FI':
273 return fi_company_org_id_filter(v)
274 return PASSPORT_FILTER.sub('', v)
277def validate_country_company_org_id(country_code: str, v: str):
278 if country_code == 'FI':
279 fi_company_org_id_validator(v)
282# ============================================================================
283# Country specific functions (countries in alphabetical order)
284# ============================================================================
287# ----------------------------------------------------------------------------
288# Belgium
289# ----------------------------------------------------------------------------
291def be_iban_validator(v: str):
292 validate_country_iban(v, 'BE')
295def be_iban_bank_info(v: str) -> Tuple[str, str]:
296 """
297 Returns BIC code and bank name from BE IBAN number.
298 :param v: IBAN account number
299 :return: (BIC code, bank name) or ('', '') if not found
300 """
301 v = iban_filter(v)
302 bic = BE_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None)
303 return (bic, BE_BANK_NAME_BY_BIC[bic]) if bic is not None else ('', '')
306# ----------------------------------------------------------------------------
307# Denmark
308# ----------------------------------------------------------------------------
310def dk_iban_validator(v: str):
311 validate_country_iban(v, 'DK')
314def dk_clearing_code_bank_name(v: str) -> str:
315 v = iban_filter(v)
316 if v.startswith('DK'):
317 v = v[4:]
318 return DK_BANK_CLEARING_MAP.get(v[:4], '')
321def dk_iban_bank_info(v: str) -> Tuple[str, str]:
322 """
323 Returns empty string (BIC not available) and bank name from DK IBAN number.
324 DK5000400440116243
325 :param v: IBAN account number
326 :return: ('', bank name) or ('', '') if not found
327 """
328 return '', dk_clearing_code_bank_name(v)
331# ----------------------------------------------------------------------------
332# Estonia
333# ----------------------------------------------------------------------------
335def ee_iban_validator(v: str):
336 validate_country_iban(v, 'EE')
339# ----------------------------------------------------------------------------
340# Finland
341# ----------------------------------------------------------------------------
343FI_SSN_FILTER = re.compile(r'[^0-9A-Z+-]')
344FI_SSN_VALIDATOR = re.compile(r'^\d{6}[+-A]\d{3}[\d\w]$')
345FI_COMPANY_ORG_ID_FILTER = re.compile(r'[^0-9]')
348def fi_payment_reference_number(num: str):
349 """
350 Appends Finland reference number checksum to existing number.
351 :param num: At least 3 digits
352 :return: Number plus checksum
353 """
354 assert isinstance(num, str)
355 num = STRIP_WHITESPACE.sub('', num)
356 num = re.sub(r'^0+', '', num)
357 assert len(num) >= 3
358 weights = [7, 3, 1]
359 weighed_sum = 0
360 numlen = len(num)
361 for j in range(numlen):
362 weighed_sum += int(num[numlen - 1 - j]) * weights[j % 3]
363 return num + str((10 - (weighed_sum % 10)) % 10)
366def fi_payment_reference_validator(v: str):
367 v = STRIP_WHITESPACE.sub('', v)
368 if fi_payment_reference_number(v[:-1]) != v:
369 raise ValidationError(_('Invalid payment reference: {}').format(v))
372def iso_payment_reference_validator(v: str):
373 """
374 Validates ISO reference number checksum.
375 :param v: Reference number
376 """
377 num = ''
378 v = STRIP_WHITESPACE.sub('', v)
379 for ch in v[4:] + v[0:4]:
380 x = ord(ch)
381 if ord('0') <= x <= ord('9'):
382 num += ch
383 else:
384 x -= 55
385 if x < 10 or x > 35:
386 raise ValidationError(_('Invalid payment reference: {}').format(v))
387 num += str(x)
388 res = Decimal(num) % Decimal('97')
389 if res != Decimal('1'): 389 ↛ 390line 389 didn't jump to line 390, because the condition on line 389 was never true
390 raise ValidationError(_('Invalid payment reference: {}').format(v))
393def fi_iban_validator(v: str):
394 validate_country_iban(v, 'FI')
397def fi_iban_bank_info(v: str) -> Tuple[str, str]:
398 """
399 Returns BIC code and bank name from FI IBAN number.
400 :param v: IBAN account number
401 :return: (BIC code, bank name) or ('', '') if not found
402 """
403 v = iban_filter(v)
404 bic = FI_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None)
405 return (bic, FI_BANK_NAME_BY_BIC[bic]) if bic is not None else ('', '')
408def fi_ssn_filter(v: str) -> str:
409 return FI_SSN_FILTER.sub('', v.upper())
412def fi_company_org_id_filter(v: str) -> str:
413 v = FI_COMPANY_ORG_ID_FILTER.sub('', v)
414 return v[:-1] + '-' + v[-1:] if len(v) >= 2 else ''
417def fi_company_org_id_validator(v0: str):
418 prefix = re.sub(r'\s+', '', v0)[:2] # retain prefix: either numeric or FI is ok
419 v = fi_company_org_id_filter(v0)
420 if v[:2] == prefix:
421 prefix = 'FI'
422 if v[-2:-1] != '-' or prefix != 'FI':
423 raise ValidationError(_('Invalid company organization ID')+' (FI.1): {}'.format(v0), code='invalid_company_org_id')
424 v = v.replace('-', '', 1)
425 if len(v) != 8: 425 ↛ 426line 425 didn't jump to line 426, because the condition on line 425 was never true
426 raise ValidationError(_('Invalid company organization ID')+' (FI.2): {}'.format(v0), code='invalid_company_org_id')
427 multipliers = (7, 9, 10, 5, 8, 4, 2)
428 x = 0
429 for i, m in enumerate(multipliers):
430 x += int(v[i]) * m
431 remainder = divmod(x, 11)[1]
432 if remainder == 1: 432 ↛ 433line 432 didn't jump to line 433, because the condition on line 432 was never true
433 raise ValidationError(_('Invalid company organization ID')+' (FI.3): {}'.format(v0), code='invalid_company_org_id')
434 if remainder >= 2:
435 check_digit = str(11 - remainder)
436 if check_digit != v[-1:]:
437 raise ValidationError(_('Invalid company organization ID')+' (FI.4): {}'.format(v0), code='invalid_company_org_id')
440def fi_company_org_id_generator() -> str:
441 remainder = 1
442 v = ''
443 while remainder < 2:
444 v = str(randint(11111111, 99999999)) # nosec
445 multipliers = (7, 9, 10, 5, 8, 4, 2)
446 x = 0
447 for i, m in enumerate(multipliers):
448 x += int(v[i]) * m
449 remainder = divmod(x, 11)[1]
450 check_digit = str(11 - remainder)
451 return v[:-1] + '-' + check_digit
454def fi_ssn_validator(v: str):
455 v = fi_ssn_filter(v)
456 if not FI_SSN_VALIDATOR.fullmatch(v):
457 raise ValidationError(_('Invalid personal identification number')+' (FI.1): {}'.format(v), code='invalid_ssn')
458 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
459 digits = {
460 10: 'A', 11: 'B', 12: 'C', 13: 'D', 14: 'E', 15: 'F', 16: 'H',
461 17: 'J', 18: 'K', 19: 'L', 20: 'M', 21: 'N', 22: 'P', 23: 'R',
462 24: 'S', 25: 'T', 26: 'U', 27: 'V', 28: 'W', 29: 'X', 30: 'Y',
463 }
464 ch = digits.get(d, str(d))
465 if ch != v[-1:]:
466 raise ValidationError(_('Invalid personal identification number')+' (FI.2): {}'.format(v), code='invalid_ssn')
469def fi_ssn_generator(min_year: int = 1920, max_year: int = 1999):
470 if not (1800 <= min_year < 2100):
471 raise ValidationError(_('Unsupported year') + ': {}'.format(min_year))
472 if not (1800 <= max_year < 2100): 472 ↛ 473line 472 didn't jump to line 473, because the condition on line 472 was never true
473 raise ValidationError(_('Unsupported year') + ': {}'.format(max_year))
475 day = randint(1, 28) # nosec
476 month = randint(1, 12) # nosec
477 year = randint(min_year, max_year) # nosec
478 year2 = ''
479 suffix = randint(100, 999) # nosec
480 sep = '-'
481 if year < 1900:
482 sep = '+'
483 year2 = year - 1800
484 elif year >= 2000:
485 sep = 'A'
486 year2 = year - 2000
487 else:
488 year2 = year - 1900
489 v = '{:02}{:02}{:02}{}{}'.format(day, month, year2, sep, suffix)
490 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
491 digits = {
492 10: 'A', 11: 'B', 12: 'C', 13: 'D', 14: 'E', 15: 'F', 16: 'H',
493 17: 'J', 18: 'K', 19: 'L', 20: 'M', 21: 'N', 22: 'P', 23: 'R',
494 24: 'S', 25: 'T', 26: 'U', 27: 'V', 28: 'W', 29: 'X', 30: 'Y',
495 }
496 ch = digits.get(d, str(d))
497 return v + ch
500def fi_ssn_birthday(v: str) -> date:
501 v = fi_ssn_filter(v)
502 fi_ssn_validator(v)
503 sep = v[6] # 231298-965X
504 year = int(v[4:6])
505 month = int(v[2:4])
506 day = int(v[0:2])
507 if sep == '+': # 1800
508 year += 1800
509 elif sep == '-':
510 year += 1900
511 elif sep == 'A': 511 ↛ 513line 511 didn't jump to line 513, because the condition on line 511 was never false
512 year += 2000
513 return date(year, month, day)
516def fi_ssn_age(ssn: str, today: Optional[date] = None) -> int:
517 return calculate_age(fi_ssn_birthday(ssn), today)
520# ----------------------------------------------------------------------------
521# Sweden
522# ----------------------------------------------------------------------------
524SE_SSN_FILTER = re.compile(r'[^-0-9]')
525SE_SSN_VALIDATOR = re.compile(r'^\d{6}[-]\d{3}[\d]$')
528def se_iban_validator(v: str):
529 validate_country_iban(v, 'SE')
532def se_ssn_filter(v: str) -> str:
533 return SE_SSN_FILTER.sub('', v.upper())
536def se_ssn_validator(v: str):
537 v = se_ssn_filter(v)
538 if not SE_SSN_VALIDATOR.fullmatch(v): 538 ↛ 539line 538 didn't jump to line 539, because the condition on line 538 was never true
539 raise ValidationError(_('Invalid personal identification number')+' (SE.1): {}'.format(v), code='invalid_ssn')
540 v = STRIP_NON_NUMBERS.sub('', v)
541 dsum = 0
542 for i in range(9):
543 x = int(v[i])
544 if i & 1 == 0:
545 x += x
546 # print('summing', v[i], 'as', x)
547 xsum = x % 10 + int(x/10) % 10
548 # print(v[i], 'xsum', xsum)
549 dsum += xsum
550 # print('sum', dsum)
551 rem = dsum % 10
552 # print('rem', rem)
553 checksum = 10 - rem
554 if checksum == 10:
555 checksum = 0
556 # print('checksum', checksum)
557 if int(v[-1:]) != checksum:
558 raise ValidationError(_('Invalid personal identification number')+' (SE.2): {}'.format(v), code='invalid_ssn')
561def se_clearing_code_bank_info(account_number: str) -> Tuple[str, Optional[int]]:
562 """
563 Returns Sweden bank info by clearning code.
564 :param account_number: Swedish account number with clearing code as prefix
565 :return: (Bank name, account digit count) or ('', None) if not found
566 """
567 v = digit_filter(account_number)
568 clearing = v[:4]
569 for name, begin, end, acc_digits in SE_BANK_CLEARING_LIST: 569 ↛ 572line 569 didn't jump to line 572, because the loop on line 569 didn't complete
570 if begin <= clearing <= end:
571 return name, acc_digits
572 return '', None