Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions submit_ce/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ def get_device_type(user_agent):
return user_agent[:25]


# Bits of the "classic" capability code produced by
# arxiv.auth.legacy.util.compute_capabilities:
# flag_edit_users -> 2 (admin)
# flag_email_verified -> 4
# flag_edit_system -> 8 (system/"god", i.e. dev)
# TODO move these to somewhere under arxiv.auth.auth
ADMIN_MASK = 1
DEV_MASK = 1<<2
ADMIN_MASK = 1 << 1
DEV_MASK = 1 << 3

def is_admin(session: Session)->bool:
return bool(getattr(session.authorizations, "classic", 0) & ADMIN_MASK)
Expand Down
1 change: 1 addition & 0 deletions submit_ce/ui/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ def user_and_client_from_session(session: auth_domian.Session) -> Tuple[User, Cl
if is_admin(session):
user = StaffUser(
user_id=session.user.user_id,
username=session.user.username,
name=name,
email=session.user.email,
endorsements = get_endorsements(session.user),
Expand Down
20 changes: 20 additions & 0 deletions submit_ce/ui/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,26 @@ def authorized_client(app, authorized_user_session):
yield app.test_client(jwt=jwt)


@pytest.fixture
def admin_client(app, authorized_user_session):
"""Authorized client whose user has the classic dev/system capability.

Needed for the ``/debug/<submission_id>`` routes, which are gated by
``is_admin_or_dev``. ``request_auth`` recomputes the ``classic`` capability
code from the database user (ignoring whatever is in the JWT), so we flip
``flag_edit_system`` on the underlying TapirUser rather than editing the
token. Reuses the same user as ``authorized_client`` so ownership-based
fixtures still line up.
"""
session, jwt = authorized_user_session
with app.app_context():
db_user = Session.get(classic.TapirUser, int(session.user.user_id))
db_user.flag_edit_system = 1
Session.commit()
app.test_client_class = ClientArxivAuth
yield app.test_client(jwt=jwt)


#################### submissions in different stages ####################
@pytest.fixture(scope="function")
def sub_created(app, authorized_user):
Expand Down
31 changes: 30 additions & 1 deletion submit_ce/ui/controllers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
#ruff: noqa: F401

from http import HTTPStatus as status
from typing import Optional

from arxiv.auth.domain import Session
from flask import current_app
from werkzeug.datastructures import MultiDict

from submit_ce.ui.routes.flow_control import advance_to_current
Expand All @@ -23,16 +25,40 @@
from .new.verify_user import verify
from .util import Response

_GS_CONSOLE = "https://console.cloud.google.com/storage/browser/"


def _gs_links(submission_id: str) -> tuple[Optional[str], Optional[str]]:
"""Return ``(gs_path, console_url)`` for the submission dir.

``gs_path`` is the ``gs://{bucket}/{path}`` URI (used as the link text)
and ``console_url`` is the matching GCS console browser URL (the href).
Both are None unless the file store reports a gs:// path (i.e. the GS
store); local/null stores return "" and get ``(None, None)``.
"""
try:
path = current_app.api.get_file_store().get_full_submission_path(
str(submission_id))
except Exception:
return None, None
if path and path.startswith("gs://"):
return path, _GS_CONSOLE + path[len("gs://"):]
return None, None


def submission_status(method: str, params: MultiDict, session: Session,
submission_id: str) -> Response:
#user, client = util.user_and_client_from_session(session)

# Will raise NotFound if there is no such submission.
submission, submission_events = get_submission(submission_id)
gs_path, gs_console_url = _gs_links(submission_id)
response_data = {
'submission': submission,
'submission_id': submission_id,
'events': submission_events
'events': submission_events,
'gs_path': gs_path,
'gs_console_url': gs_console_url,
}
return response_data, status.OK, {}

Expand All @@ -41,9 +67,12 @@ def submission_edit(method: str, params: MultiDict, session: Session,
submission_id: str) -> Response:
"""Cause flow_control to go to the current_stage of the Submission."""
submission, submission_events = get_submission(submission_id)
gs_path, gs_console_url = _gs_links(submission_id)
response_data = {
'submission': submission,
'submission_id': submission_id,
'events': submission_events,
'gs_path': gs_path,
'gs_console_url': gs_console_url,
}
return advance_to_current((response_data, status.OK, {}))
45 changes: 35 additions & 10 deletions submit_ce/ui/routes/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from arxiv.auth.auth import scopes
from arxiv.auth.auth.decorators import scoped
from arxiv.base import logging, alerts
from flask import Blueprint, make_response, redirect, request, render_template, url_for, send_file
from flask import Blueprint, make_response, redirect, request, render_template, url_for, send_file, current_app
from flask import Response as FResponse
from markupsafe import Markup
from werkzeug import Response as WResponse
Expand Down Expand Up @@ -223,21 +223,13 @@ def create_replacement(submission_id: str):


@UI.route('/<submission_id>', methods=["GET"])
@scoped(scopes.VIEW_SUBMISSION, authorizer=is_owner,
unauthorized=redirect_to_login)
def submission_status(submission_id: str) -> Response:
"""Display the current state of the submission."""
return handle(cntrls.submission_status, 'submit/status.html',
'Submission status', submission_id)


@UI.route('/<submission_id>/edit', methods=['GET'])
@scoped(scopes.VIEW_SUBMISSION, authorizer=is_owner,
unauthorized=redirect_to_login)
@flow_control()
def submission_edit(submission_id: str) -> Response:
"""Redirects to current edit stage of the submission."""
return handle(cntrls.submission_edit, 'submit/status.html',
return handle(cntrls.submission_edit, 'debug/status.html',
'Submission status', submission_id, flow_controlled=True)

# # TODO: remove me!!
Expand Down Expand Up @@ -651,6 +643,39 @@ def debug_logout() -> Response:
return response


@UI.route('/debug/mail', methods=["GET"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: all other /debug/* routes are scoped to is_admin_or_dev - but that only is relevant if EMAIL_MODE=TESTING anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fix this. TY.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@scoped(scopes.VIEW_SUBMISSION, authorizer=is_admin_or_dev,
unauthorized=redirect_to_login)
def get_debug_mail() -> Response:
"""Dev-only: show email captured by the in-memory email service.

Only available when ``EMAIL_MODE`` is ``TESTING`` and the configured
email service is the in-memory ``EmailInMemory`` capture. In any other
mode real mail was dispatched and there is nothing held in process to
show, so this returns 404.
"""
from submit_ce.implementations.email.email_in_memory import EmailInMemory
if settings.EMAIL_MODE != "TESTING":
raise NotFound()
service = current_app.api.get_email_service()
if not isinstance(service, EmailInMemory):
raise NotFound()
return make_response(
render_template('debug/debug_mail.html',
pagetitle='Debug Mail',
emails=service.sent),
200)


@UI.route('/debug/<submission_id>', methods=["GET"])
@scoped(scopes.VIEW_SUBMISSION, authorizer=is_admin_or_dev,
unauthorized=redirect_to_login)
def get_debug_submission(submission_id: Optional[str] = None) -> Response:
"""Display the current state of the submission."""
return handle(cntrls.submission_status, 'debug/status.html',
'Submission status', submission_id)


@UI.route('/debug/<submission_id>/events', methods=["GET"])
@scoped(scopes.VIEW_SUBMISSION, authorizer=is_admin_or_dev,
unauthorized=redirect_to_login)
Expand Down
45 changes: 45 additions & 0 deletions submit_ce/ui/templates/debug/debug_mail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{% extends "base/base.html" %}

{% block addl_head %}
{% endblock addl_head %}

{% block content %}
<h1>Debug Mail</h1>
<p class="help">
Email captured in memory (<code>EMAIL_MODE=TESTING</code>). Nothing here was
actually dispatched. Newest first. {{ emails | length }} message(s).
</p>

{% if emails %}
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>To</th>
<th>Subject</th>
<th>Headers</th>
<th>Body</th>
</tr>
</thead>
<tbody>
{% for m in emails | reverse %}
<tr>
<td>
<div><b>To:</b> {{ m.to | join(', ') }}</div>
{% if m.cc %}<div><b>Cc:</b> {{ m.cc | join(', ') }}</div>{% endif %}
{% if m.bcc %}<div><b>Bcc:</b> {{ m.bcc | join(', ') }}</div>{% endif %}
<div><b>Reply-To:</b> {{ m.reply_to }}</div>
</td>
<td>{{ m.subject }}</td>
<td>
{% if m.message_id %}<div><b>Message-ID:</b> {{ m.message_id }}</div>{% endif %}
{% if m.references %}<div><b>References:</b> {{ m.references }}</div>{% endif %}
</td>
<td><pre>{{ m.body }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No email has been captured yet.</p>
{% endif %}
{% endblock content %}
Loading
Loading