STARSRedacter / app.py
tanzilhussain's picture
Link BBH name in the intro to the BBH website
1b34235
Raw History Blame Contribute Delete
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 &middot; 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 &times; 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 &times; 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()