ysharma's picture
ysharma HF Staff
Preview-vs-save flow, interactive recreate checkbox, top status message
2974b38 verified
Raw
History Blame Contribute Delete
20.9 kB
"""
Build Small Hackathon — Certificate Generator
=============================================
Workflow:
1. User signs in with Hugging Face (OAuth).
2. We take their HF username and look it up in the private eligibility dataset
`build-small-hackathon/build-small-apps-for-certificates`
(registered participants ∪ org Space contributors).
• username found WITH a space -> autofill full name + project name
• username found WITHOUT a space -> autofill full name, no project
• username not found -> show contact message, do not proceed
3. All autofilled fields are editable.
4. A button generates the certificate (HTML -> PNG via the renderer Space) and
saves it to the public gallery dataset `build-small-hackathon/build-small-certificates`.
"""
import os
import uuid
import inspect
import tempfile
import urllib.parse
from functools import lru_cache
import gradio as gr
import pandas as pd
from PIL import Image
from datasets import load_dataset
from gradio_client import Client, handle_file
from certificate_upload_module import upload_user_certificate, get_certificate_image_path
HF_TOKEN = os.getenv("HF_TOKEN")
ELIGIBILITY_DATASET = "build-small-hackathon/build-small-apps-for-certificates"
RENDERER_SPACE = "https://ysharma-hackathon-certificate-html-to-image.hf.space/"
DISCORD_INVITE = "https://discord.gg/92sEPT2Zhv"
CONTACT_EMAIL = "hello@gradio.app"
_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "certificate_template.html")
with open(_TEMPLATE_PATH, encoding="utf-8") as _f:
CERTIFICATE_HTML_TEMPLATE = _f.read()
PROJECT_SECTION_WITH_NAME = '<div class="project"><span class="lbl">Project</span> {project_name}</div>'
PROJECT_SECTION_EMPTY = ""
# ----------------------------------------------------------------------------- renderer client
_gradio_client = None
def get_gradio_client():
"""Lazy init of the HTML->image renderer client (avoids event-loop issues).
The auth kwarg for gradio_client.Client changed across versions
(`hf_token` vs `token`), so pick whichever the installed version exposes.
"""
global _gradio_client
if _gradio_client is None:
params = inspect.signature(Client.__init__).parameters
attempts = []
if "hf_token" in params:
attempts.append({"hf_token": HF_TOKEN})
if "token" in params:
attempts.append({"token": HF_TOKEN})
attempts.append({"headers": {"Authorization": f"Bearer {HF_TOKEN}"}})
last_err = None
for kwargs in attempts:
try:
_gradio_client = Client(RENDERER_SPACE, **kwargs)
break
except TypeError as e:
last_err = e
if _gradio_client is None:
raise last_err
return _gradio_client
# ----------------------------------------------------------------------------- eligibility lookup
@lru_cache(maxsize=1)
def _eligibility_index():
"""Load the eligibility dataset once into {username_lower: (full_name, space_name)}."""
ds = load_dataset(ELIGIBILITY_DATASET, split="train", token=HF_TOKEN)
df = ds.to_pandas().fillna("")
index = {}
for _, row in df.iterrows():
uname = str(row.get("hf_username", "")).strip()
if not uname:
continue
index[uname.lower()] = (
str(row.get("full_name", "")).strip(),
str(row.get("space_name", "")).strip(),
)
return index
def lookup_participant(username: str):
"""Return (state, full_name, space_name).
state ∈ {"found_project", "found_no_project", "not_found"}.
"""
if not username:
return "not_found", "", ""
try:
index = _eligibility_index()
except Exception as e:
print(f"[ERROR] eligibility load failed: {e}")
return "error", "", ""
entry = index.get(username.strip().lower())
if entry is None:
return "not_found", "", ""
full_name, space_name = entry
if space_name:
return "found_project", full_name, space_name
return "found_no_project", full_name, ""
# ----------------------------------------------------------------------------- on login
def on_load(profile: gr.OAuthProfile | None):
"""Runs on page load. Drives which panel is shown, pre-fills the form, and — if the user
already generated a certificate — shows it and reveals the 'Recreate' control."""
reset_cb = gr.update(value=False) # never toggle the checkbox's own visibility (keeps it interactive)
if profile is None:
return (
"Please sign in with your Hugging Face account to continue.",
gr.update(visible=False), # main_interface
gr.update(visible=False), # contact_box
"", # name
gr.update(value="", visible=True), # project
"", # data_status
None, # certificate_image
None, # certificate_file
reset_cb, # recreate_checkbox
"", # generation_status
)
username = profile.username
profile_name = profile.name or username
state, full_name, space_name = lookup_participant(username)
if state in ("not_found", "error"):
return (
f"Signed in as **{username}**.",
gr.update(visible=False),
gr.update(visible=True),
"", gr.update(value="", visible=True), "",
None, None, reset_cb, "",
)
name_value = full_name or profile_name
if state == "found_project":
status = f"✅ Found your submission — **{space_name}**. Review the details below, then generate."
project_update = gr.update(value=space_name, visible=True)
else: # found_no_project
status = (
"✅ You're on the participant list! We couldn't find a Space submission under your "
"name — you can add a project below or leave it blank."
)
project_update = gr.update(value="", visible=True)
# Already generated a certificate before? Fetch and show it, with guidance up top.
existing = None
try:
existing = get_certificate_image_path(username)
except Exception:
existing = None
if existing:
gen_status = (
"### 📜 You already have a certificate\n"
"It's shown below — **download it** and use it however you like (LinkedIn, socials, etc.).\n\n"
"- Want a version with **different details**? Edit your name/project and click "
"**Generate my certificate** — that's just a fresh preview to download and **won't change** "
"the copy saved in our records.\n"
"- Want to **replace** your saved copy? Tick **Recreate certificate** below, then generate. "
"There's one certificate per participant, so the new one overwrites the old."
)
cb = reset_cb
else:
gen_status = ""
cb = reset_cb
return (
f"Signed in as **{username}**.",
gr.update(visible=True),
gr.update(visible=False),
name_value, project_update, status,
existing, existing, cb, gen_status,
)
# ----------------------------------------------------------------------------- LinkedIn helper
def generate_linkedin_url(participant_name, project_name):
params = {
"startTask": "CERTIFICATION_NAME",
"name": "Build Small Hackathon 2026",
"organizationName": "Hugging Face",
"issueYear": "2026",
"issueMonth": "6",
}
return "https://www.linkedin.com/profile/add?" + urllib.parse.urlencode(
params, quote_via=urllib.parse.quote
)
# ----------------------------------------------------------------------------- generate
def create_certificate(participant_name, project_name, recreate,
oauth_token: gr.OAuthToken | None, profile: gr.OAuthProfile | None):
hide_cb = gr.update(value=False)
if profile is None:
return None, None, "❌ Please sign in first to generate your certificate.", "", hide_cb
username = profile.username
state, _, _ = lookup_participant(username)
if state == "not_found":
return None, None, (
"❌ We couldn't find you in the Build Small Hackathon records, so we can't issue a "
f"certificate. Please reach out on [Discord]({DISCORD_INVITE}) or email {CONTACT_EMAIL}."
), "", hide_cb
if state == "error":
return None, None, "❌ Couldn't reach the hackathon records right now. Please try again shortly.", "", hide_cb
# Does a saved certificate already exist for this user?
existing_path = None
try:
existing_path = get_certificate_image_path(username)
except Exception:
existing_path = None
# Always render a fresh certificate from the CURRENT field values.
participant_name = (participant_name or "").strip() or (profile.name or username)
project_name = (project_name or "").strip()
project_section = (
PROJECT_SECTION_WITH_NAME.replace("{project_name}", project_name)
if project_name else PROJECT_SECTION_EMPTY
)
html = CERTIFICATE_HTML_TEMPLATE.replace("{participant_name}", participant_name)
html = html.replace("{project_section}", project_section)
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as f:
f.write(html)
html_path = f.name
try:
client = get_gradio_client()
image_path = client.predict(html_file=handle_file(html_path), api_name="/predict")[0]
except Exception as e:
return None, None, f"❌ Error generating certificate image: {e}", "", gr.update(value=False)
show_cb = gr.update(value=False)
linkedin_url = generate_linkedin_url(participant_name, project_name)
# Decide what happens to the SAVED copy in the dataset.
if existing_path is None:
# First time — save it.
try:
ok, msg = upload_user_certificate(Image.open(image_path), username, overwrite=False)
note = ("🎉 Your certificate is ready and has been **saved to our records**. Download it "
"below — you can edit your details and regenerate anytime.") if ok else \
f"⚠️ Certificate generated, but saving hit an issue: {msg}"
except Exception as e:
note = f"⚠️ Certificate generated, but saving was skipped: {e}"
return image_path, image_path, note, linkedin_url, show_cb
if recreate:
# Replace the saved copy.
try:
ok, msg = upload_user_certificate(Image.open(image_path), username, overwrite=True)
note = ("🔁 **Done — your new certificate has replaced the previous one saved in our "
"records.** Download the updated copy below.") if ok else \
f"⚠️ Generated, but replacing the saved copy hit an issue: {msg}"
except Exception as e:
note = f"⚠️ Generated, but replacing the saved copy was skipped: {e}"
return image_path, image_path, note, linkedin_url, show_cb
# Existing copy + recreate NOT ticked → fresh preview only, dataset untouched.
note = (
"👀 **Here's a new preview with your latest details** — download it below. "
"Your **saved** certificate in our records is **unchanged**. "
"To make this version the official one, tick **Recreate certificate** above and generate again."
)
return image_path, image_path, note, linkedin_url, show_cb
def render_linkedin_button(url):
if not url:
return "<div style='padding:.5rem;color:var(--ink-soft)'>Generate your certificate to enable LinkedIn sharing.</div>"
return f"""
<a href="{url}" target="_blank" class="bs-linkedin">Add to LinkedIn profile →</a>
<p style="margin-top:8px;font-size:.85rem;color:#6b6655">
Opens LinkedIn with the certification pre-filled — then upload your downloaded image.</p>
"""
# ----------------------------------------------------------------------------- theming
CUSTOM_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&family=Spline+Sans+Mono:wght@400;500;600&display=swap');
:root{
--paper:#f4eee1; --kraft:#e4d5b7; --kraft-deep:#d6c19a; --line:#cdbb95;
--ink:#33312b; --ink-soft:rgba(51,49,43,.62); --forest:#3d6a55; --forest-ink:#20392d; --amber:#e0913a;
}
/* Theme-proof: force the light "paper" look for ALL visitors, even on Gradio dark mode.
We override Gradio's theme CSS variables for both :root and .dark so blocks, inputs,
labels and text never go dark-on-dark or light-on-light. */
.gradio-container, .dark, body, body.dark{
color-scheme: light !important;
--body-background-fill:#f4eee1 !important;
--background-fill-primary:#ffffff !important;
--background-fill-secondary:#efe7d4 !important;
--block-background-fill:#ffffff !important;
--block-label-background-fill:#efe7d4 !important;
--panel-background-fill:#efe7d4 !important;
--block-label-text-color:#33312b !important;
--block-title-text-color:#33312b !important;
--block-info-text-color:rgba(51,49,43,.62) !important;
--body-text-color:#33312b !important;
--body-text-color-subdued:rgba(51,49,43,.60) !important;
--input-background-fill:#ffffff !important;
--input-text-color:#33312b !important;
--input-placeholder-color:rgba(51,49,43,.45) !important;
--border-color-primary:#33312b !important;
--checkbox-background-color:#ffffff !important;
--checkbox-background-color-selected:#3d6a55 !important;
--checkbox-border-color:#33312b !important;
--link-text-color:#3d6a55 !important;
}
.gradio-container{max-width:880px !important;margin:auto !important;
font-family:'Archivo',system-ui,sans-serif !important;background:var(--paper) !important;color:var(--ink) !important;}
.gradio-container .prose, .gradio-container .prose *, .gradio-container label,
.gradio-container p, .gradio-container span, .gradio-container .md{color:var(--ink) !important;}
.gradio-container .prose strong{color:var(--ink) !important;}
.bs-hero{position:relative;overflow:hidden;background:var(--kraft);border:2px solid var(--ink);
box-shadow:6px 6px 0 var(--ink);padding:30px 34px;margin-bottom:22px;}
.bs-hero .kick{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.24em;
text-transform:uppercase;color:var(--forest);font-weight:600;}
.bs-hero h1{font-family:'Archivo';font-weight:900;font-stretch:120%;font-size:46px;line-height:.95;
letter-spacing:-.02em;color:var(--ink);margin:8px 0 6px;}
.bs-hero p{color:#5a5446;font-size:15px;margin:0;}
.bs-section{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.18em;
text-transform:uppercase;color:var(--ink-soft);font-weight:600;margin:6px 0 2px;display:flex;
align-items:center;gap:9px;}
.bs-section::before{content:"";width:20px;height:2px;background:var(--amber);display:inline-block;}
button.primary, .bs-generate button{background:var(--forest) !important;border:2px solid var(--forest-ink) !important;
color:#fff !important;border-radius:0 !important;font-family:'Archivo' !important;font-weight:800 !important;
box-shadow:4px 4px 0 var(--forest-ink) !important;transition:transform .1s,box-shadow .1s !important;}
.bs-generate button:hover{transform:translate(2px,2px) !important;box-shadow:2px 2px 0 var(--forest-ink) !important;}
.bs-contact{background:#fbeee0;border:2px solid var(--amber);box-shadow:4px 4px 0 var(--ink);padding:18px 22px;}
.bs-contact a{color:var(--forest);font-weight:700;}
.bs-linkedin{display:inline-block;background:var(--ink);color:var(--paper) !important;text-decoration:none;
font-weight:800;padding:11px 20px;border:2px solid var(--ink);box-shadow:3px 3px 0 var(--amber);}
input:not([type='checkbox']):not([type='radio']), textarea{border-radius:0 !important;
border:2px solid var(--ink) !important;background:#fff !important;color:var(--ink) !important;
-webkit-text-fill-color:var(--ink) !important;
font-family:'Spline Sans Mono',monospace !important;}
input::placeholder, textarea::placeholder{color:rgba(51,49,43,.45) !important;-webkit-text-fill-color:rgba(51,49,43,.45) !important;}
/* Download (PNG) file row — force light bg + readable ink text in any theme */
#cert-file, #cert-file *{background-color:#fff !important;color:var(--ink) !important;
-webkit-text-fill-color:var(--ink) !important;border-color:var(--ink) !important;fill:var(--ink) !important;}
#cert-file a, #cert-file a *{color:var(--forest) !important;-webkit-text-fill-color:var(--forest) !important;}
footer{display:none !important;}
"""
BS_THEME = gr.themes.Base(
primary_hue=gr.themes.colors.green,
secondary_hue=gr.themes.colors.orange,
neutral_hue=gr.themes.colors.stone,
font=gr.themes.GoogleFont("Archivo"),
).set(button_large_radius="0px", button_small_radius="0px", block_radius="0px")
# ----------------------------------------------------------------------------- UI
with gr.Blocks(title="Build Small — Certificate Generator") as demo:
gr.HTML(
"""
<div class="bs-hero">
<div class="kick">Hugging Face × Gradio · 2026</div>
<h1>Build Small — Certificate</h1>
<p>Sign in with Hugging Face to claim your certificate of participation. Built something
small, local, and yours? Let's make it official.</p>
</div>
"""
)
with gr.Group():
login_btn = gr.LoginButton(value="Sign in with Hugging Face")
login_status = gr.Markdown("Please sign in with your Hugging Face account to continue.")
contact_box = gr.HTML(
f"""
<div class="bs-contact">
<strong>We couldn't find you in the Build Small Hackathon records.</strong><br>
Certificates are issued to registered participants and Build Small org Space contributors.
If you think this is a mistake, reach out on
<a href="{DISCORD_INVITE}" target="_blank">Discord</a> or email
<a href="mailto:{CONTACT_EMAIL}">{CONTACT_EMAIL}</a>.
</div>
""",
visible=False,
)
with gr.Column(visible=False) as main_interface:
gr.HTML('<div class="bs-section">Your details</div>')
data_status = gr.Markdown("")
# status / "you already have a certificate" notice — kept at the TOP, above the form
generation_status = gr.Markdown("")
gr.Markdown(
"✏️ **These details were auto-filled from the Build Small Hackathon records — "
"you can edit both your name and your project/Space name** below before generating "
"your certificate."
)
participant_name = gr.Textbox(
label="Full name (editable)",
info="This appears on your certificate — edit if you'd like it shown differently.",
)
project_name = gr.Textbox(
label="Project / Space name (editable, optional)",
info="Auto-filled from your Build Small submission. Edit it, or leave blank for a certificate without a project.",
)
recreate_checkbox = gr.Checkbox(
label="Recreate certificate — replace the copy saved in our records",
info="Only matters once you already have a saved certificate — tick it to overwrite the stored copy.",
value=False, visible=True, interactive=True,
)
with gr.Row(elem_classes=["bs-generate"]):
generate_btn = gr.Button("Generate my certificate", variant="primary", size="lg")
gr.HTML('<div class="bs-section">Your certificate</div>')
certificate_image = gr.Image(label="Preview", type="filepath", interactive=False)
certificate_file = gr.File(label="Download (PNG)", interactive=False, elem_id="cert-file")
gr.HTML('<div class="bs-section">Share</div>')
linkedin_html = gr.HTML(render_linkedin_button(""))
linkedin_state = gr.State("")
demo.load(
fn=on_load,
inputs=None,
outputs=[login_status, main_interface, contact_box, participant_name, project_name,
data_status, certificate_image, certificate_file, recreate_checkbox, generation_status],
)
generate_btn.click(
fn=create_certificate,
inputs=[participant_name, project_name, recreate_checkbox],
outputs=[certificate_image, certificate_file, generation_status, linkedin_state, recreate_checkbox],
).then(
fn=lambda url: render_linkedin_button(url),
inputs=[linkedin_state],
outputs=[linkedin_html],
)
if __name__ == "__main__":
demo.launch(theme=BS_THEME, css=CUSTOM_CSS)