Coverage for portality / forms / application_processors.py: 84%
519 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-04 09:41 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-04 09:41 +0100
1from flask import url_for, has_request_context
2from flask_login import current_user
3from wtforms import FormField, FieldList
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
15class ApplicationProcessor(FormProcessor):
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()
22 def patch_target(self):
23 super().patch_target()
25 self._patch_target_note_id()
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 = dates.now_str()
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 # 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
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
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
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
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
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
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
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
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
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
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())
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
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")
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
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
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")
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
158 # actually remove all the notes marked for deletion
159 removes.sort(reverse=True)
160 for r in removes:
161 tnotes.pop(r)
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)
173 if apply_notes_by_value:
174 self.target.set_notes(tnotes)
176 def _carry_continuations(self):
177 if self.source is None:
178 raise Exception("Cannot carry data from a non-existent source")
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
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
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))
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))
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
228 def _resolve_flags(self, account):
229 # handle flag resolution
231 # check that this form knows about flags
232 if getattr(self.form, "flags", None) is None:
233 return
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"])
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)
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.
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
264 ~~NewApplication:FormProcessor~~
265 """
267 ############################################################
268 # PublicApplicationForm versions of FormProcessor lifecycle functions
269 ############################################################
271 def draft(self, account, id=None, *args, **kwargs):
272 # check for validity
273 valid = self.validate()
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
280 # if not valid, then remove all fields which have validation errors
281 if not valid:
282 self.resetDefaults(self.form)
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)
290 draft_application.set_application_status("draft")
291 draft_application.set_owner(account.id)
292 draft_application.save()
293 return draft_application
295 def finalise(self, account, save_target=True, email_alert=True, id=None):
296 super(NewApplication, self).finalise()
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()
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)
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)
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 }))
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)
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.
347 ~~ManEdApplication:FormProcessor~~
348 """
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)]
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)]
361 def patch_target(self):
362 super(AdminApplication, self).patch_target()
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)
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)
372 def finalise(self, account, save_target=True, email_alert=True):
373 """
374 account is the administrator account carrying out the action
375 """
377 if self.source is None:
378 raise Exception(Messages.EXCEPTION_EDITING_NON_EXISTING_APPLICATION)
380 if self.source.application_status == constants.APPLICATION_STATUS_ACCEPTED:
381 raise Exception(Messages.EXCEPTION_EDITING_ACCEPTED_JOURNAL)
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)
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()
394 # resolve any flags that were resolved in the form
395 # self._resolve_flags(account)
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)
403 # record the event in the provenance tracker
404 # ~~-> Provenance:Model~~
405 models.Provenance.make(account, "edit", self.target)
407 # ~~->Application:Service~~
408 applicationService = DOAJ.applicationService()
410 # ~~->Event:Service~~
411 eventsSvc = DOAJ.eventsService()
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
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))
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()
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.")
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())
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()
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 }))
480 # ~~-> Email:Notifications~~
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.")
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.')
512 # Then the associate
513 if self.target.editor:
514 self.add_alert('The associate editor has been notified of the change in status.')
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
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()
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
533 return valid
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.
543 ~~EditorApplication:FormProcessor~~
544 """
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()
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
557 return valid
559 def pre_validate(self):
560 # Call to super sets all the basic disabled fields
561 super(EditorApplication, self).pre_validate()
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)]
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))
574 def patch_target(self):
575 super(EditorApplication, self).patch_target()
577 self._carry_fixed_aspects()
578 self._merge_notes_forward()
579 self._carry_continuations()
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
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.")
591 # if we are allowed to finalise, kick this up to the superclass
592 super(EditorApplication, self).finalise()
594 # Check the status change is valid
595 self._validate_status_change(self.source.application_status, self.target.application_status)
597 # ~~-> ApplicationForm:Crosswalk~~
598 new_associate_assigned = ApplicationFormXWalk.is_new_editor(self.form, self.source)
600 # Save the target
601 self.target.set_last_manual_update()
602 self.target.save()
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
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 }))
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')
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.')
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()
647 # record the event in the provenance tracker
648 # ~~-> Provenance:Model~~
649 models.Provenance.make(current_user, "status:ready", self.target)
651 self.add_alert('A notification has been sent to the Managing Editors.')
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.
661 ~~AssEdApplication:FormProcessor~~
662 """
664 def pre_validate(self):
665 # Call to super sets all the basic disabled fields
666 super(AssociateApplication, self).pre_validate()
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))
676 def patch_target(self):
677 if self.source is None:
678 raise Exception("You cannot patch a target from a non-existent source")
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()
689 def finalise(self):
690 # if we are allowed to finalise, kick this up to the superclass
691 super(AssociateApplication, self).finalise()
693 # Check the status change is valid
694 self._validate_status_change(self.source.application_status, self.target.application_status)
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 }))
705 # Save the target
706 self.target.set_last_manual_update()
707 self.target.save()
709 # record the event in the provenance tracker
710 # ~~-> Provenance:Model~~
711 models.Provenance.make(current_user, "edit", self.target)
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)
722class PublisherUpdateRequest(ApplicationProcessor):
723 """
724 ~~PublisherUpdateRequest:FormProcessor~~
725 """
727 def pre_validate(self):
728 if self.source is None:
729 raise Exception("You cannot validate a form from a non-existent source")
731 super(ApplicationProcessor, self).pre_validate()
733 def patch_target(self):
734 if self.source is None:
735 raise Exception("You cannot patch a target from a non-existent source")
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
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)
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")
756 # if we are allowed to finalise, kick this up to the superclass
757 super(PublisherUpdateRequest, self).finalise()
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)
765 # automatically assign the Editorial group (turn this off in configuration if needed)
766 DOAJ.applicationService().auto_assign_ur_editor_group(self.target)
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")
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()
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)
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 ))
809 def _carry_subjects(self):
810 # carry over the subjects
811 source_subjects = self.source.bibjson().subject
812 self.target.bibjson().subject = source_subjects
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
820 ~~PublisherUpdateRequestReadOnly:FormProcessor~~
821 """
823 def finalise(self):
824 raise Exception("You cannot edit applications using the read-only form")
827###############################################
828### Journal form processors
829###############################################
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.
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")
842 super().patch_target()
843 self._carry_fixed_aspects()
844 self._merge_notes_forward(allow_delete=True)
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)
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
854 if self.source is None:
855 raise Exception("You cannot edit a not-existent journal")
857 # if we are allowed to finalise, kick this up to the superclass
858 super(ManEdJournalReview, self).finalise()
860 # resolve any flags that were resolved in the form
861 self._resolve_flags(account)
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)
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)
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)
885 # Save the target
886 self.target.set_last_manual_update()
887 self.target.save()
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.')
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
921 return super(ManEdJournalReview, self).validate()
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.
930 ~~EditorJournal:FormProcessor~~
931 """
933 def patch_target(self):
934 if self.source is None:
935 raise Exception("You cannot patch a target from a non-existent source")
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
946 def pre_validate(self):
947 # call to super handles all the basic disabled field
948 super(EditorJournalReview, self).pre_validate()
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)]
954 def finalise(self):
955 if self.source is None:
956 raise Exception("You cannot edit a not-existent journal")
958 # if we are allowed to finalise, kick this up to the superclass
959 super(EditorJournalReview, self).finalise()
961 # ~~-> ApplicationForm:Crosswalk~~
962 email_associate = ApplicationFormXWalk.is_new_editor(self.form, self.source)
964 # Save the target
965 self.target.set_last_manual_update()
966 self.target.save()
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.')
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.
990 ~~AssEdJournal:FormProcessor~~
991 """
993 def patch_target(self):
994 if self.source is None:
995 raise Exception("You cannot patch a target from a non-existent source")
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
1007 def finalise(self):
1008 if self.source is None:
1009 raise Exception("You cannot edit a not-existent journal")
1011 # if we are allowed to finalise, kick this up to the superclass
1012 super(AssEdJournalReview, self).finalise()
1014 # Save the target
1015 self.target.set_last_manual_update()
1016 self.target.save()
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.
1024 ~~ReadOnlyJournal:FormProcessor~~
1025 """
1026 def form2target(self):
1027 pass # you can't edit objects using this form
1029 def patch_target(self):
1030 pass # you can't edit objects using this form
1032 def finalise(self):
1033 raise Exception("You cannot edit journals using the read-only form")
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.
1041 ~~ManEdBulkJournal:FormProcessor~~
1042 """
1043 pass