Coverage for portality / view / admin.py: 40%
687 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 00:09 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 00:09 +0100
1import json, re
2from collections import namedtuple
3from typing import Iterable, List
5from flask import Blueprint, request, flash, abort, make_response
6from flask import render_template, redirect, url_for, send_file
7from flask_login import current_user, login_required
8from werkzeug.datastructures import MultiDict
10from portality import models
11from portality import constants
12from portality import dao
13from portality import lock
14from portality.background import BackgroundSummary
15from portality.bll import DOAJ, exceptions
16from portality.bll.exceptions import ArticleMergeConflict, DuplicateArticleException
17from portality.bll.services.query import Query
18from portality.core import app
19from portality.crosswalks.application_form import ApplicationFormXWalk
20from portality.decorators import ssl_required, restrict_to_role, write_required
21from portality.forms.application_forms import ApplicationFormFactory, application_statuses
22from portality.forms.application_forms import JournalFormFactory
23from portality.forms.article_forms import ArticleFormFactory
24from portality.lcc import lcc_jstree
25from portality.lib.query_filters import remove_search_limits, update_request, not_update_request
26from portality.models import Journal
27from portality.tasks import journal_in_out_doaj, journal_bulk_edit, suggestion_bulk_edit, journal_bulk_delete, \
28 article_bulk_delete, admin_reports
29from portality.tasks.journal_in_out_doaj import find_matching_issns_in_doaj
30from portality.ui.messages import Messages
31from portality.ui import templates
32from portality.util import flash_with_url, jsonp, make_json_resp, get_web_json_payload, validate_json
33from portality.view.forms import EditorGroupForm, MakeContinuation
34from portality.view.view_helper import exparam_editing_user
36# ~~Admin:Blueprint~~
37blueprint = Blueprint('admin', __name__)
40# restrict everything in admin to logged in users with the "admin" role
41@blueprint.before_request
42def restrict():
43 return restrict_to_role('admin')
46# build an admin page where things can be done
47@blueprint.route('/')
48@login_required
49@ssl_required
50def index():
51 return render_template(templates.ADMIN_JOURNALS_SEARCH, admin_page=True)
54@blueprint.route("/journals", methods=["GET"])
55@login_required
56@ssl_required
57def journals():
58 qs = request.query_string
59 target = url_for("admin.index")
60 if qs:
61 target += "?" + qs.decode()
62 return redirect(target)
65@blueprint.route("/journals", methods=["POST", "DELETE"])
66@login_required
67@ssl_required
68@write_required()
69@jsonp
70def journals_list():
71 if request.method == "POST":
72 try:
73 query = json.loads(request.values.get("q"))
74 except:
75 app.logger.warning("Bad Request at admin/journals: " + str(request.values.get("q")))
76 abort(400)
78 # get the total number of journals to be affected
79 jtotal = models.Journal.hit_count(query, consistent_order=False)
81 # get the total number of articles to be affected
82 issns = models.Journal.issns_by_query(query)
83 atotal = models.Article.count_by_issns(issns)
85 resp = make_response(json.dumps({"journals": jtotal, "articles": atotal}))
86 resp.mimetype = "application/json"
87 return resp
89 elif request.method == "DELETE":
90 if not current_user.has_role("delete_article"):
91 abort(401)
93 try:
94 query = json.loads(request.data)
95 except:
96 app.logger.warning("Bad Request at admin/journals: " + str(request.data))
97 abort(400)
99 # get only the query part
100 query = {"query": query.get("query")}
101 models.Journal.delete_selected(query=query, articles=True, snapshot_journals=True, snapshot_articles=True)
102 resp = make_response(json.dumps({"status": "success"}))
103 resp.mimetype = "application/json"
104 return resp
107@blueprint.route("/articles", methods=["POST", "DELETE"])
108@login_required
109@ssl_required
110@write_required()
111@jsonp
112def articles_list():
113 if request.method == "POST":
114 try:
115 query = json.loads(request.values.get("q"))
116 except:
117 print(request.values.get("q"))
118 abort(400)
119 total = models.Article.hit_count(query, consistent_order=False)
120 resp = make_response(json.dumps({"total": total}))
121 resp.mimetype = "application/json"
122 return resp
123 elif request.method == "DELETE":
124 if not current_user.has_role("delete_article"):
125 abort(401)
127 try:
128 query = json.loads(request.data)
129 except:
130 app.logger.warning("Bad Request at admin/journals: " + str(request.data))
131 abort(400)
133 # get only the query part
134 query = {"query": query.get("query")}
135 models.Article.delete_selected(query=query, snapshot=True)
136 resp = make_response(json.dumps({"status": "success"}))
137 resp.mimetype = "application/json"
138 return resp
141@blueprint.route("/delete/article/<article_id>", methods=["POST"])
142@login_required
143@ssl_required
144@write_required()
145def article_endpoint(article_id):
146 if not current_user.has_role("delete_article"):
147 abort(401)
148 a = models.Article.pull(article_id)
149 if a is None:
150 abort(404)
151 delete = request.values.get("delete", "false")
152 if delete != "true":
153 abort(400)
154 a.snapshot()
155 a.delete()
156 # return a json response
157 resp = make_response(json.dumps({"success": True}))
158 resp.mimetype = "application/json"
159 return resp
162@blueprint.route("/article/<article_id>", methods=["GET", "POST"])
163@login_required
164@ssl_required
165@write_required()
166def article_page(article_id):
167 if not current_user.has_role("edit_article"):
168 abort(401)
169 ap = models.Article.pull(article_id)
170 if ap is None:
171 abort(404)
173 fc = ArticleFormFactory.get_from_context(role="admin", source=ap, user=current_user)
174 if request.method == "GET":
175 return fc.render_template()
177 elif request.method == "POST":
178 user = current_user._get_current_object()
179 fc = ArticleFormFactory.get_from_context(role="admin", source=ap, user=user, form_data=request.form)
181 fc.modify_authors_if_required(request.values)
183 if fc.validate():
184 try:
185 fc.finalise()
186 except ArticleMergeConflict:
187 Messages.flash(Messages.ARTICLE_METADATA_MERGE_CONFLICT)
188 except DuplicateArticleException:
189 Messages.flash(Messages.ARTICLE_METADATA_UPDATE_CONFLICT)
191 return fc.render_template()
194@blueprint.route("/journal/<journal_id>", methods=["GET", "POST"])
195@login_required
196@ssl_required
197@write_required()
198def journal_page(journal_id):
199 # ~~JournalForm:Page~~
200 auth_svc = DOAJ.authorisationService()
201 journal_svc = DOAJ.journalService()
203 journal, _ = journal_svc.journal(journal_id)
204 if journal is None:
205 abort(404)
207 try:
208 auth_svc.can_edit_journal(current_user._get_current_object(), journal)
209 except exceptions.AuthoriseException:
210 abort(401)
212 # attempt to get a lock on the object
213 try:
214 lockinfo = lock.lock(constants.LOCK_JOURNAL, journal_id, current_user.id)
215 except lock.Locked as l:
216 return render_template(templates.JOURNAL_LOCKED, journal=journal, lock=l.lock)
218 fc = JournalFormFactory.context("admin", extra_param=exparam_editing_user())
219 autochecks = models.Autocheck.for_journal(journal_id)
221 if request.method == "GET":
222 job = None
223 job_id = request.values.get("job")
224 if job_id is not None and job_id != "":
225 # ~~-> BackgroundJobs:Model~~
226 job = models.BackgroundJob.pull(job_id)
227 # ~~-> BackgroundJobs:Page~~
228 url = url_for("admin.background_jobs_search") + "?source=" + dao.Facetview2.url_encode_query(
229 dao.Facetview2.make_query(job_id))
230 Messages.flash_with_url(Messages.ADMIN__WITHDRAW_REINSTATE.format(url=url), "success")
231 fc.processor(source=journal)
233 bibjson = journal.bibjson()
234 past_cont_list = create_cont_list(journal.get_past_continuations(), bibjson.replaces)
235 future_cont_list = create_cont_list(journal.get_future_continuations(), bibjson.is_replaced_by)
237 return fc.render_template(lock=lockinfo, job=job, obj=journal, lcc_tree=lcc_jstree,
238 past_cont_list=past_cont_list, future_cont_list=future_cont_list,
239 autochecks=autochecks)
241 elif request.method == "POST":
242 processor = fc.processor(formdata=request.form, source=journal)
243 if processor.validate():
244 try:
245 processor.finalise(current_user._get_current_object())
246 flash('Journal updated.', 'success')
247 for a in processor.alert:
248 flash_with_url(a, "success")
249 return redirect(url_for("admin.journal_page", journal_id=journal.id, _anchor='done'))
250 except Exception as e:
251 flash(str(e))
252 return redirect(url_for("admin.journal_page", journal_id=journal.id, _anchor='cannot_edit'))
253 else:
254 return fc.render_template(lock=lockinfo, obj=journal, lcc_tree=lcc_jstree, autochecks=autochecks)
257@blueprint.route("/journal/readonly/<journal_id>", methods=["GET"])
258@login_required
259@ssl_required
260def journal_readonly(journal_id):
261 j = models.Journal.pull(journal_id)
262 if j is None:
263 abort(404)
265 fc = JournalFormFactory.context("admin_readonly")
266 fc.processor(source=j)
267 return fc.render_template(obj=j, lcc_tree=lcc_jstree, notabs=True)
269DisplayContData = namedtuple('DisplayContData', ['issn', 'title', 'id'])
272def create_cont_list(continuations: Iterable[Journal],
273 continuation_issns: List[str]) -> List[DisplayContData]:
274 def _issn_id_tuple(j: Journal):
275 bibjson = j.bibjson()
276 return DisplayContData(bibjson.pissn or bibjson.eissn, j.id, bibjson.title)
278 cont_list = map(_issn_id_tuple, continuations)
279 cont_list = {data.issn: data for data in cont_list}
280 cont_list.update({
281 issn: DisplayContData(issn, None, None) for issn in continuation_issns
282 if issn not in cont_list
283 })
284 return cont_list.values()
287######################################################
288# Endpoints for reinstating/withdrawing journals from the DOAJ
289#
291@blueprint.route("/journal/<journal_id>/activate", methods=["GET", "POST"])
292@login_required
293@ssl_required
294@write_required()
295def journal_activate(journal_id):
296 matching_issns = find_matching_issns_in_doaj(journal_id)
297 if not matching_issns:
298 job = journal_in_out_doaj.change_in_doaj([journal_id], True)
299 else:
300 flash_with_url(
301 Messages.CANNOT_CHANGE_THE_STATUS__OTHER_JOURNAL_IN_DOAJ_EXISTS.format(ids=", ".join(matching_issns)),
302 "error")
303 return redirect(url_for('.journal_page', journal_id=journal_id))
305 return redirect(url_for('.journal_page', journal_id=journal_id, job=job.id))
308@blueprint.route("/journal/<journal_id>/deactivate", methods=["GET", "POST"])
309@login_required
310@ssl_required
311@write_required()
312def journal_deactivate(journal_id):
313 job = journal_in_out_doaj.change_in_doaj([journal_id], False)
314 return redirect(url_for('.journal_page', journal_id=journal_id, job=job.id))
317@blueprint.route("/journals/bulk/withdraw", methods=["POST"])
318@login_required
319@ssl_required
320@write_required()
321def journals_bulk_withdraw():
322 payload = get_web_json_payload()
323 validate_json(payload, fields_must_be_present=['selection_query'], error_to_raise=BulkAdminEndpointException)
325 q = get_query_from_request(payload)
326 summary = journal_in_out_doaj.change_by_query(q, False, dry_run=payload.get("dry_run", True))
327 return make_json_resp(summary.as_dict(), status_code=200)
330@blueprint.route("/journals/bulk/reinstate", methods=["POST"])
331@login_required
332@ssl_required
333@write_required()
334def journals_bulk_reinstate():
335 payload = get_web_json_payload()
336 validate_json(payload, fields_must_be_present=['selection_query'], error_to_raise=BulkAdminEndpointException)
338 q = get_query_from_request(payload)
339 summary = journal_in_out_doaj.change_by_query(q, True, dry_run=payload.get("dry_run", True))
340 return make_json_resp(summary.as_dict(), status_code=200)
343#
344#####################################################################
346@blueprint.route("/journal/<journal_id>/article-info/", methods=["GET"])
347@login_required
348def journal_article_info(journal_id):
349 j = models.Journal.pull(journal_id)
350 if j is None:
351 abort(404)
353 return {'n_articles': models.Article.count_by_issns(j.bibjson().issns(), in_doaj=True)}
356@blueprint.route("/journal/<journal_id>/article-info/admin-site-search", methods=["GET"])
357@login_required
358def journal_article_info_admin_site_search(journal_id):
359 j = models.Journal.pull(journal_id)
360 if j is None:
361 abort(404)
363 issns = j.bibjson().issns()
364 if not issns:
365 abort(404)
367 target_url = '/admin/admin_site_search?source={"query":{"bool":{"must":[{"term":{"admin.in_doaj":true}},{"term":{"es_type.exact":"article"}},{"query_string":{"query":"%s","default_operator":"AND","default_field":"index.issn.exact"}}]}},"track_total_hits":true}'
368 return redirect(target_url % issns[0].replace('-', r'\\-'))
371@blueprint.route("/journal/<journal_id>/continue", methods=["GET", "POST"])
372@login_required
373@ssl_required
374@write_required()
375def journal_continue(journal_id):
376 j = models.Journal.pull(journal_id)
377 if j is None:
378 abort(404)
380 if request.method == "GET":
381 type = request.values.get("type")
382 form = MakeContinuation()
383 form.type.data = type
384 return render_template(templates.CONTINUATION, form=form, current=j)
386 elif request.method == "POST":
387 form = MakeContinuation(request.form)
388 if not form.validate():
389 return render_template(templates.CONTINUATION, form=form, current=j)
391 if form.type.data is None:
392 abort(400)
394 if form.type.data not in ["replaces", "is_replaced_by"]:
395 abort(400)
397 try:
398 cont = j.make_continuation(form.type.data, eissn=form.eissn.data, pissn=form.pissn.data,
399 title=form.title.data)
400 except:
401 abort(400)
403 flash("The continuation has been created (see below). You may now edit the other metadata associated with it."
404 " The original journal has also been updated with this continuation's ISSN(s). "
405 "Once you are happy with this record, you can publish it to the DOAJ",
406 "success")
407 return redirect(url_for('.journal_page', journal_id=cont.id))
410@blueprint.route("/applications", methods=["GET"])
411@login_required
412@ssl_required
413def suggestions():
414 fc = ApplicationFormFactory.context("admin", extra_param=exparam_editing_user())
415 return render_template(templates.APPLICATIONS_SEARCH,
416 admin_page=True,
417 application_status_choices=application_statuses(None, fc))
420@blueprint.route("/update_requests", methods=["GET"])
421@login_required
422@ssl_required
423def update_requests():
424 fc = ApplicationFormFactory.context("admin", extra_param=exparam_editing_user())
425 return render_template(templates.UPDATE_REQUESTS_SEARCH,
426 admin_page=True,
427 application_status_choices=application_statuses(None, fc))
430@blueprint.route("/application/<application_id>", methods=["GET", "POST"])
431@write_required()
432@login_required
433@ssl_required
434def application(application_id):
435 auth_svc = DOAJ.authorisationService()
436 application_svc = DOAJ.applicationService()
438 ap, _ = application_svc.application(application_id)
440 if ap is None:
441 abort(404)
443 try:
444 auth_svc.can_edit_application(current_user._get_current_object(), ap)
445 except exceptions.AuthoriseException:
446 abort(401)
448 try:
449 lockinfo = lock.lock(constants.LOCK_APPLICATION, application_id, current_user.id)
450 except lock.Locked as l:
451 return render_template(templates.APPLICATION_LOCKED, application=ap, lock=l.lock)
453 fc = ApplicationFormFactory.context("admin", extra_param=exparam_editing_user())
454 form_diff, current_journal = ApplicationFormXWalk.update_request_diff(ap)
456 autochecks = models.Autocheck.for_application(application_id)
458 if request.method == "GET":
459 fc.processor(source=ap)
460 return fc.render_template(obj=ap, lock=lockinfo, form_diff=form_diff,
461 current_journal=current_journal, lcc_tree=lcc_jstree, autochecks=autochecks)
463 elif request.method == "POST":
464 processor = fc.processor(formdata=request.form, source=ap)
465 if processor.validate():
466 try:
467 processor.finalise(current_user._get_current_object())
468 # if (processor.form.resettedFields):
469 # text = "Some fields has been resetted due to invalid value:"
470 # for f in processor.form.resettedFields:
471 # text += "<br>field: {}, invalid value: {}, new value: {}".format(f["name"], f["data"], f["default"])
472 # flash(text, 'info')
473 flash('Application updated.', 'success')
474 for a in processor.alert:
475 flash_with_url(a, "success")
476 return redirect(url_for("admin.application", application_id=ap.id, _anchor='done'))
477 except Exception as e:
478 flash("unexpected field " + str(e))
479 return redirect(url_for("admin.application", application_id=ap.id, _anchor='cannot_edit'))
480 else:
481 return fc.render_template(obj=ap, lock=lockinfo, form_diff=form_diff, current_journal=current_journal,
482 lcc_tree=lcc_jstree, autochecks=autochecks)
485@blueprint.route("/application_quick_reject/<application_id>", methods=["POST"])
486@login_required
487@ssl_required
488@write_required()
489def application_quick_reject(application_id):
490 # extract the note information from the request
491 canned_reason = request.values.get("quick_reject", "")
492 additional_info = request.values.get("quick_reject_details", "")
493 reasons = []
494 if canned_reason != "":
495 reasons.append(canned_reason)
496 if additional_info != "":
497 reasons.append(additional_info)
498 if len(reasons) == 0:
499 abort(400)
500 reason = " - ".join(reasons)
501 note = Messages.REJECT_NOTE_WRAPPER.format(editor=current_user.id, note=reason)
503 applicationService = DOAJ.applicationService()
505 # retrieve the application and an edit lock on that application
506 application = None
507 try:
508 application, alock = applicationService.application(application_id, lock_application=True,
509 lock_account=current_user._get_current_object())
510 except lock.Locked as e:
511 abort(409)
513 # determine if this was a new application or an update request
514 update_request = application.current_journal is not None
515 if update_request:
516 abort(400)
518 if application.owner is None:
519 Messages.flash_with_url(Messages.ADMIN__QUICK_REJECT__NO_OWNER, "error")
520 # redirect the user back to the edit page
521 return redirect(url_for('.application', application_id=application_id))
523 # reject the application
524 old_status = application.application_status
525 applicationService.reject_application(application, current_user._get_current_object(), note=note)
527 # send the notification email to the user
528 if old_status != constants.APPLICATION_STATUS_REJECTED:
529 eventsSvc = DOAJ.eventsService()
530 eventsSvc.trigger(models.Event(constants.EVENT_APPLICATION_STATUS, current_user.id, {
531 "application": application.data,
532 "old_status": old_status,
533 "new_status": constants.APPLICATION_STATUS_REJECTED,
534 "process": constants.PROCESS__QUICK_REJECT,
535 "note": reason
536 }))
538 # sort out some flash messages for the user
539 flash(note, "success")
541 msg = Messages.SENT_REJECTED_APPLICATION_EMAIL_TO_OWNER.format(user=application.owner)
542 flash(msg, "success")
544 # redirect the user back to the edit page
545 return redirect(url_for('.application', application_id=application_id))
548@blueprint.route("/admin_site_search", methods=["GET"])
549@login_required
550@ssl_required
551def admin_site_search():
552 # edit_formcontext = formcontext.ManEdBulkEdit()
553 # edit_form = edit_formcontext.render_template()
554 edit_formulaic_context = JournalFormFactory.context("bulk_edit", extra_param=exparam_editing_user())
555 edit_form = edit_formulaic_context.render_template()
557 return render_template(templates.ADMIN_SITE_SEARCH,
558 admin_page=True,
559 edit_form=edit_form)
562@blueprint.route("/editor_groups")
563@login_required
564@ssl_required
565def editor_group_search():
566 return render_template(templates.EDITOR_GROUP_SEARCH, admin_page=True)
569@blueprint.route("/background_jobs")
570@login_required
571@ssl_required
572def background_jobs_search():
573 return render_template(templates.BACKGROUND_JOBS_SEARCH, admin_page=True)
576@blueprint.route("/notifications")
577@login_required
578@ssl_required
579def global_notifications_search():
580 """ ~~->AdminNotificationsSearch:Page~~ """
581 return render_template(templates.GLOBAL_NOTIFICATIONS_SEARCH, admin_page=True)
584@blueprint.route("/editor_group", methods=["GET", "POST"])
585@blueprint.route("/editor_group/<group_id>", methods=["GET", "POST"])
586@login_required
587@ssl_required
588@write_required()
589def editor_group(group_id=None):
590 if not current_user.has_role("modify_editor_groups"):
591 abort(401)
593 # ~~->EditorGroup:Form~~
594 if request.method == "GET":
595 form = EditorGroupForm()
596 if group_id is not None:
597 eg = models.EditorGroup.pull(group_id)
598 form.group_id.data = eg.id
599 form.name.data = eg.name
600 # Do not allow the user to edit the name. issue #3859
601 form.name.render_kw = {'disabled': True}
602 form.maned.data = eg.maned
603 form.editor.data = eg.editor
604 form.associates.data = ",".join(eg.associates)
605 return render_template(templates.EDITOR_GROUP, admin_page=True, form=form)
607 elif request.method == "POST":
609 if request.values.get("delete", "false") == "true":
610 # we have been asked to delete the id
611 if group_id is None:
612 # we can only delete things that exist
613 abort(400)
614 eg = models.EditorGroup.pull(group_id)
615 if eg is None:
616 abort(404)
618 eg.delete()
620 # return a json response
621 resp = make_response(json.dumps({"success": True}))
622 resp.mimetype = "application/json"
623 return resp
625 # otherwise, we want to edit the content of the form or the object
626 form = EditorGroupForm(request.form)
628 if form.validate():
629 # get the group id from the url or from the request parameters
630 if group_id is None:
631 group_id = request.values.get("group_id")
632 group_id = group_id if group_id != "" else None
634 # if we have a group id, this is an edit, so get the existing group
635 if group_id is not None:
636 eg = models.EditorGroup.pull(group_id)
637 if eg is None:
638 abort(404)
639 else:
640 eg = models.EditorGroup()
642 associates = form.associates.data
643 if associates is not None:
644 associates = [a.strip() for a in associates.split(",") if a.strip() != ""]
646 # prep the user accounts with the correct role(s)
647 ed = models.Account.pull(form.editor.data)
648 ed.add_role("editor")
649 ed.save()
650 if associates is not None:
651 for a in associates:
652 ae = models.Account.pull(a)
653 if ae is not None: # If the account has been deleted, pull fails
654 ae.add_role("associate_editor")
655 ae.save()
657 if eg.name is None:
658 eg.set_name(form.name.data)
659 eg.set_maned(form.maned.data)
660 eg.set_editor(form.editor.data)
661 if associates is not None:
662 eg.set_associates(associates)
663 eg.save()
665 flash(
666 "Group was updated - changes may not be reflected below immediately. Reload the page to see the update.",
667 "success")
668 return redirect(url_for('admin.editor_group_search'))
669 else:
670 return render_template(templates.EDITOR_GROUP, admin_page=True, form=form)
673@blueprint.route("/autocomplete/user")
674@login_required
675@ssl_required
676def user_autocomplete():
677 q = request.values.get("q")
678 s = request.values.get("s", 10)
679 admin_only = "admin_only" in request.args
680 if admin_only:
681 ac = models.Account.admin_autocomplete("id", q, size=s)
682 else:
683 ac = models.Account.autocomplete("id", q, size=s)
685 # return a json response
686 resp = make_response(json.dumps({"suggestions": ac}))
687 resp.mimetype = "application/json"
688 return resp
691# Route which returns the associate editor account names within a given editor group
692@blueprint.route("/dropdown/eg_associates")
693@login_required
694@ssl_required
695def eg_associates_dropdown():
696 egn = request.values.get("egn")
697 eg = models.EditorGroup.pull_by_key("name", egn)
699 if eg is not None:
700 editors = [eg.editor]
701 editors += eg.associates
702 editors = list(set(editors))
703 else:
704 editors = None
706 # return a json response
707 resp = make_response(json.dumps(editors))
708 resp.mimetype = "application/json"
709 return resp
712####################################################
713## endpoints for bulk edit
715class BulkAdminEndpointException(Exception):
716 pass
719@app.errorhandler(BulkAdminEndpointException)
720def bulk_admin_endpoints_bad_request(exception):
721 r = {}
722 r['error'] = exception.message
723 return make_json_resp(r, status_code=400)
726def get_bulk_edit_background_task_manager(doaj_type):
727 if doaj_type == 'journals':
728 return journal_bulk_edit.journal_manage
729 elif doaj_type in ['applications', 'update_requests']:
730 return suggestion_bulk_edit.suggestion_manage
731 else:
732 raise BulkAdminEndpointException(
733 'Unsupported DOAJ type - you can currently only bulk edit journals and applications/update_requests.')
736def get_query_from_request(payload, doaj_type=None):
737 q = payload['selection_query']
738 q = remove_search_limits(q)
740 q = Query(q)
742 if doaj_type == "update_requests":
743 update_request(q)
744 elif doaj_type == "applications":
745 not_update_request(q)
747 return q.as_dict()
750@blueprint.route("/<doaj_type>/bulk/assign_editor_group", methods=["POST"])
751@login_required
752@ssl_required
753@write_required()
754def bulk_assign_editor_group(doaj_type):
755 task = get_bulk_edit_background_task_manager(doaj_type)
757 payload = get_web_json_payload()
758 validate_json(payload, fields_must_be_present=['selection_query', 'editor_group'],
759 error_to_raise=BulkAdminEndpointException)
761 summary = task(
762 selection_query=get_query_from_request(payload, doaj_type),
763 editor_group=payload['editor_group'],
764 dry_run=payload.get('dry_run', True)
765 )
767 return make_json_resp(summary.as_dict(), status_code=200)
770@blueprint.route("/<doaj_type>/bulk/add_note", methods=["POST"])
771@login_required
772@ssl_required
773@write_required()
774def bulk_add_note(doaj_type):
775 task = get_bulk_edit_background_task_manager(doaj_type)
777 payload = get_web_json_payload()
778 validate_json(payload, fields_must_be_present=['selection_query', 'note'],
779 error_to_raise=BulkAdminEndpointException)
781 summary = task(
782 selection_query=get_query_from_request(payload, doaj_type),
783 note=payload['note'],
784 dry_run=payload.get('dry_run', True)
785 )
787 return make_json_resp(summary.as_dict(), status_code=200)
790@blueprint.route("/journals/bulk/edit_metadata", methods=["POST"])
791@login_required
792@ssl_required
793@write_required()
794def bulk_edit_journal_metadata():
795 task = get_bulk_edit_background_task_manager("journals")
797 payload = get_web_json_payload()
798 if not "metadata" in payload:
799 raise BulkAdminEndpointException("key 'metadata' not present in request json")
801 formdata = MultiDict(payload["metadata"])
802 formulaic_context = JournalFormFactory.context("bulk_edit", extra_param=exparam_editing_user())
803 fc = formulaic_context.processor(formdata=formdata)
805 if not fc.validate():
806 msg = "Unable to submit your request due to form validation issues: "
807 for field in fc.form:
808 if field.errors:
809 msg += field.label.text + " - " + ",".join(field.errors)
810 summary = BackgroundSummary(None, error=msg)
811 else:
812 summary = task(
813 selection_query=get_query_from_request(payload),
814 dry_run=payload.get('dry_run', True),
815 **payload["metadata"]
816 )
818 return make_json_resp(summary.as_dict(), status_code=200)
821@blueprint.route("/<doaj_type>/bulk/change_status", methods=["POST"])
822@login_required
823@ssl_required
824@write_required()
825def applications_bulk_change_status(doaj_type):
826 if doaj_type not in ["applications", "update_requests"]:
827 abort(403)
828 payload = get_web_json_payload()
829 validate_json(payload, fields_must_be_present=['selection_query', 'application_status'],
830 error_to_raise=BulkAdminEndpointException)
832 q = get_query_from_request(payload, doaj_type)
833 summary = get_bulk_edit_background_task_manager('applications')(
834 selection_query=q,
835 application_status=payload['application_status'],
836 dry_run=payload.get('dry_run', True)
837 )
839 return make_json_resp(summary.as_dict(), status_code=200)
842@blueprint.route("/journals/bulk/delete", methods=['POST'])
843@write_required()
844def bulk_journals_delete():
845 if not current_user.has_role("ultra_bulk_delete"):
846 abort(403)
847 payload = get_web_json_payload()
848 validate_json(payload, fields_must_be_present=['selection_query'], error_to_raise=BulkAdminEndpointException)
850 q = get_query_from_request(payload)
851 summary = journal_bulk_delete.journal_bulk_delete_manage(
852 selection_query=q,
853 dry_run=payload.get('dry_run', True)
854 )
855 return make_json_resp(summary.as_dict(), status_code=200)
858@blueprint.route("/articles/bulk/delete", methods=['POST'])
859@write_required()
860def bulk_articles_delete():
861 if not current_user.has_role("ultra_bulk_delete"):
862 abort(403)
863 payload = get_web_json_payload()
864 validate_json(payload, fields_must_be_present=['selection_query'], error_to_raise=BulkAdminEndpointException)
866 q = get_query_from_request(payload)
867 summary = article_bulk_delete.article_bulk_delete_manage(
868 selection_query=q,
869 dry_run=payload.get('dry_run', True)
870 )
872 return make_json_resp(summary.as_dict(), status_code=200)
874#################################################
876################################################
877## Reporting endpoint
879@blueprint.route("/report", methods=["POST"])
880@write_required()
881@login_required
882def request_report():
883 model = request.values.get("model")
884 query_raw = request.values.get("query")
885 name = request.values.get("name")
886 # TODO: it's probably a bit cheeky to use the type param (used for casting) to run our lambda function, but it works
887 notes = request.values.get("notes", False, lambda x: json.loads(x))
889 if notes is True and not current_user.has_role(constants.ROLE_ADMIN_REPORT_WITH_NOTES): # "ultra_admin_reports_with_notes"
890 # Fixme: this abort is only visible within the network console, because it's a jSON endpoint to serve the page.
891 # It will just appear that the generate button isn't working (but they'll have edited the HTML to re-enable the checkbox)
892 abort(403)
894 query = json.loads(query_raw)
895 sane_query = {"query": query.get("query")}
896 if "sort" in query:
897 sane_query["sort"] = query["sort"]
899 model_endpoint_map = {
900 "journal": "journal",
901 "application": "suggestion"
902 }
904 query_svc = DOAJ.queryService()
905 real_query = query_svc.make_actionable_query("admin_query", model_endpoint_map.get(model), current_user, sane_query)
907 job = admin_reports.AdminReportsBackgroundTask.prepare(current_user.id, model=model, true_query=real_query.as_dict(), ui_query=sane_query, name=name, notes=notes)
908 admin_reports.AdminReportsBackgroundTask.submit(job)
910 return make_json_resp({"job_id": job.id}, status_code=200)
913@blueprint.route("/report/<report_id>", methods=["GET"])
914@login_required
915def get_report(report_id):
916 def safe(filename):
917 return re.sub(r'[^a-zA-Z0-9-_]', '_', filename)
919 exporter = DOAJ.exportService()
920 record, fh = exporter.retrieve(report_id)
921 safe_filename = safe(record.name) + "_" + safe(record.generated_date) + ".csv"
922 return send_file(fh, as_attachment=True, download_name=safe_filename)
924@blueprint.route("/reports", methods=["GET"])
925@login_required
926def reports_search():
927 return render_template(templates.ADMIN_REPORTS_SEARCH)
929@blueprint.route("/alerts", methods=["GET"])
930@login_required
931def admin_alerts():
932 return render_template(templates.ADMIN_ALERTS_SEARCH)
934@blueprint.route("/autoassign", methods=["GET"])
935@login_required
936def autoassign_search():
937 return render_template(templates.ADMIN_AUTOASSIGN_SEARCH)
939@blueprint.route("/journal-csv", methods=["GET"])
940@login_required
941def journal_csv_search():
942 svc = DOAJ.journalService()
943 free = svc.get_free_csv()
944 premium = svc.get_premium_csv()
945 return render_template(templates.ADMIN_JOURNAL_CSV_SEARCH, free=free, premium=premium)
947@blueprint.route("/journal-csv/delete", methods=["POST"])
948@write_required()
949@login_required
950def journal_csv_delete():
951 svc = DOAJ.journalService()
952 id = request.values.get("id")
953 if id is None:
954 abort(400)
955 try:
956 svc.delete_csv(id)
957 return make_json_resp({"status": "success"}, status_code=200)
958 except:
959 abort(400)
961@blueprint.route("/pdd", methods=["GET"])
962@login_required
963def pdd_search():
964 svc = DOAJ.publicDataDumpService()
965 free = svc.get_free_dump()
966 premium = svc.get_premium_dump()
967 return render_template(templates.ADMIN_PDD_SEARCH, free=free, premium=premium)
969@blueprint.route("/pdd/delete", methods=["POST"])
970@write_required()
971@login_required
972def pdd_delete():
973 svc = DOAJ.publicDataDumpService()
974 id = request.values.get("id")
975 if id is None:
976 abort(400)
977 try:
978 svc.delete_pdd(id)
979 return make_json_resp({"status": "success"}, status_code=200)
980 except:
981 abort(400)
983@blueprint.route("/ris", methods=["GET"])
984@login_required
985def ris_search():
986 return render_template(templates.ADMIN_RIS_SEARCH)
988@blueprint.route("/ris/<id>/<action>", methods=["POST"])
989@login_required
990def ris_manage(id, action):
991 if action not in ["delete", "regenerate"]:
992 abort(404)
994 svc = DOAJ.exportService()
996 if action == "delete":
997 svc.remove_ris(id)
998 return make_json_resp({"delete": "success"}, status_code=200)
1000 if action == "regenerate":
1001 svc.ris(id)
1002 return make_json_resp({"regenerate": "success"}, status_code=200)