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 

13# Country-specific bank constants (abc-order): 

14from jutil.bank_const_be import BE_BIC_BY_ACCOUNT_NUMBER, BE_BANK_NAME_BY_BIC 

15from jutil.bank_const_dk import DK_BANK_CLEARING_MAP 

16from jutil.bank_const_fi import FI_BIC_BY_ACCOUNT_NUMBER, FI_BANK_NAME_BY_BIC 

17from jutil.bank_const_se import SE_BANK_CLEARING_LIST 

18 

19 

20EMAIL_VALIDATOR = re.compile(r"[a-zA-Z0-9\._-]+@[a-zA-Z0-9\._-]+\.[a-zA-Z]+") 

21PHONE_FILTER = re.compile(r"[^+0-9]") 

22PHONE_VALIDATOR = re.compile(r"\+?\d{6,}") 

23PASSPORT_FILTER = re.compile(r"[^-A-Z0-9]") 

24STRIP_NON_NUMBERS = re.compile(r"[^0-9]") 

25STRIP_NON_ALPHANUMERIC = re.compile(r"[^0-9A-Za-z]") 

26STRIP_WHITESPACE = re.compile(r"\s+") 

27IBAN_FILTER = re.compile(r"[^A-Z0-9]") 

28DIGIT_FILTER = re.compile(r"[^0-9]") 

29 

30 

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

32 return PHONE_FILTER.sub("", str(v)) if v else "" 

33 

34 

35def phone_validator(v: str): 

36 v = phone_filter(v) 

37 if not v or not PHONE_VALIDATOR.fullmatch(v): 

38 v_str = _("Missing value") if v is None else str(v) 

39 raise ValidationError(_("Invalid phone number") + ": {}".format(v_str), code="invalid_phone") 

40 

41 

42def phone_sanitizer(v: str) -> str: 

43 v = phone_filter(v) 

44 if not v or not PHONE_VALIDATOR.fullmatch(v): 

45 return "" 

46 return v 

47 

48 

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

50 return str(v).lower().strip() if v else "" 

51 

52 

53def email_validator(v: str): 

54 v = email_filter(v) 

55 if not v or not EMAIL_VALIDATOR.fullmatch(v): 

56 v_str = _("Missing value") if not v else str(v) 

57 raise ValidationError(_("Invalid email") + ": {}".format(v_str), code="invalid_email") 

58 

59 

60def email_sanitizer(v: str) -> str: 

61 v = email_filter(v) 

62 if not v or not EMAIL_VALIDATOR.fullmatch(v): 

63 return "" 

64 return v 

65 

66 

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

68 return PASSPORT_FILTER.sub("", str(v).upper()) if v else "" 

69 

70 

71def passport_validator(v: str): 

72 v = passport_filter(v) 

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

74 v_str = _("Missing value") if v is None else str(v) 

75 raise ValidationError(_("Invalid passport number") + ": {}".format(v_str), code="invalid_passport") 

76 

77 

78def passport_sanitizer(v: str): 

79 v = passport_filter(v) 

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

81 return "" 

82 return v 

83 

84 

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

86 return v.strip().upper() 

87 

88 

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

90 return v.strip().upper() 

91 

92 

93def country_code_validator(v: str): 

94 """ 

95 Accepts both ISO-2 and ISO-3 formats. 

96 :param v: str 

97 :return: None 

98 """ 

99 v = country_code_filter(v) 

100 if not (2 <= len(v) <= 3): 

101 v_str = _("Missing value") if v is None else str(v) 

102 raise ValidationError(_("Invalid country code") + ": {}".format(v_str), code="invalid_country_code") 

103 

104 

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

106 v = country_code_filter(v) 

107 return v if 2 <= len(v) <= 3 else "" 

108 

109 

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

111 v = bic_filter(v) 

112 return v if 8 <= len(v) <= 11 else "" 

113 

114 

115def ascii_filter(v: str) -> str: 

116 """ 

117 Replaces Unicode accent characters with plain ASCII. 

118 For example remove_accents('HELÉN') == 'HELEN'. 

119 :param v: str 

120 :return: str 

121 """ 

122 return unicodedata.normalize("NFKD", v).encode("ASCII", "ignore").decode() 

123 

124 

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

126 return DIGIT_FILTER.sub("", str(v)) if v else "" 

127 

128 

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

130 return IBAN_FILTER.sub("", str(v).upper()) if v else "" 

131 

132 

133def iban_filter_readable(acct) -> str: 

134 acct = iban_filter(acct) 

135 if acct: 

136 i = 0 

137 j = 4 

138 out = "" 

139 nlen = len(acct) 

140 while i < nlen: 

141 if out: 

142 out += " " 

143 out += acct[i:j] 

144 i = j 

145 j += 4 

146 return out 

147 return acct 

148 

149 

150def bic_validator(v: str): 

151 """ 

152 Validates bank BIC/SWIFT code (8-11 characters). 

153 :param v: str 

154 :return: None 

155 """ 

156 v = bic_filter(v) 

157 if not (8 <= len(v) <= 11): 

158 v_str = _("Missing value") if v is None else str(v) 

159 raise ValidationError(_("Invalid bank BIC/SWIFT code") + ": {}".format(v_str), code="invalid_bic") 

160 

161 

162def iban_validator(v: str): 

163 """ 

164 Validates IBAN format bank account number. 

165 :param v: str 

166 :return: None 

167 """ 

168 # validate prefix and length 

169 v = iban_filter(v) 

170 if not v: 

171 raise ValidationError(_("Invalid IBAN account number") + ": {}".format(_("Missing value")), code="invalid_iban") 

172 country = v[:2].upper() 

173 if country not in IBAN_LENGTH_BY_COUNTRY: 

174 raise ValidationError(_("Invalid country code") + ": {}".format(country), code="invalid_country_code") 

175 iban_len = IBAN_LENGTH_BY_COUNTRY[country] 

176 if iban_len != len(v): 

177 raise ValidationError(_("Invalid IBAN account number") + ": {}".format(v), code="invalid_iban") 

178 

179 # validate IBAN numeric part 

180 if iban_len <= 26: # very long IBANs are unsupported by the numeric part validation 

181 digits = "0123456789" 

182 num = "" 

183 for ch in v[4:] + v[0:4]: 

184 if ch not in digits: 

185 ch = str(ord(ch) - ord("A") + 10) 

186 num += ch 

187 x = Decimal(num) % Decimal(97) 

188 if x != Decimal(1): 

189 raise ValidationError(_("Invalid IBAN account number") + ": {}".format(v), code="invalid_iban") 

190 

191 

192def iban_generator(country_code: str = "") -> str: 

193 """ 

194 Generates IBAN format bank account number (for testing). 

195 :param country_code: 2-character country code (optional) 

196 :return: str 

197 """ 

198 # pick random country code if not set (with max IBAN length 27) 

199 if not country_code: 

200 country_code = random.choice( # nosec 

201 list(filter(lambda cc: IBAN_LENGTH_BY_COUNTRY[cc] <= 26, IBAN_LENGTH_BY_COUNTRY.keys())) 

202 ) 

203 if country_code not in IBAN_LENGTH_BY_COUNTRY: 

204 raise ValidationError(_("Invalid country code") + ": {}".format(country_code), code="invalid_country_code") 

205 nlen = IBAN_LENGTH_BY_COUNTRY[country_code] 

206 if nlen > 26: 

207 raise ValidationError(_("IBAN checksum generation does not support >26 character IBANs"), code="invalid_iban") 

208 

209 # generate BBAN part 

210 digits = "0123456789" 

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

212 

213 # generate valid IBAN numeric part 

214 # (probably not the most efficient way to do this but write a better one if you need faster...) 

215 num0 = "" 

216 for ch in bban + country_code: 

217 if ch not in digits: 

218 ch = str(ord(ch) - ord("A") + 10) 

219 num0 += ch 

220 for checksum in range(1, 100): 220 ↛ 232line 220 didn't jump to line 232, because the loop on line 220 didn't complete

221 num = num0 

222 checksum_str = "{:02}".format(checksum) 

223 for ch in checksum_str: 

224 if ch not in digits: 224 ↛ 225line 224 didn't jump to line 225, because the condition on line 224 was never true

225 ch = str(ord(ch) - ord("A") + 10) 

226 num += ch 

227 # print(num, '/', 97, 'nlen', nlen) 

228 x = Decimal(num) % Decimal(97) 

229 if x == Decimal(1): 

230 return country_code + checksum_str + bban 

231 

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

233 

234 

235def validate_country_iban(v: str, country: str): 

236 v = iban_filter(v) 

237 if v[0:2] != country: 

238 raise ValidationError(_("Invalid IBAN account number") + " ({}.2): {}".format(country, v), code="invalid_iban") 

239 iban_validator(v) 

240 

241 

242def iban_bank_info(v: str) -> Tuple[str, str]: 

243 """ 

244 Returns BIC code and bank name from IBAN number. 

245 :param v: IBAN account number 

246 :return: (BIC code, bank name) or ('', '') if not found / unsupported country 

247 """ 

248 v = iban_filter(v) 

249 prefix = v[:2] 

250 func_name = prefix.lower() + "_iban_bank_info" # e.g. fi_iban_bank_info, be_iban_bank_info 

251 func = globals().get(func_name) 

252 if func is not None: 

253 return func(v) 

254 return "", "" 

255 

256 

257def iban_bic(v: str) -> str: 

258 """ 

259 Returns BIC code from IBAN number. 

260 :param v: IBAN account number 

261 :return: BIC code or '' if not found 

262 """ 

263 info = iban_bank_info(v) 

264 return info[0] if info else "" 

265 

266 

267def calculate_age(born: date, today: Optional[date] = None) -> int: 

268 if not today: 

269 today = now().date() 

270 return today.year - born.year - ((today.month, today.day) < (born.month, born.day)) 

271 

272 

273def filter_country_company_org_id(country_code: str, v: str): 

274 if country_code == "FI": 

275 return fi_company_org_id_filter(v) 

276 return PASSPORT_FILTER.sub("", v) 

277 

278 

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

280 if country_code == "FI": 

281 fi_company_org_id_validator(v) 

282 

283 

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

285# Country specific functions (countries in alphabetical order) 

286# ============================================================================ 

287 

288 

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

290# Belgium 

291# ---------------------------------------------------------------------------- 

292 

293 

294def be_iban_validator(v: str): 

295 validate_country_iban(v, "BE") 

296 

297 

298def be_iban_bank_info(v: str) -> Tuple[str, str]: 

299 """ 

300 Returns BIC code and bank name from BE IBAN number. 

301 :param v: IBAN account number 

302 :return: (BIC code, bank name) or ('', '') if not found 

303 """ 

304 v = iban_filter(v) 

305 bic = BE_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None) 

306 return (bic, BE_BANK_NAME_BY_BIC[bic]) if bic is not None else ("", "") 

307 

308 

309# ---------------------------------------------------------------------------- 

310# Denmark 

311# ---------------------------------------------------------------------------- 

312 

313 

314def dk_iban_validator(v: str): 

315 validate_country_iban(v, "DK") 

316 

317 

318def dk_clearing_code_bank_name(v: str) -> str: 

319 v = iban_filter(v) 

320 if v.startswith("DK"): 

321 v = v[4:] 

322 return DK_BANK_CLEARING_MAP.get(v[:4], "") 

323 

324 

325def dk_iban_bank_info(v: str) -> Tuple[str, str]: 

326 """ 

327 Returns empty string (BIC not available) and bank name from DK IBAN number. 

328 DK5000400440116243 

329 :param v: IBAN account number 

330 :return: ('', bank name) or ('', '') if not found 

331 """ 

332 return "", dk_clearing_code_bank_name(v) 

333 

334 

335# ---------------------------------------------------------------------------- 

336# Estonia 

337# ---------------------------------------------------------------------------- 

338 

339 

340def ee_iban_validator(v: str): 

341 validate_country_iban(v, "EE") 

342 

343 

344# ---------------------------------------------------------------------------- 

345# Finland 

346# ---------------------------------------------------------------------------- 

347 

348FI_SSN_FILTER = re.compile(r"[^0-9A-Z+-]") 

349FI_SSN_VALIDATOR = re.compile(r"^\d{6}[+-A]\d{3}[\d\w]$") 

350FI_COMPANY_ORG_ID_FILTER = re.compile(r"[^0-9]") 

351 

352 

353def fi_payment_reference_number(num: str): 

354 """ 

355 Appends Finland reference number checksum to existing number. 

356 :param num: At least 3 digits 

357 :return: Number plus checksum 

358 """ 

359 assert isinstance(num, str) 

360 v = STRIP_WHITESPACE.sub("", num) 

361 if digit_filter(v) != v: 

362 raise ValidationError(_("Invalid payment reference: {}").format(num)) 

363 v = v.lstrip("0") 

364 if len(v) < 3: 

365 raise ValidationError(_("Invalid payment reference: {}").format(num)) 

366 weights = [7, 3, 1] 

367 weighed_sum = 0 

368 vlen = len(v) 

369 for j in range(vlen): 

370 weighed_sum += int(v[vlen - 1 - j]) * weights[j % 3] 

371 return v + str((10 - (weighed_sum % 10)) % 10) 

372 

373 

374def fi_payment_reference_validator(v: str): 

375 v = STRIP_WHITESPACE.sub("", v) 

376 if fi_payment_reference_number(v[:-1]) != v: 

377 raise ValidationError(_("Invalid payment reference: {}").format(v)) 

378 

379 

380def iso_payment_reference_validator(v: str): 

381 """ 

382 Validates ISO reference number checksum. 

383 :param v: Reference number 

384 """ 

385 num = "" 

386 v = STRIP_WHITESPACE.sub("", v) 

387 for ch in v[4:] + v[0:4]: 

388 x = ord(ch) 

389 if ord("0") <= x <= ord("9"): 

390 num += ch 

391 else: 

392 x -= 55 

393 if x < 10 or x > 35: 

394 raise ValidationError(_("Invalid payment reference: {}").format(v)) 

395 num += str(x) 

396 res = Decimal(num) % Decimal("97") 

397 if res != Decimal("1"): 397 ↛ 398line 397 didn't jump to line 398, because the condition on line 397 was never true

398 raise ValidationError(_("Invalid payment reference: {}").format(v)) 

399 

400 

401def fi_iban_validator(v: str): 

402 validate_country_iban(v, "FI") 

403 

404 

405def fi_iban_bank_info(v: str) -> Tuple[str, str]: 

406 """ 

407 Returns BIC code and bank name from FI IBAN number. 

408 :param v: IBAN account number 

409 :return: (BIC code, bank name) or ('', '') if not found 

410 """ 

411 v = iban_filter(v) 

412 bic = FI_BIC_BY_ACCOUNT_NUMBER.get(v[4:7], None) 

413 name = FI_BANK_NAME_BY_BIC.get(bic, "") if bic is not None else "" 

414 return bic or "", name 

415 

416 

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

418 return FI_SSN_FILTER.sub("", v.upper()) 

419 

420 

421def fi_company_org_id_filter(v: str) -> str: 

422 v = FI_COMPANY_ORG_ID_FILTER.sub("", v) 

423 return v[:-1] + "-" + v[-1:] if len(v) >= 2 else "" 

424 

425 

426def fi_company_org_id_validator(v0: str): 

427 prefix = re.sub(r"\s+", "", v0)[:2] # retain prefix: either numeric or FI is ok 

428 v = fi_company_org_id_filter(v0) 

429 if v[:2] == prefix: 

430 prefix = "FI" 

431 if v[-2:-1] != "-" or prefix != "FI": 

432 raise ValidationError( 

433 _("Invalid company organization ID") + " (FI.1): {}".format(v0), code="invalid_company_org_id" 

434 ) 

435 v = v.replace("-", "", 1) 

436 if len(v) != 8: 436 ↛ 437line 436 didn't jump to line 437, because the condition on line 436 was never true

437 raise ValidationError( 

438 _("Invalid company organization ID") + " (FI.2): {}".format(v0), code="invalid_company_org_id" 

439 ) 

440 multipliers = (7, 9, 10, 5, 8, 4, 2) 

441 x = 0 

442 for i, m in enumerate(multipliers): 

443 x += int(v[i]) * m 

444 remainder = divmod(x, 11)[1] 

445 if remainder == 1: 445 ↛ 446line 445 didn't jump to line 446, because the condition on line 445 was never true

446 raise ValidationError( 

447 _("Invalid company organization ID") + " (FI.3): {}".format(v0), code="invalid_company_org_id" 

448 ) 

449 if remainder >= 2: 

450 check_digit = str(11 - remainder) 

451 if check_digit != v[-1:]: 

452 raise ValidationError( 

453 _("Invalid company organization ID") + " (FI.4): {}".format(v0), code="invalid_company_org_id" 

454 ) 

455 

456 

457def fi_company_org_id_generator() -> str: 

458 remainder = 1 

459 v = "" 

460 while remainder < 2: 

461 v = str(randint(11111111, 99999999)) # nosec 

462 multipliers = (7, 9, 10, 5, 8, 4, 2) 

463 x = 0 

464 for i, m in enumerate(multipliers): 

465 x += int(v[i]) * m 

466 remainder = divmod(x, 11)[1] 

467 check_digit = str(11 - remainder) 

468 return v[:-1] + "-" + check_digit 

469 

470 

471def fi_ssn_validator(v: str): 

472 v = fi_ssn_filter(v) 

473 if not FI_SSN_VALIDATOR.fullmatch(v): 

474 raise ValidationError(_("Invalid personal identification number") + " (FI.1): {}".format(v), code="invalid_ssn") 

475 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31)) 

476 digits = { 

477 10: "A", 

478 11: "B", 

479 12: "C", 

480 13: "D", 

481 14: "E", 

482 15: "F", 

483 16: "H", 

484 17: "J", 

485 18: "K", 

486 19: "L", 

487 20: "M", 

488 21: "N", 

489 22: "P", 

490 23: "R", 

491 24: "S", 

492 25: "T", 

493 26: "U", 

494 27: "V", 

495 28: "W", 

496 29: "X", 

497 30: "Y", 

498 } 

499 ch = digits.get(d, str(d)) 

500 if ch != v[-1:]: 

501 raise ValidationError(_("Invalid personal identification number") + " (FI.2): {}".format(v), code="invalid_ssn") 

502 

503 

504def fi_ssn_generator(min_year: int = 1920, max_year: int = 1999): 

505 if not (1800 <= min_year < 2100): 

506 raise ValidationError(_("Unsupported year") + ": {}".format(min_year)) 

507 if not (1800 <= max_year < 2100): 507 ↛ 508line 507 didn't jump to line 508, because the condition on line 507 was never true

508 raise ValidationError(_("Unsupported year") + ": {}".format(max_year)) 

509 

510 day = randint(1, 28) # nosec 

511 month = randint(1, 12) # nosec 

512 year = randint(min_year, max_year) # nosec 

513 suffix = randint(100, 999) # nosec 

514 sep = "-" 

515 if year < 1900: 

516 sep = "+" 

517 year2 = year - 1800 

518 elif year >= 2000: 

519 sep = "A" 

520 year2 = year - 2000 

521 else: 

522 year2 = year - 1900 

523 v = "{:02}{:02}{:02}{}{}".format(day, month, year2, sep, suffix) 

524 d = int(Decimal(v[0:6] + v[7:10]) % Decimal(31)) 

525 digits = { 

526 10: "A", 

527 11: "B", 

528 12: "C", 

529 13: "D", 

530 14: "E", 

531 15: "F", 

532 16: "H", 

533 17: "J", 

534 18: "K", 

535 19: "L", 

536 20: "M", 

537 21: "N", 

538 22: "P", 

539 23: "R", 

540 24: "S", 

541 25: "T", 

542 26: "U", 

543 27: "V", 

544 28: "W", 

545 29: "X", 

546 30: "Y", 

547 } 

548 ch = digits.get(d, str(d)) 

549 return v + ch 

550 

551 

552def fi_ssn_birthday(v: str) -> date: 

553 v = fi_ssn_filter(v) 

554 fi_ssn_validator(v) 

555 sep = v[6] # 231298-965X 

556 year = int(v[4:6]) 

557 month = int(v[2:4]) 

558 day = int(v[0:2]) 

559 if sep == "+": # 1800 

560 year += 1800 

561 elif sep == "-": 

562 year += 1900 

563 elif sep == "A": 563 ↛ 565line 563 didn't jump to line 565, because the condition on line 563 was never false

564 year += 2000 

565 return date(year, month, day) 

566 

567 

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

569 return calculate_age(fi_ssn_birthday(ssn), today) 

570 

571 

572# ---------------------------------------------------------------------------- 

573# Sweden 

574# ---------------------------------------------------------------------------- 

575 

576SE_SSN_FILTER = re.compile(r"[^-0-9]") 

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

578 

579 

580def se_iban_validator(v: str): 

581 validate_country_iban(v, "SE") 

582 

583 

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

585 return SE_SSN_FILTER.sub("", v.upper()) 

586 

587 

588def se_ssn_validator(v: str): 

589 v = se_ssn_filter(v) 

590 if not SE_SSN_VALIDATOR.fullmatch(v): 590 ↛ 591line 590 didn't jump to line 591, because the condition on line 590 was never true

591 raise ValidationError(_("Invalid personal identification number") + " (SE.1): {}".format(v), code="invalid_ssn") 

592 v = STRIP_NON_NUMBERS.sub("", v) 

593 dsum = 0 

594 for i in range(9): 

595 x = int(v[i]) 

596 if i & 1 == 0: 

597 x += x 

598 # print('summing', v[i], 'as', x) 

599 xsum = x % 10 + int(x / 10) % 10 

600 # print(v[i], 'xsum', xsum) 

601 dsum += xsum 

602 # print('sum', dsum) 

603 rem = dsum % 10 

604 # print('rem', rem) 

605 checksum = 10 - rem 

606 if checksum == 10: 

607 checksum = 0 

608 # print('checksum', checksum) 

609 if int(v[-1:]) != checksum: 

610 raise ValidationError(_("Invalid personal identification number") + " (SE.2): {}".format(v), code="invalid_ssn") 

611 

612 

613def se_clearing_code_bank_info(account_number: str) -> Tuple[str, Optional[int]]: 

614 """ 

615 Returns Sweden bank info by clearning code. 

616 :param account_number: Swedish account number with clearing code as prefix 

617 :return: (Bank name, account digit count) or ('', None) if not found 

618 """ 

619 v = digit_filter(account_number) 

620 clearing = v[:4] 

621 for name, begin, end, acc_digits in SE_BANK_CLEARING_LIST: 621 ↛ 624line 621 didn't jump to line 624, because the loop on line 621 didn't complete

622 if begin <= clearing <= end: 

623 return name, acc_digits 

624 return "", None