File size: 9,761 Bytes
37cb069 470bcea 37cb069 470bcea 37cb069 470bcea 37cb069 470bcea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | """Author correspondence: render accept/reject/invite email templates with author metadata."""
import os
import re
from urllib.parse import quote
import config
from review import _creator_display_names
def load_template(name: str) -> str:
"""Load an email template by name."""
path = os.path.join(config.TEMPLATES_DIR, f"{name}.md")
with open(path) as f:
return f.read()
class TemplateUnfilledKeysError(RuntimeError):
"""Raised when a template still contains {{...}} placeholders after rendering.
Hard-fail by design: author-facing mail with an unfilled key (e.g. a
'Dear {{author_name}},' greeting) is worse than no mail at all. The
raise propagates through the worker, which converts it to /pain via the
standard nerve and writes a `template_unfilled_keys` audit-log entry.
Caller should never catch and swallow this.
"""
def _render(template: str, data: dict) -> str:
"""Replace {{key}} placeholders with values from data.
Hard-fails if any {{...}} remains after substitution β by design,
silent template breakage in author-facing mail is worse than no mail.
Missing keys are still left in place during the substitution pass
(per existing semantics), but the post-pass scan catches them and
raises before any byte is sent.
"""
def sub(match):
key = match.group(1).strip()
return str(data.get(key, match.group(0)))
rendered = re.sub(r"\{\{(\w+)\}\}", sub, template)
leftover = re.findall(r"\{\{[^}]+\}\}", rendered)
if leftover:
raise TemplateUnfilledKeysError(
f"unfilled template keys after render: {sorted(set(leftover))}"
)
return rendered
def _split_creator(entry: str) -> tuple[str, str]:
"""Best-effort (first, last) split for a Zenodo creator string.
'Doe, Jane M.' -> ('Jane', 'Doe')
'Jane M. Doe' -> ('Jane', 'Doe')
'Plato' -> ('Plato', 'Plato')
"""
entry = entry.strip()
if "," in entry:
last, after = [s.strip() for s in entry.split(",", 1)]
first = after.split()[0].rstrip(".") if after else last
return (first, last)
parts = entry.split()
if len(parts) >= 2:
return (parts[0], parts[-1])
return (entry, entry)
def _greeting(creators: list, title_pref: str) -> str:
"""Build the name used after 'Dear '.
With a title preference, use 'Title Lastname' (e.g. 'Dr. Doe').
Without one, use the author's first name. Title prefs are only available
for authors who've opted into the community directory.
"""
entry = creators[0] if creators else "Researcher"
first, last = _split_creator(entry)
if title_pref and title_pref not in ("No title (first name is fine)", "Prefer not to say", ""):
return f"{title_pref} {last}"
return first
def _share_urls(paper_title: str, share_target_url: str) -> dict:
"""Build pre-filled social-share URLs for the accept email.
Share target is the ICSAC-branded landing page on icsacinstitute.org,
not the raw Zenodo record. LinkedIn and Facebook scrape OpenGraph tags
from the target URL, and the landing page's tags are ICSAC-branded so
the preview card shows ICSAC rather than generic Zenodo.
"""
share_sentence = (
f'My paper "{paper_title}" was accepted into the ICSAC Community '
f'β open peer review with AI tooling for complexity science.'
)
enc_sentence = quote(share_sentence, safe="")
enc_url = quote(share_target_url, safe="")
bluesky_text = quote(f"{share_sentence} {share_target_url}", safe="")
return {
"share_x_url": f"https://twitter.com/intent/tweet?text={enc_sentence}&url={enc_url}",
"share_linkedin_url": f"https://www.linkedin.com/sharing/share-offsite/?url={enc_url}",
"share_fb_url": f"https://www.facebook.com/sharer/sharer.php?u={enc_url}",
"share_bluesky_url": f"https://bsky.app/intent/compose?text={bluesky_text}",
}
def _base_data(review_data: dict) -> dict:
creators = _creator_display_names(review_data.get("creators"))
title_pref = review_data.get("author_title_preference", "")
paper_title = review_data.get("title", "Untitled")
record_id = review_data.get("record_id", "")
zenodo_url = f"https://zenodo.org/records/{record_id}"
site_base = getattr(config, "SITE_BASE_URL", "https://icsacinstitute.org")
share_target_url = f"{site_base}/accepted/{record_id}" if record_id else zenodo_url
# icsac_submission_id is the one canonical author-facing identifier β same
# key the audit-log uses (sub_id field on the submission record). For the
# Zenodo-watcher path, record_id is the Zenodo record ID; for the
# icsac-submission-intake path, record_id is the ICSAC-SUB-NNNNN string.
# Empty default rather than missing key β empty renders cleanly while a
# missing key would leave the literal {{icsac_submission_id}} placeholder
# and now (with the post-render assert) hard-fail the send.
data = {
"paper_title": paper_title,
"author_name": ", ".join(creators) if creators else "Researcher",
"greeting": _greeting(creators, title_pref),
"icsac_submission_id": str(record_id) if record_id else "",
"zenodo_record_url": zenodo_url,
"share_target_url": share_target_url,
"zenodo_submit_url": f"https://zenodo.org/communities/{getattr(config, 'COMMUNITY_ID', 'icsac')}",
"google_form_url": getattr(config, "GOOGLE_FORM_URL", "https://icsacinstitute.org/join"),
}
data.update(_share_urls(paper_title, share_target_url))
return data
def render_accept_email(review_data: dict, google_form_url: str = "") -> str:
"""Render the accept email."""
template = load_template("accept")
data = _base_data(review_data)
if google_form_url:
data["google_form_url"] = google_form_url
return _render(template, data)
def render_revise_and_resubmit_email(review_data: dict, review_summary: str = "",
specific_concerns: str = "") -> str:
"""Render the revise-and-resubmit email β ICSAC's default non-accept path.
Used for engageable in-scope submissions whose issues revision could
plausibly repair. The author is invited to revise and resubmit (no limit
on rounds, no bias on re-evaluation).
"""
template = load_template("revise-and-resubmit")
data = _base_data(review_data)
data["review_summary"] = review_summary or "Please see detailed review notes below."
data["specific_concerns"] = specific_concerns or "Review details available upon request."
return _render(template, data)
def render_scope_reject_email(review_data: dict, review_summary: str = "",
specific_concerns: str = "") -> str:
"""Render the scope-not-suitable rejection email.
Reserved for submissions outside ICSAC's editorial scope (pseudoscience,
non-engageable epistemics). This is NOT the standard decline path β for
engageable in-scope work that needs revision, use
`render_revise_and_resubmit_email` instead.
"""
template = load_template("scope-reject")
data = _base_data(review_data)
data["review_summary"] = review_summary or "Please see detailed review notes below."
data["specific_concerns"] = specific_concerns or "Review details available upon request."
return _render(template, data)
def render_community_invite_email(review_data: dict, google_form_url: str = "") -> str:
"""Render the community invite (perks/signup) email sent after accept."""
template = load_template("community-invite")
data = _base_data(review_data)
if google_form_url:
data["google_form_url"] = google_form_url
return _render(template, data)
def render_accept_comment(review_data: dict, landing_url: str = "") -> str:
"""Render the markdown comment we post to the Zenodo request on accept.
The comment is delivered to the author by Zenodo's notification machinery,
so it does not need a Subject line, Dear-greeting, or signature wrapper.
Share links and rich content live on the icsacinstitute.org landing page;
the comment just points there.
"""
template = load_template("accept-comment")
data = _base_data(review_data)
record_id = review_data.get("record_id", "")
site_base = getattr(config, "SITE_BASE_URL", "https://icsacinstitute.org")
data["landing_url"] = landing_url or f"{site_base}/accepted/{record_id}"
return _render(template, data)
def render_revise_and_resubmit_comment(review_data: dict, review_summary: str = "",
specific_concerns: str = "") -> str:
"""Render the markdown comment we post to the Zenodo request on R&R.
This is ICSAC's default decline path β engageable in-scope work that
needs revision. Use `render_scope_reject_comment` for scope-not-suitable
submissions.
"""
template = load_template("revise-and-resubmit-comment")
data = _base_data(review_data)
data["review_summary"] = review_summary or "Please see review notes for details."
data["specific_concerns"] = specific_concerns or "Review report available on request."
return _render(template, data)
def render_scope_reject_comment(review_data: dict) -> str:
"""Render the markdown comment posted to the Zenodo request on scope-reject.
Scope-not-suitable only. The scope-reject template does not carry a review
summary or concerns list β the verdict is "out of scope," not "revise
these points" β so the signature is intentionally minimal.
"""
template = load_template("scope-reject-comment")
data = _base_data(review_data)
return _render(template, data)
|