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

1import json, re 

2from collections import namedtuple 

3from typing import Iterable, List 

4 

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 

9 

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 

35 

36# ~~Admin:Blueprint~~ 

37blueprint = Blueprint('admin', __name__) 

38 

39 

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

44 

45 

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) 

52 

53 

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) 

63 

64 

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) 

77 

78 # get the total number of journals to be affected 

79 jtotal = models.Journal.hit_count(query, consistent_order=False) 

80 

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) 

84 

85 resp = make_response(json.dumps({"journals": jtotal, "articles": atotal})) 

86 resp.mimetype = "application/json" 

87 return resp 

88 

89 elif request.method == "DELETE": 

90 if not current_user.has_role("delete_article"): 

91 abort(401) 

92 

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) 

98 

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 

105 

106 

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) 

126 

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) 

132 

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 

139 

140 

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 

160 

161 

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) 

172 

173 fc = ArticleFormFactory.get_from_context(role="admin", source=ap, user=current_user) 

174 if request.method == "GET": 

175 return fc.render_template() 

176 

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) 

180 

181 fc.modify_authors_if_required(request.values) 

182 

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) 

190 

191 return fc.render_template() 

192 

193 

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

202 

203 journal, _ = journal_svc.journal(journal_id) 

204 if journal is None: 

205 abort(404) 

206 

207 try: 

208 auth_svc.can_edit_journal(current_user._get_current_object(), journal) 

209 except exceptions.AuthoriseException: 

210 abort(401) 

211 

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) 

217 

218 fc = JournalFormFactory.context("admin", extra_param=exparam_editing_user()) 

219 autochecks = models.Autocheck.for_journal(journal_id) 

220 

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) 

232 

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) 

236 

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) 

240 

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) 

255 

256 

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) 

264 

265 fc = JournalFormFactory.context("admin_readonly") 

266 fc.processor(source=j) 

267 return fc.render_template(obj=j, lcc_tree=lcc_jstree, notabs=True) 

268 

269DisplayContData = namedtuple('DisplayContData', ['issn', 'title', 'id']) 

270 

271 

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) 

277 

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

285 

286 

287###################################################### 

288# Endpoints for reinstating/withdrawing journals from the DOAJ 

289# 

290 

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

304 

305 return redirect(url_for('.journal_page', journal_id=journal_id, job=job.id)) 

306 

307 

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

315 

316 

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) 

324 

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) 

328 

329 

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) 

337 

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) 

341 

342 

343# 

344##################################################################### 

345 

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) 

352 

353 return {'n_articles': models.Article.count_by_issns(j.bibjson().issns(), in_doaj=True)} 

354 

355 

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) 

362 

363 issns = j.bibjson().issns() 

364 if not issns: 

365 abort(404) 

366 

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

369 

370 

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) 

379 

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) 

385 

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) 

390 

391 if form.type.data is None: 

392 abort(400) 

393 

394 if form.type.data not in ["replaces", "is_replaced_by"]: 

395 abort(400) 

396 

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) 

402 

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

408 

409 

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

418 

419 

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

428 

429 

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

437 

438 ap, _ = application_svc.application(application_id) 

439 

440 if ap is None: 

441 abort(404) 

442 

443 try: 

444 auth_svc.can_edit_application(current_user._get_current_object(), ap) 

445 except exceptions.AuthoriseException: 

446 abort(401) 

447 

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) 

452 

453 fc = ApplicationFormFactory.context("admin", extra_param=exparam_editing_user()) 

454 form_diff, current_journal = ApplicationFormXWalk.update_request_diff(ap) 

455 

456 autochecks = models.Autocheck.for_application(application_id) 

457 

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) 

462 

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) 

483 

484 

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) 

502 

503 applicationService = DOAJ.applicationService() 

504 

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) 

512 

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) 

517 

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

522 

523 # reject the application 

524 old_status = application.application_status 

525 applicationService.reject_application(application, current_user._get_current_object(), note=note) 

526 

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

537 

538 # sort out some flash messages for the user 

539 flash(note, "success") 

540 

541 msg = Messages.SENT_REJECTED_APPLICATION_EMAIL_TO_OWNER.format(user=application.owner) 

542 flash(msg, "success") 

543 

544 # redirect the user back to the edit page 

545 return redirect(url_for('.application', application_id=application_id)) 

546 

547 

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

556 

557 return render_template(templates.ADMIN_SITE_SEARCH, 

558 admin_page=True, 

559 edit_form=edit_form) 

560 

561 

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) 

567 

568 

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) 

574 

575 

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) 

582 

583 

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) 

592 

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) 

606 

607 elif request.method == "POST": 

608 

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) 

617 

618 eg.delete() 

619 

620 # return a json response 

621 resp = make_response(json.dumps({"success": True})) 

622 resp.mimetype = "application/json" 

623 return resp 

624 

625 # otherwise, we want to edit the content of the form or the object 

626 form = EditorGroupForm(request.form) 

627 

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 

633 

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

641 

642 associates = form.associates.data 

643 if associates is not None: 

644 associates = [a.strip() for a in associates.split(",") if a.strip() != ""] 

645 

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

656 

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

664 

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) 

671 

672 

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) 

684 

685 # return a json response 

686 resp = make_response(json.dumps({"suggestions": ac})) 

687 resp.mimetype = "application/json" 

688 return resp 

689 

690 

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) 

698 

699 if eg is not None: 

700 editors = [eg.editor] 

701 editors += eg.associates 

702 editors = list(set(editors)) 

703 else: 

704 editors = None 

705 

706 # return a json response 

707 resp = make_response(json.dumps(editors)) 

708 resp.mimetype = "application/json" 

709 return resp 

710 

711 

712#################################################### 

713## endpoints for bulk edit 

714 

715class BulkAdminEndpointException(Exception): 

716 pass 

717 

718 

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) 

724 

725 

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

734 

735 

736def get_query_from_request(payload, doaj_type=None): 

737 q = payload['selection_query'] 

738 q = remove_search_limits(q) 

739 

740 q = Query(q) 

741 

742 if doaj_type == "update_requests": 

743 update_request(q) 

744 elif doaj_type == "applications": 

745 not_update_request(q) 

746 

747 return q.as_dict() 

748 

749 

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) 

756 

757 payload = get_web_json_payload() 

758 validate_json(payload, fields_must_be_present=['selection_query', 'editor_group'], 

759 error_to_raise=BulkAdminEndpointException) 

760 

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 ) 

766 

767 return make_json_resp(summary.as_dict(), status_code=200) 

768 

769 

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) 

776 

777 payload = get_web_json_payload() 

778 validate_json(payload, fields_must_be_present=['selection_query', 'note'], 

779 error_to_raise=BulkAdminEndpointException) 

780 

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 ) 

786 

787 return make_json_resp(summary.as_dict(), status_code=200) 

788 

789 

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

796 

797 payload = get_web_json_payload() 

798 if not "metadata" in payload: 

799 raise BulkAdminEndpointException("key 'metadata' not present in request json") 

800 

801 formdata = MultiDict(payload["metadata"]) 

802 formulaic_context = JournalFormFactory.context("bulk_edit", extra_param=exparam_editing_user()) 

803 fc = formulaic_context.processor(formdata=formdata) 

804 

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 ) 

817 

818 return make_json_resp(summary.as_dict(), status_code=200) 

819 

820 

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) 

831 

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 ) 

838 

839 return make_json_resp(summary.as_dict(), status_code=200) 

840 

841 

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) 

849 

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) 

856 

857 

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) 

865 

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 ) 

871 

872 return make_json_resp(summary.as_dict(), status_code=200) 

873 

874################################################# 

875 

876################################################ 

877## Reporting endpoint 

878 

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

888 

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) 

893 

894 query = json.loads(query_raw) 

895 sane_query = {"query": query.get("query")} 

896 if "sort" in query: 

897 sane_query["sort"] = query["sort"] 

898 

899 model_endpoint_map = { 

900 "journal": "journal", 

901 "application": "suggestion" 

902 } 

903 

904 query_svc = DOAJ.queryService() 

905 real_query = query_svc.make_actionable_query("admin_query", model_endpoint_map.get(model), current_user, sane_query) 

906 

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) 

909 

910 return make_json_resp({"job_id": job.id}, status_code=200) 

911 

912 

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) 

918 

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) 

923 

924@blueprint.route("/reports", methods=["GET"]) 

925@login_required 

926def reports_search(): 

927 return render_template(templates.ADMIN_REPORTS_SEARCH) 

928 

929@blueprint.route("/alerts", methods=["GET"]) 

930@login_required 

931def admin_alerts(): 

932 return render_template(templates.ADMIN_ALERTS_SEARCH) 

933 

934@blueprint.route("/autoassign", methods=["GET"]) 

935@login_required 

936def autoassign_search(): 

937 return render_template(templates.ADMIN_AUTOASSIGN_SEARCH) 

938 

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) 

946 

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) 

960 

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) 

968 

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) 

982 

983@blueprint.route("/ris", methods=["GET"]) 

984@login_required 

985def ris_search(): 

986 return render_template(templates.ADMIN_RIS_SEARCH) 

987 

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) 

993 

994 svc = DOAJ.exportService() 

995 

996 if action == "delete": 

997 svc.remove_ris(id) 

998 return make_json_resp({"delete": "success"}, status_code=200) 

999 

1000 if action == "regenerate": 

1001 svc.ris(id) 

1002 return make_json_resp({"regenerate": "success"}, status_code=200)