| """ |
| 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 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 |
|
|
| 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 = "" |
|
|
|
|
| |
| _gradio_client = None |
|
|
|
|
| def get_gradio_client(): |
| """Lazy init of the HTML->image renderer client (avoids event-loop issues).""" |
| global _gradio_client |
| if _gradio_client is None: |
| _gradio_client = Client(RENDERER_SPACE, hf_token=HF_TOKEN) |
| return _gradio_client |
|
|
|
|
| |
| @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, "" |
|
|
|
|
| |
| def on_load(profile: gr.OAuthProfile | None): |
| """Runs on page load. Drives which panel is shown and pre-fills the form.""" |
| if profile is None: |
| return ( |
| "Please sign in with your Hugging Face account to continue.", |
| gr.update(visible=False), |
| gr.update(visible=False), |
| "", |
| gr.update(value="", visible=True), |
| "", |
| ) |
|
|
| username = profile.username |
| profile_name = profile.name or username |
| state, full_name, space_name = lookup_participant(username) |
|
|
| if state == "not_found": |
| return ( |
| f"Signed in as **{username}**.", |
| gr.update(visible=False), |
| gr.update(visible=True), |
| "", |
| gr.update(value="", visible=True), |
| "", |
| ) |
| if state == "error": |
| return ( |
| f"Signed in as **{username}**.", |
| gr.update(visible=False), |
| gr.update(visible=True), |
| "", |
| gr.update(value="", visible=True), |
| "", |
| ) |
|
|
| 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: |
| 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) |
|
|
| return ( |
| f"Signed in as **{username}**.", |
| gr.update(visible=True), |
| gr.update(visible=False), |
| name_value, |
| project_update, |
| status, |
| ) |
|
|
|
|
| |
| 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 |
| ) |
|
|
|
|
| |
| def create_certificate(participant_name, project_name, |
| oauth_token: gr.OAuthToken | None, profile: gr.OAuthProfile | None): |
| if profile is None: |
| return None, None, "❌ Please sign in first to generate your certificate.", "" |
|
|
| 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}." |
| ), "" |
| if state == "error": |
| return None, None, "❌ Couldn't reach the hackathon records right now. Please try again shortly.", "" |
|
|
| 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}", "" |
|
|
| |
| try: |
| cert_image = Image.open(image_path) |
| ok, msg = upload_user_certificate(cert_image, username) |
| save_note = msg if ok else f"(generated — gallery save note: {msg})" |
| except Exception as e: |
| save_note = f"(generated — gallery save skipped: {e})" |
|
|
| linkedin_url = generate_linkedin_url(participant_name, project_name) |
| status = f"🎉 Your certificate is ready, {participant_name}! {save_note}" |
| return image_path, image_path, status, linkedin_url |
|
|
|
|
| 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> |
| """ |
|
|
|
|
| |
| 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; |
| } |
| .gradio-container{max-width:880px !important;margin:auto !important; |
| font-family:'Archivo',system-ui,sans-serif !important;background:var(--paper) !important;} |
| .gradio-container .prose, .gradio-container label, .gradio-container p{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, textarea{border-radius:0 !important;border:2px solid var(--ink) !important; |
| background:#fff !important;font-family:'Spline Sans Mono',monospace !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") |
|
|
|
|
| |
| 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("") |
| participant_name = gr.Textbox( |
| label="Full name", |
| info="This appears on your certificate — edit if you'd like it shown differently.", |
| ) |
| project_name = gr.Textbox( |
| label="Project / Space name (optional)", |
| info="Auto-filled from your Build Small submission. Leave blank for a certificate without a project.", |
| ) |
| 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) |
| generation_status = gr.Markdown("") |
|
|
| 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], |
| ) |
|
|
| generate_btn.click( |
| fn=create_certificate, |
| inputs=[participant_name, project_name], |
| outputs=[certificate_image, certificate_file, generation_status, linkedin_state], |
| ).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) |
|
|