diff --git a/pyproject.toml b/pyproject.toml index 8141b57b..a7c4a72b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ dependencies = [ "pytz==2018.7", "pyyaml==6.0.2", "referencing==0.35.1", + "reportlab>=4.2.0", "requests==2.32.3", "requests-toolbelt==1.0.0", "retry==0.9.2", diff --git a/submit_ce/implementations/file_store/gs_file_store.py b/submit_ce/implementations/file_store/gs_file_store.py index f5b85395..bf13f062 100644 --- a/submit_ce/implementations/file_store/gs_file_store.py +++ b/submit_ce/implementations/file_store/gs_file_store.py @@ -191,6 +191,18 @@ def get_preview(self, submission_id: str) -> FileObj: preview_path = self._preview_path(submission_id) preview = self.bucket.blob(preview_path) if preview.exists(): + # bucket.blob() returns a local reference with empty _properties; + # .exists() does a HEAD but doesn't populate metadata. Without + # reload(), preview.size and preview.crc32c are both None and + # the route layer's set_etag()/Content-Length header crash on + # None. This matters in particular for PDFs that arrived in + # the bucket via something other than our store_preview() path + # (e.g., hand-copied for testing). + try: + preview.reload() + except Exception as exc: + logger.debug("preview reload failed for %s: %s", + preview_path, exc) return preview else: return FileDoesNotExist(preview_path) @@ -464,8 +476,18 @@ def does_source_log_exist(self, submission_id: str) -> bool: return self.bucket.blob(self._source_log_path(submission_id)).exists() def _get_checksum(self, path: str) -> str: + """Return the crc32c checksum of the blob at ``path``. + + Returns an empty string when the blob doesn't exist or has no + crc32c (rather than ``None``), so callers feeding the value + into ``Response.set_etag()`` and ``Content-Length`` headers + don't crash. This matters in particular for blobs that arrived + in the bucket via something other than our upload paths (e.g. + hand-copied for testing), which may lack a crc32c on the + object. + """ item = self.bucket.get_blob(path) - return item.crc32c if item is not None else "" + return (item.crc32c or "") if item is not None else "" def _submission_path(self, submission_id: str) -> str: """Gets GS filesystem structure ex /{rootdir}/{first 4 digits of submission id}/{submission id}""" diff --git a/submit_ce/ui/controllers/new/final.py b/submit_ce/ui/controllers/new/final.py index af08f409..16b0434c 100644 --- a/submit_ce/ui/controllers/new/final.py +++ b/submit_ce/ui/controllers/new/final.py @@ -36,6 +36,24 @@ def finalize(method: str, params: MultiDict, session: Session, form = FinalizationForm(params) + # Check whether a preview PDF actually exists in the bucket. The + # persisted submitter_confirmed_preview flag can drift from file-store + # reality (e.g., a PDF was removed out-of-band, a file-change event + # missed resetting the flag, or in dev when state is manipulated + # directly). The Confirm page should gate Submit on what's actually + # in the bucket, not just the persisted flag, so an absent PDF can + # never produce an enabled Submit button. + fstore = current_app.api.get_file_store() + preview_exists = fstore.does_preview_exist(str(submission_id)) + preview_ready = bool(submission.submitter_confirmed_preview + and preview_exists) + logger.info( + "finalize: submission=%s confirmed_preview=%s preview_exists=%s " + "preview_ready=%s", + submission_id, submission.submitter_confirmed_preview, + preview_exists, preview_ready, + ) + # The abs preview macro expects a specific struct for submission history. # TODO submission.versions removed, what do do in final? # submission_history = [{'submitted_date': s.created, 'version': s.version} @@ -47,11 +65,24 @@ def finalize(method: str, params: MultiDict, session: Session, 'submission': submission, 'submitter': submitter, 'submission_history': submission_history, + 'preview_ready': preview_ready, + 'preview_exists': preview_exists, } + # Only treat this POST as an actual submit attempt when the form's + # "next" action was used. The Confirm form is shared by the nav bar's + # "Go Back" and "Save & Exit" buttons too -- those also POST the form + # (so the CSRF token comes along) but they're navigation actions, not + # the submit action. If the user has the proofread checkbox ticked and + # then clicks Go Back, we must NOT fire FinalizeSubmission; flow_control + # is supposed to redirect them to the previous step instead. + action = (params.get('action') or '').strip() + is_submit_action = action == 'next' + command = FinalizeSubmission(creator=submitter) proofread_confirmed = form.proceed.data - if method == 'POST' and form.validate() \ + if method == 'POST' and is_submit_action \ + and form.validate() \ and proofread_confirmed \ and validate_command(form, command, submission): try: diff --git a/submit_ce/ui/controllers/new/preview.py b/submit_ce/ui/controllers/new/preview.py index 8843702f..2e1d2bcb 100644 --- a/submit_ce/ui/controllers/new/preview.py +++ b/submit_ce/ui/controllers/new/preview.py @@ -1,23 +1,172 @@ -"""Controller for serving the compiled PDF preview for a submission.""" +"""Controller for serving the compiled PDF preview for a submission. + +The single entry point :func:`file_preview` is wired to the +``//preview.pdf`` route. It serves the compiled PDF (or +raises a friendly 404 when the PDF doesn't exist yet) and fires the +domain events needed to record that the submitter has viewed the +preview. +""" import io +import logging from http import HTTPStatus as status from typing import Tuple, Dict, Any from flask import current_app from arxiv.auth.domain import Session +from arxiv.files import FileDoesNotExist +from werkzeug.exceptions import NotFound -from ...auth import user_and_client_from_session +from submit_ce.domain.event import ConfirmSourceProcessed, ConfirmPreview from submit_ce.ui.backend import get_submission +from ...auth import user_and_client_from_session + + +logger = logging.getLogger(__name__) + def file_preview(params, session: Session, submission_id: str, token: str, **kwargs: Any) -> Tuple[io.BytesIO, int, Dict[str, str]]: - """Serve the PDF preview for a submission.""" + """Serve the PDF preview for a submission. + + Raises + ------ + werkzeug.exceptions.NotFound + If no preview PDF exists for this submission yet (e.g., the Process + step has not run or compilation has not produced a PDF). Flask + renders this as a 404 response, which is a much friendlier failure + mode than the raw ``Exception("File does not exist")`` that + :class:`arxiv.files.FileDoesNotExist` would otherwise raise when the + route tries to ``open()`` the missing file. + + Side effect: when the served PDF matches the current submission preview and + the submitter has not yet confirmed it (or has confirmed a stale checksum), + fire a ``ConfirmPreview`` event so the submitter is treated as having + reviewed the PDF. This mirrors legacy Submit 1.x behavior where opening + the PDF marked ``viewed=1`` on the submission row, which then enables the + Submit button on the Confirm page after a refresh. + """ submitter, client = user_and_client_from_session(session) submission, submission_events = get_submission(submission_id) fstore = current_app.api.get_file_store() + + # Check first that a preview PDF actually exists. Without this, the + # downstream ``send_file(stream.open('rb'), ...)`` in the route raises a + # generic Exception and the user sees a 500 stack trace in the new tab. + # Common cause: edge case where compilation is not working and submitter + # has Confirm and Submit page open. stream = fstore.get_preview(submission.submission_id) + if isinstance(stream, FileDoesNotExist) or not stream.exists(): + logger.info( + "PDF preview requested but not available for submission %s", + submission.submission_id, + ) + raise NotFound( + "The PDF preview is not yet available. Please return to the " + "Process step, wait for compilation to finish, and try again. " + "If processing failed, you may need to fix your source files " + "and reprocess." + ) + pdf_checksum = fstore.get_preview_checksum(submission.submission_id) + + # ---- diagnostic logging ------------------------------------------ + # Verbose INFO logging so the operator can trace exactly what the + # preview-view side effect did. If you're staring at the server + # console wondering why the Submit button is still grayed out, + # these are the lines to grep for. + preview_set = submission.preview is not None + preview_ck = submission.preview.preview_checksum if preview_set else None + logger.info( + "file_preview: submission=%s pdf_checksum=%r preview_set=%s " + "preview_checksum=%r confirmed_preview=%s", + submission.submission_id, pdf_checksum, preview_set, preview_ck, + submission.submitter_confirmed_preview, + ) + + # Build the list of events to save when the user views the PDF. + # + # ConfirmPreview's domain validation requires submission.preview to be + # set and to match preview_checksum -- otherwise it raises + # InvalidEvent("Preview not set on submission"). The legacy DB only + # persists submitter_confirmed_preview (via the `viewed` column); the + # Preview dataclass itself is not stored. That means reading the + # submission back with get_submission() after firing + # ConfirmSourceProcessed loses preview state, and any subsequent + # ConfirmPreview save would fail validation. + # + # Workaround: pass both events to a single save() call. The save loop + # applies events sequentially with `before = after`, so ConfirmPreview + # sees the in-memory submission with preview set by + # ConfirmSourceProcessed and validates cleanly. + # + # When to self-heal ConfirmSourceProcessed: + # - submission.preview is None (process step not run, hand-copied + # PDF, lost domain event), OR + # - preview_checksum has drifted (source was reprocessed or replaced + # but the old in-memory state is stale). + # In production this path is rare; logged at WARNING for visibility. + needs_source_processed = bool(pdf_checksum) and ( + submission.preview is None + or submission.preview.preview_checksum != pdf_checksum + ) + needs_confirm = ( + bool(pdf_checksum) + and not submission.submitter_confirmed_preview + ) + logger.info( + "file_preview: needs_source_processed=%s needs_confirm=%s", + needs_source_processed, needs_confirm, + ) + + events_to_save = [] + if needs_source_processed: + events_to_save.append( + ConfirmSourceProcessed( + creator=submitter, + client=client, + source_id=-1, # unknown for hand-copied PDFs + source_checksum='', # unknown for hand-copied PDFs + preview_checksum=pdf_checksum, + size_bytes=getattr(stream, 'size', None) or 0, + added=getattr(stream, 'updated', None), + ) + ) + if needs_confirm: + events_to_save.append( + ConfirmPreview(creator=submitter, client=client, + preview_checksum=pdf_checksum) + ) + + if events_to_save: + try: + current_app.api.save( + *events_to_save, + submission_id=submission.submission_id, + ) + if needs_source_processed: + logger.warning( + "Self-healed missing/stale ConfirmSourceProcessed for " + "submission %s from served PDF (checksum=%s). " + "Investigate the upstream Process step.", + submission.submission_id, pdf_checksum, + ) + logger.info( + "file_preview: saved %d event(s) for submission %s: %s", + len(events_to_save), submission.submission_id, + [e.__class__.__name__ for e in events_to_save], + ) + except Exception as exc: + # Don't silently swallow. The PDF still streams to the browser + # via the return below, but logging at ERROR with stack trace + # makes the failure obvious in the server console. + logger.exception( + "PDF-view event save failed for submission %s " + "(events=%s): %s", + submission.submission_id, + [e.__class__.__name__ for e in events_to_save], exc, + ) + headers = {'Content-Type': 'application/pdf', 'ETag': pdf_checksum} return stream, status.OK, headers diff --git a/submit_ce/ui/controllers/new/review.py b/submit_ce/ui/controllers/new/review.py index a87d8109..3aee3af3 100644 --- a/submit_ce/ui/controllers/new/review.py +++ b/submit_ce/ui/controllers/new/review.py @@ -7,6 +7,7 @@ from flask import current_app from arxiv.auth.domain import Session from arxiv.base import alerts +from markupsafe import Markup from submit_ce.domain.event.process import ( SetDecisions, SetDirectivesAndCleanup, @@ -169,8 +170,17 @@ def review_files(method: str, params: MultiDict, session: Session, ) if preflight_data is None: + # Preflight is what populates compiler choices, top-level TeX + # candidates, and per-file usage notes. Without it, the form + # below has nothing to render -- the template hides the form + # sections when file_notes is empty and surfaces this flash + # message + the main-area placeholder instead. alerts.flash_warning( - f"We couldn't load preflight data for this submission. {SUPPORT}", + Markup( + "We couldn't analyze the files in your submission " + "right now because the preflight service is " + "temporarily unavailable. Please refresh this page " + "to try again. ") + SUPPORT, title="Preflight unavailable") return stay_on_this_stage((rdata, status.OK, {})) @@ -319,7 +329,19 @@ def _load_or_create_preflight( except InvalidEvent: pass # nothing actionable in cleanup is fine - start_preflight(params, session, submission_id, token) + # Preflight calls into the external tex2pdf service; that service + # may be unreachable (local dev without the service running, a + # transient outage in production, etc.). If it fails, log and + # continue with preflight_data=None -- review_files() already has + # a clean handler for that case (flashes "Preflight unavailable" + # and stays on this stage) instead of bubbling up as a 500. + try: + start_preflight(params, session, submission_id, token) + except Exception as exc: + logger.warning( + "Could not run preflight for submission %s: %s", + submission_id, exc, + ) preflight_data = _get_preflight_data(submission_id) _store_source_format(preflight_data, session, submission_id) diff --git a/submit_ce/ui/controllers/new/source_package.py b/submit_ce/ui/controllers/new/source_package.py new file mode 100644 index 00000000..5ec4f6ca --- /dev/null +++ b/submit_ce/ui/controllers/new/source_package.py @@ -0,0 +1,147 @@ +"""Controller for the Download Source Package endpoint. + +Builds a fresh ``.tar.gz`` on the fly from the current source files in +the file store. Mirrors the pattern used by ``compile_at_gcp.py`` when it +bundles source for the tex2pdf service, and the Submit 1.5 +``create_source_package`` Perl routine in ``Submit.pm``: in both systems +the canonical source is the individual files under +``/src/``, and the archive is built at request time +rather than maintained as a separate artifact. + +Note: We deliberately do *not* serve the persisted ``.tar.gz`` +in the bucket: that copy is only refreshed by ``compile_at_gcp.py`` on a +successful compile (see the ``shutil.copy2(temp_tar_path, new_tar_path)`` +block guarded by ``not preflight and status == 'success'``). It +represents "the source that produced the last published PDF", not the +user's current working source. For a Download Package button shown on +the pre-compile Upload Files and Review Files steps, building from +current source is the correct semantics. + +Note: A warning is logged when a file is not found in the Google Storage bucket. +We may want to improve handling for such an error by alerting the user visually +in the UI and systematically detecting and alerting on such an error. This may +indicate inconsistent access to files in the Google Storage bucket. + +Linked from the sidebar of the Upload Files and Review Files pages. +""" + +from __future__ import annotations + +import io +import logging +import tarfile +from datetime import datetime, timezone +from http import HTTPStatus as status +from typing import Any, Dict, Tuple + +from arxiv.auth.domain import Session +from flask import current_app +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import NotFound + +from arxiv.files import FileDoesNotExist +from submit_ce.ui.backend import get_submission + + +logger = logging.getLogger(__name__) + + +def _build_source_tarball(submission_id: str) -> bytes: + """Build a fresh .tar.gz from the current source files in the bucket. + + Returns the gzipped tarball bytes. Files that are listed in the + workspace but no longer exist in the bucket (which would indicate + file-store drift) are skipped with a warning rather than aborting + the build. + + Raises + ------ + werkzeug.exceptions.NotFound + If the workspace has no source files (so there's nothing to + package). + """ + file_store = current_app.api.get_file_store() + workspace = file_store.get_workspace(submission_id=submission_id) + if not workspace or not workspace.files: + raise NotFound( + "No source files are attached to this submission yet. " + "Upload files on the Upload Files step before downloading " + "a source package." + ) + + buf = io.BytesIO() + files_added = 0 + files_skipped = 0 + with tarfile.open(fileobj=buf, mode='w:gz') as tar: + for f in workspace.files: + blob = file_store.get_source_file( + submission_id=submission_id, path=f.path) + if isinstance(blob, FileDoesNotExist): + logger.warning( + "source_package: workspace lists %s but blob is missing " + "for submission %s; skipping", + f.path, submission_id, + ) + files_skipped += 1 + continue + try: + data = blob.download_as_bytes() + except Exception as exc: + logger.warning( + "source_package: could not read %s for submission %s: " + "%s; skipping", + f.path, submission_id, exc, + ) + files_skipped += 1 + continue + + info = tarfile.TarInfo(name=f.path) + info.size = len(data) + # Use the blob's updated time if available; fall back to now. + updated = getattr(blob, 'updated', None) or datetime.now(timezone.utc) + try: + info.mtime = int(updated.timestamp()) + except (AttributeError, TypeError): + info.mtime = int(datetime.now(timezone.utc).timestamp()) + info.mode = 0o644 + tar.addfile(info, io.BytesIO(data)) + files_added += 1 + + logger.info( + "source_package: built tarball for submission %s " + "(files_added=%d, files_skipped=%d, size_bytes=%d)", + submission_id, files_added, files_skipped, buf.tell(), + ) + + buf.seek(0) + return buf.getvalue() + + +def download_source_package( + method: str, + params: MultiDict, + session: Session, + submission_id: str, + **kwargs: Any, +) -> Tuple[io.BytesIO, int, Dict[str, str]]: + """Return the submission's current source as a freshly-built .tar.gz. + + The filename follows the Submit 1.5 convention: + ``submission_-.tar.gz``. + """ + # Verify the submission exists in the current user's scope. Auth is + # already enforced by the route's @scoped decorator. + submission, _ = get_submission(submission_id) + + tar_bytes = _build_source_tarball(submission_id) + stream = io.BytesIO(tar_bytes) + stream.seek(0) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M") + filename = f"submission_{submission_id}-{timestamp}.tar.gz" + headers = { + "Content-Type": "application/gzip", + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + } + return stream, status.OK, headers diff --git a/submit_ce/ui/controllers/new/submission_agreement.py b/submit_ce/ui/controllers/new/submission_agreement.py new file mode 100644 index 00000000..014ccc50 --- /dev/null +++ b/submit_ce/ui/controllers/new/submission_agreement.py @@ -0,0 +1,207 @@ +"""Controller for the Download Submission Agreement endpoint. + +Serves a personalized PDF of the submission agreement, stamped with the +submission ID, submitter name, submission date, license, and agreement_id +that the user accepted on the Agreement step. + +This endpoint is invoked from the Confirm page (see final_preview.html) so +the user can retain a copy of the submission agreement they accepted. +""" + +from __future__ import annotations + +import io +import logging +from datetime import datetime, timezone +from http import HTTPStatus as status +from typing import Any, Dict, Tuple + +from arxiv.auth.domain import Session +from werkzeug.datastructures import MultiDict + +from submit_ce.ui.auth import user_and_client_from_session +from submit_ce.ui.backend import get_submission + + +logger = logging.getLogger(__name__) + + +def _submitter_display_name(submission, session: Session) -> str: + """Best-effort: prefer submission.creator/owner.name, then session user.""" + for agent in (getattr(submission, "creator", None), + getattr(submission, "owner", None)): + name = getattr(agent, "name", None) if agent else None + if name: + return name + user = getattr(session, "user", None) + if user: + for attr in ("name", "username", "email"): + val = getattr(user, attr, None) + if val: + return val + return "Unknown submitter" + + +def _license_display(submission) -> str: + """Return a human-readable label for the chosen license, or '(none)'.""" + lic = getattr(submission, "license", None) + if not lic: + return "(no license selected)" + name = getattr(lic, "name", None) + uri = getattr(lic, "uri", None) + if name and uri: + return f"{name} ({uri})" + return name or uri or "(unknown)" + + +def _format_date(value) -> str: + if value is None: + return "(unknown)" + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S %Z").strip() + return str(value) + + +def _build_agreement_pdf(submission, session: Session) -> bytes: + """Render the personalized submission-agreement PDF and return its bytes. + + Uses reportlab's Platypus high-level API. The agreement body is currently + placeholder copy that mirrors what the Agreement step shows; replace with + the canonical legal text once it has been finalized by arXiv legal. + """ + from reportlab.lib import colors + from reportlab.lib.pagesizes import letter + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import inch + from reportlab.platypus import ( + Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, + ) + + sample = getSampleStyleSheet() + title_style = ParagraphStyle( + "AgrTitle", parent=sample["Title"], + fontSize=18, leading=22, spaceAfter=14, + textColor=colors.HexColor("#b31b1b"), + ) + h2_style = ParagraphStyle( + "AgrH2", parent=sample["Heading2"], + fontSize=12, leading=16, spaceBefore=12, spaceAfter=6, + textColor=colors.HexColor("#1e8bc3"), + ) + body_style = ParagraphStyle( + "AgrBody", parent=sample["BodyText"], + fontSize=10, leading=14, spaceAfter=6, + ) + small_style = ParagraphStyle( + "AgrSmall", parent=body_style, fontSize=8.5, + textColor=colors.HexColor("#666666"), + ) + + submitter_name = _submitter_display_name(submission, session) + submission_id = getattr(submission, "submission_id", "(unknown)") + submitted_dt = (getattr(submission, "submitted", None) + or getattr(submission, "created", None)) + license_display = _license_display(submission) + agreement_id = getattr(submission, "agreement_id", None) or "(unknown)" + generated_at = datetime.now(timezone.utc) + + story = [] + story.append(Paragraph("arXiv Submission Agreement", title_style)) + story.append(Paragraph( + "This document is a personalized record of the submission agreement " + "accepted by the submitter at the time of submission.", + body_style, + )) + + # ---- Personalized stamp ------------------------------------------------- + story.append(Paragraph("Submission record", h2_style)) + detail_rows = [ + ["Submission ID:", str(submission_id)], + ["Submitter:", submitter_name], + ["Submitted on:", _format_date(submitted_dt)], + ["License:", license_display], + ["Agreement ID:", str(agreement_id)], + ["Generated at:", _format_date(generated_at)], + ] + tbl = Table(detail_rows, colWidths=[1.3 * inch, 5.2 * inch], hAlign="LEFT") + tbl.setStyle(TableStyle([ + ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("LEADING", (0, 0), (-1, -1), 12), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.HexColor("#dddddd")), + ])) + story.append(tbl) + + # ---- Agreement body (placeholder copy for now) -------------------------- + story.append(Paragraph("Agreement", h2_style)) + placeholder_paragraphs = [ + "By accepting this submission agreement, the submitter affirms that " + "they have the right to make the work available under the license " + "indicated above, and that the work conforms to arXiv submission " + "policies including those covering content quality, attribution, " + "and acceptable subject matter.", + + "Once announced, this version of the submission cannot be amended " + "except through replacement or withdrawal. Non-core metadata " + "(journal reference, DOI, MSC or ACM classification, and report " + "number) may be updated at any time without a new revision.", + + "The license selection is permanent for this version and cannot be " + "amended after announcement. Any subsequent versions must use a " + "compatible license.", + + "This document is generated automatically as a record of acceptance " + "and does not constitute the full text of the arXiv submission " + "agreement. The canonical agreement text identified by Agreement ID " + f"{agreement_id} is the authoritative version.", + ] + for para in placeholder_paragraphs: + story.append(Paragraph(para, body_style)) + + story.append(Spacer(1, 0.25 * inch)) + story.append(Paragraph( + "For the canonical agreement text and arXiv policies, see " + "https://arxiv.org/help/policies. " + "Questions about this submission record may be directed to " + "arxiv.org/help/contact.", + small_style, + )) + + buf = io.BytesIO() + doc = SimpleDocTemplate( + buf, pagesize=letter, + leftMargin=0.75 * inch, rightMargin=0.75 * inch, + topMargin=0.75 * inch, bottomMargin=0.6 * inch, + title=f"arXiv Submission Agreement - {submission_id}", + author=submitter_name, + ) + doc.build(story) + return buf.getvalue() + + +def download_submission_agreement( + method: str, + params: MultiDict, + session: Session, + submission_id: str, + **kwargs: Any, +) -> Tuple[io.BytesIO, int, Dict[str, str]]: + """Return a personalized submission-agreement PDF for the user's records.""" + # Verify the submission exists and is loadable in the current user's scope. + submission, _ = get_submission(submission_id) + _ = user_and_client_from_session(session) # forces auth resolution + + pdf_bytes = _build_agreement_pdf(submission, session) + stream = io.BytesIO(pdf_bytes) + stream.seek(0) + + filename = f"arxiv-submission-agreement-{submission_id}.pdf" + headers = { + "Content-Type": "application/pdf", + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + } + return stream, status.OK, headers diff --git a/submit_ce/ui/controllers/new/tests/test_review.py b/submit_ce/ui/controllers/new/tests/test_review.py index 37bb903d..e0f171a5 100644 --- a/submit_ce/ui/controllers/new/tests/test_review.py +++ b/submit_ce/ui/controllers/new/tests/test_review.py @@ -27,7 +27,12 @@ def test_review_files_get_warning_via_http(app, authorized_client, sub_files_tex assert resp.status_code == status.OK assert mock_flash.called - assert "couldn't load preflight data" in mock_flash.call_args[0][0] + # Flash text changed when the page was redesigned to show a clearer + # placeholder instead of bare empty form controls. Assert on the + # title and a stable substring of the body. + assert mock_flash.call_args[1].get('title') == 'Preflight unavailable' + assert "preflight service is temporarily unavailable" in str( + mock_flash.call_args[0][0]) @@ -57,9 +62,22 @@ def test_review_files_unsupported_method_raises(mocker): def _get_csrf(authorized_client, url, mocker): - """GET the review page (with preflight stubbed out) and pull the CSRF token.""" - mocker.patch.object(review, '_load_or_create_preflight', - return_value=(None, None)) + """GET the review page and pull the CSRF token. + + Stubs preflight + file_notes so the form (including csrf_token) + actually renders. The Review Files template hides the form (and + its csrf_token) when file_notes is empty -- see review_files.html. + Tests that use this helper aren't checking the no-preflight branch, + they just need a CSRF for a subsequent POST. + """ + mocker.patch.object( + review, '_load_or_create_preflight', + return_value=({'tex_files': [], + 'detected_toplevel_files': []}, None)) + # file_notes is consumed by the template's group_preflight_files + # filter, which expects a list of dicts each carrying a 'filename'. + mocker.patch.object(review.dm, 'get_files_from_preflight', + return_value=[{'filename': 'paper.tex'}]) resp = authorized_client.get(url) return parse_csrf_token(resp) diff --git a/submit_ce/ui/routes/ui.py b/submit_ce/ui/routes/ui.py index 34644790..ad7dc30a 100644 --- a/submit_ce/ui/routes/ui.py +++ b/submit_ce/ui/routes/ui.py @@ -10,12 +10,16 @@ from markupsafe import Markup from werkzeug import Response as WResponse from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import NotFound from submit_ce.ui import controllers as cntrls from submit_ce.ui.controllers.debug import debug_events from submit_ce.ui.controllers.new import upload from submit_ce.ui.controllers.new import review from submit_ce.ui.controllers.new import upload_delete + from ..auth import is_owner, is_admin_or_dev +from submit_ce.ui.controllers.new import submission_agreement +from submit_ce.ui.controllers.new import source_package from submit_ce.ui.workflow.processor import WorkflowProcessor from submit_ce.ui.workflow.stages import FileUpload from .flow_control import flow_control, get_workflow, endpoint_name @@ -23,7 +27,6 @@ - #from submit_ce.ui import util logger = logging.getLogger(__name__) @@ -402,17 +405,110 @@ def file_process(submission_id: str) -> Response: unauthorized=redirect_to_login) # TODO @flow_control(Process)? def file_preview(submission_id: str) -> Response: - data, code, headers = cntrls.new.preview.file_preview( + try: + data, code, headers = cntrls.new.preview.file_preview( + MultiDict(request.args.items(multi=True)), + request.auth, + submission_id, + request.environ['token'] + ) + except NotFound: + # The PDF doesn't exist yet (e.g., Process step incomplete or + # tex2pdf unavailable). Redirect to a non-.pdf URL so the browser + # stops trying to render the response with its built-in PDF viewer, + # which would otherwise display a blank tab. The destination + # endpoint shows a friendly explanation and links back to the + # Process and Confirm steps. + return redirect(url_for('ui.preview_not_available', + submission_id=submission_id)) + # TODO This needs to have range request handling like arxiv-browse + rv = send_file(data.open('rb'), mimetype=headers['Content-Type']) + # ETag / Content-Length are best-effort. If the store could not provide + # them (e.g., a blob whose metadata isn't populated for some reason) + # we'd rather omit those headers than crash with TypeError. The PDF + # itself still streams to the browser. + etag = headers.get('ETag') + if etag: + rv.set_etag(etag) + size = getattr(data, 'size', None) + if size is not None: + rv.headers['Content-Length'] = str(size) + rv.headers['Cache-Control'] = 'no-store' + return rv + + +@UI.route('//preview_not_available', methods=["GET"]) +@scoped(scopes.VIEW_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def preview_not_available(submission_id: str) -> Response: + """Render a friendly HTML page when no preview PDF exists. + + Reached via a 302 redirect from the ``/preview.pdf`` endpoint when the + file store has no preview for this submission. Lives at a non-.pdf + URL so the browser renders it as a normal HTML page instead of + invoking the built-in PDF viewer. + """ + rv = make_response(render_template( + 'submit/preview_not_available.html', + submission_id=submission_id, + pagetitle="PDF Preview Not Available", + ), 404) + rv.headers['Content-Type'] = 'text/html; charset=utf-8' + rv.headers['Cache-Control'] = 'no-store' + return rv + + +@UI.route('//submission_agreement.pdf', methods=["GET"]) +@scoped(scopes.VIEW_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def submission_agreement_pdf(submission_id: str) -> Response: + """Serve a personalized PDF of the accepted submission agreement. + + The PDF is generated on the fly and stamped with the submission ID, + submitter, submission date, license, and agreement_id. Linked from + the Confirm page's sidebar ('Download Submission Agreement'). + """ + stream, code, headers = submission_agreement.download_submission_agreement( + request.method, MultiDict(request.args.items(multi=True)), request.auth, submission_id, - request.environ['token'] ) - # TODO This needs to have range request handling like arxiv-browse - rv = send_file(data.open('rb'), mimetype=headers['Content-Type']) - rv.set_etag(headers['ETag']) - rv.headers['Content-Length'] = str(data.size) - rv.headers['Cache-Control'] = 'no-store' + rv = send_file(stream, mimetype=headers['Content-Type'], + download_name=headers.get('Content-Disposition', '') + .split('filename="')[-1].rstrip('"') + or f"arxiv-submission-agreement-{submission_id}.pdf", + as_attachment=True) + rv.headers['Cache-Control'] = headers.get('Cache-Control', 'no-store') + return rv + + +@UI.route('//source_package.tar.gz', methods=["GET"]) +@scoped(scopes.VIEW_SUBMISSION, authorizer=is_owner, + unauthorized=redirect_to_login) +def source_package_tar_gz(submission_id: str) -> Response: + """Serve the submission's current source files as a fresh .tar.gz. + + Mirrors Submit 1.5's 'Download Package' button: bundles the current + individual source files on the fly. The persisted + ``.tar.gz`` in the bucket is only updated after a + successful compile (see ``compile_at_gcp.py``), so it doesn't + reliably represent the user's current working source. Linked from + the sidebar of the Upload Files and Review Files pages. + """ + stream, code, headers = source_package.download_source_package( + request.method, + MultiDict(request.args.items(multi=True)), + request.auth, + submission_id, + ) + download_name = (headers.get('Content-Disposition', '') + .split('filename="')[-1].rstrip('"') + or f"submission_{submission_id}.tar.gz") + rv = send_file(stream, mimetype=headers['Content-Type'], + download_name=download_name, + as_attachment=True) + rv.headers['Cache-Control'] = headers.get('Cache-Control', 'no-store') return rv @@ -450,7 +546,7 @@ def add_metadata(submission_id: str) -> Response: def final_preview(submission_id: str) -> Response: """Render step 10, preview.""" return handle(cntrls.finalize, 'submit/final_preview.html', - 'Preview and Approve', submission_id, flow_controlled=True) + 'Confirm and Submit', submission_id, flow_controlled=True) @UI.route('//confirmation', methods=['GET', 'POST']) diff --git a/submit_ce/ui/static/css/submit_overrides.css b/submit_ce/ui/static/css/submit_overrides.css index cb7e8423..5e96a228 100644 --- a/submit_ce/ui/static/css/submit_overrides.css +++ b/submit_ce/ui/static/css/submit_overrides.css @@ -49,3 +49,51 @@ h1.title.title-submit { padding-bottom: 0.75rem !important; /* space under buttons */ margin-bottom: 0.75rem !important; /* space before content begins */ } + +/* Make disabled buttons obviously inactive even when they carry a Bulma + color modifier like is-success, is-link, is-danger, etc. Bulma's + default disabled style only adds opacity: 0.5, which on a saturated + green/blue button still reads as "active" at a glance. We desaturate + to gray + a not-allowed cursor so users can tell from across the room + that the button is gated. */ +.submit-nav .button[disabled], +.submit-nav .button[disabled]:hover, +.submit-nav .button[disabled]:focus, +.submit-nav .button[disabled]:active { + background-color: #e4e6eb !important; + border-color: #cdd1d8 !important; + color: #8a8f98 !important; + box-shadow: none !important; + cursor: not-allowed !important; + opacity: 1 !important; /* override Bulma's 0.5 fade-only */ +} + +/* Highlight the proceed-confirmation checkbox on the Confirm page when + ticking it is the only remaining step before Submit (PDF has been + previewed). The page has a lot going on (abstract preview, sidebar, + shortcut buttons), so without a visual cue the user can lose track + of what to do next. A soft amber row + bolder label brings the eye + to the checkbox without yelling. The pulse animation gently + reinforces it for a few seconds, then settles into the static + highlight. */ +.proceed-checkbox { + padding: 0.5rem 0.75rem; + border-radius: 4px; + transition: background-color 0.25s ease, border-color 0.25s ease; + border: 1px solid transparent; +} +.proceed-checkbox.proceed-highlight { + background-color: #fff8db; /* soft amber */ + border-color: #f6c344; /* amber accent */ + font-weight: 600; + animation: proceed-pulse 1.8s ease-in-out 2; +} +@keyframes proceed-pulse { + 0% { box-shadow: 0 0 0 0 rgba(246, 195, 68, 0.55); } + 50% { box-shadow: 0 0 0 6px rgba(246, 195, 68, 0); } + 100% { box-shadow: 0 0 0 0 rgba(246, 195, 68, 0); } +} +.proceed-checkbox.proceed-highlight input[type="checkbox"] { + transform: scale(1.15); + margin-right: 0.4rem; +} diff --git a/submit_ce/ui/templates/submit/base_edit.html b/submit_ce/ui/templates/submit/base_edit.html index 084ef3dc..2d2d3c93 100644 --- a/submit_ce/ui/templates/submit/base_edit.html +++ b/submit_ce/ui/templates/submit/base_edit.html @@ -40,14 +40,14 @@

Replacing arXiv:{{ submission.arxiv_i
- {{ submit_macros.submit_nav(submission_id) }} + {% block submit_nav_top %}{{ submit_macros.submit_nav(submission_id) }}{% endblock %}
{%block info_container_middle %}{% endblock %} {% block important_text_box %}{% endblock %}
- {{ submit_macros.submit_nav(submission_id) }} + {% block submit_nav_bottom %}{{ submit_macros.submit_nav(submission_id) }}{% endblock %}
diff --git a/submit_ce/ui/templates/submit/file_upload.html b/submit_ce/ui/templates/submit/file_upload.html index fad71115..dbddf503 100644 --- a/submit_ce/ui/templates/submit/file_upload.html +++ b/submit_ce/ui/templates/submit/file_upload.html @@ -51,52 +51,86 @@ {% block title -%}Upload Files{%- endblock title %} +{# v5 mockup sidebar: lead with "Avoid common causes of delay" as the + primary heading, then accepted formats / figures / file properties as + sub-sections. Uses info_container_middle (not important_text_box) so + it follows the same sidebar pattern as Metadata and Confirm. #} +{% block info_container_middle %} +
+

Avoid common causes of delay

+

Make sure included files match the filenames exactly (it is case + sensitive), and verify your references, citations and captions.

+ +

Accepted formats, in order of preference

+ + +

Accepted formats for figures

+
    +
  • (La)TeX: Postscript
  • +
  • PDFLaTeX: JPG, GIF, PNG, or PDF
  • +
+ +

Accepted file properties

+ + + {# Download a fresh .tar.gz of the submission's current source files. + Built on the fly by source_package.download_source_package; mirrors + the Submit 1.5 "Download Package" sidebar button. Anchor + download + attribute is enough -- no JS required. #} + {% if submission_id %} + + {% endif %} +
+{% endblock info_container_middle %} +{# Upload-result notifications (success/warning/error). Lives in the + more_notifications slot that base_edit.html provides as a sibling + of within_content. Previously this rendered outside any block, so + Jinja's extends mechanic silently dropped it -- the notifications + never appeared in production. #} {% block more_notifications %} {% if immediate_notifications %} {% for notification in immediate_notifications %} - + {% endfor %} {% endif %} {% endblock more_notifications %} -{% block important_text_box %} - -

TeX and (La)TeX submissions are highly encouraged. This format is the most likely to retain readability and high-quality output in the future. TeX source uploaded to arXiv will be made publicly available.

- -

You can upload all your files at once as a single .zip or .tar.gz file, or upload individual files as needed.

-

Avoid common causes of delay: Make sure included files match the filenames exactly (it is case sensitive), and verify your references, citations and captions.

- -

- {{svg.info(klass="icon filter-dark_grey")}}Accepted formats, in order of preference -

- -

- {{svg.info(klass="icon filter-dark_grey")}}Accepted formats for figures -

-
    -
  • (La)TeX: Postscript
  • -
  • PDFLaTeX: JPG, GIF, PNG, or PDF
  • -
-

- {{svg.info(klass="icon filter-dark_grey")}}Accepted file properties -

- - {% endblock important_text_box %} - {% block within_content %} +{# Main-area intro copy (moved from the sidebar to match v5 mockup). #} +

TeX and (La)TeX submissions are highly encouraged. This format is the + most likely to retain readability and high-quality output in the future. + TeX source uploaded to arXiv will be made publicly available.

+ +

You can upload all your files at once as a single .zip or .tar.gz + file, or upload individual files as needed.

+
diff --git a/submit_ce/ui/templates/submit/final_preview.html b/submit_ce/ui/templates/submit/final_preview.html index 220e70a5..53e0078b 100644 --- a/submit_ce/ui/templates/submit/final_preview.html +++ b/submit_ce/ui/templates/submit/final_preview.html @@ -1,87 +1,312 @@ {% extends "submit/base_edit.html" %} -{% block title -%}Review and Approve Your Submission{%- endblock title %} -{% block important_text_box %} -{# - {{svg.trashcan()}} - Cancel and Delete -#} -{% endblock important_text_box %} +{% block title -%}Confirm and Submit{%- endblock title %} + +{# Submit button is "Submit" here, not "Continue". It's disabled until + `preview_ready` is true. The controller computes preview_ready as + (submission.submitter_confirmed_preview AND the PDF actually exists in + the file store), so the gate stays closed if the PDF was removed + out-of-band even when the persisted flag still says "viewed". A + client-side handler further disables Submit until the proceed + checkbox is also ticked. #} +{% set _submit_disabled = not preview_ready %} + +{% block submit_nav_top %} + {{ submit_macros.submit_nav(submission_id, + next_label="Submit", + next_aria="Submit", + next_disabled=_submit_disabled, + next_id="submit-button-top") }} +{% endblock %} + +{% block submit_nav_bottom %} + {{ submit_macros.submit_nav(submission_id, + next_label="Submit", + next_aria="Submit", + next_disabled=_submit_disabled, + next_id="submit-button-bottom") }} +{% endblock %} + +{% block info_container_middle -%} +
+

Download a copy of your submission agreement:

+ +

Note that a citation link with your final arxiv identifier is not + available until after the submission is accepted and published.

+ +

Editing after submitting:

+

Once your submission has been announced, amendments to the files or + core metadata may only be made through + replacement or + withdrawal. + The license selection is permanent for this version and cannot be + amended.

+

Content that can be edited at + any time after announcement and without a new revision:

+
    +
  • Journal reference
  • +
  • DOI
  • +
  • MSC or ACM classification
  • +
  • Report number
  • +
+
+{%- endblock info_container_middle %} {% block within_content %} -
-
-
-
-

{{svg.info(klass="icon filter-orange")}} Review your submission carefully! - Editing your submission after clicking "confirm and submit" will remove your paper from the queue. - A new timestamp will be assigned after resubmitting.

-

Note that a citation link with your final arxiv identifier is not available until after the submission is accepted and published.

-

Once your submission has been announced, amendments to files or core metadata may only be made through - replacement or withdrawal. - Exception: You will be able to update journal reference, DOI, MSC or ACM classification, or report number information at any time without a new revision.

+ {# PDF-preview gating banner. Three states: + preview_ready -> no banner (submit gate open) + preview_exists and not gate -> "preview your PDF" + not preview_exists -> "PDF not available, reprocess" + #} + {% if not preview_ready %} +
+
+
+
+ {% if preview_exists %} +

+ {{ svg.exclamation(klass="icon filter-red") }} + Preview your PDF! + You are required to preview your PDF before submitting. + Click here to open your PDF + in a new browser tab. This page will refresh automatically + when you return. +

+ {% else %} +

+ {{ svg.exclamation(klass="icon filter-red") }} + PDF preview is not + available. + The compiled PDF for your submission is missing. You + must return to the + Process + Files step to regenerate it before you can submit. +

+ {% endif %} +
+
-
-
- -{% if submission.version > 1 %} - {% set arxiv_id = submission.arxiv_id %} -{% else %} - {% set arxiv_id = "0000.00000" %} -{% endif %} - -
-{{ macros.abs( - arxiv_id or "0000.00000", - submission.metadata.title, - submission.metadata.authors_display|tex2utf, - submission.metadata.abstract, - submission.created, - submission.primary_classification, - comments = submission.metadata.comments, - msc_class = submission.metadata.msc_class, - acm_class = submission.metadata.acm_class, - journal_ref = submission.metadata.journal_ref, - doi = submission.metadata.doi, - report_num = submission.metadata.report_num, - version = submission.version, - submission_history = submission_history, - secondary_categories = submission.secondary_classification) }} -
- - - + {% endif %} + + {# Abstract preview - same domain data as before, same macro. #} + {% if submission.version > 1 %} + {% set arxiv_id = submission.arxiv_id %} + {% else %} + {% set arxiv_id = "0000.00000" %} + {% endif %}
- - {{svg.pencil()}}Edit Metadata - - - {{svg.pencil()}}Edit Uploaded Files - - - View PDF - {{svg.exclamation(klass="icon filter-white", title="PDF will open in new tab")}} - + {{ macros.abs( + arxiv_id or "0000.00000", + submission.metadata.title, + submission.metadata.authors_display|tex2utf, + submission.metadata.abstract, + submission.created, + submission.primary_classification, + comments=submission.metadata.comments, + msc_class=submission.metadata.msc_class, + acm_class=submission.metadata.acm_class, + journal_ref=submission.metadata.journal_ref, + doi=submission.metadata.doi, + report_num=submission.metadata.report_num, + version=submission.version, + submission_history=submission_history, + secondary_categories=submission.secondary_classification) }}
+ {# Proceed checkbox + reminder copy. #}
- {{ form.csrf_token }} - {% if form.proceed.errors %}
{% endif %} -
-
-
- {{ form.proceed }} - {{ form.proceed.label }} +

Review your submission and make any edits before submitting to arXiv. + Making edits after submitting will remove your paper from the + announcement queue and a new timestamp will be assigned after + resubmitting.

+ + + {{ form.csrf_token }} + {% if form.proceed.errors %}
{% endif %} +
+
+
+ {{ form.proceed(id="proceed") }} + {{ form.proceed.label }} +
+ {% for error in form.proceed.errors %} +

{{ error }}

+ {% endfor %}
- {% for error in form.proceed.errors %} -

{{ error }}

- {% endfor %} +
+ {% if form.proceed.errors %}
{% endif %} + +
+ + {# Preview & editing shortcuts (mockup's bottom section). #} +
+

Preview and Editing Shortcuts

+
+
+ {# Preview PDF button styling tracks the three states from the + gating banner above: + preview_ready -> regular secondary + preview_exists and not gate -> is-danger (red) + not preview_exists -> is-light (deemphasized) + #} + + Preview PDF + {% if preview_exists and not preview_ready %} + Required before submitting + {% elif not preview_exists %} + PDF not available + {% endif %} + + {# TODO: Preview HTML endpoint not yet implemented. Linking placeholder. #} + + Preview HTML + + Edit Metadata + Edit Files + Edit License
- {% if form.proceed.errors %}
{% endif %}
- + {# Confirm-page client-side behavior: + 1. Auto-refresh on tab return after a PDF preview link is clicked. + This way the server-side ConfirmPreview event (fired when the + PDF was served) is picked up without the user manually reloading. + We poll the server in addition to tab-focus heuristics, because + focus/visibility events are unreliable across browsers and don't + fire at all if the PDF opens in a popup that the user dismisses + without ever returning focus to the original tab. + 2. Toggle Submit button disabled state based on the proceed checkbox, + but only when the server-side PDF-preview gate is already passed. + #} + {% endblock within_content %} diff --git a/submit_ce/ui/templates/submit/preview_not_available.html b/submit_ce/ui/templates/submit/preview_not_available.html new file mode 100644 index 00000000..856089d8 --- /dev/null +++ b/submit_ce/ui/templates/submit/preview_not_available.html @@ -0,0 +1,49 @@ +{%- extends "submit/base.html" %} +{% import "svg.html" as svg %} + +{% block title %}PDF Preview Not Available{% endblock title %} + +{% block within_content %} +
+

+ {{ svg.exclamation(klass="icon filter-orange") }} + Your PDF preview could not be retrieved. +

+

+ Your submission's PDF was compiled successfully when you completed + the Process step, so this is unexpected. The most likely cause is + a temporary problem retrieving the stored PDF — for example, + a transient storage or network error. +

+
+ +
+

What to try:

+
    +
  • Wait a moment and click Preview PDF again from the + Confirm step.
  • +
  • If the problem persists, return to the + Process Files step and reprocess your submission + to regenerate the PDF.
  • +
  • If it still won't load, please contact + arXiv support with your + submission ID so we can investigate.
  • +
+
+ +
+ {% if submission_id %} + + Back to Confirm + + + Go to Process step + + {% endif %} + +
+{% endblock within_content %} diff --git a/submit_ce/ui/templates/submit/review_files.html b/submit_ce/ui/templates/submit/review_files.html index 25f36419..f68000a7 100644 --- a/submit_ce/ui/templates/submit/review_files.html +++ b/submit_ce/ui/templates/submit/review_files.html @@ -5,33 +5,100 @@ {% endblock addl_head %} -{% macro display_preflight_tree(key, item) %} +{# Renders one row of the Review Files table for a preflight-detected file, + or recurses into a subdirectory listing. The third argument + ``top_level_filename`` is the currently-selected top-level TeX file + (form.source_file.data) so we can render "Used: top-level file" for it. + 00README.json gets a dedicated label and a disabled delete checkbox -- + it's a build-directives file, not a candidate for deletion. #} +{% macro display_preflight_tree(key, item, top_level_filename) %} {% if 'filename' in item %} - + - + {% if item.filename == '00README.json' %} + + {% else %} + + {% endif %} {{ item.filename }} - {% if item.used_by %}used by: {{ item.used_by | join(', ') }}
{% endif %} - {% if item.used_by_tex %}used tex: {{ item.used_by_tex | join(', ') }}
{% endif %} - {% if item.used_by_bib %}used bib: {{ item.used_by_bib | join(', ') }}{% endif %} + {% if item.filename == '00README.json' %} + Build directives file + {% elif item.filename == top_level_filename %} + Used: top-level file + {% else %} + {% if item.used_by %}Used by: {{ item.used_by | join(', ') }}
{% endif %} + {% if item.used_by_tex %}Used by (TeX): {{ item.used_by_tex | join(', ') }}
{% endif %} + {% if item.used_by_bib %}Used by (bib): {{ item.used_by_bib | join(', ') }}{% endif %} + {% endif %} {% else %} {{ key }}/ {% for k, subitem in item.items() %} - {{ display_preflight_tree(k, subitem) }} + {{ display_preflight_tree(k, subitem, top_level_filename) }} {% endfor %} {% endif %} {% endmacro %} {% block title -%}Review Files{%- endblock title %} -{% block important_text_box %} -

Review Files sidebar information goes here

-{% endblock important_text_box %} +{# v5-style sidebar carried forward from Submit 1.5's addfiles/reviewfiles + sidebar.tt. Same content as 1.5 (Did you know..., Accepted Formats, + Important, Read more about v1.5 changes) but laid out in the v5 + heading hierarchy (h2 lead + h3 subsections, wrapped in message-body). + Adds two action buttons absent from the v5 Upload mockup: + - Download Package (new on-the-fly source_package endpoint) + - Back to upload step (go back without invalidating preflight, + e.g. when the user needs to add a missing file) +#} +{% block info_container_middle %} +
+

Did you know you can simplify your submission?

+

Upload a single .tar.gz or zip file.

+ +

Accepted Formats

+ + +

Important

+

If your submission is (La)TeX, then you must submit the source + (plus necessary macros and figures), not derivative dvi, + Postscript, or PDF (see + Why TeX?). For more + information on formats and other submission details see + Submission Help. TeX + source uploaded to arXiv will be made publicly available.

+ +

Read more about the new changes in + Submission + v1.5.

+ + {% if submission_id %} + + {% endif %} +
+{% endblock info_container_middle %} {% block within_content %} {% if immediate_notifications %} @@ -45,88 +112,121 @@ {% endfor %} {% endif %} -
-
- -
- {{ form.csrf_token }} - -
- -
-
- {{ form.source_file }} -
-
- {% if form.source_file.errors %} - {% for error in form.source_file.errors %} -

{{ error }}

- {% endfor %} - {% endif %} -
+{# The form below depends entirely on preflight output: source_file + choices come from preflight's detected TeX files, compiler choices + come from preflight-detected language, and the file table is + driven by file_notes. When preflight is unavailable we have nothing + to populate any of it with, so we hide the form sections and render + a clear placeholder instead of a row of empty dropdowns + a + misleading "No files uploaded yet" message. The submit_alerts flash + at the top of base_edit.html already tells the user *why*. #} +{% if file_notes %} +{# Flat layout matching the Metadata page (per UI designer feedback): + no per-section card framing, no red section headings. The whole + form is wrapped in a single
so the + page has the same outline/container look as add_metadata.html. The + three logical groupings (compiler, top-level file, file table) are + still present as ordinary
blocks. #} + +
+ {{ form.csrf_token }} -
-
-
- -
-
- {{ form.compiler }} -
-
- {% if form.compiler.errors %} - {% for error in form.compiler.errors %} -

{{ error }}

- {% endfor %} - {% endif %} -
-
-
-
- -
-
- {{ form.compiler_version }} -
-
- {% if form.compiler_version.errors %} - {% for error in form.compiler_version.errors %} -

{{ error }}

- {% endfor %} - {% endif %} -
-
+ {# Compiler engine + TeX Live version (auto-selected from preflight) #} +
+ +

Our automated scan tries to identify the correct + compiler. You can change it here or keep the auto-selection.

+
+
+ {{ form.compiler }}
+
+ {% for error in form.compiler.errors %} +

{{ error }}

+ {% endfor %} +
-
- - {% if file_notes %} - - - - - - - - - - {% for k, item in (file_notes | group_preflight_files).items() %} - {{ display_preflight_tree(k, item) }} - {% endfor %} - -
- Delete -
- keep all -
File NameAuto-detected Notes
- {% else %} -

- No files have been uploaded yet. -

- {% endif %} +
+ +

arXiv currently uses TeX Live 2025 by default. You + can also select TeX Live 2023 as an alternative.

+
+
+ {{ form.compiler_version }} +
+
+ {% for error in form.compiler_version.errors %} +

{{ error }}

+ {% endfor %} +
+ {# Top-level TeX file. Submit 2.0 supports a single top-level file + (unlike legacy 1.5 which allowed multiple with reordering). #} +
+ +

Our automated scan tries to identify the top-level + TeX file. You can change it here or keep the auto-selection.

+
+
+ {{ form.source_file }} +
+ {% for error in form.source_file.errors %} +

{{ error }}

+ {% endfor %} +
+ + {# Review Files list. Files checked for deletion will be removed when + the user clicks Continue, invalidating preflight and routing back + to Upload Files (see _update_preflight). #} +
+ +

The automated scan tries to identify issues and + unused files that can be deleted. You can make changes below or + leave auto-selections in place.

+ + + + + + + + + + + {% for k, item in (file_notes | group_preflight_files).items() %} + {{ display_preflight_tree(k, item, form.source_file.data) }} + {% endfor %} + +
+ Delete
+ Keep All +
File NameAuto-detected Notes
+
{# /.action-container #} +{% else %} + +{% endif %} {% endblock within_content %} diff --git a/submit_ce/ui/templates/submit/submit_macros.html b/submit_ce/ui/templates/submit/submit_macros.html index 882c8717..e1712490 100644 --- a/submit_ce/ui/templates/submit/submit_macros.html +++ b/submit_ce/ui/templates/submit/submit_macros.html @@ -1,7 +1,11 @@ {% import "svg.html" as svg %} {# The submit_nav buttons target sending the form via the `form="form"` so the id on the form should be `form` for this to work. #} -{% macro submit_nav(submission_id) %} +{% macro submit_nav(submission_id, + next_label="Continue", + next_aria="Save and continue", + next_disabled=False, + next_id="") %} {% endmacro %} diff --git a/submit_ce/ui/tests/integration/test_integration.py b/submit_ce/ui/tests/integration/test_integration.py index 133f1d59..d65ec537 100644 --- a/submit_ce/ui/tests/integration/test_integration.py +++ b/submit_ce/ui/tests/integration/test_integration.py @@ -280,7 +280,7 @@ def final_preview_page(self): res = self.client.get(self.next_page) self.assertEqual(res.status_code, 200) - self.assertIn('Review and Approve Your Submission', res.text) + self.assertIn('Confirm and Submit', res.text) res = self.client.post(self.next_page, data= { diff --git a/submit_ce/ui/workflow/stages.py b/submit_ce/ui/workflow/stages.py index fc306728..eb5186a2 100644 --- a/submit_ce/ui/workflow/stages.py +++ b/submit_ce/ui/workflow/stages.py @@ -141,12 +141,17 @@ class OptionalMetadata(Stage): class FinalPreview(Stage): - """The user is asked to review the submission before finalizing.""" + """The user is asked to review the submission before finalizing. + + Note: the class name remains FinalPreview for backward compatibility + (endpoint URL is still /final_preview), but the visible tab label is + "Confirm" per the v5 mockup redesign. + """ endpoint = 'final_preview' label = 'preview and approve your submission' - title = "Final preview" - display = "Preview" + title = "Confirm and Submit" + display = "Confirm" completed = [conditions.is_finalized] diff --git a/uv.lock b/uv.lock index 1c3a2bb5..649f9729 100644 --- a/uv.lock +++ b/uv.lock @@ -1258,6 +1258,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/0a/acfb251ba01009d3053f04f4661e96abf9d485266b04a0a4deebc702d9cb/pathable-0.4.3-py3-none-any.whl", hash = "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14", size = 9587, upload-time = "2022-09-01T22:33:31.972Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -1703,6 +1729,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/59/2056f61236782a2c86b33906c025d4f4a0b17be0161b63b70fd9e8775d36/referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de", size = 26684, upload-time = "2024-05-01T20:26:02.078Z" }, ] +[[package]] +name = "reportlab" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/3f/b3861b7e40c9d66f4a04e018958d681d16b948bfd1963c962d43a8c23f66/reportlab-4.5.1.tar.gz", hash = "sha256:9fdf68f4de9171ec66acb4a5feed8f8ca2af43479e707a6fbb0daa75d88e5494", size = 3939748, upload-time = "2026-05-12T10:14:13.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/45/ea7fad10122440de6e845568d106bffdc456ca0e8a1d8ae10b46016087e4/reportlab-4.5.1-py3-none-any.whl", hash = "sha256:06fce8cb56c83307cfa4909cdf4e6a2ddbb44e5d6ef4d2edca896d7e9769f091", size = 1957812, upload-time = "2026-05-12T10:14:10.622Z" }, +] + [[package]] name = "requests" version = "2.32.3" @@ -2025,6 +2064,7 @@ dependencies = [ { name = "pytz" }, { name = "pyyaml" }, { name = "referencing" }, + { name = "reportlab" }, { name = "requests" }, { name = "requests-toolbelt" }, { name = "retry" }, @@ -2183,6 +2223,7 @@ requires-dist = [ { name = "pytz", specifier = "==2018.7" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "referencing", specifier = "==0.35.1" }, + { name = "reportlab", specifier = ">=4.2.0" }, { name = "requests", specifier = "==2.32.3" }, { name = "requests-toolbelt", specifier = "==1.0.0" }, { name = "retry", specifier = "==0.9.2" },