Coverage for jutil/validators.py: 96%

# Coverage for jutil/validators.py : 96%

## 348 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# Country-specific bank constants (abc-order):

13 from jutil.bank_const_be import BE_BIC_BY_ACCOUNT_NUMBER, BE_BANK_NAME_BY_BIC

14 from jutil.bank_const_dk import DK_BANK_CLEARING_MAP

15 from jutil.bank_const_fi import FI_BIC_BY_ACCOUNT_NUMBER, FI_BANK_NAME_BY_BIC

16 from jutil.bank_const_se import SE_BANK_CLEARING_LIST

17

18

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

20 PHONE_FILTER = re.compile(r'[^+0-9]')

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

22 PASSPORT_FILTER = re.compile(r'[^-A-Z0-9]')

23 STRIP_NON_NUMBERS = re.compile(r'[^0-9]')

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

25 STRIP_WHITESPACE = re.compile(r'\s+')

26 IBAN_FILTER = re.compile(r'[^A-Z0-9]')

27 DIGIT_FILTER = re.compile(r'[^0-9]')

28

29

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

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

32

33

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

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

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

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

50

51

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

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

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

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

68

69

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

77 def passport_sanitizer(v: str):

78 v = passport_filter(v)

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

80 return ''

81 return v

82

83

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

85 return v.strip().upper()

86

87

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

89 return v.strip().upper()

90

91

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

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

105 v = country_code_filter(v)

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

107

108

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

110 v = bic_filter(v)

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

112

113

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

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

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

126

127

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

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

130

131

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

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

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

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

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

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

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

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

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

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

291 def be_iban_validator(v: str):

292 validate_country_iban(v, 'BE')

293

294

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

310 def dk_iban_validator(v: str):

311 validate_country_iban(v, 'DK')

312

313

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

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

335 def ee_iban_validator(v: str):

336 validate_country_iban(v, 'EE')

337

338

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

340# Finland

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

342

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

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

345 FI_COMPANY_ORG_ID_FILTER = re.compile(r'[^0-9]')

346

347

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

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

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

396 def fi_iban_validator(v: str):

397 validate_country_iban(v, 'FI')

398

399

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

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

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

413

414

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

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

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

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

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

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

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

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

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

528

529

530 def se_iban_validator(v: str):

531 validate_country_iban(v, 'SE')

532

533

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

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

536

537

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

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