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

393 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 00:09 +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.lib import dates 

9from portality.lib.dates import FMT_DATE_STD 

10from portality.models import Journal, EditorGroup, Account 

11 

12from datetime import datetime 

13from portality import regex 

14from portality.datasets import get_currency_code 

15from portality.lib import isolang 

16 

17 

18class MultiFieldValidator(object): 

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

20 

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

22 self.other_field_name = other_field 

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

24 

25 @staticmethod 

26 def get_other_field(field_name, form): 

27 other_field = form._fields.get(field_name) 

28 if not other_field: 

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

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

31 else: 

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

33 return other_field 

34 

35 

36class DataOptional(object): 

37 """ 

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

39 

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

41 from the field. 

42 

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

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

45 to check coerced fields correctly. 

46 

47 ~~DataOptional:FormValidator~~ 

48 

49 :param strip_whitespace: 

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

51 consists of only whitespace. 

52 """ 

53 field_flags = ('optional', ) 

54 

55 def __init__(self, strip_whitespace=True): 

56 if strip_whitespace: 

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

58 else: 

59 self.string_check = lambda s: s 

60 

61 def __call__(self, form, field): 

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

63 field.errors[:] = [] 

64 raise validators.StopValidation() 

65 

66 

67class OptionalIf(DataOptional, MultiFieldValidator): 

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

69 # and has a truthy value. 

70 # ~~OptionalIf:FormValidator~~ 

71 

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

73 self.other_field_name = other_field_name 

74 if not message: 

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

76 self.message = message 

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

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

79 

80 def __call__(self, form, field): 

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

82 

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

84 # are specified... 

85 if not self.optvals: 

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

87 if bool(other_field.data): 

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

89 else: 

90 # otherwise it is required 

91 dr = validators.DataRequired(self.message) 

92 dr(form, field) 

93 else: 

94 # if such values are specified, check for them 

95 no_optval_matched = True 

96 for v in self.optvals: 

97 if isinstance(other_field.data, list): 

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

99 # must be the only option submitted - OK for 

100 # radios and for checkboxes where a single 

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

102 # field optional 

103 no_optval_matched = False 

104 self.__make_optional(form, field) 

105 break 

106 if other_field.data == v: 

107 no_optval_matched = False 

108 self.__make_optional(form, field) 

109 break 

110 

111 if no_optval_matched: 

112 if not field.data: 

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

114 

115 def __make_optional(self, form, field): 

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

117 raise validators.StopValidation() 

118 

119 

120class HTTPURL(validators.Regexp): 

121 """ 

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

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

124 resolve. 

125 

126 ~~HTTPURL:FormValidator~~ 

127 

128 :param require_tld: 

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

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

131 `localhost`. 

132 :param message: 

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

134 """ 

135 def __init__(self, message=None): 

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

137 

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

139 message = self.message 

140 if message is None: 

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

142 

143 if field.data: 

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

145 

146 

147class MaxLen(object): 

148 """ 

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

150 

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

152 specified into the message. 

153 

154 ~~MaxLen:FormValidator~~ 

155 """ 

156 

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

158 self.max_len = max_len 

159 self.message = message 

160 

161 def __call__(self, form, field): 

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

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

164 

165 

166class RequiredIfRole(validators.DataRequired): 

167 """ 

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

169 

170 ~~RequiredIfRole:FormValidator~~ 

171 """ 

172 

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

174 self.role = role 

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

176 

177 def __call__(self, form, field): 

178 if current_user.has_role(self.role): 

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

180 

181 

182class RegexpOnTagList(object): 

183 """ 

184 Validates the field against a user provided regexp. 

185 

186 ~~RegexpOnTagList:FormValidator~~ 

187 

188 :param regex: 

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

190 expression pattern. 

191 :param flags: 

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

193 `regex` is not a string. 

194 :param message: 

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

196 """ 

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

198 if isinstance(regex, string_types): 

199 regex = re.compile(regex, flags) 

200 self.regex = regex 

201 self.message = message 

202 

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

204 for entry in field.data: 

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

206 if not match: 

207 if message is None: 

208 if self.message is None: 

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

210 else: 

211 message = self.message 

212 

213 raise validators.ValidationError(message) 

214 

215 

216class ThisOrThat(MultiFieldValidator): 

217 """ 

218 ~~ThisOrThat:FormValidator~~ 

219 """ 

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

221 self.message = message 

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

223 

224 def __call__(self, form, field): 

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

226 this = bool(field.data) 

227 that = bool(other_field.data) 

228 if not this and not that: 

229 if not self.message: 

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

231 raise validators.ValidationError(self.message) 

232 

233 

234class ReservedUsernames(object): 

235 """ 

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

237 their use if they are reserved. 

238 

239 ~~ReservedUsernames:FormValidator~~ 

240 """ 

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

242 self.message = message 

243 

244 def __call__(self, form, field): 

245 return self.__validate(field.data) 

246 

247 def __validate(self, username): 

248 if not isinstance(username, str): 

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

250 

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

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

253 

254 @classmethod 

255 def validate(cls, username): 

256 return cls().__validate(username) 

257 

258 

259class OwnerExists(object): 

260 """ 

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

262 exists 

263 

264 ~~OwnerExists:FormValidator~~ 

265 """ 

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

267 self.message = message 

268 

269 def __call__(self, form, field): 

270 return self.__validate(field.data) 

271 

272 def __validate(self, username): 

273 if not isinstance(username, str): 

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

275 

276 if username == "": 

277 return 

278 

279 acc = Account.pull(username) 

280 if not acc: 

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

282 

283 @classmethod 

284 def validate(cls, username): 

285 return cls().__validate(username) 

286 

287 

288class ISSNInPublicDOAJ(object): 

289 """ 

290 ~~ISSNInPublicDOAJ:FormValidator~~ 

291 """ 

292 def __init__(self, message=None): 

293 if not message: 

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

295 self.message = message 

296 

297 def __call__(self, form, field): 

298 if field.data is not None: 

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

300 if len(existing) > 0: 

301 raise validators.ValidationError(self.message) 

302 

303 

304class JournalURLInPublicDOAJ(object): 

305 """ 

306 ~~JournalURLInPublicDOAJ:FormValidator~~ 

307 """ 

308 def __init__(self, message=None): 

309 if not message: 

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

311 self.message = message 

312 

313 def __call__(self, form, field): 

314 if field.data is not None: 

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

316 if len(existing) > 0: 

317 raise validators.ValidationError(self.message) 

318 

319 

320class StopWords(object): 

321 """ 

322 ~~StopWords:FormValidator~~ 

323 """ 

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

325 self.stopwords = stopwords 

326 if not message: 

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

328 self.message = message 

329 

330 def __call__(self, form, field): 

331 for v in field.data: 

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

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

334 

335 

336class DifferentTo(MultiFieldValidator): 

337 """ 

338 ~~DifferentTo:FormValidator~~ 

339 """ 

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

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

342 self.ignore_empty = ignore_empty 

343 if not message: 

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

345 self.message = message 

346 

347 def __call__(self, form, field): 

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

349 

350 if other_field.data == field.data: 

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

352 return 

353 raise validators.ValidationError(self.message) 

354 

355 

356class RequiredIfOtherValue(MultiFieldValidator): 

357 """ 

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

359 

360 ~~RequiredIfOtherValue:FormValidator~~ 

361 """ 

362 

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

364 self.other_value = other_value 

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

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

367 

368 def __call__(self, form, field): 

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

370 try: 

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

372 except: 

373 return 

374 

375 if isinstance(self.other_value, list): 

376 self._match_list(form, field, other_field) 

377 else: 

378 self._match_single(form, field, other_field) 

379 

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

381 if isinstance(other_field.data, list): 

382 match = self.other_value in other_field.data 

383 else: 

384 match = other_field.data == self.other_value 

385 if match: 

386 dr = validators.DataRequired(self.message) 

387 dr(form, field) 

388 else: 

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

390 raise validators.StopValidation() 

391 

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

393 if isinstance(other_field.data, list): 

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

395 else: 

396 match = other_field.data in self.other_value 

397 if match: 

398 dr = validators.DataRequired(self.message) 

399 dr(form, field) 

400 else: 

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

402 raise validators.StopValidation() 

403 

404 

405class OnlyIf(MultiFieldValidator): 

406 """ 

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

408 ~~OnlyIf:FormValidator~~ 

409 """ 

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

411 self.other_fields = other_fields 

412 self.ignore_empty = ignore_empty 

413 if not message: 

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

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

416 self.message = message 

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

418 

419 def __call__(self, form, field): 

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

421 return 

422 

423 others = self.get_other_fields(form) 

424 

425 for o_f in self.other_fields: 

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

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

428 continue 

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

430 # succeed if the value is in the list 

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

432 continue 

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

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

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

436 continue 

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

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

439 # Succeed if the other field has the specified value 

440 continue 

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

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

443 if other.data: 

444 continue 

445 raise validators.ValidationError(self.message) 

446 

447 def get_other_fields(self, form): 

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

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

450 return others 

451 

452 

453class NotIf(OnlyIf): 

454 """ 

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

456 ~~NotIf:FormValidator~~ 

457 """ 

458 

459 def __call__(self, form, field): 

460 others = self.get_other_fields(form) 

461 

462 for o_f in self.other_fields: 

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

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

465 continue 

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

467 # Fail if the other field is truthy 

468 if other.data: 

469 validators.ValidationError(self.message) 

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

471 # Fail if the other field has the specified value 

472 validators.ValidationError(self.message) 

473 

474 

475class OnlyIfExists(OnlyIf): 

476 """ 

477 Field only validates if other fields DOES have ANY values (or are truthy) 

478 ~~NotIf:FormValidator~~ 

479 """ 

480 

481 def __call__(self, form, field): 

482 others = self.get_other_fields(form) 

483 

484 for o_f in self.other_fields: 

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

486 if not other.data or not field.data: 

487 validators.ValidationError(self.message) 

488 

489class NoScriptTag(object): 

490 """ 

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

492 ~~NoScriptTag:FormValidator~~ 

493 """ 

494 

495 def __init__(self, message=None): 

496 if not message: 

497 message = "Value cannot contain script tag" 

498 self.message = message 

499 

500 def __call__(self, form, field): 

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

502 raise validators.ValidationError(self.message) 

503 

504 

505class GroupMember(MultiFieldValidator): 

506 """ 

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

508 ~~GroupMember:FormValidator~~ 

509 """ 

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

511 

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

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

514 

515 def __call__(self, form, field): 

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

517 

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

519 # lifted from from formcontext 

520 editor = field.data 

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

522 editor_group_name = group_field.data 

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

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

525 if eg is not None: 

526 if eg.is_member(editor): 

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

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

529 else: 

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

531 

532 

533class RequiredValue(object): 

534 """ 

535 Checks that a field contains a required value 

536 ~~RequiredValue:FormValidator~~ 

537 """ 

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

539 self.value = value 

540 if not message: 

541 message = "You must enter {value}" 

542 self.message = message 

543 

544 def __call__(self, form, field): 

545 if field.data != self.value: 

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

547 

548 

549class BigEndDate(object): 

550 """ 

551 ~~BigEndDate:FormValidator~~ 

552 """ 

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

554 self.value = value 

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

556 

557 def __call__(self, form, field): 

558 if not field.data: 

559 return 

560 try: 

561 datetime.strptime(field.data, FMT_DATE_STD) 

562 except Exception: 

563 raise validators.ValidationError(self.message) 

564 

565 

566class DateInThePast(object): 

567 def __init__(self, message=None): 

568 self.message = message or "Date must be in the past" 

569 

570 def __call__(self, form, field): 

571 if not field.data: 

572 return 

573 try: 

574 d = datetime.strptime(field.data, FMT_DATE_STD) 

575 except Exception: 

576 raise validators.ValidationError(self.message) 

577 

578 if d > dates.now(): 

579 raise validators.ValidationError(self.message) 

580 

581class Year(object): 

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

583 self.value = value 

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

585 

586 def __call__(self, form, field): 

587 if not field.data: 

588 return 

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

590 

591 

592class CustomRequired(object): 

593 """ 

594 ~~CustomRequired:FormValidator~~ 

595 """ 

596 field_flags = ('required', ) 

597 

598 def __init__(self, message=None): 

599 self.message = message 

600 

601 def __call__(self, form, field): 

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

603 if self.message is None: 

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

605 else: 

606 message = self.message 

607 

608 field.errors[:] = [] 

609 raise validators.StopValidation(message) 

610 

611 

612class EmailAvailable(object): 

613 """ 

614 ~~EmailAvailable:FormValidator~~ 

615 """ 

616 def __init__(self, message=None): 

617 if not message: 

618 message = "Email address is already in use" 

619 self.message = message 

620 

621 def __call__(self, form, field): 

622 if field.data is not None: 

623 existing = Account.email_in_use(field.data) 

624 if existing is True: 

625 raise validators.ValidationError(self.message) 

626 

627 

628class IdAvailable(object): 

629 """ 

630 ~~IdAvailable:FormValidator~~ 

631 """ 

632 def __init__(self, message=None): 

633 if not message: 

634 message = "Account ID already in use" 

635 self.message = message 

636 

637 def __call__(self, form, field): 

638 if field.data is not None: 

639 existing = Account.pull(field.data) 

640 if existing is not None: 

641 raise validators.ValidationError(self.message) 

642 

643 

644class IgnoreUnchanged(object): 

645 """ 

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

647 

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

649 from the field. 

650 

651 ~~IgnoreUnchanged:FormValidator~~ 

652 

653 """ 

654 field_flags = ('optional', ) 

655 

656 def __call__(self, form, field): 

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

658 field.errors[:] = [] 

659 raise validators.StopValidation() 

660 

661 

662class CurrentISOCurrency(object): 

663 """ 

664 ~~EmailAvailable:FormValidator~~ 

665 """ 

666 def __init__(self, message=None): 

667 if not message: 

668 message = "Currency is not in the currently supported ISO list" 

669 self.message = message 

670 

671 def __call__(self, form, field): 

672 if field.data is not None and field.data != '': 

673 check = get_currency_code(field.data, fail_if_not_found=True) 

674 if check is None: 

675 raise validators.ValidationError(self.message) 

676 

677 

678class CurrentISOLanguage(object): 

679 def __init__(self, message=None): 

680 if not message: 

681 message = "Language is not in the currently supported ISO list" 

682 self.message = message 

683 

684 def __call__(self, form, field): 

685 if field.data is not None and field.data != '': 

686 check = isolang.find(field.data) 

687 if check is None: 

688 raise validators.ValidationError(self.message)