Coverage for portality/bll/services/application.py: 92%

249 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-20 16:12 +0100

1import logging 

2 

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 

12 

13class ApplicationService(object): 

14 """ 

15 ~~Application:Service->DOAJ:Service~~ 

16 """ 

17 

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) 

26 

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) 

41 

42 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering reject_application") 

43 

44 # ~~->Journal:Service~~ 

45 journalService = DOAJ.journalService() 

46 

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) 

50 

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) 

54 

55 # add the note to the application 

56 if note is not None: 

57 application.add_note(note) 

58 

59 # retrieve the id of the current journal if there is one 

60 cj_id = application.current_journal 

61 cj = None 

62 

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() 

72 

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") 

78 

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() 

82 

83 saved = application.save() 

84 if saved is None: 

85 raise exceptions.SaveException("Save on application in reject_application failed") 

86 

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) 

91 

92 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed reject_application") 

93 

94 

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 

105 

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") 

113 

114 if application is None: 

115 raise exceptions.ArgumentException(Messages.BLL__UNREJECT_APPLICATION__NO_APPLICATION) 

116 

117 if account is None: 

118 raise exceptions.ArgumentException(Messages.BLL__UNREJECT_APPLICATION__NO_ACCOUNT) 

119 

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) 

124 

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)) 

128 

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 )) 

138 

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 )) 

148 

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 )) 

154 

155 # update the application's view of the current journal 

156 application.set_current_journal(rjid) 

157 application.remove_related_journal() 

158 

159 # update the journal's view of the current application 

160 rj.set_current_application(application.id) 

161 rj.remove_related_application(application.id) 

162 

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 )) 

169 

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() 

173 

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 )) 

180 

181 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed unreject_application") 

182 

183 

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. 

187 

188 The account provided must have permission to create journals from applications. 

189 

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) 

207 

208 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering accept_application") 

209 

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) 

215 

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) 

219 

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") 

226 

227 # retrieve the id of the current journal if there is one 

228 cj = application.current_journal 

229 

230 # if there is a current_journal record, remove it 

231 if cj is not None: 

232 application.remove_current_journal() 

233 

234 # set the relationship with the journal 

235 application.set_related_journal(j.id) 

236 

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() 

241 

242 if provenance: 

243 # record the event in the provenance tracker 

244 models.Provenance.make(account, constants.PROVENANCE_STATUS_ACCEPTED, application) 

245 

246 # save the application if requested 

247 if save_application is True: 

248 application.save() 

249 

250 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed accept_application") 

251 

252 return j 

253 

254 def reject_update_request_of_journal(self, journal_id, account): 

255 """ 

256 Rejects update request associated with journal 

257 

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 

268 

269 def reject_update_request_of_journals(self, ids, account): 

270 """ 

271 Rejects update request associated with journal 

272 

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 

283 

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 

287 

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. 

290 

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. 

293 

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. 

297 

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) 

308 

309 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering update_request_for_journal") 

310 

311 # ~~-> Journal:Service~~ 

312 # ~~-> AuthNZ:Service~~ 

313 journalService = DOAJ.journalService() 

314 authService = DOAJ.authorisationService() 

315 

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 

323 

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 

328 

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~~ 

332 

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) 

342 

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 

360 

361 app.logger.info("Using existing application {y} as update request for journal {x}".format(y=application.id, x=journal.id)) 

362 

363 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completed update_request_for_journal; return application object") 

364 

365 return application, journal_lock, application_lock 

366 

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) 

373 

374 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Entering application_2_journal") 

375 

376 # create a new blank journal record, which we can build up 

377 journal = models.Journal() # ~~->Journal:Model~~ 

378 

379 # first thing is to copy the bibjson as-is wholesale, 

380 abj = application.bibjson() 

381 journal.set_bibjson(abj) 

382 jbj = journal.bibjson() 

383 

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 

391 

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()) 

401 

402 b = application.bibjson() 

403 if b.pissn == "": 

404 b.add_identifier("pissn", None) 

405 if b.eissn == "": 

406 b.add_identifier("eissn", None) 

407 

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) 

411 

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() 

415 

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) 

424 

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")) 

429 

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")) 

434 

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) 

443 

444 if app.logger.isEnabledFor(logging.DEBUG): app.logger.debug("Completing application_2_journal") 

445 

446 return journal 

447 

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 

451 

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) 

465 

466 # pull the application from the database 

467 application = models.Suggestion.pull(application_id) 

468 

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") 

477 

478 return application, the_lock 

479 

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) 

483 

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 

486 

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) 

496 

497 # ~~-> Journal:Service~~ 

498 # ~~-> AuthNZ:Service~~ 

499 journalService = DOAJ.journalService() 

500 authService = DOAJ.authorisationService() 

501 

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) 

504 

505 # get the application 

506 application, _ = self.application(application_id) 

507 if application is None: 

508 raise exceptions.NoSuchObjectException 

509 

510 # determine if the user can edit the application 

511 authService.can_edit_application(account, application) 

512 

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) 

516 

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 

527 

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 

539 

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") 

546 

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") 

555 

556 application.delete() 

557 

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() 

562 

563 return