Coverage for portality/api/current/crud/applications.py: 85%

248 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-22 15:59 +0100

1# ~~APICrudApplications:Feature->APICrud:Feature~~ 

2import json 

3 

4from portality.api.current.crud.common import CrudApi 

5from portality.api.common import Api401Error, Api400Error, Api404Error, Api403Error, Api409Error 

6from portality.api.current.data_objects.application import IncomingApplication, OutgoingApplication 

7from portality.core import app 

8from portality.lib import seamless, dataobj 

9from portality import models, app_email 

10 

11from portality.bll import DOAJ 

12from portality.bll.exceptions import AuthoriseException, NoSuchObjectException 

13from portality import lock 

14from portality.crosswalks.application_form import ApplicationFormXWalk 

15from portality.forms.application_forms import ApplicationFormFactory 

16 

17from copy import deepcopy 

18 

19 

20class ApplicationsCrudApi(CrudApi): 

21 

22 API_KEY_OPTIONAL = False 

23 

24 # ~~->Swagger:Feature~~ 

25 # ~~->API:Documentation~~ 

26 SWAG_TAG = 'CRUD Applications' 

27 SWAG_ID_PARAM = { 

28 "description": "<div class=\"search-query-docs\">DOAJ application ID. E.g. 4cf8b72139a749c88d043129f00e1b07 .</div>", 

29 "required": True, 

30 "type": "string", 

31 "name": "application_id", 

32 "in": "path" 

33 } 

34 SWAG_APPLICATION_BODY_PARAM = { 

35 "description": """<div class=\"search-query-docs\"> 

36 Application JSON that you would like to create or update. The contents should comply with the schema displayed in the 

37 <a href=\"/api/docs#CRUD_Applications_get_api_application_application_id\"> GET (Retrieve) an application route</a>. 

38 Explicit documentation for the structure of this data is also <a href="https://doaj.github.io/doaj-docs/master/data_models/IncomingAPIApplication">provided here</a>. 

39 Partial updates are not allowed, you have to supply the full JSON.</div>""", 

40 "required": True, 

41 "schema": {"type" : "string"}, 

42 "name": "application_json", 

43 "in": "body" 

44 } 

45 

46 @classmethod 

47 def create_swag(cls): 

48 template = deepcopy(cls.SWAG_TEMPLATE) 

49 template['parameters'].append(cls.SWAG_APPLICATION_BODY_PARAM) 

50 template['responses']['201'] = cls.R201 

51 template['responses']['400'] = cls.R400 

52 template['responses']['401'] = cls.R401 

53 template['responses']['409'] = cls.R409 

54 return cls._build_swag_response(template) 

55 

56 @classmethod 

57 def create(cls, data, account, dry_run=False): 

58 # as long as authentication (in the layer above) has been successful, and the account exists, then 

59 # we are good to proceed 

60 if account is None: 

61 raise Api401Error() 

62 

63 # first thing to do is a structural validation, but instantiating the data object 

64 try: 

65 ia = IncomingApplication(data) # ~~-> APIIncomingApplication:Model~~ 

66 except seamless.SeamlessException as e: 

67 raise Api400Error(str(e)) 

68 except dataobj.ScriptTagFoundException as e: 

69 # ~~->Email:ExternalService~~ 

70 email_data = {"application": data, "account": account.__dict__} 

71 jdata = json.dumps(email_data, indent=4) 

72 try: 

73 # send warning email about the service tag in article metadata detected 

74 to = app.config.get('SCRIPT_TAG_DETECTED_EMAIL_RECIPIENTS') 

75 fro = app.config.get("SYSTEM_EMAIL_FROM", "helpdesk@doaj.org") 

76 subject = app.config.get("SERVICE_NAME", "") + " - script tag detected in application metadata" 

77 es_type = "application" 

78 app_email.send_mail(to=to, 

79 fro=fro, 

80 subject=subject, 

81 template_name="email/script_tag_detected.jinja2", 

82 es_type=es_type, 

83 data=jdata) 

84 except app_email.EmailException: 

85 app.logger.exception('Error sending script tag detection email - ' + jdata) 

86 

87 raise Api400Error(str(e)) 

88 

89 # if that works, convert it to a Suggestion object 

90 ap = ia.to_application_model() 

91 

92 # now augment the suggestion object with all the additional information it requires 

93 

94 # set the owner 

95 ap.set_owner(account.id) 

96 

97 # they are not allowed to set "subject" 

98 ap.bibjson().remove_subjects() 

99 

100 # if this is an update request on an existing journal 

101 if ap.current_journal is not None: 

102 # DOAJ BLL for this request 

103 # ~~->Application:Service~~ 

104 applicationService = DOAJ.applicationService() 

105 

106 # load the update_request application either directly or by crosswalking the journal object 

107 vanilla_ap = None 

108 jlock = None 

109 alock = None 

110 try: 

111 #~~->UpdateRequest:Feature~~ 

112 vanilla_ap, jlock, alock = applicationService.update_request_for_journal(ap.current_journal, account=account) 

113 except AuthoriseException as e: 

114 # ~~-> AuthNZ:Feature~~ 

115 if e.reason == AuthoriseException.WRONG_STATUS: 

116 raise Api403Error("The application is no longer in a state in which it can be edited via the API") 

117 else: 

118 raise Api404Error(str(e)) 

119 except lock.Locked as e: 

120 # ~~->Lock:Feature~~ 

121 raise Api409Error("The application you are requesting an update for is locked for editing by another user") 

122 

123 # if we didn't find an application or journal, 404 the user 

124 if vanilla_ap is None: 

125 if jlock is not None: jlock.delete() 

126 if alock is not None: alock.delete() 

127 raise Api404Error(jlock, alock) 

128 

129 # convert the incoming application into the web form 

130 # ~~->ApplicationForm:Crosswalk~~ 

131 # ~~->UpdateRequest:FormContext~~ 

132 form = ApplicationFormXWalk.obj2formdata(ap) 

133 formulaic_context = ApplicationFormFactory.context("update_request") 

134 fc = formulaic_context.processor(formdata=form, source=vanilla_ap) 

135 

136 if fc.validate(): 

137 try: 

138 save_target = not dry_run 

139 fc.finalise(save_target=save_target, email_alert=False) 

140 return fc.target 

141 except Exception as e: 

142 raise Api400Error(str(e)) 

143 finally: 

144 if jlock is not None: jlock.delete() 

145 if alock is not None: alock.delete() 

146 else: 

147 if jlock is not None: jlock.delete() 

148 if alock is not None: alock.delete() 

149 raise Api400Error(cls._validation_message(fc)) 

150 

151 # otherwise, this is a brand-new application 

152 else: 

153 # ~~->ApplicationForm:Crosswalk~~ 

154 form = ApplicationFormXWalk.obj2formdata(ap) 

155 

156 # create a template that will hold all the values we want to persist across the form submission 

157 template = models.Application() # ~~->Application:Model~~ 

158 template.set_owner(account.id) 

159 

160 # ~~->NewApplication:FormContext~~ 

161 fc = ApplicationFormFactory.context("public") 

162 processor = fc.processor(form, template) 

163 if processor.validate(): 

164 try: 

165 save_target = not dry_run 

166 processor.finalise(account, save_target=save_target, email_alert=False) 

167 return processor.target 

168 except Exception as e: 

169 raise Api400Error(str(e)) 

170 else: 

171 raise Api400Error(cls._validation_message(processor)) 

172 

173 

174 @classmethod 

175 def retrieve_swag(cls): 

176 

177 template = deepcopy(cls.SWAG_TEMPLATE) 

178 template['parameters'].append(cls.SWAG_ID_PARAM) 

179 template['responses']['200'] = cls.R200 

180 ap = IncomingApplication() 

181 template['responses']['200']['schema'] = IncomingApplication().struct_to_swag(schema_title='Application schema', struct=ap.__seamless_struct__) 

182 template['responses']['401'] = cls.R401 

183 template['responses']['404'] = cls.R404 

184 return cls._build_swag_response(template) 

185 

186 @classmethod 

187 def retrieve(cls, id, account): 

188 # as long as authentication (in the layer above) has been successful, and the account exists, then 

189 # we are good to proceed 

190 if account is None: 

191 raise Api401Error() 

192 

193 # is the application id valid 

194 ap = models.Suggestion.pull(id) 

195 if ap is None: 

196 raise Api404Error() 

197 

198 # is the current account the owner of the application 

199 # if not we raise a 404 because that id does not exist for that user account. 

200 if ap.owner != account.id: 

201 raise Api404Error() 

202 

203 # if we get to here we're going to give the user back the application 

204 oa = OutgoingApplication.from_model(ap) # ~~->APIOutgoingApplication:Model~~ 

205 return oa 

206 

207 @classmethod 

208 def update_swag(cls): 

209 template = deepcopy(cls.SWAG_TEMPLATE) 

210 template['parameters'].append(cls.SWAG_ID_PARAM) 

211 template['parameters'].append(cls.SWAG_APPLICATION_BODY_PARAM) 

212 template['responses']['204'] = cls.R204 

213 template['responses']['400'] = cls.R400 

214 template['responses']['401'] = cls.R401 

215 template['responses']['403'] = cls.R403 

216 template['responses']['404'] = cls.R404 

217 template['responses']['409'] = cls.R409 

218 return cls._build_swag_response(template) 

219 

220 @classmethod 

221 def update(cls, id, data, account): 

222 # as long as authentication (in the layer above) has been successful, and the account exists, then 

223 # we are good to proceed 

224 if account is None: 

225 raise Api401Error() 

226 

227 # next thing to do is a structural validation of the replacement data, by instantiating the object 

228 try: 

229 ia = IncomingApplication(data) # ~~->APIIncomingApplication:Model~~ 

230 except seamless.SeamlessException as e: 

231 raise Api400Error(str(e)) 

232 

233 # now see if there's something for us to update 

234 ap = models.Application.pull(id) 

235 if ap is None: 

236 raise Api404Error() 

237 

238 # if that works, convert it to a Suggestion object 

239 new_ap = ia.to_application_model() 

240 

241 # now augment the suggestion object with all the additional information it requires 

242 # 

243 # they are not allowed to set "subject" 

244 new_ap.bibjson().remove_subjects() 

245 

246 # DOAJ BLL for this request 

247 # ~~->Application:Service~~ 

248 # ~~->AuthNZ:Service~~ 

249 applicationService = DOAJ.applicationService() 

250 authService = DOAJ.authorisationService() 

251 

252 # if a current_journal is specified on the incoming data then it's an update request 

253 if new_ap.current_journal is not None: 

254 # once an application has a current_journal specified, you can't change it 

255 if new_ap.current_journal != ap.current_journal: 

256 raise Api400Error("current_journal cannot be changed once set. current_journal is {x}; this request tried to change it to {y}".format(x=ap.current_journal, y=new_ap.current_journal)) 

257 

258 # load the update_request application either directly or by crosswalking the journal object 

259 vanilla_ap = None 

260 jlock = None 

261 alock = None 

262 try: 

263 # ~~->UpdateRequest:Feature~~ 

264 vanilla_ap, jlock, alock = applicationService.update_request_for_journal(new_ap.current_journal, account=account) 

265 except AuthoriseException as e: 

266 # ~~-> AuthNZ:Feature~~ 

267 if e.reason == AuthoriseException.WRONG_STATUS: 

268 raise Api403Error("The application is no longer in a state in which it can be edited via the API") 

269 else: 

270 raise Api404Error() 

271 except lock.Locked as e: 

272 # ~~->Lock:Feature~~ 

273 raise Api409Error("The application is locked for editing by another user - most likely your application is being reviewed by an editor") 

274 

275 # if we didn't find an application or journal, 404 the user 

276 if vanilla_ap is None: 

277 if jlock is not None: jlock.delete() 

278 if alock is not None: alock.delete() 

279 raise Api404Error() 

280 

281 # convert the incoming application into the web form 

282 # ~~->ApplicationForm:Crosswalk~~ 

283 # ~~->UpdateRequest:FormContext~~ 

284 form = ApplicationFormXWalk.obj2formdata(new_ap) 

285 formulaic_context = ApplicationFormFactory.context("update_request") 

286 fc = formulaic_context.processor(formdata=form, source=vanilla_ap) 

287 

288 if fc.validate(): 

289 try: 

290 fc.finalise(email_alert=False) 

291 return fc.target 

292 except Exception as e: 

293 raise Api400Error(str(e)) 

294 finally: 

295 if jlock is not None: jlock.delete() 

296 if alock is not None: alock.delete() 

297 else: 

298 if jlock is not None: jlock.delete() 

299 if alock is not None: alock.delete() 

300 raise Api400Error(cls._validation_message(fc)) 

301 else: 

302 # ~~-> AuthNZ:Feature~~ 

303 try: 

304 authService.can_edit_application(account, ap) 

305 except AuthoriseException as e: 

306 if e.reason == e.WRONG_STATUS: 

307 raise Api403Error("The application is no longer in a state in which it can be edited via the API") 

308 else: 

309 raise Api404Error() 

310 

311 # convert the incoming application into the web form 

312 # ~~->ApplicationForm:Crosswalk~~ 

313 # ~~->NewApplication:FormContext~~ 

314 form = ApplicationFormXWalk.obj2formdata(new_ap) 

315 formulaic_context = ApplicationFormFactory.context("public") 

316 fc = formulaic_context.processor(form) 

317 

318 if fc.validate(): 

319 try: 

320 fc.finalise(account, id=id, email_alert=False) 

321 return fc.target 

322 except Exception as e: 

323 raise Api400Error(str(e)) 

324 else: 

325 raise Api400Error(cls._validation_message(fc)) 

326 

327 @classmethod 

328 def delete_swag(cls): 

329 template = deepcopy(cls.SWAG_TEMPLATE) 

330 template['parameters'].append(cls.SWAG_ID_PARAM) 

331 template['responses']['204'] = cls.R204 

332 template['responses']['401'] = cls.R401 

333 template['responses']['403'] = cls.R403 

334 template['responses']['404'] = cls.R404 

335 template['responses']['409'] = cls.R409 

336 return cls._build_swag_response(template) 

337 

338 @classmethod 

339 def delete(cls, id, account, dry_run=False): 

340 # as long as authentication (in the layer above) has been successful, and the account exists, then 

341 # we are good to proceed 

342 if account is None: 

343 raise Api401Error() 

344 

345 # ~~->Application:Service~~ 

346 # ~~->AuthNZ:Service~~ 

347 applicationService = DOAJ.applicationService() 

348 authService = DOAJ.authorisationService() 

349 

350 if dry_run: 

351 application, _ = applicationService.application(id) 

352 if application is not None: 

353 try: 

354 authService.can_edit_application(account, application) 

355 except AuthoriseException as e: 

356 if e.reason == e.WRONG_STATUS: 

357 raise Api403Error() 

358 raise Api404Error() 

359 else: 

360 raise Api404Error() 

361 else: 

362 try: 

363 applicationService.delete_application(id, account) 

364 except AuthoriseException as e: 

365 if e.reason == e.WRONG_STATUS: 

366 raise Api403Error() 

367 raise Api404Error() 

368 except NoSuchObjectException as e: 

369 raise Api404Error() 

370 

371 @classmethod 

372 def _validation_message(cls, fc): 

373 errors = fc.errors 

374 msg = "The following validation errors were received: " 

375 

376 def _expand(errors): 

377 report = {} 

378 for fieldName, errorMessages in errors.items(): 

379 if isinstance(errorMessages, dict): 

380 subs = _expand(errorMessages) 

381 for sub, subErrors in subs.items(): 

382 report[fieldName + "." + sub] = subErrors 

383 else: 

384 reportable = [] 

385 for em in errorMessages: 

386 if isinstance(em, list): 

387 em = " ".join(em) 

388 reportable.append(em) 

389 report[fieldName] = list(set(reportable)) 

390 

391 return report 

392 

393 # ~~->ApplicationForm:Crosswalk~~ 

394 report = _expand(errors) 

395 for fieldName, errorMessages in report.items(): 

396 fieldName = ApplicationFormXWalk.formField2objectField(fieldName) 

397 msg += fieldName + " : " + "; ".join(errorMessages) 

398 return msg