Coverage for portality/bll/services/application.py: 96%
249 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-22 15:59 +0100
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-22 15:59 +0100
1import logging
3from portality.lib.argvalidate import argvalidate
4from portality.lib import dates
5from portality import models
6from portality.bll import exceptions
7from portality.core import app
8from portality import constants
9from portality import lock
10from portality.bll.doaj import DOAJ
11from portality.ui.messages import Messages
13class ApplicationService(object):
14 """
15 ~~Application:Service->DOAJ:Service~~
16 """
18 def reject_application(self, application, account, provenance=True, note=None, manual_update=True):
19 """
20 Reject an application. This will:
21 * set the application status to "rejected" (if not already)
22 * remove the current_journal field, and move it to related_journal (if needed)
23 * remove the current_application field from the related journal (if needed)
24 * save the application
25 * write a provenance record for the rejection (if requested)
27 :param application:
28 :param account:
29 :param provenance:
30 :param manual_update:
31 :return:
32 """
33 # first validate the incoming arguments to ensure that we've got the right thing
34 argvalidate("reject_application", [
35 {"arg": application, "instance" : models.Application, "allow_none" : False, "arg_name" : "application"},
36 {"arg" : account, "instance" : models.Account, "allow_none" : False, "arg_name" : "account"},
37 {"arg" : provenance, "instance" : bool, "allow_none" : False, "arg_name" : "provenance"},
38 {"arg" : note, "instance" : str, "allow_none" : True, "arg_name" : "note"},
39 {"arg" : manual_update, "instance" : bool, "allow_none" : False, "arg_name" : "manual_update"}
40 ], exceptions.ArgumentException)
42 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering reject_application")
44 # ~~->Journal:Service~~
45 journalService = DOAJ.journalService()
47 # check we're allowed to carry out this action
48 if not account.has_role("reject_application"):
49 raise exceptions.AuthoriseException(message="This user is not allowed to reject applications", reason=exceptions.AuthoriseException.WRONG_ROLE)
51 # ensure the application status is "rejected"
52 if application.application_status != constants.APPLICATION_STATUS_REJECTED:
53 application.set_application_status(constants.APPLICATION_STATUS_REJECTED)
55 # add the note to the application
56 if note is not None:
57 application.add_note(note)
59 # retrieve the id of the current journal if there is one
60 cj_id = application.current_journal
61 cj = None
63 # if there is a current_journal record, remove it, and record
64 # it as a related journal. This will let us come back later and know
65 # which journal record this was intended as an update against if needed.
66 if cj_id is not None:
67 cj, _ = journalService.journal(cj_id)
68 application.remove_current_journal()
69 if cj is not None:
70 application.set_related_journal(cj_id)
71 cj.remove_current_application()
73 # if there is a current journal, we will have modified it above, so save it
74 if cj is not None:
75 saved = cj.save()
76 if saved is None:
77 raise exceptions.SaveException("Save on current_journal in reject_application failed")
79 # if we were asked to record this as a manual update, record that on the application
80 if manual_update:
81 application.set_last_manual_update()
83 saved = application.save()
84 if saved is None:
85 raise exceptions.SaveException("Save on application in reject_application failed")
87 # record a provenance record that this action took place
88 if provenance:
89 # ~~->Provenance:Model~~
90 models.Provenance.make(account, constants.PROVENANCE_STATUS_REJECTED, application)
92 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed reject_application")
95 def unreject_application(self,
96 application: models.Application,
97 account: models.Account,
98 manual_update: bool = True,
99 disallow_status: list = None):
100 """
101 Un-reject an application. This will:
102 * check that the application status is no longer "rejected" (throw an error if it is)
103 * check for a related journal, and if one is present, promote that to current_journal (if no other UR exists)
104 * save the application
106 :param application:
107 :param account:
108 :param manual_update:
109 :param disallow_status: statuses that we are not allowed to unreject to (excluding rejected, which is always disallowed)
110 :return:
111 """
112 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering unreject_application")
114 if application is None:
115 raise exceptions.ArgumentException(Messages.BLL__UNREJECT_APPLICATION__NO_APPLICATION)
117 if account is None:
118 raise exceptions.ArgumentException(Messages.BLL__UNREJECT_APPLICATION__NO_ACCOUNT)
120 # check we're allowed to carry out this action
121 if not account.has_role("unreject_application"):
122 raise exceptions.AuthoriseException(message=Messages.BLL__UNREJECT_APPLICATION__WRONG_ROLE,
123 reason=exceptions.AuthoriseException.WRONG_ROLE)
125 # ensure the application status is not "rejected"
126 if application.application_status == constants.APPLICATION_STATUS_REJECTED:
127 raise exceptions.IllegalStatusException(message=Messages.BLL__UNREJECT_APPLICATION__ILLEGAL_STATE_REJECTED.format(id=application.id))
129 # by default reject transitions to the accepted status (because acceptance implies other processing that this
130 # method does not handle). You can override this by passing in an empty list
131 if disallow_status is None:
132 disallow_status = [constants.APPLICATION_STATUS_ACCEPTED]
133 if application.application_status in disallow_status:
134 raise exceptions.IllegalStatusException(
135 message=Messages.BLL__UNREJECT_APPLICATION__ILLEGAL_STATE_DISALLOWED.format(
136 id=application.id, x=application.application_status
137 ))
139 rjid = application.related_journal
140 if rjid:
141 ur = models.Application.find_latest_by_current_journal(rjid)
142 if ur:
143 raise exceptions.DuplicateUpdateRequest(message=Messages.BLL__UNREJECT_APPLICATION__DUPLICATE_UR.format(
144 id=application.id,
145 urid=ur.id,
146 jid=rjid
147 ))
149 rj = models.Journal.pull(rjid)
150 if rj is None:
151 raise exceptions.NoSuchObjectException(Messages.BLL__UNREJECT_APPLICATION__JOURNAL_MISSING.format(
152 jid=rjid, id=application.id
153 ))
155 # update the application's view of the current journal
156 application.set_current_journal(rjid)
157 application.remove_related_journal()
159 # update the journal's view of the current application
160 rj.set_current_application(application.id)
161 rj.remove_related_application(application.id)
163 saved = rj.save()
164 if saved is None:
165 raise exceptions.SaveException(Messages.BLL__UNREJECT_APPLICATION__SAVE_FAIL.format(
166 obj="current_journal",
167 id=rj.id
168 ))
170 # if we were asked to record this as a manual update, record that on the application
171 if manual_update:
172 application.set_last_manual_update()
174 saved = application.save()
175 if saved is None:
176 raise exceptions.SaveException(Messages.BLL__UNREJECT_APPLICATION__SAVE_FAIL.format(
177 obj="application",
178 id=application.id
179 ))
181 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed unreject_application")
184 def accept_application(self, application, account, manual_update=True, provenance=True, save_journal=True, save_application=True):
185 """
186 Take the given application and create the Journal object in DOAJ for it.
188 The account provided must have permission to create journals from applications.
190 :param application: The application to be converted
191 :param account: The account doing the conversion
192 :param manual_update: Whether to record this update as a manual update on both the application and journal objects
193 :param provenance: Whether to write provenance records for this operation
194 :param save_journal: Whether to save the journal that is produced
195 :param save_application: Whether to save the application after it has been modified
196 :return: The journal that was created
197 """
198 # first validate the incoming arguments to ensure that we've got the right thing
199 argvalidate("accept_application", [
200 {"arg": application, "instance" : models.Suggestion, "allow_none" : False, "arg_name" : "application"},
201 {"arg" : account, "instance" : models.Account, "allow_none" : False, "arg_name" : "account"},
202 {"arg" : manual_update, "instance" : bool, "allow_none" : False, "arg_name" : "manual_update"},
203 {"arg" : provenance, "instance" : bool, "allow_none" : False, "arg_name" : "provenance"},
204 {"arg" : save_journal, "instance" : bool, "allow_none" : False, "arg_name" : "save_journal"},
205 {"arg" : save_application, "instance" : bool, "allow_none" : False, "arg_name" : "save_application"}
206 ], exceptions.ArgumentException)
208 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering accept_application")
210 # ensure that the account holder has a suitable role
211 if not account.has_role("accept_application"):
212 raise exceptions.AuthoriseException(
213 message="User {x} is not permitted to accept application {y}".format(x=account.id, y=application.id),
214 reason=exceptions.AuthoriseException.WRONG_ROLE)
216 # ensure the application status is "accepted"
217 if application.application_status != constants.APPLICATION_STATUS_ACCEPTED:
218 application.set_application_status(constants.APPLICATION_STATUS_ACCEPTED)
220 # make the resulting journal (and save it if requested)
221 j = self.application_2_journal(application, manual_update=manual_update)
222 if save_journal is True:
223 saved = j.save()
224 if saved is None:
225 raise exceptions.SaveException("Save of resulting journal in accept_application failed")
227 # retrieve the id of the current journal if there is one
228 cj = application.current_journal
230 # if there is a current_journal record, remove it
231 if cj is not None:
232 application.remove_current_journal()
234 # set the relationship with the journal
235 application.set_related_journal(j.id)
237 # if we were asked to record this as a manual update, record that on the application
238 # (the journal is done implicitly above)
239 if manual_update:
240 application.set_last_manual_update()
242 if provenance:
243 # record the event in the provenance tracker
244 models.Provenance.make(account, constants.PROVENANCE_STATUS_ACCEPTED, application)
246 # save the application if requested
247 if save_application is True:
248 application.save()
250 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed accept_application")
252 return j
254 def reject_update_request_of_journal(self, journal_id, account):
255 """
256 Rejects update request associated with journal
258 :param journal_id:
259 :param account:
260 :return: Journal object
261 """
262 ur = models.Application.find_latest_by_current_journal(journal_id) # ~~->Application:Model~~
263 if ur:
264 self.reject_application(ur, account, note=Messages.AUTOMATICALLY_REJECTED_UPDATE_REQUEST_NOTE)
265 return ur
266 else:
267 return None
269 def reject_update_request_of_journals(self, ids, account):
270 """
271 Rejects update request associated with journal
273 :param ids:
274 :param account:
275 :return: Journal object
276 """
277 ur_ids = []
278 for journal_id in ids:
279 ur = self.reject_update_request_of_journal(journal_id, account)
280 if ur:
281 ur_ids.append(ur.id)
282 return ur_ids
284 def update_request_for_journal(self, journal_id, account=None, lock_timeout=None):
285 """
286 Obtain an update request application object for the journal with the given journal_id
288 An update request may either be loaded from the database, if it already exists, or created
289 in-memory if it has not previously been created.
291 If an account is provided, this will validate that the account holder is allowed to make
292 the conversion from journal to application, if a conversion is required.
294 When this request runs, the journal will be locked to the provided account if an account is
295 given. If the application is loaded from the database, this will also be locked for the account
296 holder.
298 :param journal_id:
299 :param account:
300 :return: a tuple of (Application Object, Journal Lock, Application Lock)
301 """
302 # first validate the incoming arguments to ensure that we've got the right thing
303 argvalidate("update_request_for_journal", [
304 {"arg": journal_id, "instance" : str, "allow_none" : False, "arg_name" : "journal_id"},
305 {"arg" : account, "instance" : models.Account, "allow_none" : True, "arg_name" : "account"},
306 {"arg" : lock_timeout, "instance" : int, "allow_none" : True, "arg_name" : "lock_timeout"}
307 ], exceptions.ArgumentException)
309 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering update_request_for_journal")
311 # ~~-> Journal:Service~~
312 # ~~-> AuthNZ:Service~~
313 journalService = DOAJ.journalService()
314 authService = DOAJ.authorisationService()
316 # first retrieve the journal, and return empty if there isn't one.
317 # We don't attempt to obtain a lock at this stage, as we want to check that the user is authorised first
318 journal_lock = None
319 journal, _ = journalService.journal(journal_id)
320 if journal is None:
321 app.logger.info("Request for journal {x} did not find anything in the database".format(x=journal_id))
322 return None, None, None
324 # if the journal is not in_doaj, we won't create an update request for it
325 if not journal.is_in_doaj():
326 app.logger.info("Request for journal {x} found it is not in_doaj; will not create update request".format(x=journal_id))
327 return None, None, None
329 # retrieve the latest application attached to this journal
330 application_lock = None
331 application = models.Suggestion.find_latest_by_current_journal(journal_id) # ~~->Application:Model~~
333 # if no such application exists, create one in memory (this will check that the user is permitted to create one)
334 # at the same time, create the lock for the journal. This will throw an AuthorisedException or a Locked exception
335 # (in that order of preference) if any problems arise.
336 if application is None:
337 app.logger.info("No existing update request for journal {x}; creating one".format(x=journal.id))
338 application = journalService.journal_2_application(journal, account=account)
339 application.set_is_update_request(True)
340 if account is not None:
341 journal_lock = lock.lock("journal", journal_id, account.id)
343 # otherwise check that the user (if given) has the rights to edit the application
344 # then lock the application and journal to the account.
345 # If a lock cannot be obtained, unlock the journal and application before we return
346 elif account is not None:
347 try:
348 authService.can_edit_application(account, application)
349 application_lock = lock.lock("suggestion", application.id, account.id)
350 journal_lock = lock.lock("journal", journal_id, account.id)
351 except lock.Locked as e:
352 if application_lock is not None: application_lock.delete()
353 if journal_lock is not None: journal_lock.delete()
354 raise
355 except exceptions.AuthoriseException as e:
356 msg = "Account {x} is not permitted to edit the current update request on journal {y}".format(x=account.id, y=journal.id)
357 app.logger.info(msg)
358 e.args += (msg,)
359 raise
361 app.logger.info("Using existing application {y} as update request for journal {x}".format(y=application.id, x=journal.id))
363 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed update_request_for_journal; return application object")
365 return application, journal_lock, application_lock
367 def application_2_journal(self, application, manual_update=True):
368 # first validate the incoming arguments to ensure that we've got the right thing
369 argvalidate("application_2_journal", [
370 {"arg": application, "instance" : models.Suggestion, "allow_none" : False, "arg_name" : "application"},
371 {"arg" : manual_update, "instance" : bool, "allow_none" : False, "arg_name" : "manual_update"}
372 ], exceptions.ArgumentException)
374 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering application_2_journal")
376 # create a new blank journal record, which we can build up
377 journal = models.Journal() # ~~->Journal:Model~~
379 # first thing is to copy the bibjson as-is wholesale,
380 abj = application.bibjson()
381 journal.set_bibjson(abj)
382 jbj = journal.bibjson()
384 # now carry over key administrative properties from the application itself
385 # * notes
386 # * editor
387 # * editor_group
388 # * owner
389 # * seal
390 notes = application.notes
392 if application.editor is not None:
393 journal.set_editor(application.editor)
394 if application.editor_group is not None:
395 journal.set_editor_group(application.editor_group)
396 for note in notes:
397 journal.add_note(note.get("note"), note.get("date"), note.get("id"))
398 if application.owner is not None:
399 journal.set_owner(application.owner)
400 journal.set_seal(application.has_seal())
402 b = application.bibjson()
403 if b.pissn == "":
404 b.add_identifier("pissn", None)
405 if b.eissn == "":
406 b.add_identifier("eissn", None)
408 # no relate the journal to the application and place it in_doaj
409 journal.add_related_application(application.id, dates.now())
410 journal.set_in_doaj(True)
412 # if we've been called in the context of a manual update, record that
413 if manual_update:
414 journal.set_last_manual_update()
416 # if this is an update to an existing journal, then we can also port information from
417 # that journal
418 if application.current_journal is not None:
419 cj = models.Journal.pull(application.current_journal)
420 if cj is not None:
421 # carry the id and the created date
422 journal.set_id(cj.id)
423 journal.set_created(cj.created_date)
425 # bring forward any notes from the old journal record
426 old_notes = cj.notes
427 for note in old_notes:
428 journal.add_note(note.get("note"), note.get("date"), note.get("id"))
430 # bring forward any related applications
431 related = cj.related_applications
432 for r in related:
433 journal.add_related_application(r.get("application_id"), r.get("date_accepted"), r.get("status"))
435 # carry over any properties that are not already set from the application
436 # * editor & editor_group (together or not at all)
437 # * owner
438 if journal.editor is None and journal.editor_group is None:
439 journal.set_editor(cj.editor)
440 journal.set_editor_group(cj.editor_group)
441 if journal.owner is None:
442 journal.set_owner(cj.owner)
444 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completing application_2_journal")
446 return journal
448 def application(self, application_id, lock_application=False, lock_account=None, lock_timeout=None):
449 """
450 Function to retrieve an application by its id
452 :param application_id: the id of the application
453 :param: lock_application: should we lock the resource on retrieval
454 :param: lock_account: which account is doing the locking? Must be present if lock_journal=True
455 :param: lock_timeout: how long to lock the resource for. May be none, in which case it will default
456 :return: Tuple of (Suggestion Object, Lock Object)
457 """
458 # first validate the incoming arguments to ensure that we've got the right thing
459 argvalidate("application", [
460 {"arg": application_id, "allow_none" : False, "arg_name" : "application_id"},
461 {"arg": lock_application, "instance" : bool, "allow_none" : False, "arg_name" : "lock_journal"},
462 {"arg": lock_account, "instance" : models.Account, "allow_none" : True, "arg_name" : "lock_account"},
463 {"arg": lock_timeout, "instance" : int, "allow_none" : True, "arg_name" : "lock_timeout"}
464 ], exceptions.ArgumentException)
466 # pull the application from the database
467 application = models.Suggestion.pull(application_id)
469 # if we've retrieved the journal, and a lock is requested, request it
470 the_lock = None
471 if application is not None and lock_application:
472 if lock_account is not None:
473 # ~~->Lock:Feature~~
474 the_lock = lock.lock(constants.LOCK_APPLICATION, application_id, lock_account.id, lock_timeout)
475 else:
476 raise exceptions.ArgumentException("If you specify lock_application on application retrieval, you must also provide lock_account")
478 return application, the_lock
480 def delete_application(self, application_id, account):
481 """
482 Function to delete an application, and all references to it in other objects (current and related journals)
484 The application and all related journals need to be locked before this process can proceed, so you may get a
485 lock.Locked exception
487 :param application_id:
488 :param account:
489 :return:
490 """
491 # first validate the incoming arguments to ensure that we've got the right thing
492 argvalidate("delete_application", [
493 {"arg": application_id, "instance" : str, "allow_none" : False, "arg_name" : "application_id"},
494 {"arg" : account, "instance" : models.Account, "allow_none" : False, "arg_name" : "account"}
495 ], exceptions.ArgumentException)
497 # ~~-> Journal:Service~~
498 # ~~-> AuthNZ:Service~~
499 journalService = DOAJ.journalService()
500 authService = DOAJ.authorisationService()
502 # get hold of a copy of the application. If there isn't one, our work here is done
503 # (note the application could be locked, in which case this will raise a lock.Locked exception)
505 # get the application
506 application, _ = self.application(application_id)
507 if application is None:
508 raise exceptions.NoSuchObjectException
510 # determine if the user can edit the application
511 authService.can_edit_application(account, application)
513 # attempt to lock the record (this may raise a Locked exception)
514 # ~~-> Lock:Feature~~
515 alock = lock.lock(constants.LOCK_APPLICATION, application_id, account.id)
517 # obtain the current journal, with associated lock
518 current_journal = None
519 cjlock = None
520 if application.current_journal is not None:
521 try:
522 current_journal, cjlock = journalService.journal(application.current_journal, lock_journal=True, lock_account=account)
523 except lock.Locked as e:
524 # if the resource is locked, we have to back out
525 if alock is not None: alock.delete()
526 raise
528 # obtain the related journal, with associated lock
529 related_journal = None
530 rjlock = None
531 if application.related_journal is not None:
532 try:
533 related_journal, rjlock = journalService.journal(application.related_journal, lock_journal=True, lock_account=account)
534 except lock.Locked as e:
535 # if the resource is locked, we have to back out
536 if alock is not None: alock.delete()
537 if cjlock is not None: cjlock.delete()
538 raise
540 try:
541 if current_journal is not None:
542 current_journal.remove_current_application()
543 saved = current_journal.save()
544 if saved is None:
545 raise exceptions.SaveException("Unable to save journal record")
547 if related_journal is not None:
548 relation_record = related_journal.related_application_record(application_id)
549 if relation_record is None:
550 relation_record = {}
551 related_journal.add_related_application(application_id, relation_record.get("date_accepted"), "deleted")
552 saved = related_journal.save()
553 if saved is None:
554 raise exceptions.SaveException("Unable to save journal record")
556 application.delete()
558 finally:
559 if alock is not None: alock.delete()
560 if cjlock is not None: cjlock.delete()
561 if rjlock is not None: rjlock.delete()
563 return