Coverage for portality / view / api_v4.py: 83%
269 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
1import json
2import re
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
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
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', {})
28blueprint = Blueprint('api_v4', __name__)
29API_VERSION_NUMBER = '4.0.0'
32@blueprint.route('/')
33def api_root():
34 return redirect(url_for('.api_spec'))
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'))
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 + "/"
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)
62@blueprint.route('/swagger.json')
63def api_spec():
64 swag = swagger(app)
65 swag['info']['title'] = ""
66 swag['info']['version'] = API_VERSION_NUMBER
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 + ".")
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': '*'}))
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))
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")
105 # check the page is an integer
106 try:
107 page = int(page)
108 except:
109 raise Api400Error("Page number was not an integer")
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")
117 try:
118 results = DiscoveryApi.search('application', current_user, search_query, page, psize, sort)
119 except DiscoveryException as e:
120 raise Api400Error(str(e))
122 return jsonify_models(results)
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")
136 # check the page is an integer
137 try:
138 page = int(page)
139 except:
140 raise Api400Error("Page number was not an integer")
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")
148 try:
149 results = DiscoveryApi.search('journal', None, search_query, page, psize, sort)
150 except DiscoveryException as e:
151 raise Api400Error(str(e))
153 return jsonify_models(results)
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")
167 # check the page is an integer
168 try:
169 page = int(page)
170 except:
171 raise Api400Error("Page number was not an integer")
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")
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))
185 return jsonify_models(results)
188#########################################
189# Application CRUD API
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")
204 # delegate to the API implementation
205 a = ApplicationsCrudApi.create(data, current_user._get_current_object())
207 # respond with a suitable Created response
208 return created(a, url_for("api_v4.retrieve_application", application_id=a.id))
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)
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")
236 # delegate to the API implementation
237 ApplicationsCrudApi.update(application_id, data, current_user._get_current_object())
239 # respond with a suitable No Content successful response
240 return no_content()
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()
255#########################################
256# Article CRUD API
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")
271 # delegate to the API implementation
272 a = ArticlesCrudApi.create(data, current_user._get_current_object())
274 # respond with a suitable Created response
275 return created(a, url_for("api_v4.retrieve_article", article_id=a.id))
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)
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")
303 # delegate to the API implementation
304 ArticlesCrudApi.update(article_id, data, current_user._get_current_object())
306 # respond with a suitable No Content successful response
307 return no_content()
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()
322#########################################
323# Journal R API
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))
335#########################################
336# Application Bulk API
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")
351 # delegate to the API implementation
352 ids = ApplicationsBulkApi.create(data, current_user._get_current_object())
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)))
359 # respond with a suitable Created response
360 return bulk_created(inl)
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")
376 ApplicationsBulkApi.delete(data, current_user._get_current_object())
378 return no_content()
381#########################################
382# Article Bulk API
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")
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.
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)
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")
430 ArticlesBulkApi.delete(data, current_user._get_current_object())
432 return no_content()
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)