Coverage for portality/api/current/crud/articles.py: 81%

170 statements  

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

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

2import json 

3 

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

5from portality.api.current import Api400Error, Api401Error, Api403Error, Api404Error, Api500Error 

6from portality.api.current.data_objects.article import IncomingArticleDO, OutgoingArticleDO 

7from portality.core import app 

8from portality.lib import dataobj 

9from portality import models, app_email 

10from portality.bll.doaj import DOAJ 

11from portality.bll.exceptions import ArticleMergeConflict, ArticleNotAcceptable, DuplicateArticleException, \ 

12 IngestException 

13from copy import deepcopy 

14 

15class ArticlesCrudApi(CrudApi): 

16 

17 API_KEY_OPTIONAL = False 

18 

19 # ~~->Swagger:Feature~~ 

20 # ~~->API:Documentation~~ 

21 SWAG_TAG = 'CRUD Articles' 

22 SWAG_ID_PARAM = { 

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

24 "required": True, 

25 "type": "string", 

26 "name": "article_id", 

27 "in": "path" 

28 } 

29 SWAG_ARTICLE_BODY_PARAM = { 

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

31 Article JSON that you would like to create or update. The contents should comply with the schema displayed 

32 in the <a href=\"/api/docs#CRUD_Articles_get_api_articles_article_id\"> GET (Retrieve) an article route</a>. 

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

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

35 "required": True, 

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

37 "name": "article_json", 

38 "in": "body" 

39 } 

40 

41 @classmethod 

42 def __handle_journal_info(cls, am): 

43 # handle journal info - first save fields users ARE allowed to update into temporary vars 

44 number = am.bibjson().number 

45 volume = am.bibjson().volume 

46 start_page = am.bibjson().start_page 

47 end_page = am.bibjson().end_page 

48 am.bibjson().remove_journal_metadata() # then destroy all journal metadata 

49 

50 try: 

51 am.add_journal_metadata() # overwrite journal part of metadata and in_doaj setting 

52 except models.NoJournalException as e: 

53 raise Api400Error("No journal found to attach article to. Each article in DOAJ must belong to a journal and the (E)ISSNs provided in the bibjson.identifiers section of this article record do not match any DOAJ journal.") 

54 

55 # restore the user's data 

56 am.bibjson().number = number 

57 am.bibjson().volume = volume 

58 am.bibjson().start_page = start_page 

59 am.bibjson().end_page = end_page 

60 return am 

61 

62 @classmethod 

63 def create_swag(cls): 

64 template = deepcopy(cls.SWAG_TEMPLATE) 

65 template['parameters'].append(cls.SWAG_ARTICLE_BODY_PARAM) 

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

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

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

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

70 return cls._build_swag_response(template) 

71 

72 @classmethod 

73 def create(cls, data, account): 

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

75 # we are good to proceed 

76 if account is None: 

77 raise Api401Error() 

78 

79 # convert the data into a suitable article model (raises Api400Error if doesn't conform to struct) 

80 am = cls.prep_article(data, account) 

81 

82 # ~~-> Article:Service~~ 

83 articleService = DOAJ.articleService() 

84 try: 

85 result = articleService.create_article(am, account, add_journal_info=True) 

86 except ArticleMergeConflict as e: 

87 raise Api400Error(str(e)) 

88 except ArticleNotAcceptable as e: 

89 raise Api400Error(str(e)) 

90 except DuplicateArticleException as e: 

91 raise Api403Error(str(e)) 

92 except IngestException as e: 

93 raise Api400Error(str(e)) 

94 

95 

96 # Check we are allowed to create an article for this journal 

97 if result.get("fail", 0) == 1: 

98 raise Api403Error("It is not possible to create an article for this journal. Have you included in the upload an ISSN which is not associated with any journal in your account? ISSNs must match exactly the ISSNs against the journal record.") 

99 

100 return am 

101 

102 

103 @classmethod 

104 def prep_article(cls, data, account): 

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

106 try: 

107 ia = IncomingArticleDO(data) 

108 except dataobj.DataStructureException as e: 

109 raise Api400Error(str(e)) 

110 except dataobj.ScriptTagFoundException as e: 

111 # ~~->Email:ExternalService~~ 

112 email_data = {"article": data, "account": account.__dict__} 

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

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

115 try: 

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

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

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

119 es_type="article" 

120 app_email.send_mail(to=to, 

121 fro=fro, 

122 subject=subject, 

123 template_name="email/script_tag_detected.jinja2", 

124 es_type=es_type, 

125 data=jdata) 

126 except app_email.EmailException: 

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

128 raise Api400Error(str(e)) 

129 

130 # if that works, convert it to an Article object 

131 am = ia.to_article_model() 

132 

133 # the user may have supplied metadata in the model for id and created_date 

134 # and we want to can that data. If this is a truly new article its fine for 

135 # us to assign a new id here, and if it's a duplicate, it will get attached 

136 # to its duplicate id anyway. 

137 am.set_id() 

138 am.set_created() 

139 

140 # not allowed to set subjects 

141 am.bibjson().remove_subjects() 

142 

143 # get the journal information set straight 

144 am = cls.__handle_journal_info(am) 

145 

146 return am 

147 

148 

149 @classmethod 

150 def retrieve_swag(cls): 

151 template = deepcopy(cls.SWAG_TEMPLATE) 

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

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

154 template['responses']['200']['schema'] = IncomingArticleDO().struct_to_swag(schema_title='Article schema') 

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

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

157 template['responses']['500'] = cls.R500 

158 return cls._build_swag_response(template, api_key_optional_override=True) 

159 

160 @classmethod 

161 def retrieve(cls, id, account): 

162 

163 # is the article id valid? 

164 ar = models.Article.pull(id) 

165 if ar is None: 

166 raise Api404Error() 

167 

168 # at this point we're happy to return the article if it's 

169 # meant to be seen by the public 

170 if ar.is_in_doaj(): 

171 try: 

172 return OutgoingArticleDO.from_model(ar) 

173 except: 

174 raise Api500Error() 

175 

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

177 # we are good to proceed 

178 if account is None or account.is_anonymous: 

179 raise Api401Error() 

180 

181 # Check we're allowed to retrieve this article 

182 articleService = DOAJ.articleService() 

183 if not articleService.is_legitimate_owner(ar, account.id): 

184 raise Api404Error() # not found for this account 

185 

186 # Return the article 

187 oa = OutgoingArticleDO.from_model(ar) 

188 return oa 

189 

190 @classmethod 

191 def update_swag(cls): 

192 template = deepcopy(cls.SWAG_TEMPLATE) 

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

194 template['parameters'].append(cls.SWAG_ARTICLE_BODY_PARAM) 

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

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

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

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

199 return cls._build_swag_response(template) 

200 

201 @classmethod 

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

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

204 # we are good to proceed 

205 if account is None: 

206 raise Api401Error() 

207 

208 # now see if there's something for us to delete 

209 ar = models.Article.pull(id) 

210 if ar is None: 

211 raise Api404Error() 

212 

213 # Check we're allowed to edit this article 

214 # ~~-> Article:Service~~ 

215 articleService = DOAJ.articleService() 

216 if not articleService.is_legitimate_owner(ar, account.id): 

217 raise Api404Error() # not found for this account 

218 

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

220 try: 

221 ia = IncomingArticleDO(data) 

222 except dataobj.DataStructureException as e: 

223 raise Api400Error(str(e)) 

224 

225 # if that works, convert it to an Article object bringing over everything outside the 

226 # incoming article from the original article 

227 

228 # we need to ensure that any properties of the existing article that aren't allowed to change 

229 # are copied over 

230 new_ar = ia.to_article_model(ar) 

231 new_ar.set_id(id) 

232 new_ar.set_created(ar.created_date) 

233 new_ar.bibjson().set_subjects(ar.bibjson().subjects()) 

234 

235 try: 

236 # Article save occurs in the BLL create_article 

237 result = articleService.create_article(new_ar, account, add_journal_info=True, update_article_id=id) 

238 except ArticleMergeConflict as e: 

239 raise Api400Error(str(e)) 

240 except ArticleNotAcceptable as e: 

241 raise Api400Error("; ".join(e.errors)) 

242 except DuplicateArticleException as e: 

243 raise Api403Error(str(e)) 

244 

245 if result.get("success") == 0: 

246 raise Api400Error("Article update failed for unanticipated reason") 

247 

248 return new_ar 

249 

250 @classmethod 

251 def delete_swag(cls): 

252 template = deepcopy(cls.SWAG_TEMPLATE) 

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

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

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

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

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

258 return cls._build_swag_response(template) 

259 

260 @classmethod 

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

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

263 # we are good to proceed 

264 if account is None: 

265 raise Api401Error() 

266 

267 # now see if there's something for us to delete 

268 ar = models.Article.pull(id) 

269 if ar is None: 

270 raise Api404Error() 

271 

272 # Check we're allowed to retrieve this article 

273 # ~~-> Article:Service~~ 

274 articleService = DOAJ.articleService() 

275 if not articleService.is_legitimate_owner(ar, account.id): 

276 raise Api404Error() # not found for this account 

277 

278 # issue the delete (no record of the delete required) 

279 if not dry_run: 

280 ar.delete()