Hide keyboard shortcuts

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 

17 

18 

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]') 

28 

29 

30def phone_filter(v: str) -> str: 

31 return PHONE_FILTER.sub('', str(v)) if v else '' 

32 

33 

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') 

39 

40 

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 

46 

47 

48def email_filter(v: str) -> str: 

49 return str(v).lower().strip() if v else '' 

50 

51 

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') 

57 

58 

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 

64 

65 

66def passport_filter(v: str) -> str: 

67 return PASSPORT_FILTER.sub('', str(v).upper()) if v else '' 

68 

69 

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') 

75 

76 

77def passport_sanitizer(v: str): 

78 v = passport_filter(v) 

79 if not v or len(v) < 5: 

80 return '' 

81 return v 

82 

83 

84def country_code_filter(v: str) -> str: 

85 return v.strip().upper() 

86 

87 

88def bic_filter(v: str) -> str: 

89 return v.strip().upper() 

90 

91 

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') 

102 

103 

104def country_code_sanitizer(v: str) -> str: 

105 v = country_code_filter(v) 

106 return v if 2 <= len(v) <= 3 else '' 

107 

108 

109def bic_sanitizer(v: str) -> str: 

110 v = bic_filter(v) 

111 return v if 8 <= len(v) <= 11 else '' 

112 

113 

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() 

122 

123 

124def digit_filter(v: str) -> str: 

125 return DIGIT_FILTER.sub('', str(v)) if v else '' 

126 

127 

128def iban_filter(v: str) -> str: 

129 return IBAN_FILTER.sub('', str(v).upper()) if v else '' 

130 

131 

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 

147 

148 

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') 

159 

160 

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') 

177 

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') 

189 

190 

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') 

206 

207 # generate BBAN part 

208 digits = '0123456789' 

209 bban = ''.join([random.choice(digits) for n in range(nlen - 4)]) # nosec 

210 

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 

229 

230 raise ValidationError(_('Invalid IBAN account number'), code='invalid_iban') # should not get here 

231 

232 

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) 

238 

239 

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 '', '' 

253 

254 

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 '' 

263 

264 

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)) 

269 

270 

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) 

275 

276 

277def validate_country_company_org_id(country_code: str, v: str): 

278 if country_code == 'FI': 

279 fi_company_org_id_validator(v) 

280 

281 

282# ============================================================================ 

283# Country specific functions (countries in alphabetical order) 

284# ============================================================================ 

285 

286 

287# ---------------------------------------------------------------------------- 

288# Belgium 

289# ---------------------------------------------------------------------------- 

290 

291def be_iban_validator(v: str): 

292 validate_country_iban(v, 'BE') 

293 

294 

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 ('', '') 

304 

305 

306# ---------------------------------------------------------------------------- 

307# Denmark 

308# ---------------------------------------------------------------------------- 

309 

310def dk_iban_validator(v: str): 

311 validate_country_iban(v, 'DK') 

312 

313 

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], '') 

319 

320 

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) 

329 

330 

331# ---------------------------------------------------------------------------- 

332# Estonia 

333# ---------------------------------------------------------------------------- 

334 

335def ee_iban_validator(v: str): 

336 validate_country_iban(v, 'EE') 

337 

338 

339# ---------------------------------------------------------------------------- 

340# Finland 

341# ---------------------------------------------------------------------------- 

342 

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]') 

346 

347 

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) 

367 

368 

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)) 

373 

374 

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)) 

394 

395 

396def fi_iban_validator(v: str): 

397 validate_country_iban(v, 'FI') 

398 

399 

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 ('', '') 

409 

410 

411def fi_ssn_filter(v: str) -> str: 

412 return FI_SSN_FILTER.sub('', v.upper()) 

413 

414 

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 '' 

418 

419 

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') 

441 

442 

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 

455 

456 

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') 

470 

471 

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)) 

477 

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 

500 

501 

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) 

516 

517 

518def fi_ssn_age(ssn: str, today: Optional[date] = None) -> int: 

519 return calculate_age(fi_ssn_birthday(ssn), today) 

520 

521 

522# ---------------------------------------------------------------------------- 

523# Sweden 

524# ---------------------------------------------------------------------------- 

525 

526SE_SSN_FILTER = re.compile(r'[^-0-9]') 

527SE_SSN_VALIDATOR = re.compile(r'^\d{6}[-]\d{3}[\d]$') 

528 

529 

530def se_iban_validator(v: str): 

531 validate_country_iban(v, 'SE') 

532 

533 

534def se_ssn_filter(v: str) -> str: 

535 return SE_SSN_FILTER.sub('', v.upper()) 

536 

537 

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') 

561 

562 

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