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
« prev ^ index » next coverage.py v6.4.2, created at 2022-08-04 15:38 +0100
1import uuid
2from datetime import datetime
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
14from flask import url_for, request, has_request_context
15from flask_login import current_user
17from wtforms import FormField, FieldList
20class ApplicationProcessor(FormProcessor):
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()
27 def _carry_fixed_aspects(self):
28 if self.source is None:
29 raise Exception("Cannot carry data from a non-existent source")
31 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
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']
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
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
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
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
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
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
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())
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
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")
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
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
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")
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
133 # actually remove all the notes marked for deletion
134 removes.sort(reverse=True)
135 for r in removes:
136 tnotes.pop(r)
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)
148 if apply_notes_by_value:
149 self.target.set_notes(tnotes)
151 def _carry_continuations(self):
152 if self.source is None:
153 raise Exception("Cannot carry data from a non-existent source")
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
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
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))
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))
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.
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
203 ~~NewApplication:FormProcessor~~
204 """
206 ############################################################
207 # PublicApplicationForm versions of FormProcessor lifecycle functions
208 ############################################################
210 def draft(self, account, id=None, *args, **kwargs):
211 # check for validity
212 valid = self.validate()
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
219 # if not valid, then remove all fields which have validation errors
220 if not valid:
221 self.resetDefaults(self.form)
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)
229 draft_application.set_application_status("draft")
230 draft_application.set_owner(account.id)
231 draft_application.save()
232 return draft_application
234 def finalise(self, account, save_target=True, email_alert=True, id=None):
235 super(NewApplication, self).finalise()
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()
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)
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)
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 }))
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.
274 ~~ManEdApplication:FormProcessor~~
275 """
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)]
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)]
286 def patch_target(self):
287 super(AdminApplication, self).patch_target()
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)
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)
297 def finalise(self, account, save_target=True, email_alert=True):
298 """
299 account is the administrator account carrying out the action
300 """
302 if self.source is None:
303 raise Exception(Messages.EXCEPTION_EDITING_NON_EXISTING_APPLICATION)
305 if self.source.application_status == constants.APPLICATION_STATUS_ACCEPTED:
306 raise Exception(Messages.EXCEPTION_EDITING_ACCEPTED_JOURNAL)
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)
316 # if we are allowed to finalise, kick this up to the superclass
317 super(AdminApplication, self).finalise()
319 # instance of the events service to pick up any events we need to send
320 eventsSvc = DOAJ.eventsService()
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)
328 # record the event in the provenance tracker
329 # ~~-> Provenance:Model~~
330 models.Provenance.make(account, "edit", self.target)
332 # ~~->Application:Service~~
333 applicationService = DOAJ.applicationService()
335 # ~~->Event:Service~~
336 eventsSvc = DOAJ.eventsService()
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
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))
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()
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.")
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())
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()
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 }))
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))
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.")
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)
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.')
446 # Then the associate
447 if self.target.editor:
448 self.add_alert('The associate editor has been notified of the change in status.')
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
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()
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
467 return valid
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.
477 ~~EditorApplication:FormProcessor~~
478 """
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()
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
491 return valid
493 def pre_validate(self):
494 # Call to super sets all the basic disabled fields
495 super(EditorApplication, self).pre_validate()
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)]
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))
508 def patch_target(self):
509 super(EditorApplication, self).patch_target()
511 self._carry_fixed_aspects()
512 self._merge_notes_forward()
513 self._carry_continuations()
515 self.target.set_owner(self.source.owner)
516 self.target.set_editor_group(self.source.editor_group)
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.")
524 # if we are allowed to finalise, kick this up to the superclass
525 super(EditorApplication, self).finalise()
527 # Check the status change is valid
528 self._validate_status_change(self.source.application_status, self.target.application_status)
530 # ~~-> ApplicationForm:Crosswalk~~
531 new_associate_assigned = ApplicationFormXWalk.is_new_editor(self.form, self.source)
533 # Save the target
534 self.target.set_last_manual_update()
535 self.target.save()
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
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 }))
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')
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)
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.')
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()
585 # record the event in the provenance tracker
586 # ~~-> Provenance:Model~~
587 models.Provenance.make(current_user, "status:ready", self.target)
589 self.add_alert('A notification has been sent to the Managing Editors.')
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.
599 ~~AssEdApplication:FormProcessor~~
600 """
602 def pre_validate(self):
603 # Call to super sets all the basic disabled fields
604 super(AssociateApplication, self).pre_validate()
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))
614 def patch_target(self):
615 if self.source is None:
616 raise Exception("You cannot patch a target from a non-existent source")
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()
626 def finalise(self):
627 # if we are allowed to finalise, kick this up to the superclass
628 super(AssociateApplication, self).finalise()
630 # Check the status change is valid
631 self._validate_status_change(self.source.application_status, self.target.application_status)
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 }))
642 # Save the target
643 self.target.set_last_manual_update()
644 self.target.save()
646 # record the event in the provenance tracker
647 # ~~-> Provenance:Model~~
648 models.Provenance.make(current_user, "edit", self.target)
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)
659class PublisherUpdateRequest(ApplicationProcessor):
660 """
661 ~~PublisherUpdateRequest:FormProcessor~~
662 """
664 def pre_validate(self):
665 if self.source is None:
666 raise Exception("You cannot validate a form from a non-existent source")
668 super(ApplicationProcessor, self).pre_validate()
670 def patch_target(self):
671 if self.source is None:
672 raise Exception("You cannot patch a target from a non-existent source")
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()
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)
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")
691 # if we are allowed to finalise, kick this up to the superclass
692 super(PublisherUpdateRequest, self).finalise()
694 # set the status to update_request (if not already)
695 self.target.set_application_status(constants.APPLICATION_STATUS_UPDATE_REQUEST)
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")
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()
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')
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
734 # carry over the seal
735 self.target.set_seal(self.source.has_seal())
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
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"
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
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
770 ~~PublisherUpdateRequestReadOnly:FormProcessor~~
771 """
773 def finalise(self):
774 raise Exception("You cannot edit applications using the read-only form")
777###############################################
778### Journal form processors
779###############################################
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.
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")
792 self._carry_fixed_aspects()
793 self._merge_notes_forward(allow_delete=True)
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)
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
803 if self.source is None:
804 raise Exception("You cannot edit a not-existent journal")
806 # if we are allowed to finalise, kick this up to the superclass
807 super(ManEdJournalReview, self).finalise()
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)
814 # Save the target
815 self.target.set_last_manual_update()
816 self.target.save()
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.')
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
850 return super(ManEdJournalReview, self).validate()
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.
859 ~~EditorJournal:FormProcessor~~
860 """
862 def patch_target(self):
863 if self.source is None:
864 raise Exception("You cannot patch a target from a non-existent source")
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()
872 def pre_validate(self):
873 # call to super handles all the basic disabled field
874 super(EditorJournalReview, self).pre_validate()
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)]
880 def finalise(self):
881 if self.source is None:
882 raise Exception("You cannot edit a not-existent journal")
884 # if we are allowed to finalise, kick this up to the superclass
885 super(EditorJournalReview, self).finalise()
887 # ~~-> ApplicationForm:Crosswalk~~
888 email_associate = ApplicationFormXWalk.is_new_editor(self.form, self.source)
890 # Save the target
891 self.target.set_last_manual_update()
892 self.target.save()
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.')
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.
916 ~~AssEdJournal:FormProcessor~~
917 """
919 def patch_target(self):
920 if self.source is None:
921 raise Exception("You cannot patch a target from a non-existent source")
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()
930 def finalise(self):
931 if self.source is None:
932 raise Exception("You cannot edit a not-existent journal")
934 # if we are allowed to finalise, kick this up to the superclass
935 super(AssEdJournalReview, self).finalise()
937 # Save the target
938 self.target.set_last_manual_update()
939 self.target.save()
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.
947 ~~ReadOnlyJournal:FormProcessor~~
948 """
949 def form2target(self):
950 pass # you can't edit objects using this form
952 def patch_target(self):
953 pass # you can't edit objects using this form
955 def finalise(self):
956 raise Exception("You cannot edit journals using the read-only form")
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.
964 ~~ManEdBulkJournal:FormProcessor~~
965 """
966 pass