Coverage for jutil/validators.py : 88%

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: 72 ↛ exitline 72 didn't return from function 'passport_validator', because the condition on line 72 was never false
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: 79 ↛ 81line 79 didn't jump to line 81, because the condition on line 79 was never false
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): 99 ↛ exitline 99 didn't return from function 'country_code_validator', because the condition on line 99 was never false
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: 134 ↛ 146line 134 didn't jump to line 146, because the condition on line 134 was never false
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: 169 ↛ 170line 169 didn't jump to line 170, because the condition on line 169 was never true
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: 172 ↛ 173line 172 didn't jump to line 173, because the condition on line 172 was never true
173 raise ValidationError(_('Invalid IBAN account number') + ': {}'.format(v), code='invalid_iban')
174 iban_len = IBAN_LENGTH_BY_COUNTRY[country]
175 if iban_len != len(v): 175 ↛ 176line 175 didn't jump to line 176, because the condition on line 175 was never true
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: 198 ↛ 201line 198 didn't jump to line 201, because the condition on line 198 was never false
199 country_code = random.choice( # nosec
200 list(filter(lambda cc: IBAN_LENGTH_BY_COUNTRY[cc] <= 26, IBAN_LENGTH_BY_COUNTRY.keys())))
201 nlen = IBAN_LENGTH_BY_COUNTRY[country_code]
202 if nlen > 26: 202 ↛ 203line 202 didn't jump to line 203, because the condition on line 202 was never true
203 raise ValidationError(_('IBAN checksum generation does not support >26 character IBANs'), code='invalid_iban')
205 # generate BBAN part
206 if country_code not in IBAN_LENGTH_BY_COUNTRY: 206 ↛ 207line 206 didn't jump to line 207, because the condition on line 206 was never true
207 raise ValidationError(_('Invalid country code') + ': {}'.format(country_code), code='invalid_country_code')
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': 278 ↛ exitline 278 didn't return from function 'validate_country_company_org_id', because the condition on line 278 was never false
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'[^-A-Z0-9]')
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: 385 ↛ 386line 385 didn't jump to line 386, because the condition on line 385 was never true
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 v = fi_company_org_id_filter(v0)
419 prefix = v[:2]
420 if v[-2:-1] != '-' and prefix != 'FI': 420 ↛ 421line 420 didn't jump to line 421, because the condition on line 420 was never true
421 raise ValidationError(_('Invalid company organization ID')+' (FI.1): {}'.format(v0), code='invalid_company_org_id')
422 v = v.replace('-', '', 1)
423 if len(v) != 8: 423 ↛ 424line 423 didn't jump to line 424, because the condition on line 423 was never true
424 raise ValidationError(_('Invalid company organization ID')+' (FI.2): {}'.format(v0), code='invalid_company_org_id')
425 multipliers = (7, 9, 10, 5, 8, 4, 2)
426 x = 0
427 for i, m in enumerate(multipliers):
428 x += int(v[i]) * m
429 remainder = divmod(x, 11)[1]
430 if remainder == 1: 430 ↛ 431line 430 didn't jump to line 431, because the condition on line 430 was never true
431 raise ValidationError(_('Invalid company organization ID')+' (FI.3): {}'.format(v0), code='invalid_company_org_id')
432 if remainder >= 2:
433 check_digit = str(11 - remainder)
434 if check_digit != v[-1:]:
435 raise ValidationError(_('Invalid company organization ID')+' (FI.4): {}'.format(v0), code='invalid_company_org_id')
438def fi_company_org_id_generator() -> str:
439 remainder = 1
440 v = ''
441 while remainder < 2:
442 v = str(randint(11111111, 99999999)) # nosec
443 multipliers = (7, 9, 10, 5, 8, 4, 2)
444 x = 0
445 for i, m in enumerate(multipliers):
446 x += int(v[i]) * m
447 remainder = divmod(x, 11)[1]
448 check_digit = str(11 - remainder)
449 return v[:-1] + '-' + check_digit
452def fi_ssn_validator(v: str):
453 v = fi_ssn_filter(v)
454 if not FI_SSN_VALIDATOR.fullmatch(v): 454 ↛ 455line 454 didn't jump to line 455, because the condition on line 454 was never true
455 raise ValidationError(_('Invalid personal identification number')+' (FI.1): {}'.format(v), code='invalid_ssn')
456 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
457 digits = {
458 10: 'A', 11: 'B', 12: 'C', 13: 'D', 14: 'E', 15: 'F', 16: 'H',
459 17: 'J', 18: 'K', 19: 'L', 20: 'M', 21: 'N', 22: 'P', 23: 'R',
460 24: 'S', 25: 'T', 26: 'U', 27: 'V', 28: 'W', 29: 'X', 30: 'Y',
461 }
462 ch = digits.get(d, str(d))
463 if ch != v[-1:]: 463 ↛ 464line 463 didn't jump to line 464, because the condition on line 463 was never true
464 raise ValidationError(_('Invalid personal identification number')+' (FI.2): {}'.format(v), code='invalid_ssn')
467def fi_ssn_generator():
468 day = randint(1, 28) # nosec
469 month = randint(1, 12) # nosec
470 year = randint(1920, 1999) # nosec
471 suffix = randint(100, 999) # nosec
472 v = '{:02}{:02}{:02}-{}'.format(day, month, year-1900, suffix)
473 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31))
474 digits = {
475 10: 'A', 11: 'B', 12: 'C', 13: 'D', 14: 'E', 15: 'F', 16: 'H',
476 17: 'J', 18: 'K', 19: 'L', 20: 'M', 21: 'N', 22: 'P', 23: 'R',
477 24: 'S', 25: 'T', 26: 'U', 27: 'V', 28: 'W', 29: 'X', 30: 'Y',
478 }
479 ch = digits.get(d, str(d))
480 return v + ch
483def fi_ssn_birthday(v: str) -> date:
484 v = fi_ssn_filter(v)
485 fi_ssn_validator(v)
486 sep = v[6] # 231298-965X
487 year = int(v[4:6])
488 month = int(v[2:4])
489 day = int(v[0:2])
490 if sep == '+': # 1800 490 ↛ 491line 490 didn't jump to line 491, because the condition on line 490 was never true
491 year += 1800
492 elif sep == '-': 492 ↛ 494line 492 didn't jump to line 494, because the condition on line 492 was never false
493 year += 1900
494 elif sep == 'A':
495 year += 2000
496 return date(year, month, day)
499def fi_ssn_age(ssn: str, today: Optional[date] = None) -> int:
500 return calculate_age(fi_ssn_birthday(ssn), today)
503# ----------------------------------------------------------------------------
504# Sweden
505# ----------------------------------------------------------------------------
507SE_SSN_FILTER = re.compile(r'[^-0-9]')
508SE_SSN_VALIDATOR = re.compile(r'^\d{6}[-]\d{3}[\d]$')
511def se_iban_validator(v: str):
512 validate_country_iban(v, 'SE')
515def se_ssn_filter(v: str) -> str:
516 return SE_SSN_FILTER.sub('', v.upper())
519def se_ssn_validator(v: str):
520 v = se_ssn_filter(v)
521 if not SE_SSN_VALIDATOR.fullmatch(v): 521 ↛ 522line 521 didn't jump to line 522, because the condition on line 521 was never true
522 raise ValidationError(_('Invalid personal identification number')+' (SE.1): {}'.format(v), code='invalid_ssn')
523 v = STRIP_NON_NUMBERS.sub('', v)
524 dsum = 0
525 for i in range(9):
526 x = int(v[i])
527 if i & 1 == 0:
528 x += x
529 # print('summing', v[i], 'as', x)
530 xsum = x % 10 + int(x/10) % 10
531 # print(v[i], 'xsum', xsum)
532 dsum += xsum
533 # print('sum', dsum)
534 rem = dsum % 10
535 # print('rem', rem)
536 checksum = 10 - rem
537 if checksum == 10:
538 checksum = 0
539 # print('checksum', checksum)
540 if int(v[-1:]) != checksum:
541 raise ValidationError(_('Invalid personal identification number')+' (SE.2): {}'.format(v), code='invalid_ssn')
544def se_clearing_code_bank_info(account_number: str) -> Tuple[str, Optional[int]]:
545 """
546 Returns Sweden bank info by clearning code.
547 :param account_number: Swedish account number with clearing code as prefix
548 :return: (Bank name, account digit count) or ('', None) if not found
549 """
550 v = digit_filter(account_number)
551 clearing = v[:4]
552 for name, begin, end, acc_digits in SE_BANK_CLEARING_LIST: 552 ↛ 555line 552 didn't jump to line 555, because the loop on line 552 didn't complete
553 if begin <= clearing <= end:
554 return name, acc_digits
555 return '', None