Coverage for portality/forms/validate.py: 49%

350 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-20 16:12 +0100

1import re 

2from flask_login import current_user 

3from wtforms import validators 

4from wtforms.compat import string_types 

5from typing import List 

6 

7from portality.core import app 

8from portality.models import Journal, EditorGroup, Account 

9 

10from datetime import datetime 

11from portality import regex 

12 

13 

14class MultiFieldValidator(object): 

15 """ A validator that accesses the value of an additional field """ 

16 

17 def __init__(self, other_field, *args, **kwargs): 

18 self.other_field_name = other_field 

19 super(MultiFieldValidator, self).__init__(*args, **kwargs) 

20 

21 @staticmethod 

22 def get_other_field(field_name, form): 

23 other_field = form._fields.get(field_name) 

24 if not other_field: 

25 if hasattr(form.meta, "parent_form"): 

26 other_field = MultiFieldValidator.get_other_field(field_name, form.meta.parent_form) 

27 else: 

28 raise Exception('No field named "{0}" in form (or its parent containers)'.format(field_name)) 

29 return other_field 

30 

31 

32class DataOptional(object): 

33 """ 

34 Allows empty input and stops the validation chain from continuing. 

35 

36 If input is empty, also removes prior errors (such as processing errors) 

37 from the field. 

38 

39 This is a near-clone of the WTForms standard Optional class, except that 

40 it checks the .data parameter not the .raw_data parameter, which allows us 

41 to check coerced fields correctly. 

42 

43 ~~DataOptional:FormValidator~~ 

44 

45 :param strip_whitespace: 

46 If True (the default) also stop the validation chain on input which 

47 consists of only whitespace. 

48 """ 

49 field_flags = ('optional', ) 

50 

51 def __init__(self, strip_whitespace=True): 

52 if strip_whitespace: 

53 self.string_check = lambda s: s.strip() 

54 else: 

55 self.string_check = lambda s: s 

56 

57 def __call__(self, form, field): 

58 if not field.data or isinstance(field.data, string_types) and not self.string_check(field.data): 

59 field.errors[:] = [] 

60 raise validators.StopValidation() 

61 

62 

63class OptionalIf(DataOptional, MultiFieldValidator): 

64 # A validator which makes a field optional if another field is set 

65 # and has a truthy value. 

66 # ~~OptionalIf:FormValidator~~ 

67 

68 def __init__(self, other_field_name, message=None, optvals=None, *args, **kwargs): 

69 self.other_field_name = other_field_name 

70 if not message: 

71 message = "This field is required in the current circumstances" 

72 self.message = message 

73 self.optvals = optvals if optvals is not None else [] 

74 super(OptionalIf, self).__init__(*args, **kwargs) 

75 

76 def __call__(self, form, field): 

77 other_field = self.get_other_field(self.other_field_name, form) 

78 

79 # if no values (for other_field) which make this field optional 

80 # are specified... 

81 if not self.optvals: 

82 # ... just make this field optional if the other is truthy 

83 if bool(other_field.data): 

84 super(OptionalIf, self).__call__(form, field) 

85 else: 

86 # otherwise it is required 

87 dr = validators.DataRequired(self.message) 

88 dr(form, field) 

89 else: 

90 # if such values are specified, check for them 

91 no_optval_matched = True 

92 for v in self.optvals: 

93 if isinstance(other_field.data, list): 

94 if v in other_field.data and len(other_field.data) == 1: 

95 # must be the only option submitted - OK for 

96 # radios and for checkboxes where a single 

97 # checkbox, but no more, is required to make the 

98 # field optional 

99 no_optval_matched = False 

100 self.__make_optional(form, field) 

101 break 

102 if other_field.data == v: 

103 no_optval_matched = False 

104 self.__make_optional(form, field) 

105 break 

106 

107 if no_optval_matched: 

108 if not field.data: 

109 raise validators.StopValidation('This field is required') 

110 

111 def __make_optional(self, form, field): 

112 super(OptionalIf, self).__call__(form, field) 

113 raise validators.StopValidation() 

114 

115 

116class HTTPURL(validators.Regexp): 

117 """ 

118 Simple regexp based url validation. Much like the email validator, you 

119 probably want to validate the url later by other means if the url must 

120 resolve. 

121 

122 ~~HTTPURL:FormValidator~~ 

123 

124 :param require_tld: 

125 If true, then the domain-name portion of the URL must contain a .tld 

126 suffix. Set this to false if you want to allow domains like 

127 `localhost`. 

128 :param message: 

129 Error message to raise in case of a validation error. 

130 """ 

131 def __init__(self, message=None): 

132 super(HTTPURL, self).__init__(regex.HTTP_URL, re.IGNORECASE, message) 

133 

134 def __call__(self, form, field, message=None): 

135 message = self.message 

136 if message is None: 

137 message = field.gettext('Invalid URL.') 

138 

139 if field.data: 

140 super(HTTPURL, self).__call__(form, field, message) 

141 

142 

143class MaxLen(object): 

144 """ 

145 Maximum length validator. Works on anything which supports len(thing). 

146 

147 Use {max_len} in your custom message to insert the maximum length you've 

148 specified into the message. 

149 

150 ~~MaxLen:FormValidator~~ 

151 """ 

152 

153 def __init__(self, max_len, message='Maximum {max_len}.', *args, **kwargs): 

154 self.max_len = max_len 

155 self.message = message 

156 

157 def __call__(self, form, field): 

158 if len(field.data) > self.max_len: 

159 raise validators.ValidationError(self.message.format(max_len=self.max_len)) 

160 

161 

162class RequiredIfRole(validators.DataRequired): 

163 """ 

164 Makes a field required, if the user has the specified role 

165 

166 ~~RequiredIfRole:FormValidator~~ 

167 """ 

168 

169 def __init__(self, role, *args, **kwargs): 

170 self.role = role 

171 super(RequiredIfRole, self).__init__(*args, **kwargs) 

172 

173 def __call__(self, form, field): 

174 if current_user.has_role(self.role): 

175 super(RequiredIfRole, self).__call__(form, field) 

176 

177 

178class RegexpOnTagList(object): 

179 """ 

180 Validates the field against a user provided regexp. 

181 

182 ~~RegexpOnTagList:FormValidator~~ 

183 

184 :param regex: 

185 The regular expression string to use. Can also be a compiled regular 

186 expression pattern. 

187 :param flags: 

188 The regexp flags to use, for example re.IGNORECASE. Ignored if 

189 `regex` is not a string. 

190 :param message: 

191 Error message to raise in case of a validation error. 

192 """ 

193 def __init__(self, regex, flags=0, message=None): 

194 if isinstance(regex, string_types): 

195 regex = re.compile(regex, flags) 

196 self.regex = regex 

197 self.message = message 

198 

199 def __call__(self, form, field, message=None): 

200 for entry in field.data: 

201 match = self.regex.match(entry or '') 

202 if not match: 

203 if message is None: 

204 if self.message is None: 

205 message = field.gettext('Invalid input.') 

206 else: 

207 message = self.message 

208 

209 raise validators.ValidationError(message) 

210 

211 

212class ThisOrThat(MultiFieldValidator): 

213 """ 

214 ~~ThisOrThat:FormValidator~~ 

215 """ 

216 def __init__(self, other_field_name, message=None, *args, **kwargs): 

217 self.message = message 

218 super(ThisOrThat, self).__init__(other_field_name, *args, **kwargs) 

219 

220 def __call__(self, form, field): 

221 other_field = self.get_other_field(self.other_field_name, form) 

222 this = bool(field.data) 

223 that = bool(other_field.data) 

224 if not this and not that: 

225 if not self.message: 

226 self.message = "Either this field or " + other_field.label.text + " is required" 

227 raise validators.ValidationError(self.message) 

228 

229 

230class ReservedUsernames(object): 

231 """ 

232 A username validator. When applied to fields containing usernames it prevents 

233 their use if they are reserved. 

234 

235 ~~ReservedUsernames:FormValidator~~ 

236 """ 

237 def __init__(self, message='The "{reserved}" user is reserved. Please choose a different username.', *args, **kwargs): 

238 self.message = message 

239 

240 def __call__(self, form, field): 

241 return self.__validate(field.data) 

242 

243 def __validate(self, username): 

244 if not isinstance(username, str): 

245 raise validators.ValidationError('Invalid username (not a string) passed to ReservedUsernames validator.') 

246 

247 if username.lower() in [u.lower() for u in app.config.get('RESERVED_USERNAMES', [])]: 

248 raise validators.ValidationError(self.message.format(reserved=username)) 

249 

250 @classmethod 

251 def validate(cls, username): 

252 return cls().__validate(username) 

253 

254 

255class OwnerExists(object): 

256 """ 

257 A username validator. When applied to fields containing usernames it ensures that the username 

258 exists 

259 

260 ~~OwnerExists:FormValidator~~ 

261 """ 

262 def __init__(self, message='The "{reserved}" user does not exist. Please choose an existing username, or create a new account first.', *args, **kwargs): 

263 self.message = message 

264 

265 def __call__(self, form, field): 

266 return self.__validate(field.data) 

267 

268 def __validate(self, username): 

269 if not isinstance(username, str): 

270 raise validators.ValidationError('Invalid username (not a string) passed to OwnerExists validator.') 

271 

272 if username == "": 

273 return 

274 

275 acc = Account.pull(username) 

276 if not acc: 

277 raise validators.ValidationError(self.message.format(reserved=username)) 

278 

279 @classmethod 

280 def validate(cls, username): 

281 return cls().__validate(username) 

282 

283 

284class ISSNInPublicDOAJ(object): 

285 """ 

286 ~~ISSNInPublicDOAJ:FormValidator~~ 

287 """ 

288 def __init__(self, message=None): 

289 if not message: 

290 message = "This ISSN already appears in the public DOAJ database" 

291 self.message = message 

292 

293 def __call__(self, form, field): 

294 if field.data is not None: 

295 existing = Journal.find_by_issn(field.data, in_doaj=True, max=1) 

296 if len(existing) > 0: 

297 raise validators.ValidationError(self.message) 

298 

299 

300class JournalURLInPublicDOAJ(object): 

301 """ 

302 ~~JournalURLInPublicDOAJ:FormValidator~~ 

303 """ 

304 def __init__(self, message=None): 

305 if not message: 

306 message = "This Journal URL already appears in the public DOAJ database" 

307 self.message = message 

308 

309 def __call__(self, form, field): 

310 if field.data is not None: 

311 existing = Journal.find_by_journal_url(field.data, in_doaj=True, max=1) 

312 if len(existing) > 0: 

313 raise validators.ValidationError(self.message) 

314 

315 

316class StopWords(object): 

317 """ 

318 ~~StopWords:FormValidator~~ 

319 """ 

320 def __init__(self, stopwords, message=None): 

321 self.stopwords = stopwords 

322 if not message: 

323 message = "You may not enter '{stop_word}' in this field" 

324 self.message = message 

325 

326 def __call__(self, form, field): 

327 for v in field.data: 

328 if v.strip() in self.stopwords: 

329 raise validators.StopValidation(self.message.format(stop_word=v)) 

330 

331 

332class DifferentTo(MultiFieldValidator): 

333 """ 

334 ~~DifferentTo:FormValidator~~ 

335 """ 

336 def __init__(self, other_field_name, ignore_empty=True, message=None): 

337 super(DifferentTo, self).__init__(other_field_name) 

338 self.ignore_empty = ignore_empty 

339 if not message: 

340 message = "This field must contain a different value to the field '{x}'".format(x=self.other_field_name) 

341 self.message = message 

342 

343 def __call__(self, form, field): 

344 other_field = self.get_other_field(self.other_field_name, form) 

345 

346 if other_field.data == field.data: 

347 if self.ignore_empty and (not other_field.data or not field.data): 

348 return 

349 raise validators.ValidationError(self.message) 

350 

351 

352class RequiredIfOtherValue(MultiFieldValidator): 

353 """ 

354 Makes a field required, if the user has selected a specific value in another field 

355 

356 ~~RequiredIfOtherValue:FormValidator~~ 

357 """ 

358 

359 def __init__(self, other_field_name, other_value, message=None, *args, **kwargs): 

360 self.other_value = other_value 

361 self.message = message if message is not None else "This field is required when {x} is {y}".format(x=other_field_name, y=other_value) 

362 super(RequiredIfOtherValue, self).__init__(other_field_name, *args, **kwargs) 

363 

364 def __call__(self, form, field): 

365 # attempt to get the other field - if it doesn't exist, just take this as valid 

366 try: 

367 other_field = self.get_other_field(self.other_field_name, form) 

368 except: 

369 return 

370 

371 if isinstance(self.other_value, list): 

372 self._match_list(form, field, other_field) 

373 else: 

374 self._match_single(form, field, other_field) 

375 

376 def _match_single(self, form, field, other_field): 

377 if isinstance(other_field.data, list): 

378 match = self.other_value in other_field.data 

379 else: 

380 match = other_field.data == self.other_value 

381 if match: 

382 dr = validators.DataRequired(self.message) 

383 dr(form, field) 

384 else: 

385 if not field.data or (isinstance(field.data, str) and not field.data.strip()): 

386 raise validators.StopValidation() 

387 

388 def _match_list(self, form, field, other_field): 

389 if isinstance(other_field.data, list): 

390 match = len(list(set(self.other_value) & set(other_field.data))) > 0 

391 else: 

392 match = other_field.data in self.other_value 

393 if match: 

394 dr = validators.DataRequired(self.message) 

395 dr(form, field) 

396 else: 

397 if not field.data or len(field.data) == 0: 

398 raise validators.StopValidation() 

399 

400 

401class OnlyIf(MultiFieldValidator): 

402 """ 

403 Field only validates if other fields have specific values (or are truthy) 

404 ~~OnlyIf:FormValidator~~ 

405 """ 

406 def __init__(self, other_fields: List[dict], ignore_empty=True, message=None, *args, **kwargs): 

407 self.other_fields = other_fields 

408 self.ignore_empty = ignore_empty 

409 if not message: 

410 fieldnames = [n['field'] for n in self.other_fields] 

411 message = "This field can only be selected with valid values in other fields: '{x}'".format(x=fieldnames) 

412 self.message = message 

413 super(OnlyIf, self).__init__(None, *args, **kwargs) 

414 

415 def __call__(self, form, field): 

416 if field.data is None or field.data is False or isinstance(field.data, str) and not field.data.strip(): 

417 return 

418 

419 others = self.get_other_fields(form) 

420 

421 for o_f in self.other_fields: 

422 other = others[o_f["field"]] 

423 if self.ignore_empty and (not other.data or not field.data): 

424 continue 

425 if o_f.get("or") is not None: 

426 # succeed if the value is in the list 

427 if other.data in o_f["or"]: 

428 continue 

429 if o_f.get('not') is not None: 

430 # Succeed if the value doesn't equal the one specified 

431 if other.data != o_f['not']: 

432 continue 

433 if o_f.get('value') is not None: 

434 if other.data == o_f['value']: 

435 # Succeed if the other field has the specified value 

436 continue 

437 if o_f.get('value') is None: 

438 # No target value supplied - succeed if the other field is truthy 

439 if other.data: 

440 continue 

441 raise validators.ValidationError(self.message) 

442 

443 def get_other_fields(self, form): 

444 # return the actual fields matching the names in self.other_fields 

445 others = {f["field"]: self.get_other_field(f["field"], form) for f in self.other_fields} 

446 return others 

447 

448 

449class NotIf(OnlyIf): 

450 """ 

451 Field only validates if other fields DO NOT have specific values (or are truthy) 

452 ~~NotIf:FormValidator~~ 

453 """ 

454 

455 def __call__(self, form, field): 

456 others = self.get_other_fields(form) 

457 

458 for o_f in self.other_fields: 

459 other = others[o_f["field"]] 

460 if self.ignore_empty and (not other.data or not field.data): 

461 continue 

462 if o_f.get('value') is None: 

463 # Fail if the other field is truthy 

464 if other.data: 

465 validators.ValidationError(self.message) 

466 elif other.data == o_f['value']: 

467 # Fail if the other field has the specified value 

468 validators.ValidationError(self.message) 

469 

470 

471class NoScriptTag(object): 

472 """ 

473 Checks that a field does not contain a script html tag 

474 ~~NoScriptTag:FormValidator~~ 

475 """ 

476 

477 def __init__(self, message=None): 

478 if not message: 

479 message = "Value cannot contain script tag" 

480 self.message = message 

481 

482 def __call__(self, form, field): 

483 if field.data is not None and "<script>" in field.data: 

484 raise validators.ValidationError(self.message) 

485 

486 

487class GroupMember(MultiFieldValidator): 

488 """ 

489 Validation passes when a field's value is a member of the specified group 

490 ~~GroupMember:FormValidator~~ 

491 """ 

492 # Fixme: should / can this be generalised to any group vs specific to editor groups? 

493 

494 def __init__(self, group_field_name, *args, **kwargs): 

495 super(GroupMember, self).__init__(group_field_name, *args, **kwargs) 

496 

497 def __call__(self, form, field): 

498 group_field = self.get_other_field(self.other_field_name, form) 

499 

500 """ Validate the choice of editor, which could be out of sync with the group in exceptional circumstances """ 

501 # lifted from from formcontext 

502 editor = field.data 

503 if editor is not None and editor != "": 

504 editor_group_name = group_field.data 

505 if editor_group_name is not None and editor_group_name != "": 

506 eg = EditorGroup.pull_by_key("name", editor_group_name) 

507 if eg is not None: 

508 if eg.is_member(editor): 

509 return # success - an editor group was found and our editor was in it 

510 raise validators.ValidationError("Editor '{0}' not found in editor group '{1}'".format(editor, editor_group_name)) 

511 else: 

512 raise validators.ValidationError("An editor has been assigned without an editor group") 

513 

514 

515class RequiredValue(object): 

516 """ 

517 Checks that a field contains a required value 

518 ~~RequiredValue:FormValidator~~ 

519 """ 

520 def __init__(self, value, message=None): 

521 self.value = value 

522 if not message: 

523 message = "You must enter {value}" 

524 self.message = message 

525 

526 def __call__(self, form, field): 

527 if field.data != self.value: 

528 raise validators.ValidationError(self.message.format(value=self.value)) 

529 

530 

531class BigEndDate(object): 

532 """ 

533 ~~BigEndDate:FormValidator~~ 

534 """ 

535 def __init__(self, value, message=None): 

536 self.value = value 

537 self.message = message or "Date must be a big-end formatted date (e.g. 2020-11-23)" 

538 

539 def __call__(self, form, field): 

540 if not field.data: 

541 return 

542 try: 

543 datetime.strptime(field.data, '%Y-%m-%d') 

544 except Exception: 

545 raise validators.ValidationError(self.message) 

546 

547class Year(object): 

548 def __init__(self, value, message=None): 

549 self.value = value 

550 self.message = message or "Date must be a year in 4 digit format (eg. 1987)" 

551 

552 def __call__(self, form, field): 

553 if not field.data: 

554 return 

555 return app.config.get('MINIMAL_OA_START_DATE', 1900) <= field.data <= datetime.now().year 

556 

557 

558class CustomRequired(object): 

559 """ 

560 ~~CustomRequired:FormValidator~~ 

561 """ 

562 field_flags = ('required', ) 

563 

564 def __init__(self, message=None): 

565 self.message = message 

566 

567 def __call__(self, form, field): 

568 if field.data is None or isinstance(field.data, str) and not field.data.strip() or isinstance(field.data, list) and len(field.data) == 0: 

569 if self.message is None: 

570 message = field.gettext('This field is required.') 

571 else: 

572 message = self.message 

573 

574 field.errors[:] = [] 

575 raise validators.StopValidation(message) 

576 

577 

578class EmailAvailable(object): 

579 """ 

580 ~~EmailAvailable:FormValidator~~ 

581 """ 

582 def __init__(self, message=None): 

583 if not message: 

584 message = "Email address is already in use" 

585 self.message = message 

586 

587 def __call__(self, form, field): 

588 if field.data is not None: 

589 existing = Account.email_in_use(field.data) 

590 if existing is True: 

591 raise validators.ValidationError(self.message) 

592 

593 

594class IdAvailable(object): 

595 """ 

596 ~~IdAvailable:FormValidator~~ 

597 """ 

598 def __init__(self, message=None): 

599 if not message: 

600 message = "Account ID already in use" 

601 self.message = message 

602 

603 def __call__(self, form, field): 

604 if field.data is not None: 

605 existing = Account.pull(field.data) 

606 if existing is not None: 

607 raise validators.ValidationError(self.message) 

608 

609 

610class IgnoreUnchanged(object): 

611 """ 

612 Disables validation when the input is unchanged and stops the validation chain from continuing. 

613 

614 If input is the same as before, also removes prior errors (such as processing errors) 

615 from the field. 

616 

617 ~~IgnoreUnchanged:FormValidator~~ 

618 

619 """ 

620 field_flags = ('optional', ) 

621 

622 def __call__(self, form, field): 

623 if field.data and field.object_data and field.data == field.object_data: 

624 field.errors[:] = [] 

625 raise validators.StopValidation()