Coverage for portality/api/current/crud/applications.py: 28%
248 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-20 16:12 +0100
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-20 16:12 +0100
1# ~~APICrudApplications:Feature->APICrud:Feature~~
2import json
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
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
17from copy import deepcopy
20class ApplicationsCrudApi(CrudApi):
22 API_KEY_OPTIONAL = False
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 }
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)
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()
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)
87 raise Api400Error(str(e))
89 # if that works, convert it to a Suggestion object
90 ap = ia.to_application_model()
92 # now augment the suggestion object with all the additional information it requires
94 # set the owner
95 ap.set_owner(account.id)
97 # they are not allowed to set "subject"
98 ap.bibjson().remove_subjects()
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()
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")
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)
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)
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))
151 # otherwise, this is a brand-new application
152 else:
153 # ~~->ApplicationForm:Crosswalk~~
154 form = ApplicationFormXWalk.obj2formdata(ap)
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)
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))
174 @classmethod
175 def retrieve_swag(cls):
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)
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()
193 # is the application id valid
194 ap = models.Suggestion.pull(id)
195 if ap is None:
196 raise Api404Error()
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()
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
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)
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()
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))
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()
238 # if that works, convert it to a Suggestion object
239 new_ap = ia.to_application_model()
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()
246 # DOAJ BLL for this request
247 # ~~->Application:Service~~
248 # ~~->AuthNZ:Service~~
249 applicationService = DOAJ.applicationService()
250 authService = DOAJ.authorisationService()
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))
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")
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()
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)
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()
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)
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))
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)
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()
345 # ~~->Application:Service~~
346 # ~~->AuthNZ:Service~~
347 applicationService = DOAJ.applicationService()
348 authService = DOAJ.authorisationService()
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()
371 @classmethod
372 def _validation_message(cls, fc):
373 errors = fc.errors
374 msg = "The following validation errors were received: "
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))
391 return report
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