Coverage for portality / app.py: 74%

306 statements  

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

1# -*- coding: UTF-8 -*- 

2 

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. 

9 

10~~DOAJ:WebApp~~ 

11""" 

12import logging 

13import os, sys 

14 

15import elasticsearch.exceptions 

16import tzlocal 

17import pytz 

18 

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 

21 

22from datetime import datetime 

23 

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 

32 

33from portality.ui.messages import Messages 

34from portality.ui import exceptions 

35from portality.internationalise import internationalise 

36 

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 

53 

54if app.config.get("DEBUG", False) and app.config.get("TESTDRIVE_ENABLED", False): 

55 from portality.view.testdrive import blueprint as testdrive 

56 

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') 

85 

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~~ 

93 

94app.register_blueprint(oaipmh) # ~~-> OAIPMH:Blueprint~~ 

95app.register_blueprint(openurl) # ~~-> OpenURL:Blueprint~~ 

96app.register_blueprint(atom) # ~~-> Atom:Blueprint~~ 

97app.register_blueprint(doaj) # ~~-> DOAJ:Blueprint~~ 

98 

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 ~~ 

102 

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) 

108 

109internationalise(app) 

110 

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) 

119 

120 

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) 

127 

128# Configure Analytics 

129# ~~-> PlausibleAnalytics:ExternalService~~ 

130from portality.lib import plausible 

131plausible.create_logfile(app.config.get('PLAUSIBLE_LOG_DIR', None)) 

132 

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) 

153 

154 

155@app.route("/doaj2csv") 

156def another_legacy_csv_route(): 

157 return redirect("/csv"), 301 

158 

159################################################### 

160 

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 ) 

169 

170 

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" 

178 

179 

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 } 

203 

204 

205# Jinja2 Template Filters 

206# ~~-> Jinja2:Environment~~ 

207 

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]) 

216 

217 

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) 

231 

232 

233human_date = app.template_filter("human_date")(dates.human_date) 

234 

235 

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 """ 

243 

244 try: 

245 return "https://doi.org/" + normalise_doi(doi) 

246 except ValueError: 

247 return "" 

248 

249 

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. 

255 

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 "" 

263 

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 

275 

276 

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 

281 

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] 

291 

292 from portality import lcc 

293 

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) 

303 

304 return ", ".join(results) 

305 

306@app.template_filter("is_in_the_past") 

307def is_in_the_past(dttm): 

308 return dates.is_before(dttm, dates.today()) 

309 

310 

311####################################################### 

312 

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) 

318 

319 

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) 

332 

333 

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) 

346 

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) 

359 

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) 

378 

379 

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}) 

425 

426@app.errorhandler(400) 

427def handle_400(e): 

428 return render_template(templates.ERROR_400), 400 

429 

430@app.errorhandler(401) 

431def handle_401(e): 

432 return render_template(templates.ERROR_401), 401 

433 

434@app.errorhandler(404) 

435def handle_404(e): 

436 return render_template(templates.ERROR_PAGE, error_code=404), 404 

437 

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 

441 

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 

445 

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 

449 

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 

458 

459 

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 

464 

465 

466is_dev_log_setup_completed = False 

467 

468 

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) 

481 

482 

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 """ 

492 

493 if app.config.get('DEBUG_DEV_LOG', False): 

494 setup_dev_log() 

495 

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 

500 

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) 

507 

508 run_kwargs = {} 

509 if fake_https: 

510 run_kwargs['ssl_context'] = 'adhoc' 

511 

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) 

516 

517 

518if __name__ == "__main__": 

519 run_server() 

520