Spaces:
Sleeping
Sleeping
Download app.py from buai-builder-hub/STARSRedacter: direct link, hf CLI and curl.
- Browser
- Download file 36.5 kB
-
https://huggingface.co/spaces/buai-builder-hub/STARSRedacter/resolve/main/app.py
- Command line
-
hf download hf://spaces/buai-builder-hub/STARSRedacter/app.py
-
curl -L -o app.py https://huggingface.co/spaces/buai-builder-hub/STARSRedacter/resolve/main/app.py
36.5 kB
| """ | |
| Streamlit front end for the STARS report redactor (student-facing). | |
| Privacy design: the uploaded PDF is redacted entirely IN MEMORY via | |
| `engine.redact_bytes` and is never written to the server's disk. Only the | |
| redacted copy is offered back to the student for download; nothing is stored | |
| unless the student explicitly chooses to submit it to BBH. | |
| Deployment: Hugging Face Space (Streamlit SDK). Set a Space secret named | |
| APP_PASSPHRASE to the access phrase before going live. | |
| Page layout (top to bottom): | |
| 1) What is BBH, and why are we doing this | |
| 2) Access code | |
| 3) Screenshots - what we keep / don't keep | |
| 4) Link to a full sample redacted STARS report | |
| 5) Try it yourself - upload & see | |
| 6) Consent form check (only required to SHARE your redacted copy) | |
| 7) Your code (shown after you submit) | |
| 8) Removal link | |
| """ | |
| import os | |
| import secrets | |
| import streamlit as st | |
| from redactors import engine | |
| PASSPHRASE = os.environ.get("APP_PASSPHRASE", "") | |
| # --- editable project constants (temporary; update here if they change) ---- | |
| CONTACT_EMAIL = "buaibuilderhub@gmail.com" # general questions | |
| DELETE_FORM_URL = ( # "delete my report" request | |
| "https://docs.google.com/forms/d/e/" | |
| "1FAIpQLSc2ewCuCLHSuci9eZPrpmdSJ2XsUDk9yXZ5ynFy5CXgkY9RWg/viewform" | |
| ) | |
| # Link to a full sample redacted report. Set this to the file's URL to show the | |
| # "View here" button; leave it empty and the whole section is hidden. | |
| # | |
| # It used to point at exampleRedactedStars.pdf in this repo, but that file was | |
| # never committed, so the button 404'd. Blanked rather than left broken; | |
| # upload the sample and put its URL back to restore the section. | |
| SAMPLE_REPORT_URL = "" | |
| # Explainer art, shown in this list order as the student scrolls. Paths are | |
| # relative to this file (not the working directory) so they resolve the same | |
| # way locally and in the Space. | |
| # | |
| # This previously listed explanationSS1/2/3.png, which were never committed to | |
| # the repo, so every student saw three error boxes where the explanation should | |
| # have been. The same ground is covered by redaction_explainer.png, which was | |
| # already here and unused. Anything listed but missing is now skipped silently: | |
| # a missing asset is our problem and shouldn't be shown to a student. | |
| KEEP_DONT_KEEP_IMAGES = [ | |
| ("redaction_explainer.png", | |
| "What gets removed from your report, and what stays in it"), | |
| ] | |
| APP_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| # --- upload guardrails ------------------------------------------------------ | |
| # A real STARS report saved from experience.usc.edu is a text PDF of a few | |
| # pages, typically well under 1 MB. Anything far outside that is either the | |
| # wrong file or someone probing the app, so it is refused early and cheaply | |
| # rather than being handed to the redaction engine. The values are deliberately | |
| # generous, several times a normal report, so a legitimate student is never | |
| # caught by them. | |
| MAX_UPLOAD_MB = 10 # ~20x the size of a normal STARS report | |
| MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024 | |
| MAX_PAGES = 15 # a long real report runs about 5 pages | |
| MAX_PROCESSING_SECONDS = 30 # a normal report redacts in ~1-2 seconds | |
| # PDFs are the only thing this tool can read, and the only thing it accepts. | |
| # The uploader's type=["pdf"] filter goes on the FILENAME, so a file of any kind | |
| # renamed to .pdf gets past it; the real check is the "%PDF-" header in the | |
| # bytes. The spec puts that header at byte 0, but readers tolerate some junk in | |
| # front of it, so scan a short window rather than the first 5 bytes. | |
| ALLOWED_UPLOAD_EXTENSIONS = (".pdf",) | |
| PDF_MAGIC = b"%PDF-" | |
| PDF_HEADER_SCAN_BYTES = 1024 | |
| # --- report ID -------------------------------------------------------------- | |
| # The ID is a bearer token in the coat-check sense: it is the ONLY thing tying a | |
| # student to their stored report, and the only thing needed to ask us to delete | |
| # one. 16 bytes = 32 hex characters = 128 bits, which puts both guessing a valid | |
| # ID and two students colliding out of reach. | |
| # | |
| # Changing this changes the shape of the stored filename (<ID>.pdf). | |
| # admin/purge_submission.py validates incoming IDs against a matching 32-hex | |
| # pattern (it can't import this constant, because importing a Streamlit script runs | |
| # it), so if you change the length, update CURRENT_ID_RE there in the same | |
| # commit or deletion requests will start being rejected as malformed. | |
| REPORT_ID_BYTES = 16 | |
| # --- submission storage (a private HF Dataset in the org) ------------------- | |
| # In-app submission switches on automatically once these Space secrets are set: | |
| # HF_TOKEN a WRITE token with access to the dataset repo | |
| # SUBMISSIONS_REPO (optional) defaults to buai-builder-hub/stars-submissions | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| SUBMISSIONS_REPO = os.environ.get("SUBMISSIONS_REPO", | |
| "buai-builder-hub/redacted-stars-submissions") | |
| def _submissions_enabled(): | |
| return bool(HF_TOKEN) | |
| def _upload_submission(data, uid): | |
| """Save the REDACTED bytes to the private dataset repo as <uid>.pdf.""" | |
| from huggingface_hub import HfApi | |
| HfApi(token=HF_TOKEN).upload_file( | |
| path_or_fileobj=data, | |
| path_in_repo=f"{uid}.pdf", | |
| repo_id=SUBMISSIONS_REPO, | |
| repo_type="dataset", | |
| commit_message=f"submission {uid}", | |
| ) | |
| def _upload_rejection(upload): | |
| """Why this upload should be refused, as a message for the student. | |
| Returns None when the file is acceptable. Checks run cheapest-first: the | |
| filename, then the size, then the bytes themselves, so an enormous file is | |
| turned away on its size before anything reads it. | |
| """ | |
| name = (upload.name or "").lower() | |
| if not name.endswith(ALLOWED_UPLOAD_EXTENSIONS): | |
| return ("We can only take **PDF** files. Open your STARS report on " | |
| "experience.usc.edu, hit the red **Print** button, and choose " | |
| "**Save as PDF**, then upload that file.") | |
| if upload.size > MAX_UPLOAD_BYTES: | |
| return (f"That file is **{upload.size / 1024 / 1024:.1f} MB**, which is " | |
| f"over our **{MAX_UPLOAD_MB} MB** limit. A STARS report saved " | |
| "from experience.usc.edu is normally well under 1 MB, so this " | |
| "is probably a different file, so double-check you picked the " | |
| f"right one. If it really is your STARS report, email us at " | |
| f"{CONTACT_EMAIL} and we'll help.") | |
| # Named .pdf, but is it one? This is the check that a renamed file fails. | |
| if PDF_MAGIC not in upload.getvalue()[:PDF_HEADER_SCAN_BYTES]: | |
| return ("This file is named `.pdf`, but it doesn't look like a PDF " | |
| "inside, so we didn't try to read it. If you renamed a " | |
| "screenshot or a document, that won't work. Use **Print → Save " | |
| "as PDF** on experience.usc.edu and upload the file it gives " | |
| "you.") | |
| return None | |
| def _clear_cached_result(): | |
| """Forget any cached redaction. | |
| Called when an upload is refused. Without this, a student who first | |
| uploaded a good report and then picked a too-large file would still have the | |
| previous file's bytes sitting in session state, and the Submit section | |
| further down would happily offer to share them. | |
| """ | |
| for key in ("uid_key", "uid", "res", "out", "submitted", "preview"): | |
| st.session_state.pop(key, None) | |
| # --- in-page preview -------------------------------------------------------- | |
| # Streamlit has no PDF viewer, and browsers refuse to render a PDF served from a | |
| # data: URI inside an <iframe>, so embedding the file directly shows most | |
| # students a blank box. Rasterising each page with PyMuPDF and handing Streamlit | |
| # plain images works everywhere instead. Like everything else here it happens in | |
| # memory: the redacted bytes are never written to the server's disk. | |
| PREVIEW_ZOOM = 2.0 # ~144 DPI: legible on screen without being enormous | |
| def _render_preview_images(data): | |
| """Rasterise the redacted PDF; returns one PNG (as bytes) per page.""" | |
| import pymupdf | |
| pages = [] | |
| with pymupdf.open(stream=data, filetype="pdf") as doc: | |
| for page in doc: | |
| pix = page.get_pixmap(matrix=pymupdf.Matrix(PREVIEW_ZOOM, PREVIEW_ZOOM)) | |
| pages.append(pix.tobytes("png")) | |
| return pages | |
| def footer(): | |
| """Always-visible byline (rendered at the bottom whether or not the page is | |
| unlocked).""" | |
| st.divider() | |
| # Two lines: the byline in cardinal, the schools beneath it in small caps, | |
| # echoing the eyebrow above the wordmark at the top of the page. | |
| st.markdown( | |
| """ | |
| <div style="text-align:center; margin:.2rem 0 .6rem 0; | |
| font-family:'Inter',system-ui,sans-serif;"> | |
| <div style="font-weight:600; font-size:.95rem; color:#990000;"> | |
| BUAI Builder Hub · Tools by Students, for Students | |
| </div> | |
| <div style="margin-top:.3rem; font-size:.72rem; letter-spacing:.14em; | |
| text-transform:uppercase; color:#6b6b6b;"> | |
| USC Marshall School of Business × Viterbi School of Engineering | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.set_page_config(page_title="STARS Report Redactor", | |
| layout="centered") | |
| # ---- theme polish: USC cardinal + gold, collegiate type -------------------- | |
| st.markdown( | |
| """ | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,600&family=Inter:wght@400;600;800&display=swap'); | |
| html, body, [class*="css"], .stMarkdown, p, li { font-family: 'Inter', system-ui, sans-serif; } | |
| /* Streamlit sets its own colour on the span inside each heading, which | |
| beat the un-flagged rule that used to be here, so headings rendered | |
| near-black while this file claimed they were cardinal. h4 is included | |
| because the step headings use it. */ | |
| h1, h2, h3, h4, h1 span, h2 span, h3 span, h4 span { | |
| font-family: 'Newsreader', Georgia, serif !important; | |
| color: #990000 !important; letter-spacing: .2px; | |
| } | |
| h1 { border-bottom: 3px solid #FFCC00; padding-bottom: .35rem; } | |
| .stButton > button, .stDownloadButton > button, .stLinkButton > a { | |
| background-color: #990000; color: #ffffff; border: 0; font-weight: 600; border-radius: 8px; | |
| } | |
| .stButton > button:hover, .stDownloadButton > button:hover, .stLinkButton > a:hover { | |
| background-color: #7a0000; color: #FFCC00; | |
| } | |
| div[data-testid="stExpander"] { border: 1px solid #ece2cc; border-radius: 8px; } | |
| div[data-testid="stExpander"] summary { font-weight: 600; color: #990000; } | |
| /* The three sharing steps used to be <h4> headings. As folds they render at | |
| body size, which stopped them reading as titles, so the heading treatment | |
| is restored here. Scoped by key so the sub-folds nested inside them stay | |
| visibly subordinate rather than all becoming titles. */ | |
| .st-key-step-fold-1 summary, .st-key-step-fold-2 summary, | |
| .st-key-step-fold-3 summary { | |
| font-family: 'Newsreader', Georgia, serif !important; | |
| font-size: 1.35rem !important; | |
| padding-top: .3rem !important; padding-bottom: .3rem !important; | |
| } | |
| .st-key-step-fold-1 summary p, .st-key-step-fold-2 summary p, | |
| .st-key-step-fold-3 summary p { | |
| font-family: 'Newsreader', Georgia, serif !important; | |
| font-size: 1.35rem !important; font-weight: 600 !important; | |
| letter-spacing: .2px; | |
| } | |
| /* Those selectors are descendant ones, so they also reach the summaries of | |
| folds nested INSIDE a step, which made the sub-folds look like titles of | |
| their own. Put them back to body size. This rule carries the same | |
| specificity as the one above and comes after it, so it wins. */ | |
| /* The report ID is the one thing a student has to walk away with, and at | |
| default code styling it read as incidental output that was easy to skim | |
| past. Set it large on USC gold, inside a cardinal rule. */ | |
| .st-key-report-id-code code { | |
| font-size: 1.3rem !important; font-weight: 700 !important; | |
| letter-spacing: .05em !important; color: #1A1A1A !important; | |
| background: transparent !important; word-break: break-all; | |
| } | |
| .st-key-report-id-code pre, .st-key-report-id-code [data-testid="stCode"] { | |
| background: #FFCC00 !important; | |
| border: 2px solid #990000 !important; | |
| border-radius: 6px !important; | |
| } | |
| .st-key-sub-fold-how summary, .st-key-sub-fold-preview summary, | |
| .st-key-sub-fold-download summary, | |
| .st-key-sub-fold-how summary p, .st-key-sub-fold-preview summary p, | |
| .st-key-sub-fold-download summary p { | |
| font-family: 'Inter', system-ui, sans-serif !important; | |
| font-size: .95rem !important; font-weight: 600 !important; | |
| letter-spacing: normal !important; | |
| } | |
| /* The upload control ships with enough vertical padding to read as a slab, | |
| especially once a file is in it and the instructions are gone. Collapse | |
| the padding so the box is the size of what it holds. The dropzone's test | |
| id was renamed between Streamlit versions, so both spellings are matched | |
| and whichever one is absent costs nothing. */ | |
| /* Strip the dropzone's cream slab entirely: no background, no border, no | |
| padding, so what shows is the upload button and, once chosen, the file | |
| itself. Three things matter when overriding it: it is a <section>, not a | |
| <div>; its emotion styles are injected at render time, so !important is | |
| needed to win; and the tag has changed across versions, so match on the | |
| test id alone. The backgroundColor it used is the theme's | |
| secondaryBackgroundColor, which expanders still use, so this is scoped to | |
| the dropzone rather than changed in config.toml. */ | |
| [data-testid="stFileUploaderDropzone"] { | |
| background: transparent !important; | |
| border: none !important; | |
| min-height: 0 !important; | |
| padding: 0 !important; | |
| gap: .7rem !important; | |
| align-items: center !important; | |
| } | |
| [data-testid="stFileUploaderDropzoneInstructions"] svg { | |
| height: 1.4rem !important; width: 1.4rem !important; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # BBH wordmark. Text rather than an image so it stays crisp at any zoom, needs | |
| # no asset in the repo, and can't break the way the explainer screenshots did. | |
| # With no <h1> above the intro copy, this is the thing that anchors the page, so | |
| # it is sized as the dominant element rather than a small header lockup. | |
| st.markdown( | |
| """ | |
| <div style="text-align:center; margin:.35rem 0 1.6rem 0; | |
| border-bottom:3px solid #FFCC00; padding-bottom:1.1rem;" | |
| role="img" aria-label="BUAI Builder Hub, USC Marshall and Viterbi"> | |
| <div style="font-family:'Inter',system-ui,sans-serif; font-weight:600; | |
| font-size:.85rem; letter-spacing:.24em; color:#990000; | |
| text-transform:uppercase; margin-bottom:.7rem;"> | |
| USC Marshall × Viterbi | |
| </div> | |
| <div style="font-family:'Inter',system-ui,sans-serif; font-weight:800; | |
| font-size:3.4rem; line-height:1.02; letter-spacing:-.03em; | |
| color:#1A1A1A;">BUAI</div> | |
| <div style="font-family:'Inter',system-ui,sans-serif; font-weight:800; | |
| font-size:3.4rem; line-height:1.02; letter-spacing:-.03em; | |
| color:#990000;">Builder Hub</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ============================================================================ | |
| # 1) HEADER: what this is, before anything is asked of the student | |
| # ============================================================================ | |
| # Rendered as HTML rather than markdown so the intro can run slightly larger | |
| # than body copy: it is the first thing a student reads and carries the ask. | |
| st.markdown( | |
| """ | |
| <div style="font-size:1.08rem; line-height:1.62;"> | |
| <p style="margin:0 0 .7rem 0;"> | |
| <b><a href="https://usc-bbh.github.io/bbh-website/" target="_blank" | |
| rel="noopener noreferrer">BUAI Builder Hub (BBH)</a></b> is working on creating tools to support | |
| students in <b>degree planning, course registration, and picking | |
| classes</b>. We need your help sourcing STARS reports that will be | |
| <b>anonymized</b> to develop our tool. | |
| </p> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # A skimmable recap for anyone who won't read every section below. Deliberately | |
| # unboxed: the bordered container and the TL;DR label framed these as an aside | |
| # to skip, when they are the four things a student most needs to see. | |
| st.markdown( | |
| "- Your **name, USC ID, address, grades, and GPA** are stripped before " | |
| "anything is stored.\n" | |
| "- Only **BBH student teams** ever see it. Never sold, never public.\n" | |
| "- **You see your redacted file first.** You can ask us to delete it " | |
| "anytime.\n" | |
| "- Takes about **two minutes**." | |
| ) | |
| # The TL;DR is deliberately thin, so anyone it left with a question needs to be | |
| # told where the long answer lives. The tabs are right below this line, and the | |
| # about tab is open to everyone, with no access code needed to read it. | |
| st.markdown( | |
| "**Questions?** Open the **How it works** tab below: it shows exactly " | |
| "what gets removed, what stays in, and who can see your report. " | |
| "If you already submitted a report and want it deleted, open the " | |
| "**Delete my report** tab, no code required." | |
| ) | |
| # ============================================================================ | |
| # 2) THE THREE THINGS A STUDENT MIGHT BE HERE TO DO | |
| # ============================================================================ | |
| # Only *sharing* a report needs the access code. Someone who submitted weeks ago | |
| # and now wants their report deleted should not have to hunt down a passphrase | |
| # to ask, and someone deciding whether to take part should be able to read what | |
| # the tool does first. Those two tabs are therefore open to everyone; the upload | |
| # and submit flow is gated exactly as strictly as before. | |
| tab_share, tab_delete, tab_about = st.tabs([ | |
| "Share my report", | |
| "Delete my report", | |
| "How it works", | |
| ]) | |
| # ---------------------------------------------------------------------------- | |
| # TAB 1: SHARE (access code required) | |
| # ---------------------------------------------------------------------------- | |
| def _render_upload_step(): | |
| """Step 1: take the file and redact it. Returns nothing; writes to state.""" | |
| with st.expander("Step 1 · Upload your STARS report", expanded=True, | |
| key="step-fold-1"): | |
| with st.expander("How do I even get my STARS report?", | |
| key="sub-fold-how"): | |
| st.markdown( | |
| "1. Go to **experience.usc.edu** → **My Academics**.\n" | |
| "2. Open your **STARS** (Degree Progress) report.\n" | |
| "3. Hit the **red Print button** up top.\n" | |
| "4. In the print pop-up, pick **Save as PDF** and save it.\n" | |
| "5. Upload that PDF here. (Grab the real PDF from the site; a screenshot " | |
| "or scan won't work.)" | |
| ) | |
| # The "Step 1" heading immediately above already says what to upload, so the | |
| # widget's own label just repeated it. Collapsed rather than blanked: an | |
| # empty label trips a Streamlit warning and leaves the control unnamed for | |
| # screen readers, which still read the string below. | |
| uploaded = st.file_uploader( | |
| "Upload your STARS report (PDF)", | |
| type=["pdf"], accept_multiple_files=False, | |
| label_visibility="collapsed") | |
| # Guardrails 1 and 2: FILE TYPE and SIZE, both settled before the bytes reach | |
| # the redaction engine. Type is checked twice over: the uploader filters on the | |
| # filename, and _upload_rejection checks the PDF header in the actual bytes. | |
| rejection = _upload_rejection(uploaded) if uploaded is not None else None | |
| if rejection: | |
| st.error(rejection) | |
| _clear_cached_result() | |
| return None | |
| if uploaded is None: | |
| return None | |
| # Redact ONCE per uploaded file and cache the exact result. Streamlit reruns | |
| # the whole script on every click, so without caching, checking the consent | |
| # box or clicking Submit would re-run redaction and upload a freshly | |
| # -recomputed file, not the same bytes the student downloaded and reviewed. | |
| # One redaction, one ID, reused for the download filename and the database | |
| # submission so they always match. | |
| file_key = getattr(uploaded, "file_id", None) or f"{uploaded.name}:{uploaded.size}" | |
| if st.session_state.get("uid_key") != file_key: | |
| with st.spinner("Scrubbing your info…"): | |
| try: | |
| # Guardrails 3 and 4: PAGE COUNT and PROCESSING TIME. Both are | |
| # enforced inside the engine because both need the PDF open: | |
| # the page count is checked before any redaction work starts, | |
| # and the time limit is checked between pages and passes. | |
| res, out = engine.redact_bytes( | |
| uploaded.getvalue(), {"pii", "grades"}, | |
| max_pages=MAX_PAGES, | |
| time_limit=MAX_PROCESSING_SECONDS, | |
| ) | |
| except Exception: | |
| res, out = {"status": "FAILED", "code": "ERROR"}, None | |
| st.session_state["uid_key"] = file_key | |
| st.session_state["uid"] = secrets.token_hex(REPORT_ID_BYTES).upper() | |
| st.session_state["res"] = res | |
| st.session_state["out"] = out | |
| st.session_state["submitted"] = False | |
| st.session_state["preview"] = None | |
| return uploaded | |
| def _render_review_step(): | |
| """Step 2: show what happened, warn about leftovers, offer the download.""" | |
| res = st.session_state.get("res") or {} | |
| out = st.session_state.get("out") | |
| uid = st.session_state.get("uid", "") | |
| status = res.get("status") | |
| if status in ("REDACTED", "REVIEW") and out: | |
| st.divider() | |
| with st.expander("Step 2 · Check your redacted copy", expanded=True, | |
| key="step-fold-2"): | |
| # The default text colour is the theme's near-black, so this needs | |
| # no markup: it is the step's own instruction, not an aside. | |
| st.markdown("Open the PDF and make sure it looks right.") | |
| # Rendered once per uploaded file and cached: Streamlit reruns the whole | |
| # script on every click, and rasterising the pages again on each one | |
| # would make the consent checkbox and Submit button feel broken. | |
| if st.session_state.get("preview") is None: | |
| with st.spinner("Rendering a preview…"): | |
| try: | |
| st.session_state["preview"] = _render_preview_images(out) | |
| except Exception: | |
| st.session_state["preview"] = [] # fall back to download-only | |
| preview = st.session_state.get("preview") or [] | |
| if preview: | |
| label = f"Preview your redacted report ({len(preview)} page" | |
| label += "s)" if len(preview) != 1 else ")" | |
| # Collapsed by default: a STARS report runs to several pages, and | |
| # expanded it pushed the download button and the consent step far | |
| # enough down the page to be missed. The page count is in the label | |
| # so the control still says what is behind it. | |
| with st.expander(label, expanded=False, key="sub-fold-preview"): | |
| for n, png in enumerate(preview, 1): | |
| st.image(png, caption=f"Page {n}", width="stretch") | |
| st.caption("This is exactly the file you'll download, and " | |
| "exactly what BBH would receive if you choose to " | |
| "share it.") | |
| # Presented as a fold like the preview above it, so the two things | |
| # a student can do with the redacted file read as one pair. | |
| with st.expander("Download your redacted report", expanded=False, | |
| key="sub-fold-download"): | |
| st.download_button("Save the PDF", data=out, | |
| file_name=f"REDACTED_{uid}.pdf", | |
| mime="application/pdf", type="primary") | |
| return True | |
| if status == "SKIPPED": | |
| st.error("This looks like a **scan or photo** (no text in it), so it can't " | |
| "be redacted here. Grab the actual PDF via **Print → Save as PDF** " | |
| "on experience.usc.edu and try again.") | |
| elif res.get("code") == "TOO_MANY_PAGES": | |
| st.error( | |
| f"That PDF is **{res.get('pages', 'a lot of')} pages**, and we cap " | |
| f"uploads at **{MAX_PAGES}**. A STARS report is only a few pages " | |
| "long, so this is probably a different document. Check you uploaded " | |
| "your STARS report and not something else." | |
| ) | |
| elif res.get("code") == "TIMEOUT": | |
| st.error( | |
| f"That file took longer than **{MAX_PROCESSING_SECONDS} seconds** to " | |
| "process, so we stopped. A normal STARS report takes a second or " | |
| "two. Try re-saving it with **Print → Save as PDF** on " | |
| f"experience.usc.edu; if it still won't go through, email {CONTACT_EMAIL}." | |
| ) | |
| elif res.get("code") == "NOT_STARS": | |
| st.error("Hmm, this doesn't look like a **STARS Degree Progress Report**. " | |
| "Double-check you uploaded the right PDF (the one from " | |
| "experience.usc.edu → My Academics → STARS), not another document.") | |
| elif res.get("code") == "UNREADABLE": | |
| st.error("We couldn't read that file. It might be corrupted or not a real " | |
| "PDF. Try re-saving it with **Print → Save as PDF** on " | |
| "experience.usc.edu and uploading again.") | |
| elif status is not None: | |
| st.error("Something didn't check out, so we **didn't produce a file**. " | |
| f"Better safe than sorry. Please email {CONTACT_EMAIL}, and " | |
| "don't share the original in the meantime.") | |
| return False | |
| def _render_report_id(): | |
| """The code the student needs to ever delete their report. | |
| Shown right where they submitted rather than in a section further down the | |
| page: it is the single most important thing to walk away with, and it is | |
| useless to them if they scroll past it. | |
| """ | |
| st.divider() | |
| with st.container(border=True): | |
| st.markdown("### Submitted: here's your report ID") | |
| # st.code renders a copy-to-clipboard button, which matters now that the | |
| # ID is 32 characters, so it is meant to be copied, not transcribed by | |
| # hand. The same ID is also in the downloaded filename | |
| # (REDACTED_<ID>.pdf), so a student who loses this screen still has a | |
| # second copy of it. | |
| # Wrapped in a keyed container purely so the CSS above can reach it: | |
| # st.code takes no key of its own. wrap_lines keeps all 32 characters on | |
| # screen at the larger size on a phone. | |
| with st.container(key="report-id-code"): | |
| st.code(st.session_state["uid"], language=None, wrap_lines=True) | |
| st.warning( | |
| "**Save this code now!** Your report is stored with no name attached, " | |
| "so this ID is the only way to delete it later. If you lose it, we " | |
| "can't find your report to remove it. It's also in the filename of " | |
| "the copy you downloaded.\n\n" | |
| "To use it later, come back to this page and open the **Delete my " | |
| "report** tab. No access code needed for that." | |
| ) | |
| def _render_share_step(): | |
| """The last step: the consent-gated submission.""" | |
| st.divider() | |
| with st.expander("Last step: Consent Acknowledgement", expanded=True, | |
| key="step-fold-3"): | |
| st.markdown( | |
| "- **What we'd keep:** only the **redacted** version of your STARS " | |
| "report, the file this tool just handed you after removing your " | |
| "personal info.\n" | |
| "- **What's removed before anything is stored:** your **name, USC ID, " | |
| "mailing address, sport/team**, every **letter grade** (each becomes a " | |
| "simple pass/fail marker), and your **GPA**.\n" | |
| "- **What stays in the file:** your **major/minor, the courses and " | |
| "terms on your record, and your degree-progress details**.\n" | |
| "- **Identifiability:** We remove your name, ID, grades, and GPA, but " | |
| "we **keep your major(s), minor, and the list of classes you've " | |
| "taken**. If your combination is unusual (e.g. the only USC student " | |
| "double-majoring in BUAI and Anthropology), someone who sees the file " | |
| "could figure out it's yours. They still **won't** see your grades, " | |
| "GPA, or student ID.\n" | |
| "- **Your original file is never saved.** It's handled right here in " | |
| "the page's memory, **never written to disk**, and **automatically " | |
| "discarded the moment you close this tab**. If you submit, we save " | |
| "**only the redacted copy** to a **private BBH archive**, tagged with " | |
| "a random code we show you. You can use this code to request that " | |
| "your report be removed anytime.\n" | |
| "- **How we use it:** to build and test tools that help USC students " | |
| "plan degrees and register for classes, and (because it's " | |
| "de-identified) possibly for research about the student experience. " | |
| "We won't sell it or try to identify you.\n" | |
| "- **Want to remove your report?** You can ask us to delete your " | |
| "redacted report anytime, using the code we give you at submission." | |
| ) | |
| if st.session_state.get("submitted"): | |
| _render_report_id() | |
| return | |
| consent = st.checkbox( | |
| "I've read the above and I consent to sharing my **redacted** STARS " | |
| "report with BBH.") | |
| if not consent: | |
| st.info("Check the box above if you'd like to submit your redacted copy.") | |
| return | |
| if not _submissions_enabled(): | |
| st.caption("Direct submission is unavailable right now, but you can still " | |
| "download your redacted file above.") | |
| return | |
| if st.button("Submit my redacted report", type="primary"): | |
| try: | |
| _upload_submission(st.session_state["out"], st.session_state["uid"]) | |
| st.session_state["submitted"] = True | |
| st.rerun() | |
| except Exception: | |
| st.error("Submission didn't go through, sorry! Your redacted " | |
| "file is still available above; please try submitting " | |
| "again in a moment.") | |
| with tab_share: | |
| if not PASSPHRASE: | |
| st.error("This app isn't set up yet (no passphrase configured). Ping the team.") | |
| st.info("Already submitted a report and want it deleted? That doesn't need " | |
| "a code. Open the **Delete my report** tab.") | |
| else: | |
| entered = st.text_input("Access code", type="password", | |
| placeholder="Enter the code you were given") | |
| _unlocked = entered.strip() == PASSPHRASE | |
| if not _unlocked: | |
| if entered: | |
| st.warning("Hmm, that's not it. Double-check with whoever sent you here.") | |
| else: | |
| st.caption("If you're a USC student using this, whoever referred " | |
| "you should've shared a passphrase.") | |
| else: | |
| _uploaded = _render_upload_step() | |
| _have_redaction = _render_review_step() if _uploaded is not None else False | |
| if _have_redaction: | |
| _render_share_step() | |
| # ---------------------------------------------------------------------------- | |
| # TAB 2: DELETE (no access code; this is a promise we made, not a feature) | |
| # ---------------------------------------------------------------------------- | |
| with tab_delete: | |
| st.markdown("#### Ask us to delete your report") | |
| st.markdown( | |
| "Changed your mind? You can have your report removed at any time." | |
| ) | |
| with st.container(border=True): | |
| st.markdown("**What you'll need: your report ID**") | |
| st.markdown( | |
| "It's the 32-character code we showed you right after you submitted. " | |
| "If you didn't save it, check the file you downloaded; the ID is in " | |
| "its filename, like `REDACTED_A1B2C3….pdf`.\n\n" | |
| "Your report is stored with no name attached, so the ID is the " | |
| "only way we can find it." | |
| ) | |
| st.link_button("Open the removal form", DELETE_FORM_URL, type="primary") | |
| with st.expander("What happens after I submit the request?"): | |
| st.markdown( | |
| "The BBH team reviews removal requests periodically. When we delete " | |
| "your report, we also clear it from our storage history, so an older " | |
| "copy can't be recovered afterwards." | |
| ) | |
| # ---------------------------------------------------------------------------- | |
| # TAB 3: HOW IT WORKS (no access code; this is what helps someone decide) | |
| # ---------------------------------------------------------------------------- | |
| with tab_about: | |
| st.markdown("#### What we keep, and what we don't") | |
| for path, caption in KEEP_DONT_KEEP_IMAGES: | |
| full = os.path.join(APP_DIR, path) | |
| if not os.path.exists(full): | |
| continue | |
| try: | |
| st.image(full, caption=caption, width="stretch") | |
| except Exception: | |
| pass # a broken asset is ours to fix, not the student's to see | |
| # Said in words as well as in the picture. This is the one point a student could | |
| # be surprised by later, so it has to survive a missing or unreadable image. | |
| st.markdown( | |
| "**Removed:** your name, USC ID, mailing address, sport/team, every letter " | |
| "grade (each becomes a simple pass/fail marker), and your GPA.\n\n" | |
| "**Kept:** your major, minor, class level, and the courses and terms on " | |
| "your record.\n\n" | |
| "**Worth knowing:** because your major and course list stay in the file, an " | |
| "unusual combination could still point back to you. Your grades, GPA, and " | |
| "student ID won't be in there either way." | |
| ) | |
| # Moved here from under the uploader. It answers a question a student asks | |
| # while deciding whether to take part, which is what this tab is for, rather | |
| # than one they ask with the file already picked. | |
| with st.expander("What happens to the file I upload?"): | |
| st.markdown( | |
| "Your upload is handled in memory only and is discarded the moment " | |
| "you close this tab. Nothing is shared with BBH unless you choose " | |
| "to submit it in the last step." | |
| ) | |
| with st.expander("Wait, are my grades going in a database?"): | |
| st.markdown( | |
| "Nope. Before anything gets saved, we swap **every grade for a plain " | |
| "\"passed / didn't pass\" marker** and **wipe your GPA**. The copy we keep " | |
| "doesn't have your letter grades in it. We can tell a class was " | |
| "finished, not what you got in it." | |
| ) | |
| with st.expander("Who actually sees my report?"): | |
| st.markdown( | |
| "Only **BBH student developer teams**, students building tools to improve " | |
| "the USC student experience. Your redacted report is kept somewhere " | |
| "**private and access-controlled** (not public, never for sale), and used " | |
| "only to build and test those tools." | |
| ) | |
| if SAMPLE_REPORT_URL: | |
| st.markdown("#### See a full example of a redacted STARS report") | |
| st.link_button("View here", SAMPLE_REPORT_URL) | |
| footer() | |