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

478 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-08-04 15:38 +0100

1import uuid 

2from datetime import datetime 

3 

4import portality.notifications.application_emails as emails 

5from portality.core import app 

6from portality import models, constants, app_email 

7from portality.lib.formulaic import FormProcessor 

8from portality.ui.messages import Messages 

9from portality.crosswalks.application_form import ApplicationFormXWalk 

10from portality.crosswalks.journal_form import JournalFormXWalk 

11from portality.bll import exceptions 

12from portality.bll.doaj import DOAJ 

13 

14from flask import url_for, request, has_request_context 

15from flask_login import current_user 

16 

17from wtforms import FormField, FieldList 

18 

19 

20class ApplicationProcessor(FormProcessor): 

21 

22 def pre_validate(self): 

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

24 # chain 

25 super(ApplicationProcessor, self).pre_validate() 

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 = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") 

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

40 if self.source.date_applied is not None: 

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

42 except AttributeError: 

43 # fixme: should there always be a date_applied? Only true for applications 

44 pass 

45 

46 try: 

47 if self.source.current_application: 

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

49 except AttributeError: 

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

51 pass 

52 

53 try: 

54 if self.source.current_journal: 

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

56 except AttributeError: 

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

58 pass 

59 

60 try: 

61 if self.source.related_journal: 

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

63 except AttributeError: 

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

65 pass 

66 

67 try: 

68 if self.source.related_applications: 

69 related = self.source.related_applications 

70 for rel in related: 

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

72 except AttributeError: 

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

74 pass 

75 

76 try: 

77 if self.source.application_type: 

78 self.target.application_type = self.source.application_type 

79 except AttributeError: 

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

81 pass 

82 

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

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

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

86 

87 def resetDefaults(self, form): 

88 # self.form.resettedFields = [] 

89 def _values_to_reset(f): 

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

91 for field in form: 

92 if field.errors: 

93 if isinstance(field, FormField): 

94 self.resetDefaults(field.form) 

95 elif isinstance(field, FieldList): 

96 for sub in field: 

97 if isinstance(sub, FormField): 

98 self.resetDefaults(sub) 

99 elif _values_to_reset(sub): 

100 sub.data = sub.default 

101 elif _values_to_reset(field): 

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

103 field.data = field.default 

104 

105 def _merge_notes_forward(self, allow_delete=False): 

106 if self.source is None: 

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

108 if self.target is None: 

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

110 

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

112 tnotes = self.target.notes 

113 snotes = self.source.notes 

114 

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

116 # need to set them by value 

117 apply_notes_by_value = len(tnotes) == 0 

118 

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

120 for n in tnotes: 

121 for sn in snotes: 

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

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

124 

125 # record the positions of any blank notes 

126 i = 0 

127 removes = [] 

128 for n in tnotes: 

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

130 removes.append(i) 

131 i += 1 

132 

133 # actually remove all the notes marked for deletion 

134 removes.sort(reverse=True) 

135 for r in removes: 

136 tnotes.pop(r) 

137 

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

139 if not allow_delete: 

140 for sn in snotes: 

141 found = False 

142 for tn in tnotes: 

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

144 found = True 

145 if not found: 

146 tnotes.append(sn) 

147 

148 if apply_notes_by_value: 

149 self.target.set_notes(tnotes) 

150 

151 def _carry_continuations(self): 

152 if self.source is None: 

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

154 

155 try: 

156 sbj = self.source.bibjson() 

157 tbj = self.target.bibjson() 

158 if sbj.replaces: 

159 tbj.replaces = sbj.replaces 

160 if sbj.is_replaced_by: 

161 tbj.is_replaced_by = sbj.is_replaced_by 

162 if sbj.discontinued_date: 

163 tbj.discontinued_date = sbj.discontinued_date 

164 except AttributeError: 

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

166 pass 

167 

168 def _validate_status_change(self, source_status, target_status): 

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

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

171 from portality.forms.application_forms import application_statuses 

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

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

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

175 

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

177 if source_status not in choices_for_role: 

178 raise Exception( 

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

180 

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

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

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

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

185 pass 

186 else: 

187 raise Exception( 

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

189 target_status)) 

190 

191 

192class NewApplication(ApplicationProcessor): 

193 """ 

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

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

196 

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

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

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

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

201 by the editors 

202 

203 ~~NewApplication:FormProcessor~~ 

204 """ 

205 

206 ############################################################ 

207 # PublicApplicationForm versions of FormProcessor lifecycle functions 

208 ############################################################ 

209 

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

211 # check for validity 

212 valid = self.validate() 

213 

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

215 # the draft to be saved needs to be valid 

216 #if not valid: 

217 # return None 

218 

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

220 if not valid: 

221 self.resetDefaults(self.form) 

222 

223 self.form2target() 

224 # ~~-> DraftApplication:Model~~ 

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

226 if id is not None: 

227 draft_application.set_id(id) 

228 

229 draft_application.set_application_status("draft") 

230 draft_application.set_owner(account.id) 

231 draft_application.save() 

232 return draft_application 

233 

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

235 super(NewApplication, self).finalise() 

236 

237 # set some administrative data 

238 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") 

239 self.target.date_applied = now 

240 self.target.set_application_status(constants.APPLICATION_STATUS_PENDING) 

241 self.target.set_owner(account.id) 

242 self.target.set_last_manual_update() 

243 

244 if id: 

245 # ~~-> Application:Model~~ 

246 replacing = models.Application.pull(id) 

247 if replacing is None: 

248 self.target.set_id(id) 

249 else: 

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

251 self.target.set_id(id) 

252 self.target.set_created(replacing.created_date) 

253 

254 # Finally save the target 

255 if save_target: 

256 self.target.save() 

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

258 if id: 

259 models.DraftApplication.remove_by_id(id) 

260 

261 # trigger an application created event 

262 eventsSvc = DOAJ.eventsService() 

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

264 "application": self.target.data 

265 })) 

266 

267 

268class AdminApplication(ApplicationProcessor): 

269 """ 

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

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

272 as well as assignment to editorial group. 

273 

274 ~~ManEdApplication:FormProcessor~~ 

275 """ 

276 

277 def pre_validate(self): 

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

279 # chain 

280 super(AdminApplication, self).pre_validate() 

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

282 

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

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

285 

286 def patch_target(self): 

287 super(AdminApplication, self).patch_target() 

288 

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

290 self._carry_fixed_aspects() 

291 self._merge_notes_forward(allow_delete=True) 

292 

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

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

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

296 

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

298 """ 

299 account is the administrator account carrying out the action 

300 """ 

301 

302 if self.source is None: 

303 raise Exception(Messages.EXCEPTION_EDITING_NON_EXISTING_APPLICATION) 

304 

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

306 raise Exception(Messages.EXCEPTION_EDITING_ACCEPTED_JOURNAL) 

307 

308 if self.source.current_journal is not None: 

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

310 if j is None: 

311 raise Exception(Messages.EXCEPTION_EDITING_DELETED_JOURNAL) 

312 elif not j.is_in_doaj(): 

313 raise Exception(Messages.EXCEPTION_EDITING_WITHDRAWN_JOURNAL) 

314 

315 

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

317 super(AdminApplication, self).finalise() 

318 

319 # instance of the events service to pick up any events we need to send 

320 eventsSvc = DOAJ.eventsService() 

321 

322 # TODO: should these be a BLL feature? 

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

324 # ~~-> ApplicationForm:Crosswalk~~ 

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

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

327 

328 # record the event in the provenance tracker 

329 # ~~-> Provenance:Model~~ 

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

331 

332 # ~~->Application:Service~~ 

333 applicationService = DOAJ.applicationService() 

334 

335 # ~~->Event:Service~~ 

336 eventsSvc = DOAJ.eventsService() 

337 

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

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

340 try: 

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

342 except exceptions.DuplicateUpdateRequest as e: 

343 self.add_alert(Messages.FORMS__APPLICATION_PROCESSORS__ADMIN_APPLICATION__FINALISE__COULD_NOT_UNREJECT) 

344 return 

345 

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

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

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

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

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

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

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

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

354 else: 

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

356 

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

358 try: 

359 # ~~-> Account:Model~~ 

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

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

362 owner.add_journal(j.id) 

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

364 owner.add_role('publisher') 

365 owner.save() 

366 

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

368 if email_alert: 

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

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

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

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

373 self.add_alert(msg) 

374 else: 

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

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

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

378 self.add_alert(msg) 

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

380 except AttributeError: 

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

382 except app_email.EmailException: 

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

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

385 

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

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

388 # reject the application 

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

390 

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

392 else: 

393 self.target.set_last_manual_update() 

394 self.target.save() 

395 

396 if email_alert: 

397 # trigger a status change event 

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

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

400 "application": self.target.data, 

401 "old_status": self.source.application_status, 

402 "new_status": self.target.application_status 

403 })) 

404 

405 # ~~-> Email:Notifications~~ 

406 # if revisions were requested, email the publisher 

407 if self.source.application_status != constants.APPLICATION_STATUS_REVISIONS_REQUIRED and self.target.application_status == constants.APPLICATION_STATUS_REVISIONS_REQUIRED: 

408 self.add_alert( 

409 Messages.SENT_REJECTED_UPDATE_REQUEST_REVISIONS_REQUIRED_EMAIL.format(user=self.target.owner)) 

410 

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

412 if is_editor_group_changed: 

413 eventsSvc.trigger(models.Event( 

414 constants.EVENT_APPLICATION_EDITOR_GROUP_ASSIGNED, 

415 account.id, { 

416 "application": self.target.data 

417 } 

418 )) 

419 # try: 

420 # emails.send_editor_group_email(self.target) 

421 # except app_email.EmailException: 

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

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

424 if is_associate_editor_changed: 

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

426 "application" : self.target.data, 

427 "old_editor": self.source.editor, 

428 "new_editor": self.target.editor 

429 })) 

430 # try: 

431 # emails.send_assoc_editor_email(self.target) 

432 # except app_email.EmailException: 

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

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

435 

436 # If this is the first time this application has been assigned to an editor, notify the publisher. 

437 old_ed = self.source.editor 

438 if (old_ed is None or old_ed == '') and self.target.editor is not None: 

439 self.add_alert(Messages.SENT_PUBLISHER_ASSIGNED_EMAIL) 

440 

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

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

443 # First, the editor 

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

445 

446 # Then the associate 

447 if self.target.editor: 

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

449 

450 

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

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

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

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

455 

456 def validate(self): 

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

458 self.pre_validate() 

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

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

461 

462 if self.form is not None: 

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

464 self.resetDefaults(self.form) 

465 return True 

466 

467 return valid 

468 

469 

470class EditorApplication(ApplicationProcessor): 

471 """ 

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

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

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

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

476 

477 ~~EditorApplication:FormProcessor~~ 

478 """ 

479 

480 def validate(self): 

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

482 self.pre_validate() 

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

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

485 

486 if self.form is not None: 

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

488 self.resetDefaults(self.form) 

489 return True 

490 

491 return valid 

492 

493 def pre_validate(self): 

494 # Call to super sets all the basic disabled fields 

495 super(EditorApplication, self).pre_validate() 

496 

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

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

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

500 

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

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

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

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

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

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

507 

508 def patch_target(self): 

509 super(EditorApplication, self).patch_target() 

510 

511 self._carry_fixed_aspects() 

512 self._merge_notes_forward() 

513 self._carry_continuations() 

514 

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

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

517 

518 def finalise(self): 

519 if self.source is None: 

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

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

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

523 

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

525 super(EditorApplication, self).finalise() 

526 

527 # Check the status change is valid 

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

529 

530 # ~~-> ApplicationForm:Crosswalk~~ 

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

532 

533 # Save the target 

534 self.target.set_last_manual_update() 

535 self.target.save() 

536 

537 # record the event in the provenance tracker 

538 # ~~-> Procenance:Model~~ 

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

540 

541 # trigger a status change event 

542 eventsSvc = DOAJ.eventsService() 

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

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

545 "application": self.target.data, 

546 "old_status": self.source.application_status, 

547 "new_status": self.target.application_status 

548 })) 

549 

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

551 # ~~-> Email:Notifications~~ 

552 if new_associate_assigned: 

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

554 "application": self.target.data, 

555 "old_editor": self.source.editor, 

556 "new_editor": self.target.editor 

557 })) 

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

559 # try: 

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

561 # emails.send_assoc_editor_email(self.target) 

562 # except app_email.EmailException: 

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

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

565 

566 # If this is the first time this application has been assigned to an editor, notify the publisher. 

567 old_ed = self.source.editor 

568 if (old_ed is None or old_ed == '') and self.target.editor is not None: 

569 self.add_alert(Messages.SENT_PUBLISHER_ASSIGNED_EMAIL) 

570 

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

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

573 if self.target.editor: 

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

575 

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

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

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

579 # ~~-> EditorGroup:Model~~ 

580 editor_group_name = self.target.editor_group 

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

582 editor_group = models.EditorGroup.pull(editor_group_id) 

583 editor_acc = editor_group.get_editor_account() 

584 

585 # record the event in the provenance tracker 

586 # ~~-> Provenance:Model~~ 

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

588 

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

590 

591 

592class AssociateApplication(ApplicationProcessor): 

593 """ 

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

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

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

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

598 

599 ~~AssEdApplication:FormProcessor~~ 

600 """ 

601 

602 def pre_validate(self): 

603 # Call to super sets all the basic disabled fields 

604 super(AssociateApplication, self).pre_validate() 

605 

606 # no longer necessary, handled by superclass pre_validate 

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

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

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

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

611 self.form.application_status.choices.append( 

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

613 

614 def patch_target(self): 

615 if self.source is None: 

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

617 

618 self._carry_fixed_aspects() 

619 self._merge_notes_forward() 

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

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

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

623 self.target.set_seal(self.source.has_seal()) 

624 self._carry_continuations() 

625 

626 def finalise(self): 

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

628 super(AssociateApplication, self).finalise() 

629 

630 # Check the status change is valid 

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

632 

633 # trigger a status change event 

634 eventsSvc = DOAJ.eventsService() 

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

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

637 "application": self.target.data, 

638 "old_status": self.source.application_status, 

639 "new_status": self.target.application_status 

640 })) 

641 

642 # Save the target 

643 self.target.set_last_manual_update() 

644 self.target.save() 

645 

646 # record the event in the provenance tracker 

647 # ~~-> Provenance:Model~~ 

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

649 

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

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

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

653 # record the event in the provenance tracker 

654 # ~~-> Procenance:Model~~ 

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

656 self.add_alert(Messages.FORMS__APPLICATION_PROCESSORS__ASSOCIATE_APPLICATION__FINALISE__STATUS_COMPLETED_NOTIFIED) 

657 

658 

659class PublisherUpdateRequest(ApplicationProcessor): 

660 """ 

661 ~~PublisherUpdateRequest:FormProcessor~~ 

662 """ 

663 

664 def pre_validate(self): 

665 if self.source is None: 

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

667 

668 super(ApplicationProcessor, self).pre_validate() 

669 

670 def patch_target(self): 

671 if self.source is None: 

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

673 

674 self._carry_subjects_and_seal() 

675 self._carry_fixed_aspects() 

676 self._merge_notes_forward() 

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

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

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

680 self._carry_continuations() 

681 

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

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

684 

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

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

687 # can be carried over from the old implementation 

688 if self.source is None: 

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

690 

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

692 super(PublisherUpdateRequest, self).finalise() 

693 

694 # set the status to update_request (if not already) 

695 self.target.set_application_status(constants.APPLICATION_STATUS_UPDATE_REQUEST) 

696 

697 # Save the target 

698 self.target.set_last_manual_update() 

699 if save_target: 

700 saved = self.target.save() 

701 if saved is None: 

702 raise Exception("Save on application failed") 

703 

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

705 # ~~-> Journal:Service~~ 

706 journal_id = self.target.current_journal 

707 from portality.bll.doaj import DOAJ 

708 journalService = DOAJ.journalService() 

709 if journal_id is not None: 

710 journal, _ = journalService.journal(journal_id) 

711 if journal is not None: 

712 journal.set_current_application(self.target.id) 

713 if save_target: 

714 saved = journal.save() 

715 if saved is None: 

716 raise Exception("Save on journal failed") 

717 else: 

718 self.target.remove_current_journal() 

719 

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

721 if email_alert: 

722 try: 

723 # ~~-> Email:Notifications~~ 

724 self._send_received_email() 

725 except app_email.EmailException as e: 

726 self.add_alert("We were unable to send you an email confirmation - possible problem with your email address") 

727 app.logger.exception('Error sending reapplication received email to publisher') 

728 

729 def _carry_subjects_and_seal(self): 

730 # carry over the subjects 

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

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

733 

734 # carry over the seal 

735 self.target.set_seal(self.source.has_seal()) 

736 

737 def _send_received_email(self): 

738 # ~~-> Account:Model~~ 

739 acc = models.Account.pull(self.target.owner) 

740 if acc is None: 

741 self.add_alert("Unable to locate account for specified owner") 

742 return 

743 

744 # ~~-> Email:Library~~ 

745 to = [acc.email] 

746 fro = app.config.get('SYSTEM_EMAIL_FROM', 'helpdesk@doaj.org') 

747 subject = app.config.get("SERVICE_NAME","") + " - update request received" 

748 

749 try: 

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

751 app_email.send_mail(to=to, 

752 fro=fro, 

753 subject=subject, 

754 template_name="email/publisher_update_request_received.jinja2", 

755 application=self.target, 

756 owner=acc) 

757 self.add_alert('A confirmation email has been sent to ' + acc.email + '.') 

758 except app_email.EmailException as e: 

759 magic = str(uuid.uuid1()) 

760 self.add_alert('Hm, sending the "update request received" email didn\'t work. Please quote this magic number when reporting the issue: ' + magic + ' . Thank you!') 

761 app.logger.error(magic + "\n" + repr(e)) 

762 raise e 

763 

764 

765class PublisherUpdateRequestReadOnly(ApplicationProcessor): 

766 """ 

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

768 currently have submitted for review 

769 

770 ~~PublisherUpdateRequestReadOnly:FormProcessor~~ 

771 """ 

772 

773 def finalise(self): 

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

775 

776 

777############################################### 

778### Journal form processors 

779############################################### 

780 

781class ManEdJournalReview(ApplicationProcessor): 

782 """ 

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

784 admin privileges. It will permit doing every action. 

785 

786 ~~ManEdJournal:FormProcessor~~ 

787 """ 

788 def patch_target(self): 

789 if self.source is None: 

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

791 

792 self._carry_fixed_aspects() 

793 self._merge_notes_forward(allow_delete=True) 

794 

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

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

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

798 

799 def finalise(self): 

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

801 # can be carried over from the old implementation 

802 

803 if self.source is None: 

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

805 

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

807 super(ManEdJournalReview, self).finalise() 

808 

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

810 # ~~-> JournalForm:Crosswalk~~ 

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

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

813 

814 # Save the target 

815 self.target.set_last_manual_update() 

816 self.target.save() 

817 

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

819 # ~~-> Email:Notifications~~ 

820 if is_editor_group_changed: 

821 eventsSvc = DOAJ.eventsService() 

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

823 "journal": self.target.data 

824 })) 

825 # try: 

826 # emails.send_editor_group_email(self.target) 

827 # except app_email.EmailException: 

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

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

830 if is_associate_editor_changed: 

831 events_svc = DOAJ.eventsService() 

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

833 "journal": self.target.data, 

834 "old_editor": self.source.editor, 

835 "new_editor": self.target.editor 

836 })) 

837 # try: 

838 # emails.send_assoc_editor_email(self.target) 

839 # except app_email.EmailException: 

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

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

842 

843 def validate(self): 

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

845 if self.form is not None: 

846 if self.form.make_all_fields_optional.data: 

847 self.pre_validate() 

848 return True 

849 

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

851 

852 

853class EditorJournalReview(ApplicationProcessor): 

854 """ 

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

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

857 editor. 

858 

859 ~~EditorJournal:FormProcessor~~ 

860 """ 

861 

862 def patch_target(self): 

863 if self.source is None: 

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

865 

866 self._carry_fixed_aspects() 

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

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

869 self._merge_notes_forward() 

870 self._carry_continuations() 

871 

872 def pre_validate(self): 

873 # call to super handles all the basic disabled field 

874 super(EditorJournalReview, self).pre_validate() 

875 

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

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

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

879 

880 def finalise(self): 

881 if self.source is None: 

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

883 

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

885 super(EditorJournalReview, self).finalise() 

886 

887 # ~~-> ApplicationForm:Crosswalk~~ 

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

889 

890 # Save the target 

891 self.target.set_last_manual_update() 

892 self.target.save() 

893 

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

895 if email_associate: 

896 events_svc = DOAJ.eventsService() 

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

898 "journal": self.target.data, 

899 "old_editor": self.source.editor, 

900 "new_editor": self.target.editor 

901 })) 

902 # ~~-> Email:Notifications~~ 

903 # try: 

904 # emails.send_assoc_editor_email(self.target) 

905 # except app_email.EmailException: 

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

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

908 

909 

910class AssEdJournalReview(ApplicationProcessor): 

911 """ 

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

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

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

915 

916 ~~AssEdJournal:FormProcessor~~ 

917 """ 

918 

919 def patch_target(self): 

920 if self.source is None: 

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

922 

923 self._carry_fixed_aspects() 

924 self._merge_notes_forward() 

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

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

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

928 self._carry_continuations() 

929 

930 def finalise(self): 

931 if self.source is None: 

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

933 

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

935 super(AssEdJournalReview, self).finalise() 

936 

937 # Save the target 

938 self.target.set_last_manual_update() 

939 self.target.save() 

940 

941 

942class ReadOnlyJournal(ApplicationProcessor): 

943 """ 

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

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

946 

947 ~~ReadOnlyJournal:FormProcessor~~ 

948 """ 

949 def form2target(self): 

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

951 

952 def patch_target(self): 

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

954 

955 def finalise(self): 

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

957 

958 

959class ManEdBulkEdit(ApplicationProcessor): 

960 """ 

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

962 admin privileges. It will permit doing every action. 

963 

964 ~~ManEdBulkJournal:FormProcessor~~ 

965 """ 

966 pass