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 v = STRIP_WHITESPACE.sub('', num)
356 if digit_filter(v) != v:
357 raise ValidationError(_('Invalid payment reference: {}').format(num))
358 v = v.lstrip('0')
359 if len(v) < 3:
360 raise ValidationError(_('Invalid payment reference: {}').format(num))
361 weights = [7, 3, 1]
362 weighed_sum = 0
363 vlen = len(v)
364 for j in range(vlen):
365 weighed_sum += int(v[vlen - 1 - j]) * weights[j % 3]
366 return v + str((10 - (weighed_sum % 10)) % 10)
369def fi_payment_reference_validator(v: str):
370 v = STRIP_WHITESPACE.sub('', v)
371 if fi_payment_reference_number(v[:-1]) != v:
372 raise ValidationError(_('Invalid payment reference: {}').format(v))
375def iso_payment_reference_validator(v: str):
376 """
377 Validates ISO reference number checksum.
378 :param v: Reference number
379 """
380 num = ''
381 v = STRIP_WHITESPACE.sub('', v)
382 for ch in v[4:] + v[0:4]:
383 x = ord(ch)
384 if ord('0') <= x <= ord('9'):
385 num += ch
386 else:
387 x -= 55
388 if x < 10 or x > 35:
389 raise ValidationError(_('Invalid payment reference: {}').format(v))
390 num += str(x)
391 res = Decimal(num) % Decimal('97')
392 if res != Decimal('1'): 392 ↛ 393line 392 didn't jump to line 393, because the condition on line 392 was never true
393 raise ValidationError(_('Invalid payment reference: {}').format(v))
396def fi_iban_validator(v: str):
397 validate_country_iban(v, 'FI')
400def fi_iban_bank_info(v: str) -> Tuple[str, str]:
401 """
402 Returns BIC code and bank name from FI IBAN number.
403 :param v: IBAN account number
404 :return: (BIC code, bank name) or ('', '') if not found
405 """
406 v = iban_filter(v)
407 bic = FI_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None)
408 return (bic, FI_BANK_NAME_BY_BIC[bic]) if bic is not None else ('', '')
411def fi_ssn_filter(v: str) -> str:
412 return FI_SSN_FILTER.sub('', v.upper())
415def fi_company_org_id_filter(v: str) -> str:
416 v = FI_COMPANY_ORG_ID_FILTER.sub('', v)
417 return v[:-1] + '-' + v[-1:] if len(v) >= 2 else ''
420def fi_company_org_id_validator(v0: str):
421 prefix = re.sub(r'\s+', '', v0)[:2] # retain prefix: either numeric or FI is ok
422 v = fi_company_org_id_filter(v0)
423 if v[:2] == prefix:
424 prefix = 'FI'
425 if v[-2:-1] != '-' or prefix != 'FI':
426 raise ValidationError(_('Invalid company organization ID')+' (FI.1): {}'.format(v0), code='invalid_company_org_id')
427 v = v.replace('-', '', 1)
428 if len(v) != 8: 428 ↛ 429line 428 didn't jump to line 429, because the condition on line 428 was never true
429 raise ValidationError(_('Invalid company organization ID')+' (FI.2): {}'.format(v0), code='invalid_company_org_id')
430 multipliers = (7, 9, 10, 5, 8, 4, 2)
431 x = 0
432 for i, m in enumerate(multipliers):
433 x += int(v[i]) * m
434 remainder = divmod(x, 11)[1]
435 if remainder == 1: 435 ↛ 436line 435 didn't jump to line 436, because the condition on line 435 was never true
436 raise ValidationError(_('Invalid company organization ID')+' (FI.3): {}'.format(v0), code='invalid_company_org_id')
437 if remainder >= 2:
438 check_digit = str(11 - remainder)
439 if check_digit != v[-1:]:
440 raise ValidationError(_('Invalid company organization ID')+' (FI.4): {}'.format(v0), code='invalid_company_org_id')
443def fi_company_org_id_generator() -> str:
444 remainder = 1
445 v = ''
446 while remainder < 2:
447 v = str(randint(11111111, 99999999)) # nosec
448 multipliers = (7, 9, 10, 5, 8, 4, 2)
449 x = 0
450 for i, m in enumerate(multipliers):
451 x += int(v[i]) * m
452 remainder = divmod(x, 11)[1]
453 check_digit = str(11 - remainder)
454 return v[:-1] + '-' + check_digit
457def fi_ssn_validator(v: str):
458 v = fi_ssn_filter(v)
459 if not FI_SSN_VALIDATOR.fullmatch(v):
460 raise ValidationError(_('Invalid personal identification number')+' (FI.1): {}'.format(v), code='invalid_ssn')
461 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
462 digits = {
463 10: 'A', 11: 'B', 12: 'C', 13: 'D', 14: 'E', 15: 'F', 16: 'H',
464 17: 'J', 18: 'K', 19: 'L', 20: 'M', 21: 'N', 22: 'P', 23: 'R',
465 24: 'S', 25: 'T', 26: 'U', 27: 'V', 28: 'W', 29: 'X', 30: 'Y',
466 }
467 ch = digits.get(d, str(d))
468 if ch != v[-1:]:
469 raise ValidationError(_('Invalid personal identification number')+' (FI.2): {}'.format(v), code='invalid_ssn')
472def fi_ssn_generator(min_year: int = 1920, max_year: int = 1999):
473 if not (1800 <= min_year < 2100):
474 raise ValidationError(_('Unsupported year') + ': {}'.format(min_year))
475 if not (1800 <= max_year < 2100): 475 ↛ 476line 475 didn't jump to line 476, because the condition on line 475 was never true
476 raise ValidationError(_('Unsupported year') + ': {}'.format(max_year))
478 day = randint(1, 28) # nosec
479 month = randint(1, 12) # nosec
480 year = randint(min_year, max_year) # nosec
481 suffix = randint(100, 999) # nosec
482 sep = '-'
483 if year < 1900:
484 sep = '+'
485 year2 = year - 1800
486 elif year >= 2000:
487 sep = 'A'
488 year2 = year - 2000
489 else:
490 year2 = year - 1900
491 v = '{:02}{:02}{:02}{}{}'.format(day, month, year2, sep, suffix)
492 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
493 digits = {
494 10: 'A', 11: 'B', 12: 'C', 13: 'D', 14: 'E', 15: 'F', 16: 'H',
495 17: 'J', 18: 'K', 19: 'L', 20: 'M', 21: 'N', 22: 'P', 23: 'R',
496 24: 'S', 25: 'T', 26: 'U', 27: 'V', 28: 'W', 29: 'X', 30: 'Y',
497 }
498 ch = digits.get(d, str(d))
499 return v + ch
502def fi_ssn_birthday(v: str) -> date:
503 v = fi_ssn_filter(v)
504 fi_ssn_validator(v)
505 sep = v[6] # 231298-965X
506 year = int(v[4:6])
507 month = int(v[2:4])
508 day = int(v[0:2])
509 if sep == '+': # 1800
510 year += 1800
511 elif sep == '-':
512 year += 1900
513 elif sep == 'A': 513 ↛ 515line 513 didn't jump to line 515, because the condition on line 513 was never false
514 year += 2000
515 return date(year, month, day)
518def fi_ssn_age(ssn: str, today: Optional[date] = None) -> int:
519 return calculate_age(fi_ssn_birthday(ssn), today)
522# ----------------------------------------------------------------------------
523# Sweden
524# ----------------------------------------------------------------------------
526SE_SSN_FILTER = re.compile(r'[^-0-9]')
527SE_SSN_VALIDATOR = re.compile(r'^\d{6}[-]\d{3}[\d]$')
530def se_iban_validator(v: str):
531 validate_country_iban(v, 'SE')
534def se_ssn_filter(v: str) -> str:
535 return SE_SSN_FILTER.sub('', v.upper())
538def se_ssn_validator(v: str):
539 v = se_ssn_filter(v)
540 if not SE_SSN_VALIDATOR.fullmatch(v): 540 ↛ 541line 540 didn't jump to line 541, because the condition on line 540 was never true
541 raise ValidationError(_('Invalid personal identification number')+' (SE.1): {}'.format(v), code='invalid_ssn')
542 v = STRIP_NON_NUMBERS.sub('', v)
543 dsum = 0
544 for i in range(9):
545 x = int(v[i])
546 if i & 1 == 0:
547 x += x
548 # print('summing', v[i], 'as', x)
549 xsum = x % 10 + int(x/10) % 10
550 # print(v[i], 'xsum', xsum)
551 dsum += xsum
552 # print('sum', dsum)
553 rem = dsum % 10
554 # print('rem', rem)
555 checksum = 10 - rem
556 if checksum == 10:
557 checksum = 0
558 # print('checksum', checksum)
559 if int(v[-1:]) != checksum:
560 raise ValidationError(_('Invalid personal identification number')+' (SE.2): {}'.format(v), code='invalid_ssn')
563def se_clearing_code_bank_info(account_number: str) -> Tuple[str, Optional[int]]:
564 """
565 Returns Sweden bank info by clearning code.
566 :param account_number: Swedish account number with clearing code as prefix
567 :return: (Bank name, account digit count) or ('', None) if not found
568 """
569 v = digit_filter(account_number)
570 clearing = v[:4]
571 for name, begin, end, acc_digits in SE_BANK_CLEARING_LIST: 571 ↛ 574line 571 didn't jump to line 574, because the loop on line 571 didn't complete
572 if begin <= clearing <= end:
573 return name, acc_digits
574 return '', None