Coverage for portality / forms / application_processors.py: 84%

519 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 00:09 +0100

1from flask import url_for, has_request_context 

2from flask_login import current_user 

3from wtforms import FormField, FieldList 

4 

5from portality import models, constants, app_email 

6from portality.bll import DOAJ, exceptions 

7from portality.core import app 

8from portality.crosswalks.application_form import ApplicationFormXWalk 

9from portality.crosswalks.journal_form import JournalFormXWalk 

10from portality.lib import dates 

11from portality.lib.formulaic import FormProcessor 

12from portality.ui.messages import Messages 

13 

14 

15class ApplicationProcessor(FormProcessor): 

16 

17 def pre_validate(self): 

18 # to bypass WTForms insistence that choices on a select field match the value, outside of the actual validation 

19 # chain 

20 super(ApplicationProcessor, self).pre_validate() 

21 

22 def patch_target(self): 

23 super().patch_target() 

24 

25 self._patch_target_note_id() 

26 

27 def _carry_fixed_aspects(self): 

28 if self.source is None: 

29 raise Exception("Cannot carry data from a non-existent source") 

30 

31 now = dates.now_str() 

32 

33 # copy over any important fields from the previous version of the object 

34 created_date = self.source.created_date if self.source.created_date else now 

35 self.target.set_created(created_date) 

36 if "id" in self.source.data: 

37 self.target.data['id'] = self.source.data['id'] 

38 

39 # date_applied is now a property of both applications and journals 

40 if self.source.date_applied is not None: 

41 self.target.date_applied = self.source.date_applied 

42 

43 try: 

44 if self.source.current_application: 

45 self.target.set_current_application(self.source.current_application) 

46 except AttributeError: 

47 # this means that the source doesn't know about current_applications, which is fine 

48 pass 

49 

50 try: 

51 if self.source.current_journal: 

52 self.target.set_current_journal(self.source.current_journal) 

53 except AttributeError: 

54 # this means that the source doesn't know about current_journals, which is fine 

55 pass 

56 

57 try: 

58 if self.source.related_journal: 

59 self.target.set_related_journal(self.source.related_journal) 

60 except AttributeError: 

61 # this means that the source doesn't know about related_journals, which is fine 

62 pass 

63 

64 try: 

65 if self.source.related_applications: 

66 related = self.source.related_applications 

67 for rel in related: 

68 self.target.add_related_application(rel.get("application_id"), rel.get("date_accepted")) 

69 except AttributeError: 

70 # this means that the source doesn't know about related_applications, which is fine 

71 pass 

72 

73 try: 

74 if self.source.application_type: 

75 self.target.application_type = self.source.application_type 

76 except AttributeError: 

77 # this means that the source doesn't know about related_journals, which is fine 

78 pass 

79 

80 try: 

81 if self.source.last_withdrawn: 

82 self.target.last_withdrawn = self.source.last_withdrawn 

83 except AttributeError: 

84 # this means that the source doesn't know about related_journals, which is fine 

85 pass 

86 

87 try: 

88 if self.source.last_reinstated: 

89 self.target.last_reinstated = self.source.last_reinstated 

90 except AttributeError: 

91 # this means that the source doesn't know about related_journals, which is fine 

92 pass 

93 

94 try: 

95 if self.source.last_owner_transfer: 

96 self.target.last_owner_transfer = self.source.last_owner_transfer 

97 except AttributeError: 

98 # this means that the source doesn't know about related_journals, which is fine 

99 pass 

100 

101 try: 

102 if self.source.date_rejected: 

103 self.target.date_rejected = self.source.date_rejected 

104 except AttributeError: 

105 # this means that the source doesn't know about related_journals, which is fine 

106 pass 

107 

108 # if the source is a journal, we need to carry the in_doaj flag 

109 if isinstance(self.source, models.Journal): 

110 self.target.set_in_doaj(self.source.is_in_doaj()) 

111 

112 def resetDefaults(self, form): 

113 # self.form.resettedFields = [] 

114 def _values_to_reset(f): 

115 return (f.data != "") and (f.data != None) and (f.data != f.default) 

116 for field in form: 

117 if field.errors: 

118 if isinstance(field, FormField): 

119 self.resetDefaults(field.form) 

120 elif isinstance(field, FieldList): 

121 for sub in field: 

122 if isinstance(sub, FormField): 

123 self.resetDefaults(sub) 

124 elif _values_to_reset(sub): 

125 sub.data = sub.default 

126 elif _values_to_reset(field): 

127 # self.form.resettedFields.append({"name": field.name, "data": field.data, "default": field.default}) 

128 field.data = field.default 

129 

130 def _merge_notes_forward(self, allow_delete=False): 

131 if self.source is None: 

132 raise Exception("Cannot carry data from a non-existent source") 

133 if self.target is None: 

134 raise Exception("Cannot carry data on to a non-existent target - run the xwalk first") 

135 

136 # first off, get the notes (by reference) in the target and the notes from the source 

137 tnotes = self.target.notes 

138 snotes = self.source.notes 

139 

140 # if there are no notes, we might not have the notes by reference, so later will 

141 # need to set them by value 

142 apply_notes_by_value = len(tnotes) == 0 

143 

144 # for each of the target notes we need to get the original dates from the source notes 

145 for n in tnotes: 

146 for sn in snotes: 

147 if n.get("id") == sn.get("id"): 

148 n["date"] = sn.get("date") 

149 

150 # record the positions of any blank notes 

151 i = 0 

152 removes = [] 

153 for n in tnotes: 

154 if n.get("note").strip() == "": 

155 removes.append(i) 

156 i += 1 

157 

158 # actually remove all the notes marked for deletion 

159 removes.sort(reverse=True) 

160 for r in removes: 

161 tnotes.pop(r) 

162 

163 # finally, carry forward any notes that aren't already in the target 

164 if not allow_delete: 

165 for sn in snotes: 

166 found = False 

167 for tn in tnotes: 

168 if sn.get("id") == tn.get("id"): 

169 found = True 

170 if not found: 

171 tnotes.append(sn) 

172 

173 if apply_notes_by_value: 

174 self.target.set_notes(tnotes) 

175 

176 def _carry_continuations(self): 

177 if self.source is None: 

178 raise Exception("Cannot carry data from a non-existent source") 

179 

180 try: 

181 sbj = self.source.bibjson() 

182 tbj = self.target.bibjson() 

183 if sbj.replaces: 

184 tbj.replaces = sbj.replaces 

185 if sbj.is_replaced_by: 

186 tbj.is_replaced_by = sbj.is_replaced_by 

187 if sbj.discontinued_date: 

188 tbj.discontinued_date = sbj.discontinued_date 

189 except AttributeError: 

190 # this means that the source doesn't know about current_applications, which is fine 

191 pass 

192 

193 def _validate_status_change(self, source_status, target_status): 

194 """ Check whether the editorial pipeline permits a change to the target status for a role. 

195 Don't run this for admins, since they can change to any role at any time. """ 

196 from portality.forms.application_forms import application_statuses 

197 choices_for_role = [s.get("value") for s in application_statuses(None, self._formulaic)] 

198 # choices_for_role = list(sum(application_statuses(None, self._formulaic), ())) 

199 # choices_for_role = list(sum(cls.application_status(role), ())) # flattens the list of tuples 

200 

201 # Don't allow edits to application when status is beyond this user's permissions in the pipeline 

202 if source_status not in choices_for_role: 

203 raise Exception( 

204 "You don't have permission to edit applications which are in status {0}.".format(source_status)) 

205 

206 # Don't permit changes to status in reverse of the editorial process 

207 if choices_for_role.index(target_status) < choices_for_role.index(source_status): 

208 # Except that editors can revert 'completed' to 'in progress' 

209 if self._formulaic.name == 'editor' and source_status == constants.APPLICATION_STATUS_COMPLETED and target_status == constants.APPLICATION_STATUS_IN_PROGRESS: 

210 pass 

211 else: 

212 raise Exception( 

213 "You are not permitted to revert the application status from {0} to {1}.".format(source_status, 

214 target_status)) 

215 

216 def _patch_target_note_id(self): 

217 if self.target.notes: 

218 # set author_id on the note if it's a new note 

219 for note in self.target.notes: 

220 note_date = dates.parse(note['date']) 

221 if not note.get('author_id') and note_date > dates.before_now(60): 

222 try: 

223 note['author_id'] = current_user.id 

224 except AttributeError: 

225 # Skip if we don't have a current_user 

226 pass 

227 

228 def _resolve_flags(self, account): 

229 # handle flag resolution 

230 

231 # check that this form knows about flags 

232 if getattr(self.form, "flags", None) is None: 

233 return 

234 

235 resolved_flags = [] 

236 for flag in self.form.flags.data: 

237 if flag["flag_resolved"] == "true": 

238 # Note: new notes do not necessarily have ids, but flags that are being 

239 # resolved must have an id because they must exist already to be resolved 

240 resolved_flags.append(flag["flag_note_id"]) 

241 

242 for flag_id in resolved_flags: 

243 acc_id = account.id if account else "unknown user" 

244 flag = self.target.get_note_by_id(flag_id) 

245 new_note_text = Messages.FORMS__APPLICATION_FLAG__RESOLVED.format( 

246 date=dates.today(), 

247 username=acc_id, 

248 note=flag.get("note", "") 

249 ) 

250 self.target.resolve_flag(flag_id, new_note_text) 

251 

252 

253class NewApplication(ApplicationProcessor): 

254 """ 

255 Public Application Form Context. This is also a sort of demonstrator as to how to implement 

256 one, so it will do unnecessary things like override methods that don't actually need to be overridden. 

257 

258 This should be used in a context where an unauthenticated user is making a request to put a journal into the 

259 DOAJ. It does not have any edit capacity (i.e. the form can only be submitted once), and it does not provide 

260 any form fields other than the essential journal bibliographic, application bibliographc and contact information 

261 for the suggester. On submission, it will set the status to "pending" and the item will be available for review 

262 by the editors 

263 

264 ~~NewApplication:FormProcessor~~ 

265 """ 

266 

267 ############################################################ 

268 # PublicApplicationForm versions of FormProcessor lifecycle functions 

269 ############################################################ 

270 

271 def draft(self, account, id=None, *args, **kwargs): 

272 # check for validity 

273 valid = self.validate() 

274 

275 # FIXME: if you can only save a valid draft, you cannot save a draft 

276 # the draft to be saved needs to be valid 

277 #if not valid: 

278 # return None 

279 

280 # if not valid, then remove all fields which have validation errors 

281 if not valid: 

282 self.resetDefaults(self.form) 

283 

284 self.form2target() 

285 # ~~-> DraftApplication:Model~~ 

286 draft_application = models.DraftApplication(**self.target.data) 

287 if id is not None: 

288 draft_application.set_id(id) 

289 

290 draft_application.set_application_status("draft") 

291 draft_application.set_owner(account.id) 

292 draft_application.save() 

293 return draft_application 

294 

295 def finalise(self, account, save_target=True, email_alert=True, id=None): 

296 super(NewApplication, self).finalise() 

297 

298 # set some administrative data 

299 now = dates.now_str() 

300 self.target.date_applied = now 

301 if app.config.get("AUTOCHECK_INCOMING", False): 

302 self.target.set_application_status(constants.APPLICATION_STATUS_POST_SUBMISSION_REVIEW) 

303 else: 

304 self.target.set_application_status(constants.APPLICATION_STATUS_PENDING) 

305 self.target.set_owner(account.id) 

306 self.target.set_last_manual_update() 

307 

308 if id: 

309 # ~~-> Application:Model~~ 

310 replacing = models.Application.pull(id) 

311 if replacing is None: 

312 self.target.set_id(id) 

313 else: 

314 if replacing.application_status == constants.APPLICATION_STATUS_PENDING and replacing.owner == account.id: 

315 self.target.set_id(id) 

316 self.target.set_created(replacing.created_date) 

317 

318 # Finally save the target 

319 if save_target: 

320 self.target.save() 

321 # a draft may have been saved, so also remove that 

322 if id: 

323 models.DraftApplication.remove_by_id(id) 

324 

325 # trigger an application created event 

326 eventsSvc = DOAJ.eventsService() 

327 eventsSvc.trigger(models.Event(constants.EVENT_APPLICATION_CREATED, account.id, { 

328 "application": self.target.data 

329 })) 

330 

331 # Kick off the post-submission review 

332 if app.config.get("AUTOCHECK_INCOMING", False): 

333 # FIXME: imports are delayed because of a circular import problem buried in portality.decorators 

334 from portality.tasks.application_autochecks import ApplicationAutochecks 

335 from portality.tasks.helpers import background_helper 

336 background_helper.submit_by_bg_task_type(ApplicationAutochecks, 

337 application=self.target.id, 

338 status_on_complete=constants.APPLICATION_STATUS_PENDING) 

339 

340 

341class AdminApplication(ApplicationProcessor): 

342 """ 

343 Managing Editor's Application Review form. Should be used in a context where the form warrants full 

344 admin priviledges. It will permit conversion of applications to journals, and assignment of owner account 

345 as well as assignment to editorial group. 

346 

347 ~~ManEdApplication:FormProcessor~~ 

348 """ 

349 

350 def pre_validate(self): 

351 # to bypass WTForms insistence that choices on a select field match the value, outside of the actual validation 

352 # chain 

353 super(AdminApplication, self).pre_validate() 

354 self.form.editor.validate_choice = False 

355 # self.form.editor.choices = [(self.form.editor.data, self.form.editor.data)] 

356 

357 # TODO: Should quick_reject be set through this form at all? 

358 self.form.quick_reject.validate_choice = False 

359 # self.form.quick_reject.choices = [(self.form.quick_reject.data, self.form.quick_reject.data)] 

360 

361 def patch_target(self): 

362 super(AdminApplication, self).patch_target() 

363 

364 # This patches the target with things that shouldn't change from the source 

365 self._carry_fixed_aspects() 

366 self._merge_notes_forward(allow_delete=True) 

367 

368 # NOTE: this means you can't unset an owner once it has been set. But you can change it. 

369 if (self.target.owner is None or self.target.owner == "") and (self.source.owner is not None): 

370 self.target.set_owner(self.source.owner) 

371 

372 def finalise(self, account, save_target=True, email_alert=True): 

373 """ 

374 account is the administrator account carrying out the action 

375 """ 

376 

377 if self.source is None: 

378 raise Exception(Messages.EXCEPTION_EDITING_NON_EXISTING_APPLICATION) 

379 

380 if self.source.application_status == constants.APPLICATION_STATUS_ACCEPTED: 

381 raise Exception(Messages.EXCEPTION_EDITING_ACCEPTED_JOURNAL) 

382 

383 if self.source.current_journal is not None: 

384 j = models.Journal.pull(self.source.current_journal) 

385 if j is None: 

386 raise Exception(Messages.EXCEPTION_EDITING_DELETED_JOURNAL) 

387 elif not j.is_in_doaj(): 

388 raise Exception(Messages.EXCEPTION_EDITING_WITHDRAWN_JOURNAL) 

389 

390 # if we are allowed to finalise, kick this up to the superclass 

391 # here I can do something before the crosswalk is called - to do, move the resovled note conversion here 

392 super(AdminApplication, self).finalise() 

393 

394 # resolve any flags that were resolved in the form 

395 # self._resolve_flags(account) 

396 

397 # TODO: should these be a BLL feature? 

398 # If we have changed the editors assigned to this application, let them know. 

399 # ~~-> ApplicationForm:Crosswalk~~ 

400 is_editor_group_changed = ApplicationFormXWalk.is_new_editor_group(self.form, self.source) 

401 is_associate_editor_changed = ApplicationFormXWalk.is_new_editor(self.form, self.source) 

402 

403 # record the event in the provenance tracker 

404 # ~~-> Provenance:Model~~ 

405 models.Provenance.make(account, "edit", self.target) 

406 

407 # ~~->Application:Service~~ 

408 applicationService = DOAJ.applicationService() 

409 

410 # ~~->Event:Service~~ 

411 eventsSvc = DOAJ.eventsService() 

412 

413 # if the application is already rejected, and we are moving it back into a non-rejected status 

414 if self.source.application_status == constants.APPLICATION_STATUS_REJECTED and self.target.application_status != constants.APPLICATION_STATUS_REJECTED: 

415 try: 

416 applicationService.unreject_application(self.target, current_user._get_current_object(), disallow_status=[]) 

417 except exceptions.DuplicateUpdateRequest as e: 

418 self.add_alert(Messages.FORMS__APPLICATION_PROCESSORS__ADMIN_APPLICATION__FINALISE__COULD_NOT_UNREJECT) 

419 return 

420 

421 # if this application is being accepted, then do the conversion to a journal 

422 if self.target.application_status == constants.APPLICATION_STATUS_ACCEPTED: 

423 j = applicationService.accept_application(self.target, account) 

424 # record the url the journal is available at in the admin are and alert the user 

425 if has_request_context(): # fixme: if we handle alerts via a notification service we won't have to toggle on request context 

426 jurl = url_for("doaj.toc", identifier=j.toc_id) 

427 if self.source.current_journal is not None: # todo: are alerts displayed? 

428 self.add_alert('<a href="{url}" target="_blank">Existing journal updated</a>.'.format(url=jurl)) 

429 else: 

430 self.add_alert('<a href="{url}" target="_blank">New journal created</a>.'.format(url=jurl)) 

431 

432 # Add the journal to the account and send the notification email 

433 try: 

434 # ~~-> Account:Model~~ 

435 owner = models.Account.pull(j.owner) 

436 self.add_alert('Associating the journal with account {username}.'.format(username=owner.id)) 

437 owner.add_journal(j.id) 

438 if not owner.has_role('publisher'): 

439 owner.add_role('publisher') 

440 owner.save() 

441 

442 # for all acceptances, send an email to the owner of the journal 

443 if email_alert: 

444 if app.config.get("ENABLE_PUBLISHER_EMAIL", False): 

445 msg = Messages.SENT_ACCEPTED_APPLICATION_EMAIL.format(user=owner.id) 

446 if self.target.application_type == constants.APPLICATION_TYPE_UPDATE_REQUEST: 

447 msg = Messages.SENT_ACCEPTED_UPDATE_REQUEST_EMAIL.format(user=owner.id) 

448 self.add_alert(msg) 

449 else: 

450 msg = Messages.NOT_SENT_ACCEPTED_APPLICATION_EMAIL.format(user=owner.id) 

451 if self.target.application_type == constants.APPLICATION_TYPE_UPDATE_REQUEST: 

452 msg = Messages.NOT_SENT_ACCEPTED_UPDATE_REQUEST_EMAIL.format(user=owner.id) 

453 self.add_alert(msg) 

454 # self._send_application_approved_email(self.target, j, owner, self.source.current_journal is not None) 

455 except AttributeError: 

456 raise Exception("Account {owner} does not exist".format(owner=j.owner)) 

457 except app_email.EmailException: 

458 self.add_alert("Problem sending email to suggester - probably address is invalid") 

459 app.logger.exception("Acceptance email to owner failed.") 

460 

461 # if the application was instead rejected, carry out the rejection actions 

462 elif self.source.application_status != constants.APPLICATION_STATUS_REJECTED and self.target.application_status == constants.APPLICATION_STATUS_REJECTED: 

463 # reject the application 

464 applicationService.reject_application(self.target, current_user._get_current_object()) 

465 

466 # the application was neither accepted or rejected, so just save it 

467 else: 

468 self.target.set_last_manual_update() 

469 self.target.save() 

470 

471 if email_alert: 

472 # trigger a status change event 

473 if self.source.application_status != self.target.application_status: 

474 eventsSvc.trigger(models.Event(constants.EVENT_APPLICATION_STATUS, account.id, { 

475 "application": self.target.data, 

476 "old_status": self.source.application_status, 

477 "new_status": self.target.application_status 

478 })) 

479 

480 # ~~-> Email:Notifications~~ 

481 

482 # if we need to email the editor and/or the associate, handle those here 

483 if is_editor_group_changed: 

484 eventsSvc.trigger(models.Event( 

485 constants.EVENT_APPLICATION_EDITOR_GROUP_ASSIGNED, 

486 account.id, { 

487 "application": self.target.data 

488 } 

489 )) 

490 # try: 

491 # emails.send_editor_group_email(self.target) 

492 # except app_email.EmailException: 

493 # self.add_alert("Problem sending email to editor - probably address is invalid") 

494 # app.logger.exception("Email to associate failed.") 

495 if is_associate_editor_changed: 

496 eventsSvc.trigger(models.Event(constants.EVENT_APPLICATION_ASSED_ASSIGNED, account.id, { 

497 "application" : self.target.data, 

498 "old_editor": self.source.editor, 

499 "new_editor": self.target.editor 

500 })) 

501 # try: 

502 # emails.send_assoc_editor_email(self.target) 

503 # except app_email.EmailException: 

504 # self.add_alert("Problem sending email to associate editor - probably address is invalid") 

505 # app.logger.exception("Email to associate failed.") 

506 

507 # Inform editor and associate editor if this application was 'ready' or 'completed', but has been changed to 'in progress' 

508 if (self.source.application_status == constants.APPLICATION_STATUS_READY or self.source.application_status == constants.APPLICATION_STATUS_COMPLETED) and self.target.application_status == constants.APPLICATION_STATUS_IN_PROGRESS: 

509 # First, the editor 

510 self.add_alert('A notification has been sent to alert the editor of the change in status.') 

511 

512 # Then the associate 

513 if self.target.editor: 

514 self.add_alert('The associate editor has been notified of the change in status.') 

515 

516 

517 # email other managing editors if this was newly set to 'ready' 

518 if self.source.application_status != constants.APPLICATION_STATUS_READY and self.target.application_status == constants.APPLICATION_STATUS_READY: 

519 self.add_alert('A notification has been sent to the Managing Editors.') 

520 # this template requires who made the change, say it was an Admin 

521 

522 def validate(self): 

523 _statuses_not_requiring_validation = ['rejected', 'pending', 'in progress', 'on hold'] 

524 self.pre_validate() 

525 # make use of the ability to disable validation, otherwise, let it run 

526 valid = super(AdminApplication, self).validate() 

527 

528 if self.form is not None: 

529 if self.form.application_status.data in _statuses_not_requiring_validation and not valid: 

530 self.resetDefaults(self.form) 

531 return True 

532 

533 return valid 

534 

535 

536class EditorApplication(ApplicationProcessor): 

537 """ 

538 Editors Application Review form. This should be used in a context where an editor who owns an editorial group 

539 is accessing an application. This prevents re-assignment of Editorial group, but permits assignment of associate 

540 editor. It also permits change in application state, except to "accepted"; therefore this form context cannot 

541 be used to create journals from applications. Deleting notes is not allowed, but adding is. 

542 

543 ~~EditorApplication:FormProcessor~~ 

544 """ 

545 

546 def validate(self): 

547 _statuses_not_requiring_validation = ['pending', 'in progress'] 

548 self.pre_validate() 

549 # make use of the ability to disable validation, otherwise, let it run 

550 valid = super(EditorApplication, self).validate() 

551 

552 if self.form is not None: 

553 if self.form.application_status.data in _statuses_not_requiring_validation and not valid: 

554 self.resetDefaults(self.form) 

555 return True 

556 

557 return valid 

558 

559 def pre_validate(self): 

560 # Call to super sets all the basic disabled fields 

561 super(EditorApplication, self).pre_validate() 

562 

563 # although the editor_group field is handled by the general pre-validator, we still need to set the choices 

564 # self.form.editor_group.data = self.source.editor_group 

565 self.form.editor.choices = [(self.form.editor.data, self.form.editor.data)] 

566 

567 # This is no longer necessary, is handled by the main pre_validate function 

568 #if self._formulaic.get('application_status').is_disabled: 

569 # self.form.application_status.data = self.source.application_status 

570 # but we do still need to add the overwritten status to the choices for validation 

571 if self.form.application_status.data not in [c[0] for c in self.form.application_status.choices]: 

572 self.form.application_status.choices.append((self.form.application_status.data, self.form.application_status.data)) 

573 

574 def patch_target(self): 

575 super(EditorApplication, self).patch_target() 

576 

577 self._carry_fixed_aspects() 

578 self._merge_notes_forward() 

579 self._carry_continuations() 

580 

581 self.target.set_owner(self.source.owner) 

582 self.target.set_editor_group(self.source.editor_group) 

583 self.target.bibjson().labels = self.source.bibjson().labels 

584 

585 def finalise(self): 

586 if self.source is None: 

587 raise Exception("You cannot edit a not-existent application") 

588 if self.source.application_status == constants.APPLICATION_STATUS_ACCEPTED: 

589 raise Exception("You cannot edit applications which have been accepted into DOAJ.") 

590 

591 # if we are allowed to finalise, kick this up to the superclass 

592 super(EditorApplication, self).finalise() 

593 

594 # Check the status change is valid 

595 self._validate_status_change(self.source.application_status, self.target.application_status) 

596 

597 # ~~-> ApplicationForm:Crosswalk~~ 

598 new_associate_assigned = ApplicationFormXWalk.is_new_editor(self.form, self.source) 

599 

600 # Save the target 

601 self.target.set_last_manual_update() 

602 self.target.save() 

603 

604 # record the event in the provenance tracker 

605 # ~~-> Procenance:Model~~ 

606 models.Provenance.make(current_user, "edit", self.target) # FIXME: shouldn't really use current_user here 

607 

608 # trigger a status change event 

609 eventsSvc = DOAJ.eventsService() 

610 if self.source.application_status != self.target.application_status: 

611 eventsSvc.trigger(models.Event(constants.EVENT_APPLICATION_STATUS, current_user.id, { 

612 "application": self.target.data, 

613 "old_status": self.source.application_status, 

614 "new_status": self.target.application_status 

615 })) 

616 

617 # if we need to email the associate because they have just been assigned, handle that here. 

618 # ~~-> Email:Notifications~~ 

619 if new_associate_assigned: 

620 eventsSvc.trigger(models.Event(constants.EVENT_APPLICATION_ASSED_ASSIGNED, context={ 

621 "application": self.target.data, 

622 "old_editor": self.source.editor, 

623 "new_editor": self.target.editor 

624 })) 

625 self.add_alert("New editor assigned - notification has been sent") 

626 # try: 

627 # self.add_alert("New editor assigned - email with confirmation has been sent") 

628 # emails.send_assoc_editor_email(self.target) 

629 # except app_email.EmailException: 

630 # self.add_alert("Problem sending email to associate editor - probably address is invalid") 

631 # app.logger.exception('Error sending associate assigned email') 

632 

633 # Email the assigned associate if the application was reverted from 'completed' to 'in progress' (failed review) 

634 if self.source.application_status == constants.APPLICATION_STATUS_COMPLETED and self.target.application_status == constants.APPLICATION_STATUS_IN_PROGRESS: 

635 if self.target.editor: 

636 self.add_alert('The associate editor has been notified of the change in status.') 

637 

638 # email managing editors if the application was newly set to 'ready' 

639 if self.source.application_status != constants.APPLICATION_STATUS_READY and self.target.application_status == constants.APPLICATION_STATUS_READY: 

640 # Tell the ManEds who has made the status change - the editor in charge of the group 

641 # ~~-> EditorGroup:Model~~ 

642 editor_group_name = self.target.editor_group 

643 editor_group_id = models.EditorGroup.group_exists_by_name(name=editor_group_name) 

644 editor_group = models.EditorGroup.pull(editor_group_id) 

645 editor_acc = editor_group.get_editor_account() 

646 

647 # record the event in the provenance tracker 

648 # ~~-> Provenance:Model~~ 

649 models.Provenance.make(current_user, "status:ready", self.target) 

650 

651 self.add_alert('A notification has been sent to the Managing Editors.') 

652 

653 

654class AssociateApplication(ApplicationProcessor): 

655 """ 

656 Associate Editors Application Review form. This is to be used in a context where an associate editor (fewest rights) 

657 needs to access an application for review. This editor cannot change the editorial group or the assigned editor. 

658 They also cannot change the owner of the application. They cannot set an application to "Accepted" so this form can't 

659 be used to create a journal from an application. They cannot delete, only add notes. 

660 

661 ~~AssEdApplication:FormProcessor~~ 

662 """ 

663 

664 def pre_validate(self): 

665 # Call to super sets all the basic disabled fields 

666 super(AssociateApplication, self).pre_validate() 

667 

668 # no longer necessary, handled by superclass pre_validate 

669 #if self._formulaic.get('application_status').is_disabled: 

670 # self.form.application_status.data = self.source.application_status 

671 # but we do still need to add the overwritten status to the choices for validation 

672 if self.form.application_status.data not in [c[0] for c in self.form.application_status.choices]: 

673 self.form.application_status.choices.append( 

674 (self.form.application_status.data, self.form.application_status.data)) 

675 

676 def patch_target(self): 

677 if self.source is None: 

678 raise Exception("You cannot patch a target from a non-existent source") 

679 

680 super().patch_target() 

681 self._carry_fixed_aspects() 

682 self._merge_notes_forward() 

683 self.target.set_owner(self.source.owner) 

684 self.target.set_editor_group(self.source.editor_group) 

685 self.target.set_editor(self.source.editor) 

686 self.target.bibjson().labels = self.source.bibjson().labels 

687 self._carry_continuations() 

688 

689 def finalise(self): 

690 # if we are allowed to finalise, kick this up to the superclass 

691 super(AssociateApplication, self).finalise() 

692 

693 # Check the status change is valid 

694 self._validate_status_change(self.source.application_status, self.target.application_status) 

695 

696 # trigger a status change event 

697 eventsSvc = DOAJ.eventsService() 

698 if self.source.application_status != self.target.application_status: 

699 eventsSvc.trigger(models.Event(constants.EVENT_APPLICATION_STATUS, current_user.id, { 

700 "application": self.target.data, 

701 "old_status": self.source.application_status, 

702 "new_status": self.target.application_status 

703 })) 

704 

705 # Save the target 

706 self.target.set_last_manual_update() 

707 self.target.save() 

708 

709 # record the event in the provenance tracker 

710 # ~~-> Provenance:Model~~ 

711 models.Provenance.make(current_user, "edit", self.target) 

712 

713 # Editor is informed via status change event if this was newly set to 'completed' 

714 # fixme: duplicated logic in notification event and here for provenance 

715 if self.source.application_status != constants.APPLICATION_STATUS_COMPLETED and self.target.application_status == constants.APPLICATION_STATUS_COMPLETED: 

716 # record the event in the provenance tracker 

717 # ~~-> Procenance:Model~~ 

718 models.Provenance.make(current_user, "status:completed", self.target) 

719 self.add_alert(Messages.FORMS__APPLICATION_PROCESSORS__ASSOCIATE_APPLICATION__FINALISE__STATUS_COMPLETED_NOTIFIED) 

720 

721 

722class PublisherUpdateRequest(ApplicationProcessor): 

723 """ 

724 ~~PublisherUpdateRequest:FormProcessor~~ 

725 """ 

726 

727 def pre_validate(self): 

728 if self.source is None: 

729 raise Exception("You cannot validate a form from a non-existent source") 

730 

731 super(ApplicationProcessor, self).pre_validate() 

732 

733 def patch_target(self): 

734 if self.source is None: 

735 raise Exception("You cannot patch a target from a non-existent source") 

736 

737 super().patch_target() 

738 self._carry_subjects() 

739 self._carry_fixed_aspects() 

740 self._merge_notes_forward() 

741 self.target.set_owner(self.source.owner) 

742 self.target.set_editor_group(self.source.editor_group) 

743 self.target.set_editor(self.source.editor) 

744 self._carry_continuations() 

745 self.target.bibjson().labels = self.source.bibjson().labels 

746 

747 # we carry this over for completeness, although it will be overwritten in the finalise() method 

748 self.target.set_application_status(self.source.application_status) 

749 

750 def finalise(self, save_target=True, email_alert=True): 

751 # FIXME: this first one, we ought to deal with outside the form context, but for the time being this 

752 # can be carried over from the old implementation 

753 if self.source is None: 

754 raise Exception("You cannot edit a not-existent application") 

755 

756 # if we are allowed to finalise, kick this up to the superclass 

757 super(PublisherUpdateRequest, self).finalise() 

758 

759 # set the status to post submission review (will be updated again later after the review job runs) 

760 if app.config.get("AUTOCHECK_INCOMING", False): 

761 self.target.set_application_status(constants.APPLICATION_STATUS_POST_SUBMISSION_REVIEW) 

762 else: 

763 self.target.set_application_status(constants.APPLICATION_STATUS_UPDATE_REQUEST) 

764 

765 # automatically assign the Editorial group (turn this off in configuration if needed) 

766 DOAJ.applicationService().auto_assign_ur_editor_group(self.target) 

767 

768 # Save the target 

769 self.target.set_last_manual_update() 

770 if save_target: 

771 saved = self.target.save() 

772 if saved is None: 

773 raise Exception("Save on application failed") 

774 

775 # obtain the related journal, and attach the current application id to it 

776 # ~~-> Journal:Service~~ 

777 journal_id = self.target.current_journal 

778 journalService = DOAJ.journalService() 

779 if journal_id is not None: 

780 journal, _ = journalService.journal(journal_id) 

781 if journal is not None: 

782 journal.set_current_application(self.target.id) 

783 if save_target: 

784 saved = journal.save() 

785 if saved is None: 

786 raise Exception("Save on journal failed") 

787 else: 

788 self.target.remove_current_journal() 

789 

790 # Kick off the post-submission review 

791 if app.config.get("AUTOCHECK_INCOMING", False): 

792 # FIXME: imports are delayed because of a circular import problem buried in portality.decorators 

793 from portality.tasks.application_autochecks import ApplicationAutochecks 

794 from portality.tasks.helpers import background_helper 

795 background_helper.submit_by_bg_task_type(ApplicationAutochecks, 

796 application=self.target.id, 

797 status_on_complete=constants.APPLICATION_STATUS_UPDATE_REQUEST) 

798 

799 # email the publisher to tell them we received their update request 

800 if email_alert: 

801 DOAJ.eventsService().trigger(models.Event( 

802 constants.EVENT_APPLICATION_UR_SUBMITTED, 

803 current_user and current_user.id, 

804 context={ 

805 'application': self.target.data, 

806 } 

807 )) 

808 

809 def _carry_subjects(self): 

810 # carry over the subjects 

811 source_subjects = self.source.bibjson().subject 

812 self.target.bibjson().subject = source_subjects 

813 

814 

815class PublisherUpdateRequestReadOnly(ApplicationProcessor): 

816 """ 

817 Read Only Application form for publishers. Nothing can be changed. Useful to show publishers what they 

818 currently have submitted for review 

819 

820 ~~PublisherUpdateRequestReadOnly:FormProcessor~~ 

821 """ 

822 

823 def finalise(self): 

824 raise Exception("You cannot edit applications using the read-only form") 

825 

826 

827############################################### 

828### Journal form processors 

829############################################### 

830 

831class ManEdJournalReview(ApplicationProcessor): 

832 """ 

833 Managing Editor's Journal Review form. Should be used in a context where the form warrants full 

834 admin privileges. It will permit doing every action. 

835 

836 ~~ManEdJournal:FormProcessor~~ 

837 """ 

838 def patch_target(self): 

839 if self.source is None: 

840 raise Exception("You cannot patch a target from a non-existent source") 

841 

842 super().patch_target() 

843 self._carry_fixed_aspects() 

844 self._merge_notes_forward(allow_delete=True) 

845 

846 # NOTE: this means you can't unset an owner once it has been set. But you can change it. 

847 if (self.target.owner is None or self.target.owner == "") and (self.source.owner is not None): 

848 self.target.set_owner(self.source.owner) 

849 

850 def finalise(self, account=None): 

851 # FIXME: this first one, we ought to deal with outside the form context, but for the time being this 

852 # can be carried over from the old implementation 

853 

854 if self.source is None: 

855 raise Exception("You cannot edit a not-existent journal") 

856 

857 # if we are allowed to finalise, kick this up to the superclass 

858 super(ManEdJournalReview, self).finalise() 

859 

860 # resolve any flags that were resolved in the form 

861 self._resolve_flags(account) 

862 

863 # If we have changed the editors assinged to this application, let them know. 

864 # ~~-> JournalForm:Crosswalk~~ 

865 is_editor_group_changed = JournalFormXWalk.is_new_editor_group(self.form, self.source) 

866 is_associate_editor_changed = JournalFormXWalk.is_new_editor(self.form, self.source) 

867 

868 # has a full review been done 

869 if self.source.last_full_review != self.target.last_full_review: 

870 n = Messages.LAST_FULL_REVIEW_NOTE.format(date=self.target.last_full_review, username=account.id) 

871 self.target.add_note(n, date=dates.now_str(), author_id=account.id) 

872 

873 # has the owner changed? 

874 if self.source.owner != self.target.owner: 

875 self.target.last_owner_transfer = dates.now_str() 

876 changed_by = "unknown user" 

877 if account is not None: 

878 changed_by = account.id 

879 n = Messages.OWNER_CHANGED_NOTE.format(date=dates.now_str(), 

880 old_owner=self.source.owner, 

881 new_owner=self.target.owner, 

882 changed_by=changed_by) 

883 self.target.add_note(n, date=dates.now_str(), author_id=changed_by) 

884 

885 # Save the target 

886 self.target.set_last_manual_update() 

887 self.target.save() 

888 

889 # if we need to email the editor and/or the associate, handle those here 

890 # ~~-> Email:Notifications~~ 

891 if is_editor_group_changed: 

892 eventsSvc = DOAJ.eventsService() 

893 eventsSvc.trigger(models.Event(constants.EVENT_JOURNAL_EDITOR_GROUP_ASSIGNED, current_user.id, { 

894 "journal": self.target.data 

895 })) 

896 # try: 

897 # emails.send_editor_group_email(self.target) 

898 # except app_email.EmailException: 

899 # self.add_alert("Problem sending email to editor - probably address is invalid") 

900 # app.logger.exception('Error sending assignment email to editor.') 

901 if is_associate_editor_changed: 

902 events_svc = DOAJ.eventsService() 

903 events_svc.trigger(models.Event(constants.EVENT_JOURNAL_ASSED_ASSIGNED, current_user.id, context={ 

904 "journal": self.target.data, 

905 "old_editor": self.source.editor, 

906 "new_editor": self.target.editor 

907 })) 

908 # try: 

909 # emails.send_assoc_editor_email(self.target) 

910 # except app_email.EmailException: 

911 # self.add_alert("Problem sending email to associate editor - probably address is invalid") 

912 # app.logger.exception('Error sending assignment email to associate.') 

913 

914 def validate(self): 

915 # make use of the ability to disable validation, otherwise, let it run 

916 if self.form is not None: 

917 if self.form.make_all_fields_optional.data: 

918 self.pre_validate() 

919 return True 

920 

921 return super(ManEdJournalReview, self).validate() 

922 

923 

924class EditorJournalReview(ApplicationProcessor): 

925 """ 

926 Editors Journal Review form. This should be used in a context where an editor who owns an editorial group 

927 is accessing a journal. This prevents re-assignment of Editorial group, but permits assignment of associate 

928 editor. 

929 

930 ~~EditorJournal:FormProcessor~~ 

931 """ 

932 

933 def patch_target(self): 

934 if self.source is None: 

935 raise Exception("You cannot patch a target from a non-existent source") 

936 

937 super().patch_target() 

938 self._carry_fixed_aspects() 

939 self.target.set_owner(self.source.owner) 

940 self.target.set_editor_group(self.source.editor_group) 

941 self._merge_notes_forward() 

942 self._carry_continuations() 

943 self.target.bibjson().labels = self.source.bibjson().labels 

944 self.target.last_full_review = self.source.last_full_review 

945 

946 def pre_validate(self): 

947 # call to super handles all the basic disabled field 

948 super(EditorJournalReview, self).pre_validate() 

949 

950 # although the superclass sets the value of the disabled field, we still need to set the choices 

951 # self.form.editor_group.data = self.source.editor_group 

952 self.form.editor.choices = [(self.form.editor.data, self.form.editor.data)] 

953 

954 def finalise(self): 

955 if self.source is None: 

956 raise Exception("You cannot edit a not-existent journal") 

957 

958 # if we are allowed to finalise, kick this up to the superclass 

959 super(EditorJournalReview, self).finalise() 

960 

961 # ~~-> ApplicationForm:Crosswalk~~ 

962 email_associate = ApplicationFormXWalk.is_new_editor(self.form, self.source) 

963 

964 # Save the target 

965 self.target.set_last_manual_update() 

966 self.target.save() 

967 

968 # if we need to email the associate, handle that here. 

969 if email_associate: 

970 events_svc = DOAJ.eventsService() 

971 events_svc.trigger(models.Event(constants.EVENT_JOURNAL_ASSED_ASSIGNED, current_user.id, context={ 

972 "journal": self.target.data, 

973 "old_editor": self.source.editor, 

974 "new_editor": self.target.editor 

975 })) 

976 # ~~-> Email:Notifications~~ 

977 # try: 

978 # emails.send_assoc_editor_email(self.target) 

979 # except app_email.EmailException: 

980 # self.add_alert("Problem sending email to associate editor - probably address is invalid") 

981 # app.logger.exception('Error sending assignment email to associate.') 

982 

983 

984class AssEdJournalReview(ApplicationProcessor): 

985 """ 

986 Associate Editors Journal Review form. This is to be used in a context where an associate editor (fewest rights) 

987 needs to access a journal for review. This editor cannot change the editorial group or the assigned editor. 

988 They also cannot change the owner of the journal. They cannot delete, only add notes. 

989 

990 ~~AssEdJournal:FormProcessor~~ 

991 """ 

992 

993 def patch_target(self): 

994 if self.source is None: 

995 raise Exception("You cannot patch a target from a non-existent source") 

996 

997 super().patch_target() 

998 self._carry_fixed_aspects() 

999 self._merge_notes_forward() 

1000 self.target.set_owner(self.source.owner) 

1001 self.target.set_editor_group(self.source.editor_group) 

1002 self.target.set_editor(self.source.editor) 

1003 self._carry_continuations() 

1004 self.target.bibjson().labels = self.source.bibjson().labels 

1005 self.target.last_full_review = self.source.last_full_review 

1006 

1007 def finalise(self): 

1008 if self.source is None: 

1009 raise Exception("You cannot edit a not-existent journal") 

1010 

1011 # if we are allowed to finalise, kick this up to the superclass 

1012 super(AssEdJournalReview, self).finalise() 

1013 

1014 # Save the target 

1015 self.target.set_last_manual_update() 

1016 self.target.save() 

1017 

1018 

1019class ReadOnlyJournal(ApplicationProcessor): 

1020 """ 

1021 Read Only Journal form. Nothing can be changed. Useful for reviewing a journal and an application 

1022 (or update request) side by side in 2 browser windows or tabs. 

1023 

1024 ~~ReadOnlyJournal:FormProcessor~~ 

1025 """ 

1026 def form2target(self): 

1027 pass # you can't edit objects using this form 

1028 

1029 def patch_target(self): 

1030 pass # you can't edit objects using this form 

1031 

1032 def finalise(self): 

1033 raise Exception("You cannot edit journals using the read-only form") 

1034 

1035 

1036class ManEdBulkEdit(ApplicationProcessor): 

1037 """ 

1038 Managing Editor's Journal Review form. Should be used in a context where the form warrants full 

1039 admin privileges. It will permit doing every action. 

1040 

1041 ~~ManEdBulkJournal:FormProcessor~~ 

1042 """ 

1043 pass