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
5 changes: 4 additions & 1 deletion app/email_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from arxiv.db.models import Submission, SubmissionCategory
from arxiv.taxonomy.definitions import CATEGORY_ALIASES

from app.schema import SubEmailData, SimplifiedNotification, CommentData, PromoteData, NewPropData, PropRespData, EmailTask, UserContact
from app.schema import SubEmailData, SimplifiedNotification, CommentData, PromoteData, NewPropData, PropRespData, CategoryRejectionData, EmailTask, UserContact

_ET = ZoneInfo(arxiv_settings.ARXIV_BUSINESS_TZ)
def _fmt_time(dt: datetime) -> str:
Expand All @@ -19,6 +19,7 @@ def _fmt_time(dt: datetime) -> str:
from app.templates.promote import render_promote_block
from app.templates.new_prop import render_new_prop_block
from app.templates.prop_resp import render_prop_resp_block
from app.templates.category_rejection import render_category_rejection_block
from app.templates.submission import render_submission_block
from app.templates.email_body import render_body

Expand Down Expand Up @@ -96,6 +97,8 @@ def render_change_block(change: SimplifiedNotification, user_name: str) -> tuple
return render_new_prop_block(change, user_name)
case PropRespData():
return render_prop_resp_block(change, user_name)
case CategoryRejectionData():
return render_category_rejection_block(change, user_name)
case _:
raise ValueError(f"unknown change data type: {type(change.data)}")

Expand Down
4 changes: 3 additions & 1 deletion app/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from app.config import settings
from app.email import send_email
from app.email_content import get_submission_info, render_email
from app.schema import NotificationParams, SimplifiedNotification, ConsolidatedNotifications, EmailTask, NotificationType, CommentData, PromoteData, NewPropData, PropRespData, UserContact, SubEmailData
from app.schema import NotificationParams, SimplifiedNotification, ConsolidatedNotifications, EmailTask, NotificationType, CommentData, PromoteData, NewPropData, PropRespData, CategoryRejectionData, UserContact, SubEmailData
from app.moderators import get_all_moderators, get_recipient_ids_for_categories, get_mod_emails

logger = logging.getLogger(__name__)
Expand All @@ -29,6 +29,8 @@ def _parse_message(payload)-> tuple[NotificationParams, SimplifiedNotification]:
data = PromoteData.model_validate(full_note.data)
case NotificationType.PROP_RESP:
data = PropRespData.model_validate(full_note.data)
case NotificationType.CATEGORY_REJECTION:
data = CategoryRejectionData.model_validate(full_note.data)
case _:
logger.error(f"unhandled action type: {full_note.action}, skipping message")
raise ValueError(f"unhandled action: {full_note.action}")
Expand Down
9 changes: 7 additions & 2 deletions app/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class NotificationType(str, Enum):
PROP_RESP = 'proposal-response'
NEW_PROP = 'new-proposal'
PROMOTE = 'category-promotion'
#TODO should rejections eventually send emails?
CATEGORY_REJECTION = 'category-rejection'

#the shape the data comes in the pubsub message
class NotificationParams(BaseModel):
Expand All @@ -37,11 +37,16 @@ class PromoteData(BaseModel):
promotion_type: Literal["primary", "secondary"]
category_change: str

class CategoryRejectionData(BaseModel):
category: str
rejection_type: Literal["reject", "accept_secondary", "cross_submission"]
category_change: str


class SimplifiedNotification(BaseModel):
time: datetime
user_id: int
data: Union[CommentData, PromoteData, PropRespData, NewPropData]
data: Union[CommentData, PromoteData, PropRespData, NewPropData, CategoryRejectionData]

@dataclass
class UserContact:
Expand Down
24 changes: 24 additions & 0 deletions app/templates/category_rejection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from app.schema import SimplifiedNotification, CategoryRejectionData
from app.email_content import _fmt_time

_REJECTION_LABELS = {
"reject": "removed from submission",
"accept_secondary": "demoted to secondary",
"cross_submission": "removed from cross submission",
}


def render_category_rejection_block(change: SimplifiedNotification, user_name: str) -> tuple[str, str]:
data: CategoryRejectionData = change.data
when = _fmt_time(change.time)
label = _REJECTION_LABELS.get(data.rejection_type, data.rejection_type)
text = (
f"[{when}] {user_name} rejected {data.category} ({label}):\n"
f" Change: {data.category_change}\n"
)
html_out = (
f"<p><strong>[{when}] {user_name}</strong> "
f"rejected {data.category} ({label})<br>\n"
f"Change: {data.category_change}</p>\n"
)
return text, html_out
87 changes: 86 additions & 1 deletion tests/test_email_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@

import pytest

from app.schema import SimplifiedNotification, CommentData, PromoteData, NewPropData, PropRespData
from app.schema import SimplifiedNotification, CommentData, PromoteData, NewPropData, PropRespData, CategoryRejectionData
from app.schema import SubEmailData
from app.email_content import get_submission_info, _build_category_string, render_change_block, render_email
from app.schema import EmailTask, ConsolidatedNotifications
from app.templates.comment import render_comment_block
from app.templates.promote import render_promote_block
from app.templates.new_prop import render_new_prop_block
from app.templates.prop_resp import render_prop_resp_block
from app.templates.category_rejection import render_category_rejection_block
from app.templates.submission import render_submission_block, truncate_authors, MAX_AUTHORS
from app.templates.email_body import render_body, CHECK_GUIDE_URL, HOW_TO_MOD_URL, MOD_HUB_URL

Expand Down Expand Up @@ -69,6 +70,30 @@ def test_render_prop_resp_block():
assert _USER in text and _USER in html_out


# ── category rejection ────────────────────────────────────────────────────────

def test_render_category_rejection_block_reject():
note = _note(CategoryRejectionData(category="cs.LG", rejection_type="reject", category_change="cs.LG cs.AI => no primary cs.AI"))
text, html_out = render_category_rejection_block(note, _USER)
assert "cs.LG" in text and "cs.LG" in html_out
assert "removed from submission" in text and "removed from submission" in html_out
assert "cs.LG cs.AI => no primary cs.AI" in text and "cs.LG cs.AI => no primary cs.AI" in html_out
assert _USER in text and _USER in html_out

def test_render_category_rejection_block_accept_secondary():
note = _note(CategoryRejectionData(category="cs.LG", rejection_type="accept_secondary", category_change="cs.LG cs.AI => no primary cs.AI cs.LG"))
text, html_out = render_category_rejection_block(note, _USER)
assert "demoted to secondary" in text and "demoted to secondary" in html_out
assert _USER in text and _USER in html_out

def test_render_category_rejection_block_cross_submission():
note = _note(CategoryRejectionData(category="hep-ph", rejection_type="cross_submission", category_change="cs.LG hep-ph => cs.LG"))
text, html_out = render_category_rejection_block(note, _USER)
assert "removed from cross submission" in text and "removed from cross submission" in html_out
assert "hep-ph" in text and "hep-ph" in html_out
assert _USER in text and _USER in html_out


# ── dispatcher ────────────────────────────────────────────────────────────────

def test_render_change_block_dispatches():
Expand All @@ -95,6 +120,12 @@ def test_render_change_block_dispatches():
assert "q-bio.BM" in text and "q-bio.BM" in html_out
assert "cs.LG cs.DC hep-ph => eess.AS cs.DC cs.LG hep-ph q-bio.BM" in text and "cs.LG cs.DC hep-ph => eess.AS cs.DC cs.LG hep-ph q-bio.BM" in html_out

rejection = _note(CategoryRejectionData(category="cs.LG", rejection_type="reject", category_change="cs.LG cs.AI => no primary cs.AI"))
text, html_out = render_change_block(rejection, _USER)
assert "cs.LG" in text and "cs.LG" in html_out
assert "removed from submission" in text and "removed from submission" in html_out
assert "cs.LG cs.AI => no primary cs.AI" in text and "cs.LG cs.AI => no primary cs.AI" in html_out


# ── submission block ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -281,6 +312,60 @@ def test_prop_resp_exact_html():
)


def test_rejection_exact_text_reject():
note = _note(CategoryRejectionData(category="cs.LG", rejection_type="reject", category_change="cs.LG cs.AI => no primary cs.AI"))
text, _ = render_category_rejection_block(note, _USER)
assert text == (
f"[{_WHEN}] {_USER} rejected cs.LG (removed from submission):\n"
f" Change: cs.LG cs.AI => no primary cs.AI\n"
)


def test_rejection_exact_html_reject():
note = _note(CategoryRejectionData(category="cs.LG", rejection_type="reject", category_change="cs.LG cs.AI => no primary cs.AI"))
_, html_out = render_category_rejection_block(note, _USER)
assert html_out == (
f"<p><strong>[{_WHEN}] {_USER}</strong> rejected cs.LG (removed from submission)<br>\n"
f"Change: cs.LG cs.AI => no primary cs.AI</p>\n"
)


def test_rejection_exact_text_accept_secondary():
note = _note(CategoryRejectionData(category="cs.LG", rejection_type="accept_secondary", category_change="cs.LG cs.AI => no primary cs.AI cs.LG"))
text, _ = render_category_rejection_block(note, _USER)
assert text == (
f"[{_WHEN}] {_USER} rejected cs.LG (demoted to secondary):\n"
f" Change: cs.LG cs.AI => no primary cs.AI cs.LG\n"
)


def test_rejection_exact_html_accept_secondary():
note = _note(CategoryRejectionData(category="cs.LG", rejection_type="accept_secondary", category_change="cs.LG cs.AI => no primary cs.AI cs.LG"))
_, html_out = render_category_rejection_block(note, _USER)
assert html_out == (
f"<p><strong>[{_WHEN}] {_USER}</strong> rejected cs.LG (demoted to secondary)<br>\n"
f"Change: cs.LG cs.AI => no primary cs.AI cs.LG</p>\n"
)


def test_rejection_exact_text_cross_submission():
note = _note(CategoryRejectionData(category="hep-ph", rejection_type="cross_submission", category_change="cs.LG hep-ph => cs.LG"))
text, _ = render_category_rejection_block(note, _USER)
assert text == (
f"[{_WHEN}] {_USER} rejected hep-ph (removed from cross submission):\n"
f" Change: cs.LG hep-ph => cs.LG\n"
)


def test_rejection_exact_html_cross_submission():
note = _note(CategoryRejectionData(category="hep-ph", rejection_type="cross_submission", category_change="cs.LG hep-ph => cs.LG"))
_, html_out = render_category_rejection_block(note, _USER)
assert html_out == (
f"<p><strong>[{_WHEN}] {_USER}</strong> rejected hep-ph (removed from cross submission)<br>\n"
f"Change: cs.LG hep-ph => cs.LG</p>\n"
)


def test_submission_exact_text():
sub = _mock_submission()
text, _ = render_submission_block(sub)
Expand Down
58 changes: 56 additions & 2 deletions tests/test_process_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from arxiv.taxonomy.definitions import CATEGORIES_ACTIVE

from app.process import process_messages, _parse_message, _convert_messages, _build_email_tasks
from app.schema import CommentData, PromoteData, NewPropData, PropRespData, ConsolidatedNotifications, SimplifiedNotification
from app.schema import CommentData, PromoteData, NewPropData, PropRespData, CategoryRejectionData, ConsolidatedNotifications, SimplifiedNotification

GOOD_COMMENT = {
"time": "2024-01-01T10:00:00Z",
Expand Down Expand Up @@ -56,6 +56,32 @@
}
}

GOOD_REJECTION = {
"time": "2024-01-01T10:00:00Z",
"submission_id": 125,
"user_id": 3,
"categories": ["cs.LG", "cs.AI"],
"action": "category-rejection",
"data": {
"category": "cs.LG",
"rejection_type": "reject",
"category_change": "cs.LG cs.AI => no primary cs.AI"
}
}

BAD_REJECTION = {
"time": "2024-01-01T10:00:00Z",
"submission_id": 125,
"user_id": 3,
"categories": ["cs.LG"],
"action": "category-rejection",
"data": {
"category": "cs.LG",
"rejection_type": "invalid_type", # bad enum
"category_change": "cs.LG => "
}
}

def _make_pubsub_message(ack_id: str, payload: dict):
"helper function to model what pubsub messages look like"
return SimpleNamespace(
Expand Down Expand Up @@ -184,6 +210,23 @@ def test_parse_promote():
assert simple_note.user_id == 1
assert simple_note.time == datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)

def test_parse_category_rejection():
with pytest.raises(Exception):
_parse_message(BAD_REJECTION)

full_note, simple_note = _parse_message(GOOD_REJECTION)

assert full_note.action == "category-rejection"
assert full_note.categories == ["cs.LG", "cs.AI"]
assert full_note.submission_id == 125

assert isinstance(simple_note.data, CategoryRejectionData)
assert simple_note.data.category == "cs.LG"
assert simple_note.data.rejection_type == "reject"
assert simple_note.data.category_change == "cs.LG cs.AI => no primary cs.AI"
assert simple_note.user_id == 3
assert simple_note.time == datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)

def test_parse_prop_response():

bad_prop_resp = {
Expand Down Expand Up @@ -220,7 +263,8 @@ def test_consolidate_messages():
msg3 = _make_pubsub_message("ack-4", GOOD_PROMOTE)
msg4 = _make_pubsub_message("ack-5", BAD_PROMOTE)
msg5 = _make_pubsub_message("ack-7", GOOD_PROP_RESP)
messages=[msg1, msg2, msg3, msg4, msg5]
msg6 = _make_pubsub_message("ack-8", GOOD_REJECTION)
messages=[msg1, msg2, msg3, msg4, msg5, msg6]

mock_ack = Mock()
data = _convert_messages(messages, ack_fn=mock_ack)
Expand Down Expand Up @@ -257,6 +301,16 @@ def test_consolidate_messages():
assert sub1.changes[2].data.category_change == 'no primary -> hep-lat'
assert sub1.changes[2].data.responses=="Primary accepted: hep-lat"

sub3=data[125]
assert sub3.ack_ids == ['ack-8']
assert sub3.categories == {CATEGORIES_ACTIVE['cs.LG'], CATEGORIES_ACTIVE['cs.AI']} #type: ignore
assert sub3.user_ids == {3}
assert len(sub3.changes) == 1
assert isinstance(sub3.changes[0].data, CategoryRejectionData)
assert sub3.changes[0].data.category == 'cs.LG'
assert sub3.changes[0].data.rejection_type == 'reject'
assert sub3.changes[0].data.category_change == 'cs.LG cs.AI => no primary cs.AI'

_NOTE = SimplifiedNotification(
time=datetime(2024, 1, 1, tzinfo=timezone.utc),
user_id=246231,
Expand Down
Loading