Coverage for portality / app.py: 74%
306 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# -*- coding: UTF-8 -*-
3"""
4This is the default app controller for portality.
5For inclusion in your own project you should make your own version of this controller
6and include the views you require, as well as writing new ones. Of course, views must
7also be backed up by models, so have a look at the example models and use them / write
8new ones as required too.
10~~DOAJ:WebApp~~
11"""
12import logging
13import os, sys
15import elasticsearch.exceptions
16import tzlocal
17import pytz
19from flask import request, abort, render_template, redirect, send_file, url_for, jsonify, send_from_directory, g
20from flask_login import login_user, current_user
22from datetime import datetime
24import portality.models as models
25import portality.ui.exceptions
26from portality.core import app, es_connection, initialise_index
27from portality import settings
28from portality.lib import edges, dates
29from portality.lib.dates import FMT_DATETIME_STD, FMT_YEAR
30from portality.ui import templates
31from portality import constants
33from portality.ui.messages import Messages
34from portality.ui import exceptions
35from portality.internationalise import internationalise
37from portality.view.account import blueprint as account
38from portality.view.admin import blueprint as admin
39from portality.view.publisher import blueprint as publisher
40from portality.view.query import blueprint as query
41from portality.view.doaj import blueprint as doaj
42from portality.view.oaipmh import blueprint as oaipmh
43from portality.view.openurl import blueprint as openurl
44from portality.view.atom import blueprint as atom
45from portality.view.editor import blueprint as editor
46from portality.view.doajservices import blueprint as services
47from portality.view.jct import blueprint as jct
48from portality.view.apply import blueprint as apply
49from portality.view.status import blueprint as status
50from portality.lib.normalise import normalise_doi
51from portality.view.dashboard import blueprint as dashboard
52from portality.view.tours import blueprint as tours
54if app.config.get("DEBUG", False) and app.config.get("TESTDRIVE_ENABLED", False):
55 from portality.view.testdrive import blueprint as testdrive
57app.register_blueprint(account, url_prefix='/account') #~~->Account:Blueprint~~
58app.register_blueprint(account, name="locale_account", url_prefix='/<lang>/account') #~~->Account:Blueprint~~
59app.register_blueprint(admin, url_prefix='/admin') #~~-> Admin:Blueprint~~
60app.register_blueprint(publisher, url_prefix='/publisher') #~~-> Publisher:Blueprint~~
61app.register_blueprint(query, name='query', url_prefix='/query') # ~~-> Query:Blueprint~~
62app.register_blueprint(query, name='admin_query', url_prefix='/admin_query')
63app.register_blueprint(query, name='publisher_query', url_prefix='/publisher_query')
64app.register_blueprint(query, name='editor_query', url_prefix='/editor_query')
65app.register_blueprint(query, name='associate_query', url_prefix='/associate_query')
66app.register_blueprint(query, name='dashboard_query', url_prefix="/dashboard_query")
67app.register_blueprint(editor, url_prefix='/editor') # ~~-> Editor:Blueprint~~
68app.register_blueprint(services, url_prefix='/service') # ~~-> Services:Blueprint~~
69if 'api1' in app.config['FEATURES']:
70 from portality.view.api_v1 import blueprint as api_v1
71 app.register_blueprint(api_v1, url_prefix='/api/v1') # ~~-> APIv1:Blueprint~~
72if 'api2' in app.config['FEATURES']:
73 from portality.view.api_v2 import blueprint as api_v2
74 app.register_blueprint(api_v2, url_prefix='/api/v2') # ~~-> APIv2:Blueprint~~
75if 'api3' in app.config['FEATURES']:
76 from portality.view.api_v3 import blueprint as api_v3
77 app.register_blueprint(api_v3, name='api_v3', url_prefix='/api/v3') # ~~-> APIv3:Blueprint~~
78 if app.config.get("CURRENT_API_MAJOR_VERSION") == "3":
79 app.register_blueprint(api_v3, name='api', url_prefix='/api')
80if 'api4' in app.config['FEATURES']:
81 from portality.view.api_v4 import blueprint as api_v4
82 app.register_blueprint(api_v4, name='api_v4', url_prefix='/api/v4') # ~~-> APIv4:Blueprint~~
83 if app.config.get("CURRENT_API_MAJOR_VERSION", "4") == "4":
84 app.register_blueprint(api_v4, name='api', url_prefix='/api')
86app.register_blueprint(status, name='status', url_prefix='/status') # ~~-> Status:Blueprint~~
87app.register_blueprint(status, name='_status', url_prefix='/_status')
88app.register_blueprint(apply, url_prefix='/apply') # ~~-> Apply:Blueprint~~
89app.register_blueprint(apply, name="apply_locale", url_prefix='/<lang>/apply') # ~~-> Apply:Blueprint~~
90app.register_blueprint(jct, url_prefix="/jct") # ~~-> JCT:Blueprint~~
91app.register_blueprint(dashboard, url_prefix="/dashboard") #~~-> Dashboard:Blueprint~~
92app.register_blueprint(tours, url_prefix="/tours") # ~~-> Tours:Blueprint~~
94app.register_blueprint(oaipmh) # ~~-> OAIPMH:Blueprint~~
95app.register_blueprint(openurl) # ~~-> OpenURL:Blueprint~~
96app.register_blueprint(atom) # ~~-> Atom:Blueprint~~
97app.register_blueprint(doaj) # ~~-> DOAJ:Blueprint~~
99if app.config.get("DEBUG", False) and app.config.get("TESTDRIVE_ENABLED", False):
100 app.logger.warning('Enabling TESTDRIVE at /testdrive')
101 app.register_blueprint(testdrive, url_prefix="/testdrive") # ~~-> Testdrive:Feature ~~
103# initialise the index - don't put into if __name__ == '__main__' block,
104# because that does not run if gunicorn is loading the app, as opposed
105# to the app being run directly by python portality/app.py
106# putting it here ensures it will run under any web server
107initialise_index(app, es_connection)
109internationalise(app)
111# serve static files from multiple potential locations
112# this allows us to override the standard static file handling with our own dynamic version
113# ~~-> Assets:WebRoute~~
114# @app.route("/static_content/<path:filename>")
115@app.route("/static/<path:filename>")
116@app.route("/assets/<path:filename>")
117def our_static(filename):
118 return custom_static(filename)
121def custom_static(path):
122 for dir in app.config.get("STATIC_PATHS", []):
123 target = os.path.join(app.root_path, dir, path)
124 if os.path.isfile(target):
125 return send_from_directory(os.path.dirname(target), os.path.basename(target))
126 abort(404)
128# Configure Analytics
129# ~~-> PlausibleAnalytics:ExternalService~~
130from portality.lib import plausible
131plausible.create_logfile(app.config.get('PLAUSIBLE_LOG_DIR', None))
133# Redirects from previous DOAJ app.
134# RJ: I have decided to put these here so that they can be managed
135# alongside the DOAJ codebase. I know they could also go into the
136# nginx config, but there is a chance that they will get lost or forgotten
137# some day, whereas this approach doesn't have that risk.
138# ~~-> Legacy:WebRoute~~
139@app.route("/doaj")
140def legacy():
141 func = request.values.get("func")
142 if func == "csv":
143 return redirect(url_for('doaj.csv_data')), 301
144 elif func == "rss":
145 return redirect(url_for('atom.feed')), 301
146 elif func == "browse" or func == 'byPublicationFee ':
147 return redirect(url_for('doaj.search')), 301
148 elif func == "openurl":
149 vals = request.values.to_dict(flat=True)
150 del vals["func"]
151 return redirect(url_for('openurl.openurl', **vals), 301)
152 abort(404)
155@app.route("/doaj2csv")
156def another_legacy_csv_route():
157 return redirect("/csv"), 301
159###################################################
161# ~~-> DOAJArticleXML:Schema~~
162@app.route("/schemas/doajArticles.xsd")
163def legacy_doaj_XML_schema():
164 schema_fn = 'doajArticles.xsd'
165 return send_file(
166 os.path.join(app.config.get("STATIC_DIR"), "doaj", schema_fn),
167 mimetype="application/xml", as_attachment=True, download_name=schema_fn
168 )
171# ~~-> CrossrefArticleXML:WebRoute~~
172@app.route("/isCrossrefLoaded")
173def is_crossref_loaded():
174 if app.config.get("LOAD_CROSSREF_THREAD") is not None and app.config.get("LOAD_CROSSREF_THREAD").is_alive():
175 return "false"
176 else:
177 return "true"
180# FIXME: this used to calculate the site stats on request, but for the time being
181# this is an unnecessary overhead, so taking it out. Will need to put something
182# equivalent back in when we do the admin area
183# ~~-> SiteStats:Feature~~
184@app.context_processor
185def set_current_context():
186 """ Set some template context globals. """
187 '''
188 Inserts variables into every template this blueprint renders. This
189 one deals with the announcement in the header, which can't be built
190 into the template directly, as various styles are applied only if a
191 header is present on the page. It also makes the list of DOAJ
192 sponsors available and may include similar minor pieces of
193 information.
194 '''
195 return {
196 'settings': settings,
197 # 'statistics': models.JournalArticle.site_statistics(),
198 "current_user": current_user,
199 "app": app,
200 "current_year": dates.now_str(FMT_YEAR),
201 "base_url": app.config.get('BASE_URL'),
202 }
205# Jinja2 Template Filters
206# ~~-> Jinja2:Environment~~
208@app.template_filter("bytesToFilesize")
209def bytes_to_filesize(size):
210 units = ["bytes", "Kb", "Mb", "Gb"]
211 scale = 0
212 while size > 1000 and scale < len(units):
213 size = float(size) / 1000.0 # note that it is no longer 1024
214 scale += 1
215 return "{size:.1f}{unit}".format(size=size, unit=units[scale])
218@app.template_filter('utc_timestamp')
219def utc_timestamp(stamp, string_format=FMT_DATETIME_STD):
220 """
221 Format a local time datetime object to UTC
222 :param stamp: a datetime object
223 :param string_format: defaults to "%Y-%m-%dT%H:%M:%SZ", which complies with ISO 8601
224 :return: the string formatted datetime
225 """
226 local = pytz.timezone(str(tzlocal.get_localzone()))
227 ld = local.localize(stamp)
228 tt = ld.utctimetuple()
229 utcdt = datetime(tt.tm_year, tt.tm_mon, tt.tm_mday, tt.tm_hour, tt.tm_min, tt.tm_sec, tzinfo=pytz.utc)
230 return utcdt.strftime(string_format)
233human_date = app.template_filter("human_date")(dates.human_date)
236@app.template_filter('doi_url')
237def doi_url(doi):
238 """
239 Create a link from a DOI.
240 :param doi: the string DOI
241 :return: the HTML link
242 """
244 try:
245 return "https://doi.org/" + normalise_doi(doi)
246 except ValueError:
247 return ""
250@app.template_filter('form_diff_table_comparison_value')
251def form_diff_table_comparison_value(val):
252 """
253 Function for converting the given value to a suitable UI value for presentation in the diff table
254 on the admin forms for update requests.
256 :param val: the raw value to be converted to a display value
257 :return:
258 """
259 if val is None:
260 return ""
261 if isinstance(val, list) and len(val) == 0:
262 return ""
264 if isinstance(val, list):
265 dvals = []
266 for v in val:
267 dvals.append(form_diff_table_comparison_value(v))
268 return ", ".join(dvals)
269 else:
270 if val is True or (isinstance(val, str) and val.lower() == "true"):
271 return "Yes"
272 elif val is False or (isinstance(val, str) and val.lower() == "false"):
273 return "No"
274 return val
277@app.template_filter('form_diff_table_subject_expand')
278def form_diff_table_subject_expand(val):
279 """
280 Function for expanding one or more subject classifications out to their full terms
282 :param val:
283 :return:
284 """
285 if val is None:
286 return ""
287 if isinstance(val, list) and len(val) == 0:
288 return ""
289 if not isinstance(val, list):
290 val = [val]
292 from portality import lcc
294 results = []
295 for v in val:
296 if v is None or v == "":
297 continue
298 expanded = lcc.lcc_index_by_code.get(v)
299 if expanded is not None:
300 results.append(expanded + " [code: " + v + "]")
301 else:
302 results.append(v)
304 return ", ".join(results)
306@app.template_filter("is_in_the_past")
307def is_in_the_past(dttm):
308 return dates.is_before(dttm, dates.today())
311#######################################################
313@app.context_processor
314def search_query_source_wrapper():
315 def search_query_source(**params):
316 return edges.make_url_query(**params)
317 return dict(search_query_source=search_query_source)
320@app.context_processor
321def maned_of_wrapper():
322 def maned_of():
323 # ~~-> EditorGroup:Model ~~
324 egs = []
325 assignments = {}
326 if current_user.has_role("admin"):
327 egs = models.EditorGroup.groups_by_maned(current_user.id)
328 if len(egs) > 0:
329 assignments = models.Application.assignment_to_editor_groups(egs)
330 return egs, assignments
331 return dict(maned_of=maned_of)
334@app.context_processor
335def editor_of_wrapper():
336 def editor_of():
337 # ~~-> EditorGroup:Model ~~
338 egs = []
339 assignments = {}
340 if current_user.has_role("editor"):
341 egs = models.EditorGroup.groups_by_editor(current_user.id)
342 if len(egs) > 0:
343 assignments = models.Application.assignment_to_editor_groups(egs)
344 return egs, assignments
345 return dict(editor_of=editor_of)
347@app.context_processor
348def associate_of_wrapper():
349 def associate_of():
350 # ~~-> EditorGroup:Model ~~
351 egs = []
352 assignments = {}
353 if current_user.has_role("associate_editor"):
354 egs = models.EditorGroup.groups_by_associate(current_user.id)
355 if len(egs) > 0:
356 assignments = models.Application.assignment_to_editor_groups(egs)
357 return egs, assignments
358 return dict(associate_of=associate_of)
360# ~~-> Account:Model~~
361# ~~-> AuthNZ:Feature~~
362@app.before_request
363def standard_authentication():
364 """Check remote_user on a per-request basis."""
365 remote_user = request.headers.get('REMOTE_USER', '')
366 if remote_user:
367 user = models.Account.pull(remote_user)
368 if user:
369 login_user(user, remember=False)
370 elif 'api_key' in request.values:
371 q = models.Account.query(q='api_key:"' + request.values['api_key'] + '"')
372 if 'hits' in q:
373 res = q['hits']['hits']
374 if len(res) == 1:
375 user = models.Account.pull(res[0]['_source']['id'])
376 if user:
377 login_user(user, remember=False)
380# Register configured API versions
381# ~~-> APIv1:Blueprint~~
382# ~~-> APIv2:Blueprint~~
383# ~~-> APIv3:Blueprint~~
384features = app.config.get('FEATURES', [])
385if 'api1' in features or 'api2' in features or 'api3' in features:
386 @app.route('/api/')
387 def api_directory():
388 vers = []
389 # NOTE: we never could run API v1 and v2 at the same time.
390 # This code is here for future reference to add additional API versions
391 if 'api1' in features:
392 vers.append(
393 {
394 'version': '1.0.0',
395 'base_url': url_for('api_v1.api_spec', _external=True,
396 _scheme=app.config.get('PREFERRED_URL_SCHEME', 'https')),
397 'note': 'First version of the DOAJ API',
398 'docs_url': url_for('api_v1.docs', _external=True,
399 _scheme=app.config.get('PREFERRED_URL_SCHEME', 'https'))
400 }
401 )
402 if 'api2' in features:
403 vers.append(
404 {
405 'version': '2.0.0',
406 'base_url': url_for('api_v2.api_spec', _external=True,
407 _scheme=app.config.get('PREFERRED_URL_SCHEME', 'https')),
408 'note': 'Second version of the DOAJ API',
409 'docs_url': url_for('api_v2.docs', _external=True,
410 _scheme=app.config.get('PREFERRED_URL_SCHEME', 'https'))
411 }
412 )
413 if 'api3' in features:
414 vers.append(
415 {
416 'version': '3.0.0',
417 'base_url': url_for('api_v3.api_spec', _external=True,
418 _scheme=app.config.get('PREFERRED_URL_SCHEME', 'https')),
419 'note': 'Third version of the DOAJ API',
420 'docs_url': url_for('api_v3.docs', _external=True,
421 _scheme=app.config.get('PREFERRED_URL_SCHEME', 'https'))
422 }
423 )
424 return jsonify({'api_versions': vers})
426@app.errorhandler(400)
427def handle_400(e):
428 return render_template(templates.ERROR_400), 400
430@app.errorhandler(401)
431def handle_401(e):
432 return render_template(templates.ERROR_401), 401
434@app.errorhandler(404)
435def handle_404(e):
436 return render_template(templates.ERROR_PAGE, error_code=404), 404
438@app.errorhandler(exceptions.ArticleFromWithdrawnJournal)
439def handle_article_from_withdrawn_journal(e):
440 return render_template(templates.ERROR_PAGE, record=constants.ERROR_RECORD_ARTICLE, context=constants.ERROR_410_WITHDRAWN, error_code=410), 410
442@app.errorhandler(exceptions.TombstoneArticle)
443def handle_tombstone_article(e):
444 return render_template(templates.ERROR_PAGE, record=constants.ERROR_RECORD_ARTICLE, context=constants.ERROR_410_TOMBSTONE, error_code=410), 410
446@app.errorhandler(exceptions.JournalWithdrawn)
447def handle_journal_withdrawn(e):
448 return render_template(templates.ERROR_PAGE, record=constants.ERROR_RECORD_JOURNAL, context=constants.ERROR_410_WITHDRAWN, error_code=410), 410
450@app.errorhandler(ValueError)
451@app.errorhandler(500)
452def handle_500(e):
453 if not hasattr(e, 'description') or e.description == e.__class__.description:
454 description = Messages.DEFAULT_500_DESCRIPTION
455 else:
456 description = e.description
457 return render_template(templates.ERROR_500, description=description), 500
460@app.errorhandler(elasticsearch.exceptions.RequestError)
461def handle_es_request_error(e):
462 app.logger.exception(e)
463 return render_template(templates.ERROR_400), 400
466is_dev_log_setup_completed = False
469def setup_dev_log():
470 global is_dev_log_setup_completed
471 if not is_dev_log_setup_completed:
472 is_dev_log_setup_completed = True
473 app.logger.handlers = []
474 log = logging.getLogger()
475 log.setLevel(logging.DEBUG)
476 ch = logging.StreamHandler()
477 ch.setLevel(logging.DEBUG)
478 ch.setFormatter(logging.Formatter('%(asctime)s %(levelname).4s %(processName)s%(threadName)s - '
479 '%(message)s --- [%(name)s][%(funcName)s:%(lineno)d]'))
480 log.addHandler(ch)
483def run_server(host=None, port=None, fake_https=False):
484 """
485 :param host:
486 :param port:
487 :param fake_https:
488 if fake_https is True, develop can use https:// to access the server
489 that can help for debugging Plausible
490 :return:
491 """
493 if app.config.get('DEBUG_DEV_LOG', False):
494 setup_dev_log()
496 pycharm_debug = app.config.get('DEBUG_PYCHARM', False)
497 if len(sys.argv) > 1:
498 if sys.argv[1] == '-d':
499 pycharm_debug = True
501 if pycharm_debug:
502 app.config['DEBUG'] = False
503 import pydevd_pycharm as pydevd
504 pydevd.settrace(app.config.get('DEBUG_PYCHARM_SERVER', 'localhost'),
505 port=app.config.get('DEBUG_PYCHARM_PORT', 6000),
506 stdoutToServer=True, stderrToServer=True)
508 run_kwargs = {}
509 if fake_https:
510 run_kwargs['ssl_context'] = 'adhoc'
512 host = host or app.config['HOST']
513 port = port or app.config['PORT']
514 app.run(host=host, debug=app.config['DEBUG'], port=port,
515 **run_kwargs)
518if __name__ == "__main__":
519 run_server()