Coverage for portality / api / current / crud / articles.py: 83%
195 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# ~~APICrudArticles:Feature->APICrud:Feature~~
2import json
3from typing import Dict
5from portality import constants
6from portality.api.current.crud.common import CrudApi
7from portality.api.current import Api400Error, Api401Error, Api403Error, Api404Error, Api500Error
8from portality.api.current.data_objects.article import IncomingArticleDO, OutgoingArticleDO
9from portality.bll import exceptions
10from portality.core import app
11from portality.dao import ElasticSearchWriteException, DAOSaveExceptionMaxRetriesReached
12from portality.lib import dataobj
13from portality import models, app_email
14from portality.bll.doaj import DOAJ
15from portality.bll.exceptions import ArticleMergeConflict, ArticleNotAcceptable, DuplicateArticleException, \
16 IngestException
17from portality.dao import ElasticSearchWriteException, DAOSaveExceptionMaxRetriesReached
18from copy import deepcopy
19from portality.ui import templates
22class ArticlesCrudApi(CrudApi):
24 API_KEY_OPTIONAL = False
26 # ~~->Swagger:Feature~~
27 # ~~->API:Documentation~~
28 SWAG_TAG = 'CRUD Articles'
29 SWAG_ID_PARAM = {
30 "description": "<div class=\"search-query-docs\">DOAJ article ID. E.g. 4cf8b72139a749c88d043129f00e1b07 .</div>",
31 "required": True,
32 "type": "string",
33 "name": "article_id",
34 "in": "path"
35 }
36 SWAG_ARTICLE_BODY_PARAM = {
37 "description": """<div class=\"search-query-docs\">
38 Article JSON that you would like to create or update. The contents should comply with the schema displayed
39 in the <a href=\"/api/docs#CRUD_Articles_get_api_articles_article_id\"> GET (Retrieve) an article route</a>.
40 <a href="https://doaj.github.io/doaj-docs/master/data_models/IncomingAPIArticle">Explicit documentation for the structure of this data is available</a>.
41 Partial updates are not allowed; you have to supply the full JSON.</div>""",
42 "required": True,
43 "schema": {"type" : "string"},
44 "name": "article_json",
45 "in": "body"
46 }
48 @classmethod
49 def __handle_journal_info(cls, am):
50 # handle journal info - first save fields users ARE allowed to update into temporary vars
51 number = am.bibjson().number
52 volume = am.bibjson().volume
53 start_page = am.bibjson().start_page
54 end_page = am.bibjson().end_page
55 am.bibjson().remove_journal_metadata() # then destroy all journal metadata
57 try:
58 am.add_journal_metadata() # overwrite journal part of metadata and in_doaj setting
59 except models.NoJournalException as e:
60 raise Api400Error("No journal found to attach the article to. The ISSN(s) provided in the bibjson.identifiers section of this article record do not match any DOAJ journal.")
62 # restore the user's data
63 am.bibjson().number = number
64 am.bibjson().volume = volume
65 am.bibjson().start_page = start_page
66 am.bibjson().end_page = end_page
67 return am
69 @classmethod
70 def create_swag(cls):
71 template = deepcopy(cls.SWAG_TEMPLATE)
72 template['parameters'].append(cls.SWAG_ARTICLE_BODY_PARAM)
73 template['responses']['201'] = cls.R201
74 template['responses']['400'] = cls.R400
75 template['responses']['401'] = cls.R401
76 template['responses']['403'] = cls.R403
77 return cls._build_swag_response(template)
79 @classmethod
80 def create(cls, data, account):
81 # as long as authentication (in the layer above) has been successful, and the account exists, then
82 # we are good to proceed
83 if account is None:
84 raise Api401Error()
86 # convert the data into a suitable article model (raises Api400Error if doesn't conform to struct)
87 am = cls.prep_article_for_api(data, account)
89 # ~~-> Article:Service~~
90 articleService = DOAJ.articleService()
91 try:
92 result = articleService.create_article(am, account, add_journal_info=True)
93 except (
94 ArticleMergeConflict, ArticleNotAcceptable, IngestException,
95 ) as e:
96 raise Api400Error(str(e))
97 except DuplicateArticleException as e:
98 raise Api403Error(str(e))
99 except IngestException as e:
100 raise Api400Error(str(e))
101 except (ElasticSearchWriteException, DAOSaveExceptionMaxRetriesReached) as e:
102 raise Api500Error(str(e))
105 # Check we are allowed to create an article for this journal
106 if result.get("fail", 0) == 1:
107 raise Api403Error("It is not possible to create an article for this journal. Does the upload include an ISSN that is not associated with any journal in your account? ISSNs must match exactly the ISSNs in the journal record.")
109 return am
111 @classmethod
112 def prep_article_for_api(cls, data, account) -> models.Article:
113 try:
114 return cls.prep_article(data, account)
115 except (
116 dataobj.DataStructureException,
117 dataobj.ScriptTagFoundException,
118 ) as e:
119 raise Api400Error(str(e))
121 @classmethod
122 def _normalise_and_prune_identifiers(cls, data: Dict) -> None:
123 """
124 Normalises and prunes identifiers in the article data.
125 This method modifies the data in place.
126 """
127 idents = data.get("bibjson", {}).get("identifier")
128 if idents is not None:
129 # normalise the identifiers to lower case
130 for ident in idents:
131 if ident.get("type") is not None:
132 ident["type"] = ident["type"].lower()
134 # remove any identifiers that are not allowed
135 allowed_idents = constants.ALLOWED_ARTICLE_IDENT_TYPES
136 data["bibjson"]["identifier"] = [i for i in idents if i.get("type") in allowed_idents]
138 @classmethod
139 def prep_article(cls, data: Dict, account: models.Account) -> models.Article:
140 # ensure that the identifiers supplied are allowed, before we send it over to the
141 # data object. We will silently remove any identifiers that are not allowed
142 cls._normalise_and_prune_identifiers(data)
144 # first thing to do is a structural validation, by instantiating the data object
145 try:
146 ia = IncomingArticleDO(data)
147 except dataobj.DataStructureException as e:
148 raise e # let caller know there could have dataobj.DataStructureException
149 except dataobj.ScriptTagFoundException as e:
150 # ~~->Email:ExternalService~~
151 email_data = {"article": data, "account": account.__dict__}
152 jdata = json.dumps(email_data, indent=4)
153 # send warning email about the service tag in article metadata detected
154 try:
155 to = app.config.get('SCRIPT_TAG_DETECTED_EMAIL_RECIPIENTS')
156 fro = app.config.get("SYSTEM_EMAIL_FROM", "helpdesk@doaj.org")
157 subject = app.config.get("SERVICE_NAME", "") + " - script tag detected in application metadata"
158 es_type="article"
159 app_email.send_mail(to=to,
160 fro=fro,
161 subject=subject,
162 template_name=templates.EMAIL_SCRIPT_TAG_DETECTED,
163 es_type=es_type,
164 data=jdata)
165 except app_email.EmailException:
166 app.logger.exception('Error sending script tag detection email - ' + jdata)
167 raise e
169 # if that works, convert it to an Article object
170 am = ia.to_article_model()
172 # the user may have supplied metadata in the model for id and created_date
173 # and we want to can that data. If this is a truly new article, it's fine for
174 # us to assign a new ID here, and if it's a duplicate, it will get attached
175 # to its duplicate ID anyway.
176 am.set_id()
177 am.set_created()
179 # not allowed to set subjects
180 am.bibjson().remove_subjects()
182 # get the journal information set straight
183 am = cls.__handle_journal_info(am)
185 return am
188 @classmethod
189 def retrieve_swag(cls):
190 template = deepcopy(cls.SWAG_TEMPLATE)
191 template['parameters'].append(cls.SWAG_ID_PARAM)
192 template['responses']['200'] = cls.R200
193 template['responses']['200']['schema'] = IncomingArticleDO().struct_to_swag(schema_title='Article schema')
194 template['responses']['401'] = cls.R401
195 template['responses']['404'] = cls.R404
196 template['responses']['500'] = cls.R500
197 return cls._build_swag_response(template, api_key_optional_override=True)
199 @classmethod
200 def retrieve(cls, id, account):
202 # is the article id valid?
203 ar = models.Article.pull(id)
204 if ar is None:
205 raise Api404Error()
207 # at this point we're happy to return the article if it's
208 # meant to be seen by the public
209 if ar.is_in_doaj():
210 try:
211 return OutgoingArticleDO.from_model(ar)
212 except:
213 raise Api500Error()
215 # as long as authentication (in the layer above) has been successful, and the account exists, then
216 # we are good to proceed
217 if account is None or account.is_anonymous:
218 raise Api401Error()
220 # Check we're allowed to retrieve this article
221 articleService = DOAJ.articleService()
222 if not articleService.is_legitimate_owner(ar, account.id):
223 raise Api404Error() # not found for this account
225 # Return the article
226 oa = OutgoingArticleDO.from_model(ar)
227 return oa
229 @classmethod
230 def update_swag(cls):
231 template = deepcopy(cls.SWAG_TEMPLATE)
232 template['parameters'].append(cls.SWAG_ID_PARAM)
233 template['parameters'].append(cls.SWAG_ARTICLE_BODY_PARAM)
234 template['responses']['204'] = cls.R204
235 template['responses']['400'] = cls.R400
236 template['responses']['401'] = cls.R401
237 template['responses']['404'] = cls.R404
238 return cls._build_swag_response(template)
240 @classmethod
241 def update(cls, id, data, account):
242 # as long as authentication (in the layer above) has been successful and the account exists, then
243 # we are good to proceed
244 if account is None:
245 raise Api401Error()
247 # now see if there's something for us to delete
248 ar = models.Article.pull(id)
249 if ar is None:
250 raise Api404Error()
252 # Check we're allowed to edit this article
253 # ~~-> Article:Service~~
254 articleService = DOAJ.articleService()
255 if not articleService.is_legitimate_owner(ar, account.id):
256 raise Api404Error() # not found for this account
258 # ensure that the identifiers supplied are allowed, before we send it over to the
259 # data object. We will silently remove any identifiers that are not allowed
260 cls._normalise_and_prune_identifiers(data)
262 # next thing to do is a structural validation of the replacement data, by instantiating the object
263 try:
264 ia = IncomingArticleDO(data)
265 except dataobj.DataStructureException as e:
266 raise Api400Error(str(e))
268 # if that works, convert it to an Article object bringing over everything outside the
269 # incoming article from the original article
271 # we need to ensure that any properties of the existing article that aren't allowed to change
272 # are copied over
273 new_ar = ia.to_article_model(ar)
274 new_ar.set_id(id)
275 new_ar.set_created(ar.created_date)
276 new_ar.bibjson().set_subjects(ar.bibjson().subjects())
278 try:
279 # Article save occurs in the BLL create_article
280 result = articleService.create_article(new_ar, account, add_journal_info=True, update_article_id=id)
281 except ArticleMergeConflict as e:
282 raise Api400Error(str(e))
283 except ArticleNotAcceptable as e:
284 raise Api400Error((str(e)))
285 except DuplicateArticleException as e:
286 raise Api403Error(str(e))
287 except (ElasticSearchWriteException, DAOSaveExceptionMaxRetriesReached) as e:
288 raise Api500Error(str(e))
290 if result.get("success") == 0:
291 raise Api400Error("Article update failed for unanticipated reason")
293 return new_ar
295 @classmethod
296 def delete_swag(cls):
297 template = deepcopy(cls.SWAG_TEMPLATE)
298 template['parameters'].append(cls.SWAG_ID_PARAM)
299 template['responses']['204'] = cls.R204
300 template['responses']['401'] = cls.R401
301 template['responses']['403'] = cls.R403
302 template['responses']['404'] = cls.R404
303 return cls._build_swag_response(template)
305 @classmethod
306 def delete(cls, id, account, dry_run=False):
307 # as long as authentication (in the layer above) has been successful, and the account exists, then
308 # we are good to proceed
309 if account is None:
310 raise Api401Error()
312 # now see if there's something for us to delete
313 ar = models.Article.pull(id)
314 if ar is None:
315 raise Api404Error()
317 # Check we're allowed to retrieve this article
318 # ~~-> Article:Service~~
319 articleService = DOAJ.articleService()
320 if not articleService.is_legitimate_owner(ar, account.id):
321 raise Api404Error() # not found for this account
323 # issue the delete (no record of the delete required)
324 if not dry_run:
325 ar.delete()