Coverage for jutil/validators.py: 96%

# Coverage for jutil/validators.py : 96%

## 349 statements

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

1 import random

2 import re

3 import unicodedata

4 from datetime import date

5 from decimal import Decimal

6 from random import randint

7 from typing import Tuple, Optional

8 from django.core.exceptions import ValidationError

9 from django.utils.timezone import now

10 from django.utils.translation import gettext as _

11 from jutil.bank_const_iban import IBAN_LENGTH_BY_COUNTRY

12

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

14 from jutil.bank_const_be import BE_BIC_BY_ACCOUNT_NUMBER, BE_BANK_NAME_BY_BIC

15 from jutil.bank_const_dk import DK_BANK_CLEARING_MAP

16 from jutil.bank_const_fi import FI_BIC_BY_ACCOUNT_NUMBER, FI_BANK_NAME_BY_BIC

17 from jutil.bank_const_se import SE_BANK_CLEARING_LIST

18

19

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

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

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

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

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

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

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

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

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

29

30

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

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

33

34

35 def 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

42 def 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

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

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

51

52

53 def 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

60 def 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

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

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

69

70

71 def 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

78 def passport_sanitizer(v: str):

79 v = passport_filter(v)

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

81 return ""

82 return v

83

84

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

86 return v.strip().upper()

87

88

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

90 return v.strip().upper()

91

92

93 def 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

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

106 v = country_code_filter(v)

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

108

109

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

111 v = bic_filter(v)

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

113

114

115 def 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

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

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

127

128

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

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

131

132

133 def 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

150 def 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

162 def 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

192 def 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

235 def 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

242 def 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

257 def 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

267 def 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

273 def 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

279 def 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

294 def be_iban_validator(v: str):

295 validate_country_iban(v, "BE")

296

297

298 def 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

314 def dk_iban_validator(v: str):

315 validate_country_iban(v, "DK")

316

317

318 def 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

325 def 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

340 def ee_iban_validator(v: str):

341 validate_country_iban(v, "EE")

342

343

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

345# Finland

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

347

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

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

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

351

352

353 def 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

374 def 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

380 def 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

401 def fi_iban_validator(v: str):

402 validate_country_iban(v, "FI")

403

404

405 def 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

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

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

419

420

421 def 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

426 def 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

457 def 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

471 def 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

504 def 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

552 def 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

568 def 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

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

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

578

579

580 def se_iban_validator(v: str):

581 validate_country_iban(v, "SE")

582

583

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

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

586

587

588 def 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

613 def 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