Coverage for portality / view / api_v4.py: 83%

269 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 00:09 +0100

1import json 

2import re 

3 

4from flask import Blueprint, request 

5from flask import jsonify, url_for, make_response, render_template 

6from flask import redirect 

7from flask_login import current_user 

8from flask_swagger import swagger 

9 

10from portality.api import respond 

11from portality.api.common import Api400Error 

12from portality.api.current import ApplicationsCrudApi, ArticlesCrudApi, JournalsCrudApi, ApplicationsBulkApi 

13from portality.api.current import ArticlesBulkApi 

14from portality.api.current import DiscoveryApi, DiscoveryException 

15from portality.api.current import jsonify_models, jsonify_data_object, Api404Error, created, \ 

16 no_content, bulk_created 

17from portality.core import app 

18from portality.decorators import api_key_optional 

19from portality.decorators import api_key_required, swag, write_required 

20from portality.lib import plausible 

21from portality.models import BulkArticles 

22from portality.ui import templates 

23 

24# Google Analytics category for API events 

25GA_CATEGORY = app.config.get('GA_CATEGORY_API', 'API Hit') 

26GA_ACTIONS = app.config.get('GA_ACTIONS_API', {}) 

27 

28blueprint = Blueprint('api_v4', __name__) 

29API_VERSION_NUMBER = '4.0.0' 

30 

31 

32@blueprint.route('/') 

33def api_root(): 

34 return redirect(url_for('.api_spec')) 

35 

36 

37@blueprint.route('/docs') 

38def docs(): 

39 account_url = None 

40 if current_user.is_authenticated: 

41 account_url = url_for('account.username', username=current_user.id, _external=True, 

42 _scheme=app.config.get('PREFERRED_URL_SCHEME', 'https')) 

43 

44 major_version = app.config.get("CURRENT_API_MAJOR_VERSION") 

45 is_current = False 

46 if major_version is not None: 

47 is_current = API_VERSION_NUMBER.startswith(major_version + ".") 

48 base_url = app.config.get("BASE_API_URL") 

49 if not base_url.endswith("/"): 

50 base_url = base_url + "/" 

51 if not is_current: 

52 this_major_version = API_VERSION_NUMBER.split(".")[0] 

53 base_url = base_url + "v" + this_major_version + "/" 

54 

55 return render_template(templates.API_V4_DOCS, 

56 api_version=API_VERSION_NUMBER, 

57 base_url=base_url, 

58 contact_us_url=url_for('doaj.contact'), 

59 account_url=account_url) 

60 

61 

62@blueprint.route('/swagger.json') 

63def api_spec(): 

64 swag = swagger(app) 

65 swag['info']['title'] = "" 

66 swag['info']['version'] = API_VERSION_NUMBER 

67 

68 major_version = app.config.get("CURRENT_API_MAJOR_VERSION") 

69 is_current = False 

70 if major_version is not None: 

71 is_current = API_VERSION_NUMBER.startswith(major_version + ".") 

72 

73 if is_current: 

74 # Strip out all the `vN` specific routes, leaving only the "current" /api route displayed 

75 [swag['paths'].pop(p) for p in list(swag['paths'].keys()) if re.match(r'/api/v\d+/', p)] 

76 else: 

77 this_major_version = API_VERSION_NUMBER.split(".")[0] 

78 # strip out all the routes that are not for this version 

79 [swag['paths'].pop(p) for p in list(swag['paths'].keys()) if not re.match('/api/v' + this_major_version + '/', p)] 

80 return make_response((jsonify(swag), 200, {'Access-Control-Allow-Origin': '*'})) 

81 

82 

83# Handle wayward paths by raising an API404Error 

84@blueprint.route("/<path:invalid_path>", methods=["POST", "GET", "PUT", "DELETE", "PATCH", 

85 "HEAD"]) # leaving out methods should mean all, but tests haven't shown that behaviour. 

86def missing_resource(invalid_path): 

87 docs_url = app.config.get("BASE_URL", "") + url_for('.docs') 

88 spec_url = app.config.get("BASE_URL", "") + url_for('.api_spec') 

89 raise Api404Error("No endpoint at {0}. See {1} for valid paths or read the documentation at {2}." 

90 .format(invalid_path, spec_url, docs_url)) 

91 

92 

93@swag(swag_summary='Search your applications <span class="red">[Authenticated, not public]</span>', 

94 swag_spec=DiscoveryApi.get_application_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

95@blueprint.route("/search/applications/<path:search_query>") 

96@api_key_required 

97@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('search_applications', 'Search applications'), 

98 record_value_of_which_arg='search_query') 

99def search_applications(search_query): 

100 # get the values for the 2 other bits of search info: the page number and the page size 

101 page = request.values.get("page", 1) 

102 psize = request.values.get("pageSize", 10) 

103 sort = request.values.get("sort") 

104 

105 # check the page is an integer 

106 try: 

107 page = int(page) 

108 except: 

109 raise Api400Error("Page number was not an integer") 

110 

111 # check the page size is an integer 

112 try: 

113 psize = int(psize) 

114 except: 

115 raise Api400Error("Page size was not an integer") 

116 

117 try: 

118 results = DiscoveryApi.search('application', current_user, search_query, page, psize, sort) 

119 except DiscoveryException as e: 

120 raise Api400Error(str(e)) 

121 

122 return jsonify_models(results) 

123 

124 

125@swag(swag_summary='Search journals', 

126 swag_spec=DiscoveryApi.get_journal_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

127@blueprint.route('/search/journals/<path:search_query>') 

128@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('search_journals', 'Search journals'), 

129 record_value_of_which_arg='search_query') 

130def search_journals(search_query): 

131 # get the values for the 2 other bits of search info: the page number and the page size 

132 page = request.values.get("page", 1) 

133 psize = request.values.get("pageSize", 10) 

134 sort = request.values.get("sort") 

135 

136 # check the page is an integer 

137 try: 

138 page = int(page) 

139 except: 

140 raise Api400Error("Page number was not an integer") 

141 

142 # check the page size is an integer 

143 try: 

144 psize = int(psize) 

145 except: 

146 raise Api400Error("Page size was not an integer") 

147 

148 try: 

149 results = DiscoveryApi.search('journal', None, search_query, page, psize, sort) 

150 except DiscoveryException as e: 

151 raise Api400Error(str(e)) 

152 

153 return jsonify_models(results) 

154 

155 

156@swag(swag_summary='Search articles', 

157 swag_spec=DiscoveryApi.get_article_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

158@blueprint.route('/search/articles/<path:search_query>') 

159@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('search_articles', 'Search articles'), 

160 record_value_of_which_arg='search_query') 

161def search_articles(search_query): 

162 # get the values for the 2 other bits of search info: the page number and the page size 

163 page = request.values.get("page", 1) 

164 psize = request.values.get("pageSize", 10) 

165 sort = request.values.get("sort") 

166 

167 # check the page is an integer 

168 try: 

169 page = int(page) 

170 except: 

171 raise Api400Error("Page number was not an integer") 

172 

173 # check the page size is an integer 

174 try: 

175 psize = int(psize) 

176 except: 

177 raise Api400Error("Page size was not an integer") 

178 

179 results = None 

180 try: 

181 results = DiscoveryApi.search('article', None, search_query, page, psize, sort) 

182 except DiscoveryException as e: 

183 raise Api400Error(str(e)) 

184 

185 return jsonify_models(results) 

186 

187 

188######################################### 

189# Application CRUD API 

190 

191@blueprint.route("/applications", methods=["POST"]) 

192@api_key_required 

193@write_required(api=True) 

194@swag(swag_summary='Create an application <span class="red">[Authenticated, not public]</span>', 

195 swag_spec=ApplicationsCrudApi.create_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

196@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('create_application', 'Create application')) 

197def create_application(): 

198 # get the data from the request 

199 try: 

200 data = json.loads(request.data.decode("utf-8")) 

201 except: 

202 raise Api400Error("Supplied data was not valid JSON") 

203 

204 # delegate to the API implementation 

205 a = ApplicationsCrudApi.create(data, current_user._get_current_object()) 

206 

207 # respond with a suitable Created response 

208 return created(a, url_for("api_v4.retrieve_application", application_id=a.id)) 

209 

210 

211@blueprint.route("/applications/<application_id>", methods=["GET"]) 

212@api_key_required 

213@swag(swag_summary='Retrieve an application <span class="red">[Authenticated, not public]</span>', 

214 swag_spec=ApplicationsCrudApi.retrieve_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

215@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('retrieve_application', 'Retrieve application'), 

216 record_value_of_which_arg='application_id') 

217def retrieve_application(application_id): 

218 a = ApplicationsCrudApi.retrieve(application_id, current_user) 

219 return jsonify_models(a) 

220 

221 

222@blueprint.route("/applications/<application_id>", methods=["PUT"]) 

223@api_key_required 

224@write_required(api=True) 

225@swag(swag_summary='Update an application <span class="red">[Authenticated, not public]</span>', 

226 swag_spec=ApplicationsCrudApi.update_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

227@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('update_application', 'Update application'), 

228 record_value_of_which_arg='application_id') 

229def update_application(application_id): 

230 # get the data from the request 

231 try: 

232 data = json.loads(request.data.decode("utf-8")) 

233 except: 

234 raise Api400Error("Supplied data was not valid JSON") 

235 

236 # delegate to the API implementation 

237 ApplicationsCrudApi.update(application_id, data, current_user._get_current_object()) 

238 

239 # respond with a suitable No Content successful response 

240 return no_content() 

241 

242 

243@blueprint.route("/applications/<application_id>", methods=["DELETE"]) 

244@api_key_required 

245@write_required(api=True) 

246@swag(swag_summary='Delete an application <span class="red">[Authenticated, not public]</span>', 

247 swag_spec=ApplicationsCrudApi.delete_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

248@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('delete_application', 'Delete application'), 

249 record_value_of_which_arg='application_id') 

250def delete_application(application_id): 

251 ApplicationsCrudApi.delete(application_id, current_user._get_current_object()) 

252 return no_content() 

253 

254 

255######################################### 

256# Article CRUD API 

257 

258@blueprint.route("/articles", methods=["POST"]) 

259@api_key_required 

260@write_required(api=True) 

261@swag(swag_summary='Create an article <span class="red">[Authenticated, not public]</span>', 

262 swag_spec=ArticlesCrudApi.create_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

263@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('create_article', 'Create article')) 

264def create_article(): 

265 # get the data from the request 

266 try: 

267 data = json.loads(request.data.decode("utf-8")) 

268 except: 

269 raise Api400Error("Supplied data was not valid JSON") 

270 

271 # delegate to the API implementation 

272 a = ArticlesCrudApi.create(data, current_user._get_current_object()) 

273 

274 # respond with a suitable Created response 

275 return created(a, url_for("api_v4.retrieve_article", article_id=a.id)) 

276 

277 

278@blueprint.route("/articles/<article_id>", methods=["GET"]) 

279@api_key_optional 

280@swag(swag_summary='Retrieve an article', 

281 swag_spec=ArticlesCrudApi.retrieve_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

282@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('retrieve_article', 'Retrieve article'), 

283 record_value_of_which_arg='article_id') 

284def retrieve_article(article_id): 

285 a = ArticlesCrudApi.retrieve(article_id, current_user) 

286 return jsonify_models(a) 

287 

288 

289@blueprint.route("/articles/<article_id>", methods=["PUT"]) 

290@api_key_required 

291@write_required(api=True) 

292@swag(swag_summary='Update an article <span class="red">[Authenticated, not public]</span>', 

293 swag_spec=ArticlesCrudApi.update_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

294@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('update_article', 'Update article'), 

295 record_value_of_which_arg='article_id') 

296def update_article(article_id): 

297 # get the data from the request 

298 try: 

299 data = json.loads(request.data.decode("utf-8")) 

300 except: 

301 raise Api400Error("Supplied data was not valid JSON") 

302 

303 # delegate to the API implementation 

304 ArticlesCrudApi.update(article_id, data, current_user._get_current_object()) 

305 

306 # respond with a suitable No Content successful response 

307 return no_content() 

308 

309 

310@blueprint.route("/articles/<article_id>", methods=["DELETE"]) 

311@api_key_required 

312@write_required(api=True) 

313@swag(swag_summary='Delete an article <span class="red">[Authenticated, not public]</span>', 

314 swag_spec=ArticlesCrudApi.delete_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

315@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('delete_article', 'Delete article'), 

316 record_value_of_which_arg='article_id') 

317def delete_article(article_id): 

318 ArticlesCrudApi.delete(article_id, current_user) 

319 return no_content() 

320 

321 

322######################################### 

323# Journal R API 

324 

325@blueprint.route('/journals/<journal_id>', methods=['GET']) 

326@api_key_optional 

327@swag(swag_summary='Retrieve a journal by ID', 

328 swag_spec=JournalsCrudApi.retrieve_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

329@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('retrieve_journal', 'Retrieve journal'), 

330 record_value_of_which_arg='journal_id') 

331def retrieve_journal(journal_id): 

332 return jsonify_data_object(JournalsCrudApi.retrieve(journal_id, current_user)) 

333 

334 

335######################################### 

336# Application Bulk API 

337 

338@blueprint.route("/bulk/applications", methods=["POST"]) 

339@api_key_required 

340@write_required(api=True) 

341@swag(swag_summary='Create applications in bulk <span class="red">[Authenticated, not public]</span>', 

342 swag_spec=ApplicationsBulkApi.create_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

343@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('bulk_application_create', 'Bulk application create')) 

344def bulk_application_create(): 

345 # get the data from the request 

346 try: 

347 data = json.loads(request.data.decode("utf-8")) 

348 except: 

349 raise Api400Error("Supplied data was not valid JSON") 

350 

351 # delegate to the API implementation 

352 ids = ApplicationsBulkApi.create(data, current_user._get_current_object()) 

353 

354 # get all the locations for the ids 

355 inl = [] 

356 for id in ids: 

357 inl.append((id, url_for("api_v4.retrieve_application", application_id=id))) 

358 

359 # respond with a suitable Created response 

360 return bulk_created(inl) 

361 

362 

363@blueprint.route("/bulk/applications", methods=["DELETE"]) 

364@api_key_required 

365@write_required(api=True) 

366@swag(swag_summary='Delete applications in bulk <span class="red">[Authenticated, not public]</span>', 

367 swag_spec=ApplicationsBulkApi.delete_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

368@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('bulk_application_delete', 'Bulk application delete')) 

369def bulk_application_delete(): 

370 # get the data from the request 

371 try: 

372 data = json.loads(request.data.decode("utf-8")) 

373 except: 

374 raise Api400Error("Supplied data was not valid JSON") 

375 

376 ApplicationsBulkApi.delete(data, current_user._get_current_object()) 

377 

378 return no_content() 

379 

380 

381######################################### 

382# Article Bulk API 

383 

384def load_income_articles_json(request): 

385 # get the data from the request 

386 try: 

387 return json.loads(request.data.decode("utf-8")) 

388 except: 

389 raise Api400Error("Supplied data was not valid JSON") 

390 

391 

392@blueprint.route("/bulk/articles", methods=["POST"]) 

393@api_key_required 

394@write_required(api=True) 

395@swag(swag_summary='Bulk article creation (asynchronous) <span class="red">[Authenticated, not public]</span>', 

396 swag_spec=ArticlesBulkApi.create_async_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

397@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('bulk_article_create_async', 

398 'Bulk article create asynchronously')) 

399def bulk_article_create(): 

400 """ 

401 the different compare to v3 is it becomes asynchronously, 

402 mosly validation will be done in the background task. 

403 

404 :return: 

405 """ 

406 data = load_income_articles_json(request) 

407 upload_id = ArticlesBulkApi.create_async(data, current_user._get_current_object()) 

408 resp_content = json.dumps( 

409 {'msg': 'Your article bulk upload is being processed. Please check the status link for progress.', 

410 'upload_id': upload_id, 

411 "status": app.config.get("BASE_URL", "") + url_for("api_v4.bulk_article_create_status", 

412 upload_id=upload_id, 

413 api_key=current_user._get_current_object().api_key)}) 

414 return respond(resp_content, 202) 

415 

416 

417@blueprint.route("/bulk/articles", methods=["DELETE"]) 

418@api_key_required 

419@write_required(api=True) 

420@swag(swag_summary='Bulk article delete <span class="red">[Authenticated, not public]</span>', 

421 swag_spec=ArticlesBulkApi.delete_swag()) # must be applied after @api_key_(optional|required) decorators. They don't preserve func attributes. 

422@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('bulk_article_delete', 'Bulk article delete')) 

423def bulk_article_delete(): 

424 # get the data from the request 

425 try: 

426 data = json.loads(request.data.decode("utf-8")) 

427 except: 

428 raise Api400Error("Supplied data was not valid JSON") 

429 

430 ArticlesBulkApi.delete(data, current_user._get_current_object()) 

431 

432 return no_content() 

433 

434 

435@blueprint.route("/bulk/articles/<upload_id>", methods=["GET"]) 

436@api_key_required 

437# @write_required(api=True) 

438@swag(swag_summary='Get bulk article creation status <span class="red">[Authenticated, not public]</span>', 

439 swag_spec=ArticlesBulkApi.get_async_status_swag()) 

440@plausible.pa_event(GA_CATEGORY, action=GA_ACTIONS.get('bulk_article_create_status', 

441 'Get bulk article creation status')) 

442def bulk_article_create_status(upload_id=None): 

443 status = ArticlesBulkApi.get_async_status(current_user.id, upload_id) 

444 resp_content = json.dumps(status) 

445 return respond(resp_content, 200)