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

75 

76 

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 

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

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

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

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

204 

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 

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

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

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

364 

365 

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

370 

371 

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

391 

392 

393def fi_iban_validator(v: str): 

394 validate_country_iban(v, 'FI') 

395 

396 

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

406 

407 

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

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

410 

411 

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

415 

416 

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

436 

437 

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 

450 

451 

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

465 

466 

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 

481 

482 

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) 

497 

498 

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

500 return calculate_age(fi_ssn_birthday(ssn), today) 

501 

502 

503# ---------------------------------------------------------------------------- 

504# Sweden 

505# ---------------------------------------------------------------------------- 

506 

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

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

509 

510 

511def se_iban_validator(v: str): 

512 validate_country_iban(v, 'SE') 

513 

514 

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

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

517 

518 

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

542 

543 

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