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

254 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-04 09:41 +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 

16from portality.ui import templates 

17 

18from copy import deepcopy 

19 

20 

21class ApplicationsCrudApi(CrudApi): 

22 

23 API_KEY_OPTIONAL = False 

24 

25 # ~~->Swagger:Feature~~ 

26 # ~~->API:Documentation~~ 

27 SWAG_TAG = 'CRUD Applications' 

28 SWAG_ID_PARAM = { 

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

30 "required": True, 

31 "type": "string", 

32 "name": "application_id", 

33 "in": "path" 

34 } 

35 SWAG_APPLICATION_BODY_PARAM = { 

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

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

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

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

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

41 "required": True, 

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

43 "name": "application_json", 

44 "in": "body" 

45 } 

46 

47 @classmethod 

48 def create_swag(cls): 

49 template = deepcopy(cls.SWAG_TEMPLATE) 

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

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

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

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

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

55 return cls._build_swag_response(template) 

56 

57 @classmethod 

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

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

60 # we are good to proceed 

61 if account is None: 

62 raise Api401Error() 

63 

64 # first thing to do is a structural validation by instantiating the data object 

65 try: 

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

67 except seamless.SeamlessException as e: 

68 raise Api400Error(str(e)) 

69 except dataobj.ScriptTagFoundException as e: 

70 # ~~->Email:ExternalService~~ 

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

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

73 try: 

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

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

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

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

78 es_type = "application" 

79 app_email.send_mail(to=to, 

80 fro=fro, 

81 subject=subject, 

82 template_name=templates.EMAIL_SCRIPT_TAG_DETECTED, 

83 es_type=es_type, 

84 data=jdata) 

85 except app_email.EmailException: 

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

87 

88 raise Api400Error(str(e)) 

89 

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

91 ap = ia.to_application_model() 

92 

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

94 

95 # set the owner 

96 ap.set_owner(account.id) 

97 

98 # they are not allowed to set "subject" 

99 ap.bibjson().remove_subjects() 

100 

101 # they are not allowed to set "labels" 

102 # (though in reality, this is handled by the form transitions which don't contain this facility in this context 

103 # so labels wouldn't be transmitted anyway. This is here for absolute clarity) 

104 ap.bibjson().clear_labels() 

105 

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

107 if ap.current_journal is not None: 

108 # DOAJ BLL for this request 

109 # ~~->Application:Service~~ 

110 applicationService = DOAJ.applicationService() 

111 

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

113 vanilla_ap = None 

114 jlock = None 

115 alock = None 

116 try: 

117 #~~->UpdateRequest:Feature~~ 

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

119 except AuthoriseException as e: 

120 # ~~-> AuthNZ:Feature~~ 

121 if e.reason == AuthoriseException.WRONG_STATUS: 

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

123 else: 

124 raise Api404Error(str(e)) 

125 except lock.Locked as e: 

126 # ~~->Lock:Feature~~ 

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

128 

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

130 if vanilla_ap is None: 

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

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

133 raise Api404Error(jlock, alock) 

134 

135 # convert the incoming application into the web form 

136 # ~~->ApplicationForm:Crosswalk~~ 

137 # ~~->UpdateRequest:FormContext~~ 

138 form = ApplicationFormXWalk.obj2formdata(ap) 

139 formulaic_context = ApplicationFormFactory.context("update_request") 

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

141 

142 if fc.validate(): 

143 try: 

144 save_target = not dry_run 

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

146 return fc.target 

147 except Exception as e: 

148 raise Api400Error(str(e)) 

149 finally: 

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

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

152 else: 

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

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

155 raise Api400Error(cls._validation_message(fc)) 

156 

157 # otherwise, this is a brand-new application 

158 else: 

159 # ~~->ApplicationForm:Crosswalk~~ 

160 form = ApplicationFormXWalk.obj2formdata(ap) 

161 

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

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

164 template.set_owner(account.id) 

165 

166 # ~~->NewApplication:FormContext~~ 

167 fc = ApplicationFormFactory.context("public") 

168 processor = fc.processor(form, template) 

169 if processor.validate(): 

170 try: 

171 save_target = not dry_run 

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

173 return processor.target 

174 except Exception as e: 

175 raise Api400Error(str(e)) 

176 else: 

177 raise Api400Error(cls._validation_message(processor)) 

178 

179 

180 @classmethod 

181 def retrieve_swag(cls): 

182 

183 template = deepcopy(cls.SWAG_TEMPLATE) 

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

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

186 ap = IncomingApplication() 

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

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

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

190 return cls._build_swag_response(template) 

191 

192 @classmethod 

193 def retrieve(cls, id, account): 

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

195 # we are good to proceed 

196 if account is None: 

197 raise Api401Error() 

198 

199 # is the application id valid 

200 ap = models.Suggestion.pull(id) 

201 if ap is None: 

202 raise Api404Error() 

203 

204 # is the current account the owner of the application? 

205 # if not we raise a 404 because that ID does not exist for that user account. 

206 if not account.is_super and ap.owner != account.id: 

207 raise Api404Error() 

208 

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

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

211 return oa 

212 

213 @classmethod 

214 def update_swag(cls): 

215 template = deepcopy(cls.SWAG_TEMPLATE) 

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

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

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

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

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

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

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

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

224 return cls._build_swag_response(template) 

225 

226 @classmethod 

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

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

229 # we are good to proceed 

230 if account is None: 

231 raise Api401Error() 

232 

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

234 try: 

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

236 except seamless.SeamlessException as e: 

237 raise Api400Error(str(e)) 

238 

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

240 ap = models.Application.pull(id) 

241 if ap is None: 

242 raise Api404Error() 

243 

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

245 new_ap = ia.to_application_model() 

246 

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

248 # 

249 # they are not allowed to set "subject" 

250 new_ap.bibjson().remove_subjects() 

251 

252 # DOAJ BLL for this request 

253 # ~~->Application:Service~~ 

254 # ~~->AuthNZ:Service~~ 

255 applicationService = DOAJ.applicationService() 

256 authService = DOAJ.authorisationService() 

257 

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

259 if new_ap.current_journal is not None: 

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

261 if new_ap.current_journal != ap.current_journal: 

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

263 

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

265 vanilla_ap = None 

266 jlock = None 

267 alock = None 

268 try: 

269 # ~~->UpdateRequest:Feature~~ 

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

271 except AuthoriseException as e: 

272 # ~~-> AuthNZ:Feature~~ 

273 if e.reason == AuthoriseException.WRONG_STATUS: 

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

275 else: 

276 raise Api404Error() 

277 except lock.Locked as e: 

278 # ~~->Lock:Feature~~ 

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

280 

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

282 if vanilla_ap is None: 

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

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

285 raise Api404Error() 

286 

287 # convert the incoming application into the web form 

288 # ~~->ApplicationForm:Crosswalk~~ 

289 # ~~->UpdateRequest:FormContext~~ 

290 form = ApplicationFormXWalk.obj2formdata(new_ap) 

291 formulaic_context = ApplicationFormFactory.context("update_request") 

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

293 

294 if fc.validate(): 

295 try: 

296 fc.finalise(email_alert=False) 

297 return fc.target 

298 except Exception as e: 

299 raise Api400Error(str(e)) 

300 finally: 

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

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

303 else: 

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

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

306 raise Api400Error(cls._validation_message(fc)) 

307 else: 

308 # ~~-> AuthNZ:Feature~~ 

309 try: 

310 authService.can_edit_application(account, ap) 

311 except AuthoriseException as e: 

312 if e.reason == e.WRONG_STATUS: 

313 raise Api403Error("The application can no longer be edited via the API") 

314 else: 

315 raise Api404Error() 

316 

317 # convert the incoming application into the web form 

318 # ~~->ApplicationForm:Crosswalk~~ 

319 # ~~->NewApplication:FormContext~~ 

320 form = ApplicationFormXWalk.obj2formdata(new_ap) 

321 formulaic_context = ApplicationFormFactory.context("public") 

322 fc = formulaic_context.processor(form) 

323 

324 if fc.validate(): 

325 try: 

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

327 return fc.target 

328 except Exception as e: 

329 raise Api400Error(str(e)) 

330 else: 

331 raise Api400Error(cls._validation_message(fc)) 

332 

333 @classmethod 

334 def delete_swag(cls): 

335 template = deepcopy(cls.SWAG_TEMPLATE) 

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

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

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

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

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

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

342 return cls._build_swag_response(template) 

343 

344 @classmethod 

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

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

347 # we are good to proceed 

348 if account is None: 

349 raise Api401Error() 

350 

351 # ~~->Application:Service~~ 

352 # ~~->AuthNZ:Service~~ 

353 applicationService = DOAJ.applicationService() 

354 authService = DOAJ.authorisationService() 

355 

356 if dry_run: 

357 application, _ = applicationService.application(id) 

358 if application is not None: 

359 try: 

360 authService.can_edit_application(account, application) 

361 except AuthoriseException as e: 

362 if e.reason == e.WRONG_STATUS: 

363 raise Api403Error() 

364 raise Api404Error() 

365 else: 

366 raise Api404Error() 

367 else: 

368 try: 

369 applicationService.delete_application(id, account) 

370 except AuthoriseException as e: 

371 if e.reason == e.WRONG_STATUS: 

372 raise Api403Error() 

373 raise Api404Error() 

374 except NoSuchObjectException as e: 

375 raise Api404Error() 

376 

377 @classmethod 

378 def _validation_message(cls, fc): 

379 errors = fc.errors 

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

381 

382 def _expand(errors): 

383 report = {} 

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

385 if isinstance(errorMessages, dict): 

386 subs = _expand(errorMessages) 

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

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

389 else: 

390 reportable = [] 

391 for em in errorMessages: 

392 if isinstance(em, dict): 

393 for sub, subErrors in em.items(): 

394 fieldName = fieldName + "." + sub 

395 em = " ".join(subErrors) 

396 elif isinstance(em, list): 

397 em = " ".join(em) 

398 reportable.append(em) 

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

400 

401 return report 

402 

403 # ~~->ApplicationForm:Crosswalk~~ 

404 report = _expand(errors) 

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

406 fieldName = ApplicationFormXWalk.formField2objectField(fieldName) 

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

408 return msg