Build Small certificate generator
Browse files- README.md +20 -8
- app.py +344 -0
- certificate_template.html +185 -0
- certificate_upload_module.py +99 -0
- requirements.txt +3 -0
README.md
CHANGED
|
@@ -1,13 +1,25 @@
|
|
| 1 |
---
|
| 2 |
-
title: Certificate Generator
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Build Small Hackathon Certificate Generator
|
| 3 |
+
emoji: πͺ΅
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.1.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: mit
|
| 11 |
+
hf_oauth: true
|
| 12 |
+
short_description: Claim your Build Small Hackathon certificate
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Build Small β Certificate Generator
|
| 16 |
+
|
| 17 |
+
Sign in with Hugging Face to generate your **Build Small Hackathon** certificate of
|
| 18 |
+
participation. Your name and (if you shipped a Space) your project are auto-filled from the
|
| 19 |
+
hackathon records β all fields are editable before you generate.
|
| 20 |
+
|
| 21 |
+
Eligibility is validated against `build-small-hackathon/build-small-apps-for-certificates`
|
| 22 |
+
(registered participants βͺ org Space contributors). Generated certificates are saved to the
|
| 23 |
+
public gallery dataset `build-small-hackathon/build-small-certificates`.
|
| 24 |
+
|
| 25 |
+
Check the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build Small Hackathon β Certificate Generator
|
| 3 |
+
=============================================
|
| 4 |
+
Workflow:
|
| 5 |
+
1. User signs in with Hugging Face (OAuth).
|
| 6 |
+
2. We take their HF username and look it up in the private eligibility dataset
|
| 7 |
+
`build-small-hackathon/build-small-apps-for-certificates`
|
| 8 |
+
(registered participants βͺ org Space contributors).
|
| 9 |
+
β’ username found WITH a space -> autofill full name + project name
|
| 10 |
+
β’ username found WITHOUT a space -> autofill full name, no project
|
| 11 |
+
β’ username not found -> show contact message, do not proceed
|
| 12 |
+
3. All autofilled fields are editable.
|
| 13 |
+
4. A button generates the certificate (HTML -> PNG via the renderer Space) and
|
| 14 |
+
saves it to the public gallery dataset `build-small-hackathon/build-small-certificates`.
|
| 15 |
+
"""
|
| 16 |
+
import os
|
| 17 |
+
import uuid
|
| 18 |
+
import tempfile
|
| 19 |
+
import urllib.parse
|
| 20 |
+
from functools import lru_cache
|
| 21 |
+
|
| 22 |
+
import gradio as gr
|
| 23 |
+
import pandas as pd
|
| 24 |
+
from PIL import Image
|
| 25 |
+
from datasets import load_dataset
|
| 26 |
+
from gradio_client import Client, handle_file
|
| 27 |
+
|
| 28 |
+
from certificate_upload_module import upload_user_certificate
|
| 29 |
+
|
| 30 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 31 |
+
|
| 32 |
+
ELIGIBILITY_DATASET = "build-small-hackathon/build-small-apps-for-certificates"
|
| 33 |
+
RENDERER_SPACE = "https://ysharma-hackathon-certificate-html-to-image.hf.space/"
|
| 34 |
+
|
| 35 |
+
DISCORD_INVITE = "https://discord.gg/92sEPT2Zhv"
|
| 36 |
+
CONTACT_EMAIL = "hello@gradio.app"
|
| 37 |
+
|
| 38 |
+
_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "certificate_template.html")
|
| 39 |
+
with open(_TEMPLATE_PATH, encoding="utf-8") as _f:
|
| 40 |
+
CERTIFICATE_HTML_TEMPLATE = _f.read()
|
| 41 |
+
|
| 42 |
+
PROJECT_SECTION_WITH_NAME = '<div class="project"><span class="lbl">Project</span> {project_name}</div>'
|
| 43 |
+
PROJECT_SECTION_EMPTY = ""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ----------------------------------------------------------------------------- renderer client
|
| 47 |
+
_gradio_client = None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_gradio_client():
|
| 51 |
+
"""Lazy init of the HTML->image renderer client (avoids event-loop issues)."""
|
| 52 |
+
global _gradio_client
|
| 53 |
+
if _gradio_client is None:
|
| 54 |
+
_gradio_client = Client(RENDERER_SPACE, hf_token=HF_TOKEN)
|
| 55 |
+
return _gradio_client
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ----------------------------------------------------------------------------- eligibility lookup
|
| 59 |
+
@lru_cache(maxsize=1)
|
| 60 |
+
def _eligibility_index():
|
| 61 |
+
"""Load the eligibility dataset once into {username_lower: (full_name, space_name)}."""
|
| 62 |
+
ds = load_dataset(ELIGIBILITY_DATASET, split="train", token=HF_TOKEN)
|
| 63 |
+
df = ds.to_pandas().fillna("")
|
| 64 |
+
index = {}
|
| 65 |
+
for _, row in df.iterrows():
|
| 66 |
+
uname = str(row.get("hf_username", "")).strip()
|
| 67 |
+
if not uname:
|
| 68 |
+
continue
|
| 69 |
+
index[uname.lower()] = (
|
| 70 |
+
str(row.get("full_name", "")).strip(),
|
| 71 |
+
str(row.get("space_name", "")).strip(),
|
| 72 |
+
)
|
| 73 |
+
return index
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def lookup_participant(username: str):
|
| 77 |
+
"""Return (state, full_name, space_name).
|
| 78 |
+
|
| 79 |
+
state β {"found_project", "found_no_project", "not_found"}.
|
| 80 |
+
"""
|
| 81 |
+
if not username:
|
| 82 |
+
return "not_found", "", ""
|
| 83 |
+
try:
|
| 84 |
+
index = _eligibility_index()
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"[ERROR] eligibility load failed: {e}")
|
| 87 |
+
return "error", "", ""
|
| 88 |
+
|
| 89 |
+
entry = index.get(username.strip().lower())
|
| 90 |
+
if entry is None:
|
| 91 |
+
return "not_found", "", ""
|
| 92 |
+
full_name, space_name = entry
|
| 93 |
+
if space_name:
|
| 94 |
+
return "found_project", full_name, space_name
|
| 95 |
+
return "found_no_project", full_name, ""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ----------------------------------------------------------------------------- on login
|
| 99 |
+
def on_load(profile: gr.OAuthProfile | None):
|
| 100 |
+
"""Runs on page load. Drives which panel is shown and pre-fills the form."""
|
| 101 |
+
if profile is None:
|
| 102 |
+
return (
|
| 103 |
+
"Please sign in with your Hugging Face account to continue.",
|
| 104 |
+
gr.update(visible=False), # main_interface
|
| 105 |
+
gr.update(visible=False), # contact_box
|
| 106 |
+
"", # name
|
| 107 |
+
gr.update(value="", visible=True), # project
|
| 108 |
+
"", # data_status
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
username = profile.username
|
| 112 |
+
profile_name = profile.name or username
|
| 113 |
+
state, full_name, space_name = lookup_participant(username)
|
| 114 |
+
|
| 115 |
+
if state == "not_found":
|
| 116 |
+
return (
|
| 117 |
+
f"Signed in as **{username}**.",
|
| 118 |
+
gr.update(visible=False),
|
| 119 |
+
gr.update(visible=True),
|
| 120 |
+
"",
|
| 121 |
+
gr.update(value="", visible=True),
|
| 122 |
+
"",
|
| 123 |
+
)
|
| 124 |
+
if state == "error":
|
| 125 |
+
return (
|
| 126 |
+
f"Signed in as **{username}**.",
|
| 127 |
+
gr.update(visible=False),
|
| 128 |
+
gr.update(visible=True),
|
| 129 |
+
"",
|
| 130 |
+
gr.update(value="", visible=True),
|
| 131 |
+
"",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
name_value = full_name or profile_name
|
| 135 |
+
|
| 136 |
+
if state == "found_project":
|
| 137 |
+
status = f"β
Found your submission β **{space_name}**. Review the details below, then generate."
|
| 138 |
+
project_update = gr.update(value=space_name, visible=True)
|
| 139 |
+
else: # found_no_project
|
| 140 |
+
status = (
|
| 141 |
+
"β
You're on the participant list! We couldn't find a Space submission under your "
|
| 142 |
+
"name β you can add a project below or leave it blank."
|
| 143 |
+
)
|
| 144 |
+
project_update = gr.update(value="", visible=True)
|
| 145 |
+
|
| 146 |
+
return (
|
| 147 |
+
f"Signed in as **{username}**.",
|
| 148 |
+
gr.update(visible=True),
|
| 149 |
+
gr.update(visible=False),
|
| 150 |
+
name_value,
|
| 151 |
+
project_update,
|
| 152 |
+
status,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ----------------------------------------------------------------------------- LinkedIn helper
|
| 157 |
+
def generate_linkedin_url(participant_name, project_name):
|
| 158 |
+
params = {
|
| 159 |
+
"startTask": "CERTIFICATION_NAME",
|
| 160 |
+
"name": "Build Small Hackathon 2026",
|
| 161 |
+
"organizationName": "Hugging Face",
|
| 162 |
+
"issueYear": "2026",
|
| 163 |
+
"issueMonth": "6",
|
| 164 |
+
}
|
| 165 |
+
return "https://www.linkedin.com/profile/add?" + urllib.parse.urlencode(
|
| 166 |
+
params, quote_via=urllib.parse.quote
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ----------------------------------------------------------------------------- generate
|
| 171 |
+
def create_certificate(participant_name, project_name,
|
| 172 |
+
oauth_token: gr.OAuthToken | None, profile: gr.OAuthProfile | None):
|
| 173 |
+
if profile is None:
|
| 174 |
+
return None, None, "β Please sign in first to generate your certificate.", ""
|
| 175 |
+
|
| 176 |
+
username = profile.username
|
| 177 |
+
state, _, _ = lookup_participant(username)
|
| 178 |
+
if state == "not_found":
|
| 179 |
+
return None, None, (
|
| 180 |
+
"β We couldn't find you in the Build Small Hackathon records, so we can't issue a "
|
| 181 |
+
f"certificate. Please reach out on [Discord]({DISCORD_INVITE}) or email {CONTACT_EMAIL}."
|
| 182 |
+
), ""
|
| 183 |
+
if state == "error":
|
| 184 |
+
return None, None, "β Couldn't reach the hackathon records right now. Please try again shortly.", ""
|
| 185 |
+
|
| 186 |
+
participant_name = (participant_name or "").strip() or (profile.name or username)
|
| 187 |
+
project_name = (project_name or "").strip()
|
| 188 |
+
|
| 189 |
+
project_section = (
|
| 190 |
+
PROJECT_SECTION_WITH_NAME.replace("{project_name}", project_name)
|
| 191 |
+
if project_name else PROJECT_SECTION_EMPTY
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
html = CERTIFICATE_HTML_TEMPLATE.replace("{participant_name}", participant_name)
|
| 195 |
+
html = html.replace("{project_section}", project_section)
|
| 196 |
+
|
| 197 |
+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".html", encoding="utf-8") as f:
|
| 198 |
+
f.write(html)
|
| 199 |
+
html_path = f.name
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
client = get_gradio_client()
|
| 203 |
+
image_path = client.predict(html_file=handle_file(html_path), api_name="/predict")[0]
|
| 204 |
+
except Exception as e:
|
| 205 |
+
return None, None, f"β Error generating certificate image: {e}", ""
|
| 206 |
+
|
| 207 |
+
# Save to the public gallery dataset
|
| 208 |
+
try:
|
| 209 |
+
cert_image = Image.open(image_path)
|
| 210 |
+
ok, msg = upload_user_certificate(cert_image, username)
|
| 211 |
+
save_note = msg if ok else f"(generated β gallery save note: {msg})"
|
| 212 |
+
except Exception as e:
|
| 213 |
+
save_note = f"(generated β gallery save skipped: {e})"
|
| 214 |
+
|
| 215 |
+
linkedin_url = generate_linkedin_url(participant_name, project_name)
|
| 216 |
+
status = f"π Your certificate is ready, {participant_name}! {save_note}"
|
| 217 |
+
return image_path, image_path, status, linkedin_url
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def render_linkedin_button(url):
|
| 221 |
+
if not url:
|
| 222 |
+
return "<div style='padding:.5rem;color:var(--ink-soft)'>Generate your certificate to enable LinkedIn sharing.</div>"
|
| 223 |
+
return f"""
|
| 224 |
+
<a href="{url}" target="_blank" class="bs-linkedin">Add to LinkedIn profile β</a>
|
| 225 |
+
<p style="margin-top:8px;font-size:.85rem;color:#6b6655">
|
| 226 |
+
Opens LinkedIn with the certification pre-filled β then upload your downloaded image.</p>
|
| 227 |
+
"""
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ----------------------------------------------------------------------------- theming
|
| 231 |
+
CUSTOM_CSS = """
|
| 232 |
+
@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');
|
| 233 |
+
:root{
|
| 234 |
+
--paper:#f4eee1; --kraft:#e4d5b7; --kraft-deep:#d6c19a; --line:#cdbb95;
|
| 235 |
+
--ink:#33312b; --ink-soft:rgba(51,49,43,.62); --forest:#3d6a55; --forest-ink:#20392d; --amber:#e0913a;
|
| 236 |
+
}
|
| 237 |
+
.gradio-container{max-width:880px !important;margin:auto !important;
|
| 238 |
+
font-family:'Archivo',system-ui,sans-serif !important;background:var(--paper) !important;}
|
| 239 |
+
.gradio-container .prose, .gradio-container label, .gradio-container p{color:var(--ink) !important;}
|
| 240 |
+
.bs-hero{position:relative;overflow:hidden;background:var(--kraft);border:2px solid var(--ink);
|
| 241 |
+
box-shadow:6px 6px 0 var(--ink);padding:30px 34px;margin-bottom:22px;}
|
| 242 |
+
.bs-hero .kick{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.24em;
|
| 243 |
+
text-transform:uppercase;color:var(--forest);font-weight:600;}
|
| 244 |
+
.bs-hero h1{font-family:'Archivo';font-weight:900;font-stretch:120%;font-size:46px;line-height:.95;
|
| 245 |
+
letter-spacing:-.02em;color:var(--ink);margin:8px 0 6px;}
|
| 246 |
+
.bs-hero p{color:#5a5446;font-size:15px;margin:0;}
|
| 247 |
+
.bs-section{font-family:'Spline Sans Mono',monospace;font-size:12px;letter-spacing:.18em;
|
| 248 |
+
text-transform:uppercase;color:var(--ink-soft);font-weight:600;margin:6px 0 2px;display:flex;
|
| 249 |
+
align-items:center;gap:9px;}
|
| 250 |
+
.bs-section::before{content:"";width:20px;height:2px;background:var(--amber);display:inline-block;}
|
| 251 |
+
button.primary, .bs-generate button{background:var(--forest) !important;border:2px solid var(--forest-ink) !important;
|
| 252 |
+
color:#fff !important;border-radius:0 !important;font-family:'Archivo' !important;font-weight:800 !important;
|
| 253 |
+
box-shadow:4px 4px 0 var(--forest-ink) !important;transition:transform .1s,box-shadow .1s !important;}
|
| 254 |
+
.bs-generate button:hover{transform:translate(2px,2px) !important;box-shadow:2px 2px 0 var(--forest-ink) !important;}
|
| 255 |
+
.bs-contact{background:#fbeee0;border:2px solid var(--amber);box-shadow:4px 4px 0 var(--ink);padding:18px 22px;}
|
| 256 |
+
.bs-contact a{color:var(--forest);font-weight:700;}
|
| 257 |
+
.bs-linkedin{display:inline-block;background:var(--ink);color:var(--paper) !important;text-decoration:none;
|
| 258 |
+
font-weight:800;padding:11px 20px;border:2px solid var(--ink);box-shadow:3px 3px 0 var(--amber);}
|
| 259 |
+
input, textarea{border-radius:0 !important;border:2px solid var(--ink) !important;
|
| 260 |
+
background:#fff !important;font-family:'Spline Sans Mono',monospace !important;}
|
| 261 |
+
footer{display:none !important;}
|
| 262 |
+
"""
|
| 263 |
+
|
| 264 |
+
BS_THEME = gr.themes.Base(
|
| 265 |
+
primary_hue=gr.themes.colors.green,
|
| 266 |
+
secondary_hue=gr.themes.colors.orange,
|
| 267 |
+
neutral_hue=gr.themes.colors.stone,
|
| 268 |
+
font=gr.themes.GoogleFont("Archivo"),
|
| 269 |
+
).set(button_large_radius="0px", button_small_radius="0px", block_radius="0px")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ----------------------------------------------------------------------------- UI
|
| 273 |
+
with gr.Blocks(title="Build Small β Certificate Generator", theme=BS_THEME, css=CUSTOM_CSS) as demo:
|
| 274 |
+
gr.HTML(
|
| 275 |
+
"""
|
| 276 |
+
<div class="bs-hero">
|
| 277 |
+
<div class="kick">Hugging Face Γ Gradio Β· 2026</div>
|
| 278 |
+
<h1>Build Small β Certificate</h1>
|
| 279 |
+
<p>Sign in with Hugging Face to claim your certificate of participation. Built something
|
| 280 |
+
small, local, and yours? Let's make it official.</p>
|
| 281 |
+
</div>
|
| 282 |
+
"""
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
with gr.Group():
|
| 286 |
+
login_btn = gr.LoginButton(value="Sign in with Hugging Face")
|
| 287 |
+
login_status = gr.Markdown("Please sign in with your Hugging Face account to continue.")
|
| 288 |
+
|
| 289 |
+
contact_box = gr.HTML(
|
| 290 |
+
f"""
|
| 291 |
+
<div class="bs-contact">
|
| 292 |
+
<strong>We couldn't find you in the Build Small Hackathon records.</strong><br>
|
| 293 |
+
Certificates are issued to registered participants and Build Small org Space contributors.
|
| 294 |
+
If you think this is a mistake, reach out on
|
| 295 |
+
<a href="{DISCORD_INVITE}" target="_blank">Discord</a> or email
|
| 296 |
+
<a href="mailto:{CONTACT_EMAIL}">{CONTACT_EMAIL}</a>.
|
| 297 |
+
</div>
|
| 298 |
+
""",
|
| 299 |
+
visible=False,
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
with gr.Column(visible=False) as main_interface:
|
| 303 |
+
gr.HTML('<div class="bs-section">Your details</div>')
|
| 304 |
+
data_status = gr.Markdown("")
|
| 305 |
+
participant_name = gr.Textbox(
|
| 306 |
+
label="Full name",
|
| 307 |
+
info="This appears on your certificate β edit if you'd like it shown differently.",
|
| 308 |
+
)
|
| 309 |
+
project_name = gr.Textbox(
|
| 310 |
+
label="Project / Space name (optional)",
|
| 311 |
+
info="Auto-filled from your Build Small submission. Leave blank for a certificate without a project.",
|
| 312 |
+
)
|
| 313 |
+
with gr.Row(elem_classes=["bs-generate"]):
|
| 314 |
+
generate_btn = gr.Button("Generate my certificate", variant="primary", size="lg")
|
| 315 |
+
|
| 316 |
+
gr.HTML('<div class="bs-section">Your certificate</div>')
|
| 317 |
+
certificate_image = gr.Image(label="Preview", type="filepath", interactive=False, show_download_button=True)
|
| 318 |
+
certificate_file = gr.File(label="Download (PNG)", interactive=False)
|
| 319 |
+
generation_status = gr.Markdown("")
|
| 320 |
+
|
| 321 |
+
gr.HTML('<div class="bs-section">Share</div>')
|
| 322 |
+
linkedin_html = gr.HTML(render_linkedin_button(""))
|
| 323 |
+
|
| 324 |
+
linkedin_state = gr.State("")
|
| 325 |
+
|
| 326 |
+
demo.load(
|
| 327 |
+
fn=on_load,
|
| 328 |
+
inputs=None,
|
| 329 |
+
outputs=[login_status, main_interface, contact_box, participant_name, project_name, data_status],
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
generate_btn.click(
|
| 333 |
+
fn=create_certificate,
|
| 334 |
+
inputs=[participant_name, project_name],
|
| 335 |
+
outputs=[certificate_image, certificate_file, generation_status, linkedin_state],
|
| 336 |
+
).then(
|
| 337 |
+
fn=lambda url: render_linkedin_button(url),
|
| 338 |
+
inputs=[linkedin_state],
|
| 339 |
+
outputs=[linkedin_html],
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
if __name__ == "__main__":
|
| 344 |
+
demo.launch()
|
certificate_template.html
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Build Small β Certificate</title>
|
| 7 |
+
<style>
|
| 8 |
+
@import url('https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@100..125,400..900&family=Spline+Sans+Mono:wght@400;500;600&display=swap');
|
| 9 |
+
|
| 10 |
+
:root{
|
| 11 |
+
--paper:#f4eee1; --paper-2:#efe7d4; --kraft:#e4d5b7; --kraft-deep:#d6c19a; --line:#cdbb95;
|
| 12 |
+
--ink:#33312b; --ink-2:#4a4639; --ink-soft:rgba(51,49,43,.62); --ink-faint:rgba(51,49,43,.40);
|
| 13 |
+
--forest:#3d6a55; --forest-deep:#2b4d3d; --forest-ink:#20392d;
|
| 14 |
+
--amber:#e0913a; --amber-deep:#c2762a; --clay:#bd5f37;
|
| 15 |
+
--font-display:'Archivo',system-ui,sans-serif;
|
| 16 |
+
--font-mono:'Spline Sans Mono',ui-monospace,monospace;
|
| 17 |
+
}
|
| 18 |
+
*{margin:0;padding:0;box-sizing:border-box;}
|
| 19 |
+
body{width:2000px;height:1414px;overflow:hidden;}
|
| 20 |
+
|
| 21 |
+
.certificate{
|
| 22 |
+
width:2000px;height:1414px;position:relative;overflow:hidden;
|
| 23 |
+
background:var(--paper);color:var(--ink);
|
| 24 |
+
font-family:var(--font-display);
|
| 25 |
+
print-color-adjust:exact;-webkit-print-color-adjust:exact;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* paper grain (from the field-guide global.css) */
|
| 29 |
+
.grain{position:absolute;inset:0;z-index:1;pointer-events:none;opacity:.5;
|
| 30 |
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E");}
|
| 31 |
+
|
| 32 |
+
/* topographic contour backdrops β corners, forest tint */
|
| 33 |
+
.topo{position:absolute;z-index:1;pointer-events:none;opacity:.32;}
|
| 34 |
+
.topo--br{right:-260px;bottom:-300px;width:1100px;height:1100px;}
|
| 35 |
+
.topo--tl{left:-320px;top:-340px;width:1000px;height:1000px;opacity:.22;}
|
| 36 |
+
|
| 37 |
+
/* frame */
|
| 38 |
+
.frame{position:absolute;inset:46px;z-index:3;border:3px solid var(--ink);background:transparent;}
|
| 39 |
+
.frame::after{content:"";position:absolute;inset:13px;border:2px solid var(--ink-faint);pointer-events:none;}
|
| 40 |
+
|
| 41 |
+
/* registration corner ticks */
|
| 42 |
+
.tick{position:absolute;z-index:5;width:46px;height:46px;}
|
| 43 |
+
.tick path{stroke:var(--ink);stroke-width:3;fill:none;}
|
| 44 |
+
.tick--tl{top:30px;left:30px;} .tick--tr{top:30px;right:30px;transform:scaleX(-1);}
|
| 45 |
+
.tick--bl{bottom:30px;left:30px;transform:scaleY(-1);} .tick--br{bottom:30px;right:30px;transform:scale(-1,-1);}
|
| 46 |
+
|
| 47 |
+
.content{position:relative;z-index:4;height:100%;padding:60px 120px 50px;
|
| 48 |
+
display:flex;flex-direction:column;text-align:center;}
|
| 49 |
+
|
| 50 |
+
/* ---- top bar ---- */
|
| 51 |
+
.topbar{display:flex;justify-content:space-between;align-items:center;}
|
| 52 |
+
.eyebrow{font-family:var(--font-mono);font-size:18px;font-weight:500;letter-spacing:.30em;
|
| 53 |
+
text-transform:uppercase;color:var(--ink-soft);display:inline-flex;align-items:center;gap:14px;}
|
| 54 |
+
.eyebrow::before{content:"";width:34px;height:2px;background:var(--amber);display:inline-block;}
|
| 55 |
+
.coord{font-family:var(--font-mono);font-size:16px;letter-spacing:.10em;color:var(--ink-faint);}
|
| 56 |
+
|
| 57 |
+
/* ---- lockup ---- */
|
| 58 |
+
.lockup{display:flex;align-items:center;justify-content:center;gap:34px;margin-top:30px;}
|
| 59 |
+
.lockup img{height:48px;display:block;}
|
| 60 |
+
.lockup .hf-wm{font-weight:800;font-stretch:112%;font-size:34px;letter-spacing:-.01em;color:var(--ink);}
|
| 61 |
+
.lockup .cross{font-family:var(--font-mono);font-size:30px;color:var(--ink-faint);font-weight:400;}
|
| 62 |
+
.hf-mark{display:inline-flex;align-items:center;gap:16px;}
|
| 63 |
+
|
| 64 |
+
/* ---- title ---- */
|
| 65 |
+
.kicker{font-family:var(--font-mono);font-size:19px;font-weight:600;letter-spacing:.26em;
|
| 66 |
+
text-transform:uppercase;color:var(--forest);margin-top:30px;}
|
| 67 |
+
.title{font-weight:900;font-stretch:122%;font-size:142px;line-height:.92;letter-spacing:-.02em;
|
| 68 |
+
margin-top:12px;color:var(--ink);}
|
| 69 |
+
.subtitle{font-family:var(--font-mono);font-size:21px;font-weight:500;letter-spacing:.42em;
|
| 70 |
+
text-transform:uppercase;color:var(--ink-2);margin-top:16px;}
|
| 71 |
+
|
| 72 |
+
/* ---- body ---- */
|
| 73 |
+
.body{flex-grow:1;display:flex;flex-direction:column;justify-content:center;}
|
| 74 |
+
.certifies{font-family:var(--font-mono);font-size:23px;color:var(--ink-soft);letter-spacing:.04em;margin-bottom:6px;}
|
| 75 |
+
.name{font-weight:800;font-stretch:105%;font-size:98px;line-height:1;letter-spacing:-.01em;
|
| 76 |
+
color:var(--forest-deep);padding:14px 0 20px;display:inline-block;margin:6px auto 0;
|
| 77 |
+
border-bottom:3px solid var(--ink);}
|
| 78 |
+
.desc{font-size:25px;line-height:1.58;color:var(--ink-2);max-width:1300px;margin:32px auto 0;font-weight:500;text-wrap:pretty;}
|
| 79 |
+
.desc strong{font-weight:800;color:var(--ink);}
|
| 80 |
+
.project{margin:22px auto 0;display:inline-flex;align-items:center;gap:16px;
|
| 81 |
+
font-family:var(--font-mono);font-size:24px;font-weight:600;color:var(--ink);
|
| 82 |
+
background:var(--kraft);border:2px solid var(--ink);box-shadow:5px 5px 0 var(--ink);
|
| 83 |
+
padding:18px 34px;}
|
| 84 |
+
.project .lbl{color:var(--forest);font-weight:600;letter-spacing:.06em;text-transform:uppercase;font-size:18px;}
|
| 85 |
+
|
| 86 |
+
/* ---- stats ---- */
|
| 87 |
+
.stats{display:flex;justify-content:center;width:fit-content;margin:32px auto 0;
|
| 88 |
+
border:2px solid var(--ink);background:var(--paper);box-shadow:6px 6px 0 var(--ink);}
|
| 89 |
+
.stat{padding:18px 50px;border-right:2px solid var(--ink);}
|
| 90 |
+
.stat:last-child{border-right:none;}
|
| 91 |
+
.stat b{display:block;font-weight:800;font-stretch:118%;font-size:44px;line-height:1;color:var(--ink);}
|
| 92 |
+
.stat span{display:block;font-family:var(--font-mono);font-size:15px;letter-spacing:.10em;
|
| 93 |
+
text-transform:uppercase;color:var(--ink-soft);margin-top:10px;}
|
| 94 |
+
|
| 95 |
+
/* ---- signature row ---- */
|
| 96 |
+
.sigrow{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;margin-top:0;padding-top:30px;}
|
| 97 |
+
.sig{display:flex;flex-direction:column;gap:10px;}
|
| 98 |
+
.sig--l{align-items:flex-start;text-align:left;} .sig--r{align-items:flex-end;text-align:right;}
|
| 99 |
+
.sig__val{font-weight:800;font-stretch:110%;font-size:26px;color:var(--ink);}
|
| 100 |
+
.sig__lbl{font-family:var(--font-mono);font-size:15px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft);}
|
| 101 |
+
.sig__rule{width:280px;height:0;border-top:2px solid var(--ink);margin-bottom:2px;}
|
| 102 |
+
|
| 103 |
+
/* circular stamp seal */
|
| 104 |
+
.seal{width:152px;height:152px;position:relative;transform:rotate(-7deg);}
|
| 105 |
+
|
| 106 |
+
/* ---- sponsors ---- */
|
| 107 |
+
.sponsors{margin-top:24px;padding-top:22px;border-top:2px dashed var(--line);}
|
| 108 |
+
.sponsors__t{font-family:var(--font-mono);font-size:15px;letter-spacing:.24em;text-transform:uppercase;
|
| 109 |
+
color:var(--ink-faint);margin-bottom:18px;}
|
| 110 |
+
.sponsors__l{font-weight:800;font-stretch:108%;font-size:31px;color:var(--ink);letter-spacing:-.005em;}
|
| 111 |
+
.sponsors__l i{color:var(--kraft-deep);font-style:normal;font-weight:400;margin:0 16px;
|
| 112 |
+
position:relative;top:-3px;font-size:24px;}
|
| 113 |
+
</style>
|
| 114 |
+
</head>
|
| 115 |
+
<body>
|
| 116 |
+
<div class="certificate">
|
| 117 |
+
<div class="grain"></div>
|
| 118 |
+
|
| 119 |
+
<!-- topo contour backdrops -->
|
| 120 |
+
<svg class="topo topo--br" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#3d6a55" stroke-width="1.4">
|
| 121 |
+
<path d="M200 40C120 60 60 120 50 210s40 150 130 150 160-70 150-160S280 20 200 40Z"/>
|
| 122 |
+
<path d="M200 80C140 96 96 142 90 210s34 116 110 116 128-54 122-120S262 64 200 80Z"/>
|
| 123 |
+
<path d="M200 120C158 132 128 166 124 212s26 84 78 84 96-40 92-86S240 108 200 120Z"/>
|
| 124 |
+
<path d="M200 160C176 168 158 188 156 214s16 52 46 52 58-24 56-50S224 152 200 160Z"/>
|
| 125 |
+
<path d="M200 196c-14 4-24 16-24 30s12 26 26 26 28-12 28-28-16-30-30-28Z"/>
|
| 126 |
+
</svg>
|
| 127 |
+
<svg class="topo topo--tl" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#e0913a" stroke-width="1.4">
|
| 128 |
+
<path d="M200 40C120 60 60 120 50 210s40 150 130 150 160-70 150-160S280 20 200 40Z"/>
|
| 129 |
+
<path d="M200 90C146 104 104 146 98 208s32 110 104 110 122-50 116-114S256 76 200 90Z"/>
|
| 130 |
+
<path d="M200 140C166 150 142 180 138 214s22 74 68 74 84-34 80-74S232 130 200 140Z"/>
|
| 131 |
+
<path d="M200 190c-18 4-32 20-32 38s14 32 34 32 34-16 34-36-18-38-36-34Z"/>
|
| 132 |
+
</svg>
|
| 133 |
+
|
| 134 |
+
<div class="frame"></div>
|
| 135 |
+
<svg class="tick tick--tl" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
|
| 136 |
+
<svg class="tick tick--tr" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
|
| 137 |
+
<svg class="tick tick--bl" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
|
| 138 |
+
<svg class="tick tick--br" viewBox="0 0 46 46"><path d="M2 2 L2 22 M2 2 L22 2"/></svg>
|
| 139 |
+
|
| 140 |
+
<div class="content">
|
| 141 |
+
<div class="topbar">
|
| 142 |
+
<span class="eyebrow">Certificate of Participation</span>
|
| 143 |
+
<span class="coord">June 15, 2026</span>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div class="lockup">
|
| 147 |
+
<span class="hf-mark">
|
| 148 |
+
<img src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg" alt="Hugging Face">
|
| 149 |
+
<span class="hf-wm">Hugging Face</span>
|
| 150 |
+
</span>
|
| 151 |
+
<span class="cross">Γ</span>
|
| 152 |
+
<img src="https://www.gradio.app/_app/immutable/assets/gradio-logo-with-title.3SNGTZpF.svg" alt="Gradio">
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div>
|
| 156 |
+
<div class="kicker">Hugging Face Γ Gradio Hackathon</div>
|
| 157 |
+
<div class="title">Build Small</div>
|
| 158 |
+
<div class="subtitle">Small Β· Local Β· Yours</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<div class="body">
|
| 162 |
+
<div class="certifies">This certifies that</div>
|
| 163 |
+
<div class="name" id="participantName">{participant_name}</div>
|
| 164 |
+
<div class="desc">
|
| 165 |
+
has successfully participated in <strong>Build Small</strong>, a Hugging Face Γ Gradio
|
| 166 |
+
hackathon celebrating small, local, open-weight models β building real, tinkerable apps
|
| 167 |
+
powered entirely by models <strong>under 32B parameters</strong>.
|
| 168 |
+
</div>
|
| 169 |
+
{project_section}
|
| 170 |
+
<div class="stats">
|
| 171 |
+
<div class="stat"><b>< 32B</b><span>Params Β· Local-first</span></div>
|
| 172 |
+
<div class="stat"><b>$48K+</b><span>Prize Pool</span></div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div class="sponsors">
|
| 177 |
+
<div class="sponsors__t">Proudly supported by</div>
|
| 178 |
+
<div class="sponsors__l">
|
| 179 |
+
OpenBMB <i>Β·</i> Black Forest Labs <i>Β·</i> OpenAI <i>Β·</i> NVIDIA <i>Β·</i> Modal <i>Β·</i> JetBrains <i>Β·</i> Cohere Labs
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</body>
|
| 185 |
+
</html>
|
certificate_upload_module.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upload generated certificates to the public Build Small gallery dataset."""
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
import tempfile
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from PIL import Image as PILImage
|
| 8 |
+
from datasets import Dataset, Image, load_dataset, concatenate_datasets
|
| 9 |
+
from huggingface_hub import HfApi
|
| 10 |
+
|
| 11 |
+
logging.basicConfig(level=logging.INFO)
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# PUBLIC dataset that acts as the gallery of issued certificates
|
| 15 |
+
CERTIFICATE_DATASET_NAME = "build-small-hackathon/build-small-certificates"
|
| 16 |
+
|
| 17 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def safe_add_certificate_to_dataset(certificate_image, hf_username, max_retries=5, retry_delay=3):
|
| 21 |
+
"""Append a certificate image to the dataset, handling empty/existing datasets and dedup."""
|
| 22 |
+
try:
|
| 23 |
+
if not hf_username or not hf_username.strip():
|
| 24 |
+
return False, "β Error: HF username is required"
|
| 25 |
+
if certificate_image is None:
|
| 26 |
+
return False, "β Error: Certificate image is required"
|
| 27 |
+
hf_username = hf_username.strip()
|
| 28 |
+
logger.info(f"Processing certificate for user: {hf_username}")
|
| 29 |
+
|
| 30 |
+
existing_dataset = None
|
| 31 |
+
load_successful = False
|
| 32 |
+
is_empty_dataset = False
|
| 33 |
+
|
| 34 |
+
for attempt in range(max_retries):
|
| 35 |
+
try:
|
| 36 |
+
existing_dataset = load_dataset(CERTIFICATE_DATASET_NAME, split="train", token=HF_TOKEN)
|
| 37 |
+
logger.info(f"Loaded {len(existing_dataset)} existing certificates")
|
| 38 |
+
load_successful = True
|
| 39 |
+
break
|
| 40 |
+
except Exception as load_error:
|
| 41 |
+
error_str = str(load_error).lower()
|
| 42 |
+
if "corresponds to no data" in error_str or "no data" in error_str or "doesn't exist" in error_str or "not found" in error_str:
|
| 43 |
+
logger.info("Dataset empty / not yet created β will create first entry")
|
| 44 |
+
is_empty_dataset = True
|
| 45 |
+
load_successful = True
|
| 46 |
+
existing_dataset = None
|
| 47 |
+
break
|
| 48 |
+
logger.warning(f"Attempt {attempt + 1} failed: {str(load_error)[:120]}")
|
| 49 |
+
if attempt < max_retries - 1:
|
| 50 |
+
time.sleep(retry_delay)
|
| 51 |
+
|
| 52 |
+
if not load_successful:
|
| 53 |
+
return False, ("β Certificate upload temporarily unavailable. Please try again in a few minutes.")
|
| 54 |
+
|
| 55 |
+
# Dedup by username (stored in the 'label' column)
|
| 56 |
+
if existing_dataset is not None:
|
| 57 |
+
if hf_username in existing_dataset["label"]:
|
| 58 |
+
return True, f"a certificate for '{hf_username}' already exists in the gallery."
|
| 59 |
+
|
| 60 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 61 |
+
if isinstance(certificate_image, PILImage.Image):
|
| 62 |
+
temp_image_path = os.path.join(temp_dir, f"certificate_{hf_username}_{int(time.time())}.png")
|
| 63 |
+
certificate_image.save(temp_image_path, "PNG")
|
| 64 |
+
elif isinstance(certificate_image, str) and os.path.exists(certificate_image):
|
| 65 |
+
temp_image_path = certificate_image
|
| 66 |
+
else:
|
| 67 |
+
return False, "β Error: Invalid image format provided"
|
| 68 |
+
|
| 69 |
+
new_dataset = Dataset.from_dict(
|
| 70 |
+
{"image": [temp_image_path], "label": [hf_username]}
|
| 71 |
+
).cast_column("image", Image())
|
| 72 |
+
|
| 73 |
+
if existing_dataset is not None and not is_empty_dataset:
|
| 74 |
+
combined_dataset = concatenate_datasets([existing_dataset, new_dataset])
|
| 75 |
+
else:
|
| 76 |
+
combined_dataset = new_dataset
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
combined_dataset.push_to_hub(CERTIFICATE_DATASET_NAME, private=False, token=HF_TOKEN)
|
| 80 |
+
logger.info(f"Saved certificate. Total now: {len(combined_dataset)}")
|
| 81 |
+
return True, f"β
saved to the gallery for {hf_username}."
|
| 82 |
+
except Exception as upload_error:
|
| 83 |
+
msg = str(upload_error).lower()
|
| 84 |
+
if any(i in msg for i in ("rate limit", "429", "too many requests")):
|
| 85 |
+
return False, "β³ Upload busy due to high load β please try again in 10β15 minutes."
|
| 86 |
+
logger.error(f"Upload failed: {upload_error}")
|
| 87 |
+
return False, f"β Certificate upload failed: {str(upload_error)}"
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Unexpected error in certificate upload: {e}")
|
| 90 |
+
return False, f"β Certificate upload failed: {str(e)}"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def upload_user_certificate(certificate_image, hf_username):
|
| 94 |
+
"""Public entry point β returns (success, message)."""
|
| 95 |
+
if not certificate_image:
|
| 96 |
+
return False, "β No certificate image provided"
|
| 97 |
+
if not hf_username or not hf_username.strip():
|
| 98 |
+
return False, "β HF username is required"
|
| 99 |
+
return safe_add_certificate_to_dataset(certificate_image, hf_username)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
datasets
|
| 2 |
+
pandas
|
| 3 |
+
gradio_client
|