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
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-04 09:41 +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
16from portality.ui import templates
18from copy import deepcopy
21class ApplicationsCrudApi(CrudApi):
23 API_KEY_OPTIONAL = False
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 }
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)
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()
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)
88 raise Api400Error(str(e))
90 # if that works, convert it to a Suggestion object
91 ap = ia.to_application_model()
93 # now augment the suggestion object with all the additional information it requires
95 # set the owner
96 ap.set_owner(account.id)
98 # they are not allowed to set "subject"
99 ap.bibjson().remove_subjects()
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()
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()
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")
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)
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)
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))
157 # otherwise, this is a brand-new application
158 else:
159 # ~~->ApplicationForm:Crosswalk~~
160 form = ApplicationFormXWalk.obj2formdata(ap)
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)
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))
180 @classmethod
181 def retrieve_swag(cls):
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)
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()
199 # is the application id valid
200 ap = models.Suggestion.pull(id)
201 if ap is None:
202 raise Api404Error()
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()
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
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)
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()
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))
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()
244 # if that works, convert it to a Suggestion object
245 new_ap = ia.to_application_model()
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()
252 # DOAJ BLL for this request
253 # ~~->Application:Service~~
254 # ~~->AuthNZ:Service~~
255 applicationService = DOAJ.applicationService()
256 authService = DOAJ.authorisationService()
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))
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")
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()
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)
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()
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)
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))
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)
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()
351 # ~~->Application:Service~~
352 # ~~->AuthNZ:Service~~
353 applicationService = DOAJ.applicationService()
354 authService = DOAJ.authorisationService()
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()
377 @classmethod
378 def _validation_message(cls, fc):
379 errors = fc.errors
380 msg = "The following validation errors were received: "
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))
401 return report
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