Coverage for portality / view / publisher.py: 37%
305 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
1from flask import Blueprint, request, make_response
2from flask import render_template, abort, redirect, url_for, flash
3from flask_login import current_user, login_required
5from portality.app_email import EmailException
6from portality import models, constants
7from portality.bll.exceptions import AuthoriseException, ArticleMergeConflict, DuplicateArticleException, \
8 ArticleNotAcceptable, NoSuchObjectException
9from portality.decorators import ssl_required, restrict_to_role, write_required
10from portality.dao import ESMappingMissingError
11from portality.forms.application_forms import ApplicationFormFactory
12from portality.tasks.ingestarticles import IngestArticlesBackgroundTask, BackgroundException
13from portality.tasks.preservation import *
14from portality.ui.messages import Messages
15from portality.ui import templates
16from portality import lock
17from portality.models import DraftApplication
18from portality.lcc import lcc_jstree
19from portality.forms.article_forms import ArticleFormFactory
20from portality.store import StoreFactory
22from huey.exceptions import TaskException
24import uuid
26from portality.view.view_helper import exparam_editing_user
28blueprint = Blueprint('publisher', __name__)
30# restrict everything in admin to logged in users with the "publisher" role
31@blueprint.before_request
32def restrict():
33 return restrict_to_role('publisher')
35@blueprint.route("/")
36@login_required
37@ssl_required
38def index():
39 return render_template(templates.PUBLISHER_DRAFTS)
42@blueprint.route("/journal")
43@login_required
44@ssl_required
45def journals():
46 return render_template(templates.PUBLISHER_JOURNAL_SEARCH, lcc_tree=lcc_jstree)
49@blueprint.route("/application/<application_id>/delete", methods=["GET"])
50@write_required()
51def delete_application(application_id):
52 # FIXME: this should be a POST or DELETE request
53 # if this is a draft application, we can just remove it
54 draft_application = DraftApplication.pull(application_id)
55 if draft_application is not None:
56 draft_application.delete()
57 return redirect(url_for("publisher.deleted_thanks"))
59 # otherwise delegate to the application service to sort this out
60 appService = DOAJ.applicationService()
61 try:
62 appService.delete_application(application_id, current_user._get_current_object())
63 except NoSuchObjectException:
64 abort(404)
66 return redirect(url_for("publisher.deleted_thanks"))
69@blueprint.route("/application/deleted")
70def deleted_thanks():
71 return render_template(templates.PUBLISHER_APPLICATION_DELETED)
74@blueprint.route("/update_request/<journal_id>", methods=["GET", "POST", "DELETE"])
75@login_required
76@ssl_required
77@write_required()
78def update_request(journal_id):
79 # DOAJ BLL for this request
80 journalService = DOAJ.journalService()
81 applicationService = DOAJ.applicationService()
83 # if this is a delete request, deal with it first and separately from the below logic
84 if request.method == "DELETE":
85 journal, _ = journalService.journal(journal_id)
86 application_id = journal.current_application
87 if application_id is not None:
88 applicationService.delete_application(application_id, current_user._get_current_object())
89 else:
90 abort(404)
91 return ""
93 # load the application either directly or by crosswalking the journal object
94 application = None
95 jlock = None
96 alock = None
97 try:
98 application, jlock, alock = applicationService.update_request_for_journal(journal_id, account=current_user._get_current_object())
99 except AuthoriseException as e:
100 if e.reason == AuthoriseException.WRONG_STATUS:
101 journal, _ = journalService.journal(journal_id)
102 return render_template(templates.PUBLISHER_APPLICATION_ALREADY_SUBMITTED, journal=journal)
103 else:
104 abort(404)
105 except lock.Locked as e:
106 journal, _ = journalService.journal(journal_id)
107 return render_template(templates.PUBLISHER_LOCKED, journal=journal, lock=e.lock)
109 # if we didn't find an application or journal, 404 the user
110 if application is None:
111 if jlock is not None: jlock.delete()
112 if alock is not None: alock.delete()
113 abort(404)
115 # if we have a live application and cancel was hit, then cancel the operation and redirect
116 # first determine if this is a cancel request on the form
117 cancelled = request.values.get("cancel")
118 if cancelled is not None:
119 if jlock is not None: jlock.delete()
120 if alock is not None: alock.delete()
121 return redirect(url_for("publisher.updates_in_progress"))
123 fc = ApplicationFormFactory.context("update_request", extra_param=exparam_editing_user())
125 # if we are requesting the page with a GET, we just want to show the form
126 if request.method == "GET":
127 fc.processor(source=application)
128 return fc.render_template(obj=application)
130 # if we are requesting the page with a POST, we need to accept the data and handle it
131 elif request.method == "POST":
132 processor = fc.processor(formdata=request.form, source=application)
133 if processor.validate():
134 try:
135 processor.finalise()
136 Messages.flash(Messages.PUBLISHER_APPLICATION_UPDATE_SUBMITTED_FLASH)
137 for a in processor.alert:
138 Messages.flash_with_url(a, "success")
139 return redirect(url_for("publisher.updates_in_progress"))
140 except Exception as e:
141 Messages.flash(str(e))
142 return redirect(url_for("publisher.update_request", journal_id=journal_id, _anchor='cannot_edit'))
143 finally:
144 if jlock is not None: jlock.delete()
145 if alock is not None: alock.delete()
146 else:
147 return fc.render_template(obj=application)
150@blueprint.route("/view_application/<application_id>", methods=["GET"])
151@login_required
152@ssl_required
153def application_readonly(application_id):
154 # DOAJ BLL for this request
155 applicationService = DOAJ.applicationService()
156 authService = DOAJ.authorisationService()
158 application, _ = applicationService.application(application_id)
159 try:
160 authService.can_view_application(current_user._get_current_object(), application)
161 except AuthoriseException as e:
162 abort(404)
164 fc = ApplicationFormFactory.context("application_read_only")
165 fc.processor(source=application)
166 # fc = formcontext.ApplicationFormFactory.get_form_context(role="update_request_readonly", source=application)
168 return fc.render_template(obj=application)
171@blueprint.route("/view_update_request/<application_id>", methods=["GET", "POST"])
172@login_required
173@ssl_required
174def update_request_readonly(application_id):
175 return redirect(url_for("publisher.application_readonly", application_id=application_id))
178@blueprint.route('/progress')
179@login_required
180@ssl_required
181def updates_in_progress():
182 return render_template(templates.PUBLISHER_UPDATE_REQUESTS)
185@blueprint.route("/uploadfile", methods=["GET", "POST"])
186@login_required
187@ssl_required
188@write_required()
189def upload_file():
190 """
191 ~~UploadMetadata:Feature->UploadMetadata:Page~~
192 ~~->Crossref442:Feature~~
193 ~~->Crossref531:Feature~~
194 """
196 # all responses involve getting the previous uploads
197 previous = models.FileUpload.by_owner(current_user.id)
199 if request.method == "GET":
200 schema = request.cookies.get("schema")
201 if schema is None:
202 schema = ""
203 return render_template(templates.PUBLISHER_XML_UPLOAD, previous=previous, schema=schema, error=False)
205 # otherwise we are dealing with a POST - file upload or supply of url
206 f = request.files.get("file")
207 schema = request.values.get("schema")
208 if not schema:
209 abort(400)
210 url = request.values.get("upload-xml-link")
211 resp = make_response(redirect(url_for("publisher.upload_file")))
212 resp.set_cookie("schema", schema)
214 # file upload takes precedence over URL, in case the user has given us both
215 if f is not None and f.filename != "" and url is not None and url != "":
216 flash("You provided a file and a URL - the URL has been ignored")
218 try:
219 job = IngestArticlesBackgroundTask.prepare(current_user.id, upload_file=f, schema=schema, url=url, previous=previous)
220 IngestArticlesBackgroundTask.submit(job)
221 except TaskException as e:
222 flash(Messages.PUBLISHER_UPLOAD_ERROR.format(error_str=str(e)))
223 app.logger.exception('File upload error. ' + str(e))
224 return resp
225 except BackgroundException as e:
226 if str(e) == Messages.NO_FILE_UPLOAD_ID:
227 schema = request.cookies.get("schema")
228 if schema is None:
229 schema = ""
230 return render_template(templates.PUBLISHER_XML_UPLOAD, previous=previous, schema=schema, error=True)
232 flash(Messages.PUBLISHER_UPLOAD_ERROR.format(error_str=str(e)))
233 app.logger.exception('File upload error. ' + str(e))
234 return resp
236 if f is not None and f.filename != "":
237 flash("File uploaded and waiting to be processed. Check back here for updates.", "success")
238 return resp
240 if url is not None and url != "":
241 flash("File successfully received - it will be processed shortly", "success")
242 return resp
244 flash("No file or URL provided", "error")
245 return resp
248@blueprint.route("/preservation", methods=["GET", "POST"])
249@login_required
250@ssl_required
251@write_required()
252def preservation():
253 """Upload articles on Internet Servers for archiving.
254 This feature is available for the users who has 'preservation' role.
255 """
257 if app.config.get('PRESERVATION_PAGE_UNDER_MAINTENANCE', False):
258 return render_template(templates.PUBLISHER_PRESERVATION_READONLY)
260 previous = []
261 try:
262 previous = models.PreservationState.by_owner(current_user.id)
263 # handle exception if there are no records available
264 except ESMappingMissingError:
265 pass
267 if request.method == "GET":
268 return render_template(templates.PUBLISHER_PRESERVATION, previous=previous)
270 if request.method == "POST":
272 f = request.files.get("file")
274 resp = make_response(redirect(url_for("publisher.preservation")))
276 # create model object to store status details
277 preservation_model = models.PreservationState()
278 preservation_model.set_id()
281 previous.insert(0, preservation_model)
283 app.logger.debug(f"Preservation model created with id {preservation_model.id}")
285 if f is None or f.filename == "":
286 error_str = Messages.PRESERVATION_NO_FILE
287 flash(error_str, "error")
288 preservation_model.initiated(current_user.id, "none")
289 preservation_model.failed(error_str)
290 preservation_model.save()
291 return resp
293 app.logger.info(f"Preservation file {f.filename}")
295 preservation_model.initiated(current_user.id, f.filename)
296 preservation_model.validated()
297 preservation_model.save()
299 # check if collection has been assigned for the user
300 # collection must be in the format {"user_id1",["collection_name1","collection_id1"],
301 # "user_id2",["collection_name2","collection_id2"]}
302 collection_dict = app.config.get("PRESERVATION_COLLECTION", {})
303 collection_available = True if collection_dict else False
304 if collection_dict and not current_user.id in collection_dict:
305 collection_available = False
306 elif collection_dict:
307 params = collection_dict[current_user.id]
308 if not len(params) == 2:
309 collection_available = False
311 if not collection_available:
312 flash(
313 "Cannot process upload - you do not have Collection details associated with your user ID. Please contact the DOAJ team.",
314 "error")
315 preservation_model.failed(FailedReasons.collection_not_available)
316 preservation_model.save()
318 else:
319 try:
320 job = PreservationBackgroundTask.prepare(current_user.id, upload_file=f)
321 PreservationBackgroundTask.set_param(job.params, "model_id", preservation_model.id)
322 PreservationBackgroundTask.submit(job)
324 flash("File uploaded and waiting to be processed.", "success")
326 except EmailException:
327 app.logger.exception('Error sending email' )
328 except (PreservationStorageException, PreservationException, Exception) as exp:
329 try:
330 uid = str(uuid.uuid1())
331 flash("An error has occurred and your preservation upload may not have succeeded. Please report the issue with the ID " + uid)
332 preservation_model.failed(str(exp) + " Issue id : " + uid)
333 preservation_model.save()
334 app.logger.exception('Preservation upload error. ' + uid)
335 if job:
336 background_task = PreservationBackgroundTask(job)
337 background_task.cleanup()
338 except Exception as e:
339 app.logger.exception('Unknown error.' + str(e))
340 return resp
343@blueprint.route("/metadata", methods=["GET", "POST"])
344@login_required
345@ssl_required
346@write_required()
347def metadata():
348 user = current_user._get_current_object()
349 # if this is a get request, give the blank form - there is no edit feature
350 if request.method == "GET":
351 fc = ArticleFormFactory.get_from_context(user=user, role="publisher")
352 return fc.render_template()
354 # if this is a post request, a form button has been hit and we need to do
355 # a bunch of work
356 elif request.method == "POST":
358 fc = ArticleFormFactory.get_from_context(role="publisher", user=user,
359 form_data=request.form)
360 # first we need to do any server-side form modifications which
361 # the user might request by pressing the add/remove authors buttons
363 fc.modify_authors_if_required(request.values)
365 validated = False
366 if fc.validate():
367 try:
368 fc.finalise()
369 validated = True
370 except ArticleMergeConflict:
371 Messages.flash(Messages.ARTICLE_METADATA_MERGE_CONFLICT)
372 except DuplicateArticleException:
373 Messages.flash(Messages.ARTICLE_METADATA_UPDATE_CONFLICT)
374 except ArticleNotAcceptable as e:
375 Messages.flash_with_param(e.message, "error")
376 return fc.render_template(validated=validated)
378@blueprint.route("/journal-csv", methods=["GET"])
379@login_required
380@ssl_required
381@write_required()
382def journal_csv():
383 if current_user.has_role(constants.ROLE_PUBLISHER_JOURNAL_CSV):
384 return render_template(templates.PUBLISHER_CSV_UPLOAD)
385 abort(403)
387@blueprint.route("/journal-csv/validate", methods=["POST"])
388@login_required
389@ssl_required
390@write_required()
391def journal_csv_validate():
392 if not current_user.has_role(constants.ROLE_PUBLISHER_JOURNAL_CSV):
393 abort(403)
394 if "journal_csv" not in request.files:
395 abort(400)
396 file = request.files["journal_csv"]
397 if not file.filename.endswith(".csv"):
398 abort(400)
400 tmpStore = StoreFactory.tmp()
401 container_id = app.config.get("JOURNAL_CSV_UPLOAD__TMP_CONTAINER", "publisher_csvs_validation")
402 filename = current_user.id + "_" + dates.now_str() + ".csv"
403 out = tmpStore.path(container_id, filename, create_container=True, must_exist=False)
404 file.save(out)
406 try:
407 report = DOAJ.applicationService().validate_update_csv(out, current_user)
408 finally:
409 tmpStore.delete_file(container_id, filename)
411 resp = make_response(report.json())
412 resp.mimetype = "application/json"
413 return resp
416@blueprint.route("/help")
417@login_required
418@ssl_required
419def help():
420 return render_template(templates.PUBLISHER_XML_HELP)
423def _validate_authors(form, require=1):
424 counted = 0
425 for entry in form.authors.entries:
426 name = entry.data.get("name")
427 if name is not None and name != "":
428 counted += 1
429 return counted >= require