diff --git a/app/email_content.py b/app/email_content.py index 4dab632..287c58b 100644 --- a/app/email_content.py +++ b/app/email_content.py @@ -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: @@ -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 @@ -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)}") diff --git a/app/process.py b/app/process.py index cfe6a22..d8c2514 100644 --- a/app/process.py +++ b/app/process.py @@ -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__) @@ -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}") diff --git a/app/schema.py b/app/schema.py index f19f179..771d633 100644 --- a/app/schema.py +++ b/app/schema.py @@ -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): @@ -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: diff --git a/app/templates/category_rejection.py b/app/templates/category_rejection.py new file mode 100644 index 0000000..01408db --- /dev/null +++ b/app/templates/category_rejection.py @@ -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"

[{when}] {user_name} " + f"rejected {data.category} ({label})
\n" + f"Change: {data.category_change}

\n" + ) + return text, html_out diff --git a/tests/test_email_content.py b/tests/test_email_content.py index 48731ea..02ae634 100644 --- a/tests/test_email_content.py +++ b/tests/test_email_content.py @@ -3,7 +3,7 @@ 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 @@ -11,6 +11,7 @@ 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 @@ -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(): @@ -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 ────────────────────────────────────────────────────────── @@ -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"

[{_WHEN}] {_USER} rejected cs.LG (removed from submission)
\n" + f"Change: cs.LG cs.AI => no primary cs.AI

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

[{_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_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"

[{_WHEN}] {_USER} rejected hep-ph (removed from cross submission)
\n" + f"Change: cs.LG hep-ph => cs.LG

\n" + ) + + def test_submission_exact_text(): sub = _mock_submission() text, _ = render_submission_block(sub) diff --git a/tests/test_process_message.py b/tests/test_process_message.py index 3e263e6..dcfe8de 100644 --- a/tests/test_process_message.py +++ b/tests/test_process_message.py @@ -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", @@ -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( @@ -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 = { @@ -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) @@ -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,