Download email_render.py from ICSAC-Institute/editorial-system: direct link, hf CLI and curl.
- Browser
- Download file 9.76 kB
-
https://huggingface.co/spaces/ICSAC-Institute/editorial-system/resolve/main/email_render.py
- Command line
-
hf download hf://spaces/ICSAC-Institute/editorial-system/email_render.py
-
curl -L -o email_render.py https://huggingface.co/spaces/ICSAC-Institute/editorial-system/resolve/main/email_render.py
9.76 kB
| """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 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) | |