Coverage for portality / view / doaj.py: 65%
402 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 os.path
3import re
4import urllib.error
5import urllib.parse
6import urllib.request
8from flask import Blueprint, request, make_response
9from flask import render_template, abort, redirect, url_for, send_file, jsonify
10from flask_login import current_user, login_required
12from portality.ui import exceptions as ui_exceptions
13from portality.bll import exceptions, exceptions as bll_exceptions
14from portality import constants
15from portality import dao
16from portality import models
17from portality import store
18from portality.bll import DOAJ
19from portality.core import app
20from portality.decorators import ssl_required, api_key_required
21from portality.lcc import lcc_jstree
22from portality.lib import plausible
23from portality.ui.messages import Messages
24from portality.ui import templates
25from portality.bll import DOAJ
27# ~~DOAJ:Blueprint~~
28blueprint = Blueprint('doaj', __name__)
31@blueprint.route("/")
32def home():
33 news = models.News.latest(app.config.get("FRONT_PAGE_NEWS_ITEMS", 5))
34 recent_journals = models.Journal.recent(max=16)
35 stats = DOAJ.siteService().site_statistics()
36 return render_template(templates.PUBLIC_INDEX, news=news, recent_journals=recent_journals, statistics=stats)
39@blueprint.route('/login/')
40def login():
41 return redirect(url_for('account.login'))
44@blueprint.route("/dismiss_site_note")
45def dismiss_site_note():
46 cont = request.values.get("continue")
47 if cont is not None:
48 resp = redirect(cont)
49 else:
50 resp = make_response()
51 # set a cookie that lasts for one year
52 resp.set_cookie(app.config.get("SITE_NOTE_KEY"), app.config.get("SITE_NOTE_COOKIE_VALUE"),
53 max_age=app.config.get("SITE_NOTE_SLEEP"), samesite=None, secure=True)
54 return resp
57@blueprint.route("/news/")
58def news():
59 # NOTE: On live this is also handled by the nginx redirect map, but this will strip those with parameters supplied
60 return redirect("https://blog.doaj.org")
63@blueprint.route("/fqw_hit", methods=['POST'])
64def fqw_hit():
65 page = request.form.get('embedding_page')
66 if page is not None:
67 plausible.send_event(app.config.get('ANALYTICS_CATEGORY_FQW', 'FQW'),
68 action=app.config.get('ANALYTICS_ACTION_FQW', 'hit'),
69 label=request.form.get('embedding_page'))
71 # No content response, whether data there or not.
72 return '', 204
75@blueprint.route("/search/journals", methods=["GET"])
76def journals_search():
77 return render_template(templates.PUBLIC_JOURNAL_SEARCH, lcc_tree=lcc_jstree)
80@blueprint.route("/search/articles", methods=["GET"])
81def articles_search():
82 return render_template(templates.PUBLIC_ARTICLE_SEARCH, lcc_tree=lcc_jstree)
85@blueprint.route("/search", methods=['GET'])
86def search():
87 # If there are URL params, check if we need to redirect to articles rather than journals
88 if request.values:
89 # Flat search the query params as string so we don't have to traverse all the way down the decoded json.
90 if re.search(r'\"_type\"\s*:\s*\"article\"', request.values.get('source', '')):
91 return redirect(url_for("doaj.articles_search"), 301)
92 return redirect(url_for("doaj.journals_search"), 301)
95@blueprint.route("/search", methods=['POST'])
96def search_post():
97 """ Redirect a query from the box on the index page to the search page. """
98 if request.form.get('origin') != 'ui':
99 abort(400) # bad request - we must receive searches from our own UI
101 ref = request.form.get("ref")
102 if ref is None:
103 abort(400) # Referrer is required
105 ct = request.form.get("content-type")
106 kw = request.form.get("keywords")
107 field = request.form.get("fields")
109 if kw is None:
110 kw = request.form.get("q") # back-compat for the simple search widget
112 # lhs for journals, rhs for articles
113 field_map = {
114 "all": (None, None),
115 "title": ("bibjson.title", "bibjson.title"),
116 "abstract": (None, "bibjson.abstract"),
117 "subject": ("index.classification", "index.classification"),
118 "author": (None, "bibjson.author.name"),
119 "issn": ("index.issn.exact", None),
120 "publisher": ("bibjson.publisher.name", None),
121 "country": ("index.country", None)
122 }
123 default_field_opts = field_map.get(field, None)
124 default_field = None
126 route = ""
127 if not ct or ct == "journals":
128 route = url_for("doaj.journals_search")
129 if default_field_opts:
130 default_field = default_field_opts[0]
131 elif ct == "articles":
132 route = url_for("doaj.articles_search")
133 if default_field_opts:
134 default_field = default_field_opts[1]
135 else:
136 abort(400)
138 query = dao.Facetview2.make_query(kw, default_field=default_field, default_operator="AND")
140 return redirect(route + '?source=' + urllib.parse.quote(json.dumps(query)) + "&ref=" + urllib.parse.quote(ref))
143#############################################
145@blueprint.route("/csv")
146@plausible.pa_event(app.config.get('ANALYTICS_CATEGORY_JOURNALCSV', 'JournalCSV'),
147 action=app.config.get('ANALYTICS_ACTION_JOURNALCSV', 'Download'))
148def csv_data():
149 svc = DOAJ.journalService()
150 if current_user and not current_user.is_anonymous and current_user.has_role(constants.ROLE_PREMIUM_CSV):
151 jc = svc.get_premium_csv()
152 else:
153 jc = svc.get_free_csv()
155 if jc is None:
156 abort(404)
158 store_url = svc.get_temporary_url(jc)
160 if store_url.startswith("/"):
161 store_url = "/store" + store_url
163 return redirect(store_url, code=307)
166@blueprint.route("/sitemap.xml")
167def sitemap_legacy():
168 return redirect(url_for('doaj.sitemap', suffix="0"), 301)
170@blueprint.route("/sitemap_index.xml")
171def sitemap_index_legacy():
172 return redirect(url_for('doaj.sitemap', suffix="_index_0"), 301)
174@blueprint.route("/sitemap<suffix>.xml")
175def sitemap(suffix):
176 """
177 This route handles both sitemaps, of the form /sitemapN.xml, and sitemap indexes, of the form /sitemap_index_N.xml
178 :param suffix:
179 :return:
180 """
181 url = None
182 if suffix.startswith("_index_"):
183 n = suffix[len("_index_"):]
184 url = models.Cache.get_sitemap_index(n)
185 else:
186 url = models.Cache.get_sitemap(suffix)
187 if url is None:
188 abort(404)
189 if url.startswith("/"):
190 url = "/store" + url
191 return redirect(url, code=307)
194@blueprint.route("/public-data-dump/<record_type>")
195@login_required
196@plausible.pa_event(app.config.get('ANALYTICS_CATEGORY_PUBLICDATADUMP', 'PublicDataDump'),
197 action=app.config.get('ANALYTICS_ACTION_PUBLICDATADUMP', 'Download'))
198def public_data_dump_redirect(record_type):
199 if not current_user.has_role(constants.ROLE_PUBLIC_DATA_DUMP) and not current_user.has_role(constants.ROLE_PREMIUM_PDD):
200 abort(404)
202 svc = DOAJ.publicDataDumpService()
203 if current_user.has_role(constants.ROLE_PREMIUM_PDD):
204 dd = svc.get_premium_dump()
205 else:
206 dd = svc.get_free_dump()
208 if dd is None:
209 abort(404)
211 store_url = svc.get_temporary_url(dd, record_type)
213 if store_url.startswith("/"):
214 store_url = "/store" + store_url
216 return redirect(store_url, code=307)
218@blueprint.route("/store/<container>/<dir>/<filename>")
219def get_from_local_store_dir(container, dir, filename):
220 file = os.path.join(dir, filename)
221 return get_from_local_store(container, file)
223@blueprint.route("/store/<container>/<filename>")
224def get_from_local_store(container, filename):
225 if not app.config.get("STORE_LOCAL_EXPOSE", False):
226 abort(404)
228 from portality import store
229 localStore = store.StoreFactory.get(None)
230 file_handle = localStore.get(container, filename)
231 return send_file(file_handle, mimetype="application/octet-stream", as_attachment=True,
232 download_name=os.path.basename(filename))
235@blueprint.route('/autocomplete/<doc_type>/<field_name>', methods=["GET", "POST"])
236def autocomplete(doc_type, field_name):
237 prefix = request.args.get('q', '')
238 if not prefix:
239 return jsonify({'suggestions': [{"id": "",
240 "text": "No results found"}]}) # select2 does not understand 400, which is the correct code here...
242 m = models.lookup_model(doc_type)
243 if not m:
244 return jsonify({'suggestions': [{"id": "",
245 "text": "No results found"}]}) # select2 does not understand 404, which is the correct code here...
247 size = request.args.get('size', 5)
249 filter_field = app.config.get("AUTOCOMPLETE_ADVANCED_FIELD_MAPS", {}).get(field_name)
251 suggs = []
252 if filter_field is None:
253 suggs = m.autocomplete(field_name, prefix, size=size)
254 else:
255 suggs = m.advanced_autocomplete(filter_field, field_name, prefix, size=size, prefix_only=False)
257 return jsonify({'suggestions': suggs})
258 # you shouldn't return lists top-level in a JSON response:
259 # http://flask.pocoo.org/docs/security/#json-security
262def is_issn_by_identifier(identifier):
263 return len(identifier) == 9
266def find_correct_redirect_identifier(identifier, bibjson) -> str:
267 """
268 return None if identifier is correct and no redirect is needed
270 :param identifier:
271 :param bibjson:
272 :return:
273 """
274 if is_issn_by_identifier(identifier): # the journal is referred to by an ISSN
275 # if there is an E-ISSN (and it's not the one in the request), redirect to it
276 eissn = bibjson.get_one_identifier(bibjson.E_ISSN)
277 if eissn and identifier != eissn:
278 return eissn
280 # if there's no E-ISSN, but there is a P-ISSN (and it's not the one in the request), redirect to the P-ISSN
281 if not eissn:
282 pissn = bibjson.get_one_identifier(bibjson.P_ISSN)
283 if pissn and identifier != pissn:
284 return pissn
286 # The journal has neither a PISSN or an EISSN. Yet somehow
287 # issn_ref is True, the request was referring to the journal
288 # by its ISSN. Not sure how this could ever happen, but just
289 # continue loading the data and do nothing else in such a
290 # case.
292 else: # the journal is NOT referred to by any ISSN
294 # if there is an E-ISSN, redirect to it
295 # if not, but there is a P-ISSN, redirect to it
296 # if neither ISSN is present, continue loading the page
297 issn = bibjson.get_one_identifier(bibjson.E_ISSN)
298 if not issn:
299 issn = bibjson.get_one_identifier(bibjson.P_ISSN)
300 if issn:
301 return issn
303 # let it continue loading if we only have the hex UUID for the journal (no ISSNs)
304 # and the user is referring to the toc page via that ID
307@blueprint.route("/toc/<identifier>")
308def toc(identifier=None):
309 """ Table of Contents page for a journal. identifier may be the journal id or an issn """
310 # If this route is changed, update JOURNAL_TOC_URL_FRAG in settings.py (partial ToC page link for journal CSV)
311 if identifier is None:
312 abort(400)
314 journalSvc = DOAJ.journalService()
315 try:
316 journal = journalSvc.find_best(identifier)
317 except bll_exceptions.ArgumentException:
318 abort(400)
319 except bll_exceptions.TooManyJournals:
320 abort(500)
322 if journal is None:
323 abort(404)
325 if journal.is_in_doaj() is False:
326 raise ui_exceptions.JournalWithdrawn()
328 # journal = find_toc_journal_by_identifier(identifier)
329 bibjson = journal.bibjson()
330 real_identifier = find_correct_redirect_identifier(identifier, bibjson)
331 if real_identifier:
332 return redirect(url_for('doaj.toc', identifier=real_identifier), 301)
333 else:
334 # now render all that information
335 return render_template(templates.PUBLIC_TOC_MAIN, journal=journal, bibjson=bibjson, tab="main")
338@blueprint.route("/toc/articles/<identifier>")
339def toc_articles_legacy(identifier=None):
340 return redirect(url_for('doaj.toc_articles', identifier=identifier, volume=1, issue=1), 301)
343@blueprint.route("/toc/<identifier>/articles")
344def toc_articles(identifier=None):
345 if identifier is None:
346 abort(400)
348 journalSvc = DOAJ.journalService()
349 try:
350 journal = journalSvc.find_best(identifier)
351 except bll_exceptions.ArgumentException:
352 abort(400)
353 except bll_exceptions.TooManyJournals:
354 abort(500)
356 if journal is None:
357 abort(404)
359 if journal.is_in_doaj() is False:
360 raise ui_exceptions.JournalWithdrawn()
362 bibjson = journal.bibjson()
363 articles_no = journal.article_stats()["total"]
364 real_identifier = find_correct_redirect_identifier(identifier, bibjson)
365 if real_identifier:
366 return redirect(url_for('doaj.toc_articles', identifier=real_identifier), 301)
367 else:
368 return render_template(templates.PUBLIC_TOC_ARTICLES, journal=journal, bibjson=bibjson, articles_no=articles_no, tab="articles")
371# ~~->Article:Page~~
372@blueprint.route("/article/<identifier>")
373def article_page(identifier=None):
374 # identifier must be the article id
375 article = models.Article.pull(identifier)
377 if article is None:
378 article = models.ArticleTombstone.pull(identifier)
379 if article:
380 raise ui_exceptions.TombstoneArticle()
381 else:
382 abort(404, description=Messages.ARTICLE_NOT_FOUND)
384 if not article.is_in_doaj():
385 raise ui_exceptions.ArticleFromWithdrawnJournal()
387 # find the related journal record
388 journal = article.get_journal()
389 if journal is None:
390 app.logger.exception(Messages.ARTICLE_ABANDONED_LOG.format(article_id=article.id))
391 abort(500, description=Messages.ARTICLE_ABANDONED_PUBLIC)
392 if journal.is_in_doaj() is False:
393 raise ui_exceptions.ArticleFromWithdrawnJournal()
395 # issns = article.bibjson().issns()
396 # more_issns = article.bibjson().journal_issns
397 # for issn in issns + more_issns:
398 # journals = models.Journal.find_by_issn(issn)
399 # if len(journals) == 0:
400 # app.logger.exception(Messages.ARTICLE_ABANDONED_LOG.format(article_id=article.id))
401 # abort(500, description=Messages.ARTICLE_ABANDONED_PUBLIC)
402 # try:
403 # journal = models.Journal.get_active_journal(journals)
404 # except exceptions.TooManyJournals:
405 # app.logger.exception(Messages.TOO_MANY_JOURNALS_LOG.format(identifier=identifier))
406 # abort(500, description=Messages.TOO_MANY_JOURNALS.format(identifier=identifier))
407 # except exceptions.JournalWithdrawn:
408 # raise exceptions.ArticleFromWithdrawnJournal
410 return render_template(templates.PUBLIC_ARTICLE, article=article, journal=journal, page={"highlight" : True})
413###############################################################
414# The various static endpoints
415###############################################################
417@blueprint.route("/googlebdb21861de30fe30.html")
418def google_webmaster_tools():
419 return 'google-site-verification: googlebdb21861de30fe30.html'
422@blueprint.route("/accessibility/")
423def accessibility():
424 return render_template(templates.STATIC_PAGE, page_frag="/legal/accessibility.html")
427@blueprint.route("/privacy/")
428def privacy():
429 return render_template(templates.STATIC_PAGE, page_frag="/legal/privacy.html")
432@blueprint.route("/contact/")
433def contact():
434 return render_template(templates.STATIC_PAGE, page_frag="/legal/contact.html")
437@blueprint.route("/terms/")
438def terms():
439 return render_template(templates.STATIC_PAGE, page_frag="/legal/terms.html")
442@blueprint.route("/code-of-conduct/")
443def conduct():
444 """
445 ~~Conduct:WebRoute~~
446 """
447 return render_template(templates.STATIC_PAGE, page_frag="/legal/code-of-conduct.html")
450@blueprint.route("/media/")
451def media():
452 """
453 ~~Media:WebRoute~~
454 """
455 return render_template(templates.STATIC_PAGE, page_frag="/legal/media.html")
458@blueprint.route("/support/")
459def support():
460 return render_template(templates.STATIC_PAGE, page_frag="/support/index.html")
463@blueprint.route("/support/sponsors/")
464def sponsors():
465 return render_template(templates.STATIC_PAGE, page_frag="/support/sponsors.html")
468@blueprint.route("/support/publisher-supporters/")
469def publisher_supporters():
470 return render_template(templates.STATIC_PAGE, page_frag="/support/publisher-supporters.html")
473@blueprint.route("/support/supporters/")
474def supporters():
475 return render_template(templates.STATIC_PAGE, page_frag="/support/supporters.html")
478@blueprint.route("/support/funders/")
479def funders():
480 return render_template(templates.STATIC_PAGE, page_frag="/support/funders.html")
483@blueprint.route("/support/thank-you/")
484def application_thanks():
485 return render_template(templates.STATIC_PAGE, page_frag="/support/thank-you.html")
488@blueprint.route("/apply/guide/")
489def guide():
490 return render_template(templates.STATIC_PAGE, page_frag="/apply/guide.html")
493@blueprint.route("/apply/transparency/")
494def transparency():
495 return render_template(templates.STATIC_PAGE, page_frag="/apply/transparency.html")
498@blueprint.route("/apply/why-index/")
499def why_index():
500 return render_template(templates.STATIC_PAGE, page_frag="/apply/why-index.html")
503@blueprint.route("/apply/publisher-responsibilities/")
504def publisher_responsibilities():
505 return render_template(templates.STATIC_PAGE, page_frag="/apply/publisher-responsibilities.html")
508@blueprint.route("/apply/copyright-and-licensing/")
509def copyright_and_licensing():
510 return render_template(templates.STATIC_PAGE, page_frag="/apply/copyright-and-licensing.html")
513@blueprint.route("/docs/oai-pmh/")
514def oai_pmh():
515 return render_template(templates.STATIC_PAGE, page_frag="/docs/oai-pmh.html")
518@blueprint.route('/docs/api/')
519def docs():
520 major_version = app.config.get("CURRENT_API_MAJOR_VERSION")
521 return redirect(url_for('api_v' + major_version + '.docs'))
524@blueprint.route("/docs/xml/")
525def xml():
526 return render_template(templates.STATIC_PAGE, page_frag="/docs/xml.html")
529@blueprint.route("/docs/widgets/")
530def widgets():
531 return render_template(templates.STATIC_PAGE, page_frag="/docs/widgets.html", base_url=app.config.get('BASE_URL'))
534@blueprint.route("/docs/public-data-dump/")
535def public_data_dump():
536 if current_user and not current_user.is_anonymous and (
537 current_user.has_role(constants.ROLE_PUBLIC_DATA_DUMP) or current_user.has_role(constants.ROLE_PREMIUM_PDD)):
538 dds = DOAJ.publicDataDumpService()
539 if current_user.has_role(constants.ROLE_PREMIUM_PDD):
540 dd = dds.get_premium_dump()
541 else:
542 dd = dds.get_free_dump()
543 return render_template(templates.STATIC_PAGE, page_frag="/docs/pdd-access.html", pdd=dd)
544 return render_template(templates.STATIC_PAGE, page_frag="/docs/pdd-contact.html")
547@blueprint.route("/docs/openurl/")
548def openurl():
549 return render_template(templates.STATIC_PAGE, page_frag="/docs/openurl.html")
552@blueprint.route("/docs/faq/")
553def faq():
554 return render_template(templates.STATIC_PAGE, page_frag="/docs/faq.html")
556@blueprint.route("/docs/journal-csv")
557def journal_csv():
558 svc = DOAJ.journalService()
559 if not current_user.is_anonymous and current_user.has_role(constants.ROLE_PREMIUM_CSV):
560 jc = svc.get_premium_csv()
561 else:
562 jc = svc.get_free_csv()
563 return render_template(templates.STATIC_PAGE, page_frag="/docs/journal-csv.html", jc=jc)
565@blueprint.route("/docs/premium")
566def premium():
567 return render_template(templates.STATIC_PAGE, page_frag="/docs/premium.html")
569@blueprint.route("/about/")
570def about():
571 return render_template(templates.STATIC_PAGE, page_frag="/about/index.html")
575@blueprint.route("/at-20/")
576def at_20():
577 return render_template(templates.STATIC_PAGE, page_frag="/about/at-20.html")
582@blueprint.route("/about/ambassadors/")
583def ambassadors():
584 return render_template(templates.STATIC_PAGE, page_frag="/about/ambassadors.html")
587@blueprint.route("/about/advisory-board-council/")
588def abc():
589 return render_template(templates.STATIC_PAGE, page_frag="/about/advisory-board-council.html")
592@blueprint.route("/about/editorial-policy-advisory-group/")
593def epag():
594 return render_template(templates.STATIC_PAGE, page_frag="/about/editorial-policy-advisory-group.html")
597@blueprint.route("/about/volunteers/")
598def volunteers():
599 return render_template(templates.STATIC_PAGE, page_frag="/about/volunteers.html")
602@blueprint.route("/about/team/")
603def team():
604 return render_template(templates.STATIC_PAGE, page_frag="/about/team.html")
607@blueprint.route("/preservation/")
608def preservation():
609 return render_template(templates.STATIC_PAGE, page_frag="/preservation/index.html")
612# LEGACY ROUTES
613@blueprint.route("/subjects")
614def subjects():
615 # return render_template("doaj/subjects.html", subject_page=True, lcc_jstree=json.dumps(lcc_jstree))
616 return redirect(url_for("doaj.journals_search"), 301)
619@blueprint.route("/application/new")
620def old_application():
621 return redirect(url_for("apply.public_application", **request.args), code=308)
624@blueprint.route("/<cc>/mejorespracticas")
625@blueprint.route("/<cc>/boaspraticas")
626@blueprint.route("/<cc>/bestpractice")
627@blueprint.route("/<cc>/editionsavante")
628@blueprint.route("/bestpractice")
629@blueprint.route("/oainfo")
630def bestpractice(cc=None):
631 return redirect(url_for("doaj.transparency", **request.args), code=308)
634@blueprint.route("/membership")
635def membership():
636 return redirect(url_for("doaj.support", **request.args), code=308)
639@blueprint.route("/publishermembers")
640def old_sponsors():
641 return redirect(url_for("doaj.sponsors", **request.args), code=308)
644@blueprint.route("/members")
645def members():
646 return redirect(url_for("doaj.supporters", **request.args), code=308)
649@blueprint.route('/features')
650def features():
651 return redirect(url_for("doaj.xml", **request.args), code=308)
654# @blueprint.route('/widgets')
655# def old_widgets():
656# return redirect(url_for("doaj.widgets", **request.args), code=308)
659# @blueprint.route("/public-data-dump/<record_type>")
660# def old_public_data_dump(record_type):
661# return redirect(url_for("doaj.public_data_dump", **request.args), code=308)
664@blueprint.route("/openurl/help")
665def old_openurl():
666 return redirect(url_for("doaj.openurl", **request.args), code=308)
669@blueprint.route("/faq")
670def old_faq():
671 return redirect(url_for("doaj.faq", **request.args), code=308)
674@blueprint.route("/publishers")
675def publishers():
676 return redirect(url_for("doaj.guide", **request.args), code=308)
679# Redirects necessitated by new templates
680@blueprint.route("/password-reset/")
681def new_password_reset():
682 return redirect(url_for('account.forgot'), code=301)
685@blueprint.route("/u/<alias>")
686@plausible.pa_event(app.config.get('ANALYTICS_CATEGORY_URLSHORT', 'Urlshort'),
687 action=app.config.get('ANALYTICS_ACTION_URLSHORT_REDIRECT', 'Redirect'))
688def shortened_url(alias):
689 short = DOAJ.shortUrlService().find_url_by_alias(alias)
690 if short:
691 return redirect(short.url)
693 app.logger.debug(f"Shortened URL not found: [{alias}]")
694 abort(404)