burtenshaw commited on
Commit
359570c
Β·
1 Parent(s): 768c800

implement certificate in app

Browse files
Quattrocento-Bold.ttf ADDED
Binary file (154 kB). View file
 
Quattrocento-Regular.ttf ADDED
Binary file (148 kB). View file
 
app.py CHANGED
@@ -1,6 +1,12 @@
1
  import os
2
  from datetime import datetime
3
  import random
 
 
 
 
 
 
4
 
5
  import pandas as pd
6
  from huggingface_hub import HfApi, hf_hub_download, Repository
@@ -10,24 +16,22 @@ import gradio as gr
10
  from datasets import load_dataset, Dataset
11
  from huggingface_hub import whoami
12
 
 
 
 
13
  EXAM_DATASET_ID = os.getenv("EXAM_DATASET_ID") or "agents-course/unit_1_quiz"
14
- EXAM_MAX_QUESTIONS = os.getenv("EXAM_MAX_QUESTIONS") or 10
15
  EXAM_PASSING_SCORE = os.getenv("EXAM_PASSING_SCORE") or 0.8
 
 
16
 
17
  ds = load_dataset(EXAM_DATASET_ID, split="train")
18
 
19
  DATASET_REPO_URL = "https://huggingface.co/datasets/agents-course/certificates"
20
- CERTIFIED_USERS_FILENAME = "certified_students.csv"
21
- CERTIFIED_USERS_DIR = "certificates"
22
- repo = Repository(
23
- local_dir=CERTIFIED_USERS_DIR,
24
- clone_from=DATASET_REPO_URL,
25
- use_auth_token=os.getenv("HF_TOKEN"),
26
- )
27
 
28
  # Convert dataset to a list of dicts and randomly sort
29
  quiz_data = ds.to_pandas().to_dict("records")
30
- random.shuffle(quiz_data)
31
 
32
  # Limit to max questions if specified
33
  if EXAM_MAX_QUESTIONS:
@@ -69,43 +73,109 @@ def on_user_logged_in(token: gr.OAuthToken | None):
69
  ]
70
 
71
 
72
- def add_certified_user(hf_username, pass_percentage, submission_time):
73
- """
74
- Add the certified user to the database
75
- """
76
- print("ADD CERTIFIED USER")
77
- repo.git_pull()
78
- history = pd.read_csv(os.path.join(CERTIFIED_USERS_DIR, CERTIFIED_USERS_FILENAME))
79
-
80
- # Check if this hf_username is already in our dataset:
81
- check = history.loc[history["hf_username"] == hf_username]
82
- if not check.empty:
83
- history = history.drop(labels=check.index[0], axis=0)
84
-
85
- new_row = pd.DataFrame(
86
- {
87
- "hf_username": hf_username,
88
- "pass_percentage": pass_percentage,
89
- "datetime": submission_time,
90
- },
91
- index=[0],
92
  )
93
- history = pd.concat([new_row, history[:]]).reset_index(drop=True)
 
94
 
95
- history.to_csv(
96
- os.path.join(CERTIFIED_USERS_DIR, CERTIFIED_USERS_FILENAME), index=False
97
- )
98
- repo.push_to_hub(commit_message="Update certified users list")
99
 
 
 
100
 
101
- def push_results_to_hub(user_answers, token: gr.OAuthToken | None):
102
- """
103
- Create a new dataset from user_answers and push it to the Hub.
104
- Calculates grade and checks against passing threshold.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  """
106
- if token is None:
107
- gr.Warning("Please log in to Hugging Face before pushing!")
108
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
  # Calculate grade
111
  correct_count = sum(1 for answer in user_answers if answer["is_correct"])
@@ -113,36 +183,54 @@ def push_results_to_hub(user_answers, token: gr.OAuthToken | None):
113
  grade = correct_count / total_questions if total_questions > 0 else 0
114
 
115
  if grade < float(EXAM_PASSING_SCORE):
116
- gr.Warning(
117
- f"Score {grade:.1%} below passing threshold of {float(EXAM_PASSING_SCORE):.1%}"
 
 
 
 
 
 
118
  )
119
- return f"You scored {grade:.1%}. Please try again to achieve at least {float(EXAM_PASSING_SCORE):.1%}"
120
-
121
- gr.Info("Submitting answers to the Hub. Please wait...", duration=2)
122
-
123
- user_info = whoami(token=token.token)
124
- repo_id = f"{EXAM_DATASET_ID}_student_responses"
125
- submission_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
126
-
127
- # filter down user answers to only include first character of the question and the answer
128
- new_ds = Dataset.from_list(user_answers)
129
- new_ds = new_ds.map(
130
- lambda x: {
131
- "username": user_info["name"],
132
- "datetime": submission_time,
133
- "grade": grade,
134
- }
135
- )
136
- sanitized_name = user_info["name"].replace("-", "000")
137
- new_ds.push_to_hub(repo_id=repo_id, split=sanitized_name)
138
 
139
- # I'm adding a csv version
140
- # The idea, if the user passed, we create a simple row in a csv
141
- print("ADD CERTIFIED USER")
142
- # Add this user to our database
143
- add_certified_user(sanitized_name, grade, submission_time)
 
 
 
 
 
 
 
 
144
 
145
- return f"Your responses have been submitted to the Hub! Final grade: {grade:.1%}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
 
148
  def handle_quiz(
@@ -153,12 +241,21 @@ def handle_quiz(
153
  token: gr.OAuthToken | None,
154
  profile: gr.OAuthProfile | None,
155
  ):
156
- """
157
- Handle quiz state transitions and store answers
158
- """
159
  if token is None or profile is None:
160
  gr.Warning("Please log in to Hugging Face before starting the quiz!")
161
- return
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  if not is_start and question_idx < len(quiz_data):
164
  current_q = quiz_data[question_idx]
@@ -184,34 +281,37 @@ def handle_quiz(
184
  f"Your score: {grade:.1%}\n"
185
  f"Passing score: {float(EXAM_PASSING_SCORE):.1%}\n\n"
186
  )
 
187
  return [
188
  "", # question_text
189
- gr.update(choices=[], visible=False), # hide radio choices
190
- f"{'πŸŽ‰ Passed! Click now on βœ… Submit to save your exam score!' if grade >= float(EXAM_PASSING_SCORE) else '❌ Did not pass'}",
191
- question_idx,
192
- user_answers,
193
- gr.update(visible=False), # start button visibility
194
- gr.update(visible=False), # next button visibility
195
- gr.update(visible=True), # submit button visibility
196
- results_text, # final results text
 
197
  ]
198
 
199
  # Show next question
200
  q = quiz_data[question_idx]
201
  return [
202
- f"## Question {question_idx + 1} \n### {q['question']}", # question text
203
- gr.update( # properly update radio choices
204
  choices=[q["answer_a"], q["answer_b"], q["answer_c"], q["answer_d"]],
205
  value=None,
206
  visible=True,
207
  ),
208
- "Select an answer and click 'Next' to continue.",
209
- question_idx,
210
- user_answers,
211
- gr.update(visible=False), # start button visibility
212
- gr.update(visible=True), # next button visibility
213
- gr.update(visible=False), # submit button visibility
214
- "", # clear final markdown
 
215
  ]
216
 
217
 
@@ -239,18 +339,19 @@ with gr.Blocks() as demo:
239
  with gr.Row(variant="panel"):
240
  question_text = gr.Markdown("")
241
  radio_choices = gr.Radio(
242
- choices=[], label="Your Answer", scale=1.5, visible=False
243
  )
244
 
245
  with gr.Row(variant="compact"):
246
  status_text = gr.Markdown("")
247
- final_markdown = gr.Markdown("")
 
248
 
249
  with gr.Row(variant="compact"):
250
  login_btn = gr.LoginButton(visible=True)
251
  start_btn = gr.Button("Start ⏭️", visible=True)
252
  next_btn = gr.Button("Next ⏭️", visible=False)
253
- submit_btn = gr.Button("Submit βœ…", visible=False)
254
 
255
  # Wire up the event handlers
256
  login_btn.click(
@@ -266,7 +367,8 @@ with gr.Blocks() as demo:
266
  status_text,
267
  question_idx,
268
  user_answers,
269
- final_markdown,
 
270
  user_token,
271
  ],
272
  )
@@ -283,7 +385,8 @@ with gr.Blocks() as demo:
283
  start_btn,
284
  next_btn,
285
  submit_btn,
286
- final_markdown,
 
287
  ],
288
  )
289
 
@@ -299,13 +402,19 @@ with gr.Blocks() as demo:
299
  start_btn,
300
  next_btn,
301
  submit_btn,
302
- final_markdown,
 
303
  ],
304
  )
305
 
306
- submit_btn.click(fn=push_results_to_hub, inputs=[user_answers])
 
 
 
 
307
 
308
  if __name__ == "__main__":
309
  # Note: If testing locally, you'll need to run `huggingface-cli login` or set HF_TOKEN
310
  # environment variable for the login to work locally.
 
311
  demo.launch()
 
1
  import os
2
  from datetime import datetime
3
  import random
4
+ import requests
5
+ from io import BytesIO
6
+ from datetime import date
7
+ import tempfile
8
+ from PIL import Image, ImageDraw, ImageFont
9
+ from huggingface_hub import upload_file
10
 
11
  import pandas as pd
12
  from huggingface_hub import HfApi, hf_hub_download, Repository
 
16
  from datasets import load_dataset, Dataset
17
  from huggingface_hub import whoami
18
 
19
+ import asyncio
20
+ from functools import partial
21
+
22
  EXAM_DATASET_ID = os.getenv("EXAM_DATASET_ID") or "agents-course/unit_1_quiz"
23
+ EXAM_MAX_QUESTIONS = os.getenv("EXAM_MAX_QUESTIONS") or 1
24
  EXAM_PASSING_SCORE = os.getenv("EXAM_PASSING_SCORE") or 0.8
25
+ CERTIFYING_ORG_LINKEDIN_ID = os.getenv("CERTIFYING_ORG_LINKEDIN_ID", "000000")
26
+ COURSE_TITLE = os.getenv("COURSE_TITLE", "AI Agents Fundamentals")
27
 
28
  ds = load_dataset(EXAM_DATASET_ID, split="train")
29
 
30
  DATASET_REPO_URL = "https://huggingface.co/datasets/agents-course/certificates"
 
 
 
 
 
 
 
31
 
32
  # Convert dataset to a list of dicts and randomly sort
33
  quiz_data = ds.to_pandas().to_dict("records")
34
+ # random.shuffle(quiz_data)
35
 
36
  # Limit to max questions if specified
37
  if EXAM_MAX_QUESTIONS:
 
73
  ]
74
 
75
 
76
+ def generate_certificate(name: str, profile_url: str):
77
+ """Generate certificate image and PDF."""
78
+ certificate_path = os.path.join(
79
+ os.path.dirname(__file__), "templates", "certificate.png"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  )
81
+ im = Image.open(certificate_path)
82
+ d = ImageDraw.Draw(im)
83
 
84
+ name_font = ImageFont.truetype("Quattrocento-Regular.ttf", 100)
85
+ date_font = ImageFont.truetype("Quattrocento-Regular.ttf", 48)
 
 
86
 
87
+ name = name.title()
88
+ d.text((1000, 740), name, fill="black", anchor="mm", font=name_font)
89
 
90
+ d.text((1480, 1170), str(date.today()), fill="black", anchor="mm", font=date_font)
91
+
92
+ pdf = im.convert("RGB")
93
+ pdf.save("certificate.pdf")
94
+
95
+ return im, "certificate.pdf"
96
+
97
+
98
+ def create_linkedin_button(username: str, cert_url: str | None) -> str:
99
+ """Create LinkedIn 'Add to Profile' button HTML."""
100
+ current_year = date.today().year
101
+ current_month = date.today().month
102
+
103
+ # Use the dataset certificate URL if available, otherwise fallback to default
104
+ certificate_url = cert_url or "https://huggingface.co/agents-course-finishers"
105
+
106
+ linkedin_params = {
107
+ "startTask": "CERTIFICATION_NAME",
108
+ "name": COURSE_TITLE,
109
+ "organizationName": "Hugging Face",
110
+ "organizationId": CERTIFYING_ORG_LINKEDIN_ID,
111
+ "organizationIdissueYear": str(current_year),
112
+ "issueMonth": str(current_month),
113
+ "certUrl": certificate_url,
114
+ "certId": username, # Using username as cert ID
115
+ }
116
+
117
+ # Build the LinkedIn button URL
118
+ base_url = "https://www.linkedin.com/profile/add?"
119
+ params = "&".join(
120
+ f"{k}={requests.utils.quote(v)}" for k, v in linkedin_params.items()
121
+ )
122
+ button_url = base_url + params
123
+
124
+ message = f"""
125
+ <a href="{button_url}" target="_blank" style="display: block; margin-top: 20px; text-align: center;">
126
+ <img src="https://download.linkedin.com/desktop/add2profile/buttons/en_US.png"
127
+ alt="LinkedIn Add to Profile button">
128
+ </a>
129
  """
130
+ return message
131
+
132
+
133
+ async def upload_certificate_to_hub(username: str, certificate_img) -> str:
134
+ """Upload certificate to the dataset hub and return the URL asynchronously."""
135
+ # Save image to temporary file
136
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
137
+ certificate_img.save(tmp.name)
138
+
139
+ try:
140
+ # Run upload in a thread pool since upload_file is blocking
141
+ loop = asyncio.get_event_loop()
142
+ upload_func = partial(
143
+ upload_file,
144
+ path_or_fileobj=tmp.name,
145
+ path_in_repo=f"certificates/{username}/{date.today()}.png",
146
+ repo_id="agents-course/certificates",
147
+ repo_type="dataset",
148
+ token=os.getenv("HF_TOKEN"),
149
+ )
150
+ await loop.run_in_executor(None, upload_func)
151
+
152
+ # Construct the URL to the image
153
+ cert_url = (
154
+ f"https://huggingface.co/datasets/agents-course/certificates/"
155
+ f"resolve/main/certificates/{username}/{date.today()}.png"
156
+ )
157
+
158
+ # Clean up temp file
159
+ os.unlink(tmp.name)
160
+ return cert_url
161
+
162
+ except Exception as e:
163
+ print(f"Error uploading certificate: {e}")
164
+ os.unlink(tmp.name)
165
+ return None
166
+
167
+
168
+ async def push_results_to_hub(
169
+ user_answers, token: gr.OAuthToken | None, profile: gr.OAuthProfile | None
170
+ ):
171
+ """Handle quiz completion and certificate generation."""
172
+ if token is None or profile is None:
173
+ gr.Warning("Please log in to Hugging Face before submitting!")
174
+ return (
175
+ gr.update(visible=True, value="Please login first"),
176
+ gr.update(visible=False),
177
+ gr.update(visible=False),
178
+ )
179
 
180
  # Calculate grade
181
  correct_count = sum(1 for answer in user_answers if answer["is_correct"])
 
183
  grade = correct_count / total_questions if total_questions > 0 else 0
184
 
185
  if grade < float(EXAM_PASSING_SCORE):
186
+ return (
187
+ gr.update(
188
+ visible=True,
189
+ value=f"You scored {grade:.1%}. Please try again to achieve at least "
190
+ f"{float(EXAM_PASSING_SCORE):.1%}",
191
+ ),
192
+ gr.update(visible=False),
193
+ gr.update(visible=False),
194
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
+ try:
197
+ # Generate certificate
198
+ certificate_img, _ = generate_certificate(
199
+ name=profile.name, profile_url=profile.picture
200
+ )
201
+
202
+ # Start certificate upload asynchronously
203
+ gr.Info("Uploading your certificate...")
204
+ cert_url = await upload_certificate_to_hub(profile.username, certificate_img)
205
+
206
+ if cert_url is None:
207
+ gr.Warning("Certificate upload failed, but you still passed!")
208
+ cert_url = "https://huggingface.co/agents-course"
209
 
210
+ # Create LinkedIn button
211
+ linkedin_button = create_linkedin_button(profile.username, cert_url)
212
+
213
+ result_message = f"""
214
+ πŸŽ‰ Congratulations! You passed with a score of {grade:.1%}!
215
+
216
+ {linkedin_button}
217
+ """
218
+
219
+ return (
220
+ gr.update(visible=True, value=result_message),
221
+ gr.update(visible=True, value=certificate_img),
222
+ gr.update(visible=True),
223
+ )
224
+
225
+ except Exception as e:
226
+ print(f"Error generating certificate: {e}")
227
+ return (
228
+ gr.update(
229
+ visible=True, value=f"πŸŽ‰ Congratulations! You passed with {grade:.1%}!"
230
+ ),
231
+ gr.update(visible=False),
232
+ gr.update(visible=False),
233
+ )
234
 
235
 
236
  def handle_quiz(
 
241
  token: gr.OAuthToken | None,
242
  profile: gr.OAuthProfile | None,
243
  ):
244
+ """Handle quiz state transitions and store answers"""
 
 
245
  if token is None or profile is None:
246
  gr.Warning("Please log in to Hugging Face before starting the quiz!")
247
+ return [
248
+ "", # question_text
249
+ gr.update(choices=[], visible=False), # radio choices
250
+ "Please login first", # status_text
251
+ question_idx, # question_idx
252
+ user_answers, # user_answers
253
+ gr.update(visible=True), # start button
254
+ gr.update(visible=False), # next button
255
+ gr.update(visible=False), # submit button
256
+ gr.update(visible=False), # certificate image
257
+ gr.update(visible=False), # linkedin button
258
+ ]
259
 
260
  if not is_start and question_idx < len(quiz_data):
261
  current_q = quiz_data[question_idx]
 
281
  f"Your score: {grade:.1%}\n"
282
  f"Passing score: {float(EXAM_PASSING_SCORE):.1%}\n\n"
283
  )
284
+ has_passed = grade >= float(EXAM_PASSING_SCORE)
285
  return [
286
  "", # question_text
287
+ gr.update(choices=[], visible=False), # radio choices
288
+ f"{'πŸŽ‰ Passed! Click now on πŸŽ“ Get your certificate!' if has_passed else '❌ Did not pass'}", # status_text
289
+ question_idx, # question_idx
290
+ user_answers, # user_answers
291
+ gr.update(visible=False), # start button
292
+ gr.update(visible=False), # next button
293
+ gr.update(visible=True, value=f"πŸŽ“ Get your certificate" if has_passed else "❌ Did not pass", interactive=has_passed), # submit button
294
+ gr.update(visible=False), # certificate image
295
+ gr.update(visible=False), # linkedin button
296
  ]
297
 
298
  # Show next question
299
  q = quiz_data[question_idx]
300
  return [
301
+ f"## Question {question_idx + 1} \n### {q['question']}", # question_text
302
+ gr.update( # radio choices
303
  choices=[q["answer_a"], q["answer_b"], q["answer_c"], q["answer_d"]],
304
  value=None,
305
  visible=True,
306
  ),
307
+ "Select an answer and click 'Next' to continue.", # status_text
308
+ question_idx, # question_idx
309
+ user_answers, # user_answers
310
+ gr.update(visible=False), # start button
311
+ gr.update(visible=True), # next button
312
+ gr.update(visible=False), # submit button
313
+ gr.update(visible=False), # certificate image
314
+ gr.update(visible=False), # linkedin button
315
  ]
316
 
317
 
 
339
  with gr.Row(variant="panel"):
340
  question_text = gr.Markdown("")
341
  radio_choices = gr.Radio(
342
+ choices=[], label="Your Answer", scale=1, visible=False
343
  )
344
 
345
  with gr.Row(variant="compact"):
346
  status_text = gr.Markdown("")
347
+ certificate_img = gr.Image(type="pil", visible=False)
348
+ linkedin_btn = gr.HTML(visible=False)
349
 
350
  with gr.Row(variant="compact"):
351
  login_btn = gr.LoginButton(visible=True)
352
  start_btn = gr.Button("Start ⏭️", visible=True)
353
  next_btn = gr.Button("Next ⏭️", visible=False)
354
+ submit_btn = gr.Button("πŸŽ“ Get your certificate", visible=False)
355
 
356
  # Wire up the event handlers
357
  login_btn.click(
 
367
  status_text,
368
  question_idx,
369
  user_answers,
370
+ certificate_img,
371
+ linkedin_btn,
372
  user_token,
373
  ],
374
  )
 
385
  start_btn,
386
  next_btn,
387
  submit_btn,
388
+ certificate_img,
389
+ linkedin_btn,
390
  ],
391
  )
392
 
 
402
  start_btn,
403
  next_btn,
404
  submit_btn,
405
+ certificate_img,
406
+ linkedin_btn,
407
  ],
408
  )
409
 
410
+ submit_btn.click(
411
+ fn=push_results_to_hub,
412
+ inputs=[user_answers],
413
+ outputs=[status_text, certificate_img, linkedin_btn],
414
+ )
415
 
416
  if __name__ == "__main__":
417
  # Note: If testing locally, you'll need to run `huggingface-cli login` or set HF_TOKEN
418
  # environment variable for the login to work locally.
419
+ demo.queue() # Enable queuing for async operations
420
  demo.launch()
certificate.pdf ADDED
Binary file (209 kB). View file
 
templates/certificate.png ADDED