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,