LovnishVerma commited on
Commit
c37eb7e
·
verified ·
1 Parent(s): 2c87221

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +752 -314
app.py CHANGED
@@ -1,25 +1,16 @@
1
  #!/usr/bin/env python3
2
  """
3
- CCC Plus Quiz App — Production-ready Gradio app
4
-
5
- Features / improvements:
6
- - Replaces unsupported gr.Card() with gr.Group(elem_classes="card")
7
- - Robust uploaded-file path extraction (works with multiple Gradio versions)
8
- - Uses tempfile for PDF generation and ensures unique filenames
9
- - Visual progress bar (HTML <progress>)
10
- - Better error handling & logging
11
- - CLI args for host/port/share
12
- - Clean UI, report preview, and PDF download
13
- - Reset/Start controls guarded to avoid invalid state transitions
14
  """
15
 
16
  import argparse
17
  import logging
18
  import os
19
  import tempfile
 
20
  from datetime import datetime
21
  from pathlib import Path
22
- from typing import Optional, Tuple
23
 
24
  import gradio as gr
25
  import pandas as pd
@@ -30,7 +21,7 @@ from reportlab.pdfgen import canvas
30
  from reportlab.platypus import Image, Table, TableStyle
31
 
32
  # -----------------------
33
- # Logging (production)
34
  # -----------------------
35
  logging.basicConfig(
36
  level=logging.INFO,
@@ -38,24 +29,27 @@ logging.basicConfig(
38
  )
39
  logger = logging.getLogger(__name__)
40
 
 
 
 
 
 
 
41
 
42
  # -----------------------
43
- # Utility helpers
44
  # -----------------------
45
  def extract_file_path(uploaded) -> Optional[str]:
46
  """
47
  Robustly extract a usable file-system path from Gradio's uploaded-file object.
48
- Gradio's File/Image components can return:
49
- - a dict (e.g. {'name': 'file.csv', 'tmp_path': '/tmp/tmpxxx', ...})
50
- - a file-like object with .name path
51
- - a plain string path (older setups)
52
- Return None if no usable path found.
53
  """
54
  if uploaded is None:
55
  return None
 
56
  # If already a filesystem path string
57
  if isinstance(uploaded, str):
58
  return uploaded if os.path.exists(uploaded) else None
 
59
  # If Gradio returns a dict (common)
60
  if isinstance(uploaded, dict):
61
  for key in ("tmp_path", "tempfile", "file", "name", "tmpfile", "path"):
@@ -66,9 +60,11 @@ def extract_file_path(uploaded) -> Optional[str]:
66
  if "name" in uploaded and os.path.exists(uploaded["name"]):
67
  return uploaded["name"]
68
  return None
 
69
  # If file-like object (has .name pointing to path)
70
  if hasattr(uploaded, "name") and isinstance(uploaded.name, str) and os.path.exists(uploaded.name):
71
  return uploaded.name
 
72
  # last-ditch: try repr
73
  try:
74
  s = str(uploaded)
@@ -76,466 +72,908 @@ def extract_file_path(uploaded) -> Optional[str]:
76
  return s
77
  except Exception:
78
  pass
 
79
  return None
80
 
81
 
82
  def safe_read_csv(path: str) -> pd.DataFrame:
83
  """
84
- Read CSV with some guard rails (UTF-8 fallback etc.)
85
  """
86
  if path is None:
87
  raise FileNotFoundError("No CSV path provided.")
88
- # allow reading from path-like
89
  if not os.path.exists(path):
90
  raise FileNotFoundError(f"File not found: {path}")
91
- # try reading with utf-8, fallback to latin1
92
- try:
93
- return pd.read_csv(path)
94
- except Exception:
95
- return pd.read_csv(path, encoding="latin1")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
 
98
  # -----------------------
99
- # Quiz CSV loader
100
  # -----------------------
101
- REQUIRED_COLUMNS = {"question", "option1", "option2", "option3", "option4", "answer"}
102
-
103
-
104
- def load_quiz_from_path(path: str):
105
  df = safe_read_csv(path)
106
- cols = set(df.columns.str.lower())
107
- # Accept case-insensitive columns by mapping lower->actual
108
- col_map = {c.lower(): c for c in df.columns}
109
- missing = REQUIRED_COLUMNS - set(cols)
 
 
 
110
  if missing:
111
  raise ValueError(f"CSV missing required columns (case-insensitive): {missing}")
112
- # normalize rows
 
113
  quiz = []
114
- for _, r in df.iterrows():
115
- q = str(r[col_map["question"]])
116
- opts = [
117
- str(r[col_map["option1"]]),
118
- str(r[col_map["option2"]]),
119
- str(r[col_map["option3"]]),
120
- str(r[col_map["option4"]]),
121
- ]
122
- ans = str(r[col_map["answer"]])
123
- quiz.append({"question": q, "options": opts, "answer": ans})
124
- return quiz
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  # -----------------------
128
- # PDF generation
129
  # -----------------------
130
- def generate_report_pdf(user_name: str, quiz_title: str, history: list, score: int, logo_path: Optional[str] = None) -> str:
 
 
 
 
 
 
 
131
  """
132
- Create a PDF report, store in a secure temporary file and return the path.
133
- history: list of {"question","selected","answer","correct"}
134
  """
135
  ts = datetime.now().strftime("%Y%m%d_%H%M%S")
136
- tmp = tempfile.NamedTemporaryFile(prefix=f"CCCPlus_Report_{ts}_", suffix=".pdf", delete=False)
 
 
 
 
137
  tmp.close()
138
  filename = tmp.name
139
 
140
  c = canvas.Canvas(filename, pagesize=A4)
141
  width, height = A4
142
- margin = 20 * mm
143
- y = height - margin
144
 
145
- # Optional logo
146
  if logo_path and os.path.exists(logo_path):
147
  try:
148
  img_w = 30 * mm
149
  img = Image(logo_path, img_w, img_w)
150
- img.drawOn(c, margin, y - img_w)
151
- x_title = margin + img_w + 8
152
- except Exception:
153
- logger.exception("Failed to draw logo on PDF")
154
- x_title = margin
155
  else:
156
- x_title = margin
157
 
158
  # Header
159
  c.setFont("Helvetica-Bold", 18)
160
  c.drawString(x_title, y - 8, quiz_title)
 
161
  c.setFont("Helvetica", 10)
162
  c.drawString(x_title, y - 28, f"Candidate: {user_name}")
163
  c.drawString(x_title, y - 42, f"Date: {datetime.now().strftime('%d %b %Y %H:%M:%S')}")
164
  y -= 60
165
 
166
- # Score
167
  c.setFont("Helvetica-Bold", 14)
168
- c.drawString(margin, y, f"Final Score: {score} / {len(history)}")
 
 
 
169
  y -= 22
170
 
171
- # Table
 
 
 
 
 
 
 
 
 
 
 
 
172
  table_data = [["#", "Question", "Your Answer", "Correct Answer", "Result"]]
 
173
  for i, h in enumerate(history, start=1):
174
- result = "Correct" if h.get("correct") else "Incorrect"
175
- # Protect excessively long text from breaking layout by truncating
176
- qtext = (h.get("question") or "")[:300]
177
- sel = (h.get("selected") or "")[:80]
178
- ans = (h.get("answer") or "")[:80]
179
- table_data.append([str(i), qtext, sel, ans, result])
180
-
 
 
 
 
 
 
 
 
 
181
  col_widths = [12 * mm, 90 * mm, 40 * mm, 40 * mm, 25 * mm]
182
  table = Table(table_data, colWidths=col_widths)
183
- style = TableStyle(
184
- [
185
- ("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
186
- ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#003366")),
187
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
188
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
189
- ("FONTSIZE", (0, 0), (-1, -1), 8),
190
- ]
191
- )
192
- table.setStyle(style)
193
-
194
- # Layout the table; if doesn't fit, create new page(s)
195
- w, h_table = table.wrapOn(c, width - 2 * margin, y - margin)
196
- if y - h_table < margin + 30:
 
 
 
 
 
 
 
197
  c.showPage()
198
- y = height - margin
199
- table.drawOn(c, margin, y - h_table)
 
200
  y = y - h_table - 30
201
 
202
  # Footer
203
  c.setFont("Helvetica", 8)
204
- footer_text = f"NIELIT Chandigarh — © {datetime.now().year}"
205
  c.drawCentredString(width / 2, 12 * mm, footer_text)
206
 
207
  c.save()
208
- logger.info("Generated PDF report at %s", filename)
209
  return filename
210
 
211
 
212
  # -----------------------
213
- # Gradio app logic
214
  # -----------------------
215
  def prepare_quiz(csv_file_text: str, csv_upload):
216
  """
217
- Called to load questions from CSV and initialize state.
218
- Returns:
219
- - first question text (or empty string)
220
- - options update
221
- - visibility updates for controls
222
- - message
223
- - initialized state dict
224
  """
225
- # prefer uploaded file if present
226
  uploaded_path = extract_file_path(csv_upload)
227
- path = uploaded_path or (csv_file_text if os.path.exists(csv_file_text) else None)
 
228
  if not path:
229
- msg = "No questions loaded. Upload a CSV or place quiz.csv in the app folder."
230
  return "", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), msg, {}
231
 
232
  try:
233
- quiz = load_quiz_from_path(path)
234
  except Exception as e:
235
  logger.exception("Failed to load quiz CSV")
236
- return "", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), f"Error loading CSV: {e}", {}
 
237
 
238
  if not quiz:
239
- return "", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), "No questions found in CSV.", {}
 
240
 
241
- store = {"quiz": quiz, "index": 0, "score": 0, "history": [], "started": False}
242
- first = quiz[0]
243
- msg = f"Loaded {len(quiz)} questions. Click Start to begin."
244
- return first["question"], gr.update(choices=first["options"], value=None), gr.update(visible=True), gr.update(visible=False), msg, store
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  def start_quiz(state):
 
248
  if not state or "quiz" not in state or not state["quiz"]:
249
- return "No quiz loaded. Load CSV first.", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
250
- state["index"] = 0
251
- state["score"] = 0
252
- state["history"] = []
253
- state["started"] = True
254
- first = state["quiz"][0]
255
- # show Submit, hide Next until submitted
256
- return first["question"], gr.update(choices=first["options"], value=None), gr.update(visible=True), gr.update(visible=False), state
 
 
 
 
 
 
 
 
 
 
 
257
 
258
 
259
  def submit_answer(state, selected):
 
260
  if not state or "quiz" not in state:
261
- return "No quiz loaded.", gr.update(visible=False), gr.update(visible=False), state
 
262
  if not state.get("started"):
263
- return "Quiz has not been started. Click Start.", gr.update(visible=False), gr.update(visible=False), state
264
-
265
- idx = state["index"]
266
- q = state["quiz"][idx]
267
- selected_val = selected if selected is not None else ""
268
- correct = (selected_val == q["answer"])
269
- last = {"question": q["question"], "selected": selected_val, "answer": q["answer"], "correct": correct}
270
- state["last"] = last
271
- feedback = "✅ Correct!" if correct else f"❌ Incorrect. Correct answer: {q['answer']}"
272
- # enable Next button after submit
 
 
 
 
 
 
 
 
 
 
 
 
273
  return feedback, gr.update(visible=True), gr.update(visible=True), state
274
 
275
 
276
- def next_q(state):
 
277
  if not state or "quiz" not in state:
278
- return "No quiz loaded.", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
279
- last = state.get("last")
280
- if last:
281
- state["history"].append(last)
282
- if last.get("correct"):
 
 
283
  state["score"] += 1
284
- # move to next index
 
285
  state["index"] += 1
286
- state.pop("last", None)
287
-
288
  if state["index"] >= len(state["quiz"]):
289
- finished_msg = f"Quiz Completed! Final Score: {state['score']} / {len(state['quiz'])}"
290
  state["started"] = False
291
- # hide question controls
292
- return finished_msg, gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
293
-
294
- q = state["quiz"][state["index"]]
295
- return q["question"], gr.update(choices=q["options"], value=None), gr.update(visible=True), gr.update(visible=False), state
296
-
297
-
298
- def get_progress_html(state) -> str:
299
- """
300
- Returns an HTML string with a styled progress bar showing progress and score.
301
- We'll use a simple <progress> element and a little text.
302
- """
303
- if not state or "quiz" not in state or not state["quiz"]:
304
- return "<div>No quiz loaded.</div>"
305
- total = len(state["quiz"])
306
- # If quiz complete, progress equals total
307
- idx = min(state.get("index", 0), total)
308
- # when 'last' present but not yet saved, count current index as answered? Keep visual simple: show index+1 if not finished
309
- pct_value = int((idx / total) * 100)
310
- # Create a small HTML snippet
311
  return (
312
- f"<div style='font-size:14px;margin-bottom:6px;'>"
313
- f"Question: {min(idx + 1, total)} of {total} &nbsp; — &nbsp; Score: {state.get('score',0)}</div>"
314
- f"<progress value='{pct_value}' max='100' style='width:100%;height:14px;'></progress>"
 
 
315
  )
316
 
317
 
318
  def show_report(state, user_name: str, logo_file):
 
319
  if not state or "history" not in state:
320
- return "No results to show.", None
 
321
  history = list(state["history"])
322
- # include last if user submitted but didn't press next
323
- if "last" in state:
324
- history.append(state["last"])
325
- md_lines = []
326
- md_lines.append(f"## Report Card — {user_name or 'Candidate'}")
327
- md_lines.append(f"**Date:** {datetime.now().strftime('%d %b %Y %H:%M:%S')}")
328
- md_lines.append(f"**Score:** **{state['score']} / {len(state['quiz'])}**")
329
- md_lines.append("")
330
- md_lines.append("| # | Question | Your Answer | Correct Answer | Result |")
331
- md_lines.append("|---:|---|---|---|---:|")
332
- for i, h in enumerate(history, start=1):
333
- res = "✅" if h["correct"] else "❌"
334
- qtext = (h["question"] or "").replace("\n", " ")
335
- md_lines.append(f"| {i} | {qtext[:120]} | {h['selected']} | {h['answer']} | {res} |")
336
- md = "\n".join(md_lines)
337
-
338
- # Resolve logo path (uploaded or local default)
339
- logo_path = extract_file_path(logo_file) or ("nielit_logo.png" if os.path.exists("nielit_logo.png") else None)
340
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  try:
342
- pdf_path = generate_report_pdf(user_name or "Candidate", "CCC Plus - Computer Concepts Plus", history, state["score"], logo_path=logo_path)
 
 
 
 
 
 
 
 
 
343
  except Exception as e:
344
- logger.exception("Failed to generate PDF")
345
- return md + f"\n\n**PDF generation failed:** {e}", None
346
-
347
- return md, pdf_path
348
 
349
 
350
  # -----------------------
351
- # Build Gradio interface
352
  # -----------------------
353
  def build_app():
 
354
  css = """
355
- .header {display:flex; align-items:center; gap:12px;}
356
- .logo-small {height:56px;}
357
- .card {border-radius:12px; padding:14px; box-shadow: 0 6px 18px rgba(0,0,0,0.06);}
358
- .muted {color: #666; font-size: 13px;}
359
- .small {font-size:13px; color:#444;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  """
361
 
362
- with gr.Blocks(title="CCC Plus Quiz App — NIELIT Style", css=css) as demo:
363
- # Top row
 
 
 
 
 
364
  with gr.Row():
365
- with gr.Column(scale=1):
366
- logo_img = gr.Image(value="nielit_logo.png" if os.path.exists("nielit_logo.png") else None,
367
- visible=True, elem_id="logo-img", interactive=False)
 
 
 
 
 
 
368
  with gr.Column(scale=6):
369
- gr.Markdown("<div class='header'><h2 style='margin:0'>🖥️ CCC Plus — Computer Concepts Plus</h2></div>")
370
- gr.Markdown("<div class='muted'>Interactive quiz for practice & assessment. Upload CSV or use default quiz.csv.</div>")
 
 
 
 
 
 
371
 
372
  gr.Markdown("---")
373
 
 
374
  with gr.Row():
375
- # LEFT: controls
376
  with gr.Column(scale=3):
377
- gr.Markdown("### Load Questions")
378
- csv_file_text = gr.Textbox(value="quiz.csv", label="Local CSV path (default)", interactive=True)
379
- csv_upload = gr.File(label="Or upload CSV file", file_count="single", file_types=[".csv"])
380
- load_btn = gr.Button("Load CSV")
381
- load_msg = gr.Textbox(label="Status", interactive=False, lines=2, value="No quiz loaded.")
382
-
383
- gr.Markdown("### Candidate Info")
384
- user_name = gr.Textbox(label="Candidate Name (to appear on report)", placeholder="Your name here")
385
-
386
- start_btn = gr.Button("Start Quiz", elem_id="start-btn")
387
- restart_btn = gr.Button("Reset All", elem_id="reset-btn", visible=True)
388
-
389
- gr.Markdown("### Controls")
390
- submit_btn = gr.Button("Submit Answer", variant="primary", visible=False)
391
- next_btn = gr.Button("Next Question ➡️", visible=False)
392
- feedback = gr.Textbox(label="Feedback", interactive=False, visible=False, lines=2)
393
-
394
- gr.Markdown("### Report")
395
- show_report_btn = gr.Button("Generate / Refresh Report")
396
- report_preview = gr.Markdown("No report yet.")
397
- report_download = gr.File(label="Download PDF Report", visible=False)
398
-
399
- # RIGHT: question and progress area
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  with gr.Column(scale=4):
 
401
  with gr.Group(elem_classes="card"):
402
- question_txt = gr.Markdown("**Question will appear here after you start the quiz**")
403
- options = gr.Radio(choices=[], label="Choose one option", interactive=True)
404
- progress_html = gr.HTML("<div>No quiz loaded.</div>")
405
- score_display = gr.Textbox(label="Score", interactive=False, value="0 / 0")
406
-
407
- gr.Markdown("### Upload Logo (optional - PNG/JPG)")
408
- logo_upload = gr.File(label="Optional: Upload logo to appear on PDF", file_count="single", file_types=[".png", ".jpg", ".jpeg"])
409
-
410
- # State store
411
- state_store = gr.State({})
412
-
413
- # Wiring: Load CSV
414
- def on_load(csv_path_text, uploaded_file):
415
- q_text, q_choices, show_submit, show_next, msg, store = prepare_quiz(csv_path_text, uploaded_file)
416
- # initial progress & score
417
- prog = "<div>No quiz loaded.</div>"
418
- score = "0 / 0"
419
- if store and "quiz" in store:
420
- prog = get_progress_html(store)
421
- score = f"{store.get('score',0)} / {len(store.get('quiz',[]))}"
422
- return q_text, q_choices, show_submit, show_next, msg, store, prog, score
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
 
424
  load_btn.click(
425
- fn=on_load,
426
  inputs=[csv_file_text, csv_upload],
427
- outputs=[question_txt, options, submit_btn, next_btn, load_msg, state_store, progress_html, score_display],
 
 
 
428
  )
429
 
430
- # Start quiz
431
- def on_start(state):
432
  q_text, q_choices, show_submit, show_next, new_state = start_quiz(state)
433
- prog = get_progress_html(new_state) if new_state else "<div>No quiz loaded.</div>"
434
- score = f"{new_state.get('score',0)} / {len(new_state.get('quiz',[]))}" if new_state else "0 / 0"
435
- # show submit button, hide next
436
- return q_text, q_choices, gr.update(visible=True), gr.update(visible=False), new_state, prog, score
437
-
438
- start_btn.click(fn=on_start, inputs=[state_store], outputs=[question_txt, options, submit_btn, next_btn, state_store, progress_html, score_display])
 
 
 
 
 
439
 
440
- # Submit answer
441
- def on_submit(state, selected):
442
  feedback_text, show_feedback, show_next_btn, new_state = submit_answer(state, selected)
443
- prog = get_progress_html(new_state) if new_state else "<div>No quiz loaded.</div>"
444
- score = f"{new_state.get('score',0)} / {len(new_state.get('quiz',[]))}" if new_state else "0 / 0"
445
- # show feedback area and enable Next button
446
- return feedback_text, show_feedback, show_next_btn, new_state, prog, score
447
 
448
  submit_btn.click(
449
- fn=on_submit,
450
- inputs=[state_store, options],
451
- outputs=[feedback, feedback, next_btn, state_store, progress_html, score_display],
452
  )
453
 
454
- # Next question
455
- def on_next(state):
456
- q_text, q_choices, show_submit, show_next, new_state = next_q(state)
457
- prog = get_progress_html(new_state) if new_state else "<div>No quiz loaded.</div>"
458
- score = f"{new_state.get('score',0)} / {len(new_state.get('quiz',[]))}" if new_state else "0 / 0"
459
- md = ""
460
- pdf_path = None
461
- # If quiz just finished, prepare a quick report preview (without name/logo defaults)
462
  if new_state and new_state.get("index", 0) >= len(new_state.get("quiz", [])):
463
- md, pdf_path = show_report(new_state, user_name.value if user_name.value else "Candidate", None)
464
- # set visibility: if quiz finished, hide controls
465
- return q_text, q_choices, show_submit, show_next, new_state, prog, score, md, (gr.File.update(value=pdf_path) if pdf_path else gr.File.update(visible=False))
 
 
 
 
 
 
 
 
466
 
467
  next_btn.click(
468
- fn=on_next,
469
- inputs=[state_store],
470
- outputs=[question_txt, options, submit_btn, next_btn, state_store, progress_html, score_display, report_preview, report_download],
 
 
 
471
  )
472
 
473
- # Show/Refresh report button
474
- def on_show_report(state, name, logo_file):
475
- md, pdf_path = show_report(state, name, logo_file)
476
- rfile = gr.File.update(value=pdf_path) if pdf_path else gr.File.update(visible=False)
477
- return md, rfile
478
 
479
  show_report_btn.click(
480
- fn=on_show_report,
481
- inputs=[state_store, user_name, logo_upload],
482
- outputs=[report_preview, report_download],
483
  )
484
 
485
- # Reset button
486
- def on_reset():
487
- # try to clean leftover PDF files (best-effort)
488
- # NOTE: we don't remove uploaded files; they are managed by Gradio / platform
489
  return (
490
- "**Reset** no quiz loaded.",
491
  gr.update(choices=[]),
492
  gr.update(visible=False),
493
  gr.update(visible=False),
494
- "No quiz loaded.",
 
495
  {},
496
- "<div>No quiz loaded.</div>",
497
- "0 / 0",
498
- "No report yet.",
499
- gr.File.update(visible=False),
500
  )
501
 
502
  restart_btn.click(
503
- fn=on_reset,
504
  inputs=[],
505
- outputs=[question_txt, options, submit_btn, next_btn, load_msg, state_store, progress_html, score_display, report_preview, report_download],
 
 
 
506
  )
507
 
508
- # Live update progress when state changes
509
- def on_state_change(state):
510
- if not state or "quiz" not in state:
511
- return "<div>No quiz loaded.</div>", "0 / 0"
512
- return get_progress_html(state), f"{state.get('score',0)} / {len(state.get('quiz',[]))}"
513
 
514
- state_store.change(fn=on_state_change, inputs=[state_store], outputs=[progress_html, score_display])
 
 
 
 
515
 
516
- gr.Markdown("---")
517
- gr.Markdown("NIELIT Chandigarh — © " + str(datetime.now().year))
 
 
 
 
 
 
518
 
519
  return demo
520
 
521
 
522
  # -----------------------
523
- # CLI & server start
524
  # -----------------------
525
  def main():
526
- parser = argparse.ArgumentParser(description="CCC Plus Quiz App — Gradio")
527
- parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default 127.0.0.1)")
528
- parser.add_argument("--port", type=int, default=7860, help="Port to serve on (default 7860)")
529
- parser.add_argument("--share", action="store_true", help="Expose a public share link via Gradio")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  args = parser.parse_args()
531
-
 
 
 
 
 
 
532
  demo = build_app()
533
- # Production-minded defaults: set server name if binding to all interfaces
534
- server_kwargs = {"server_name": args.host, "server_port": args.port}
535
- logger.info("Launching app on %s:%s (share=%s)", args.host, args.port, args.share)
536
- demo.launch(**server_kwargs, share=args.share)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
 
539
  if __name__ == "__main__":
 
540
  demo = build_app()
541
- demo.launch(share=True) # always creates a public link
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ CCC Plus Quiz App — Enhanced Production-ready Gradio app
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
  import argparse
7
  import logging
8
  import os
9
  import tempfile
10
+ import json
11
  from datetime import datetime
12
  from pathlib import Path
13
+ from typing import Optional, Tuple, List, Dict
14
 
15
  import gradio as gr
16
  import pandas as pd
 
21
  from reportlab.platypus import Image, Table, TableStyle
22
 
23
  # -----------------------
24
+ # Configuration & Logging
25
  # -----------------------
26
  logging.basicConfig(
27
  level=logging.INFO,
 
29
  )
30
  logger = logging.getLogger(__name__)
31
 
32
+ # Configuration constants
33
+ DEFAULT_CSV_PATH = "quiz.csv"
34
+ REQUIRED_COLUMNS = {"question", "option1", "option2", "option3", "option4", "answer"}
35
+ MAX_QUESTION_LENGTH = 300
36
+ MAX_OPTION_LENGTH = 80
37
+ PDF_MARGIN = 20 * mm
38
 
39
  # -----------------------
40
+ # Enhanced Utility helpers
41
  # -----------------------
42
  def extract_file_path(uploaded) -> Optional[str]:
43
  """
44
  Robustly extract a usable file-system path from Gradio's uploaded-file object.
 
 
 
 
 
45
  """
46
  if uploaded is None:
47
  return None
48
+
49
  # If already a filesystem path string
50
  if isinstance(uploaded, str):
51
  return uploaded if os.path.exists(uploaded) else None
52
+
53
  # If Gradio returns a dict (common)
54
  if isinstance(uploaded, dict):
55
  for key in ("tmp_path", "tempfile", "file", "name", "tmpfile", "path"):
 
60
  if "name" in uploaded and os.path.exists(uploaded["name"]):
61
  return uploaded["name"]
62
  return None
63
+
64
  # If file-like object (has .name pointing to path)
65
  if hasattr(uploaded, "name") and isinstance(uploaded.name, str) and os.path.exists(uploaded.name):
66
  return uploaded.name
67
+
68
  # last-ditch: try repr
69
  try:
70
  s = str(uploaded)
 
72
  return s
73
  except Exception:
74
  pass
75
+
76
  return None
77
 
78
 
79
  def safe_read_csv(path: str) -> pd.DataFrame:
80
  """
81
+ Read CSV with enhanced error handling and encoding detection.
82
  """
83
  if path is None:
84
  raise FileNotFoundError("No CSV path provided.")
85
+
86
  if not os.path.exists(path):
87
  raise FileNotFoundError(f"File not found: {path}")
88
+
89
+ # Try multiple encodings
90
+ encodings = ['utf-8', 'latin1', 'cp1252', 'iso-8859-1']
91
+
92
+ for encoding in encodings:
93
+ try:
94
+ df = pd.read_csv(path, encoding=encoding)
95
+ logger.info(f"Successfully read CSV with {encoding} encoding")
96
+ return df
97
+ except UnicodeDecodeError:
98
+ continue
99
+ except Exception as e:
100
+ logger.warning(f"Error reading CSV with {encoding}: {e}")
101
+ continue
102
+
103
+ raise ValueError("Could not read CSV file with any supported encoding")
104
+
105
+
106
+ def validate_quiz_data(quiz: List[Dict]) -> List[str]:
107
+ """
108
+ Validate quiz data and return list of warnings/errors.
109
+ """
110
+ warnings = []
111
+
112
+ if not quiz:
113
+ warnings.append("No questions found in the quiz data")
114
+ return warnings
115
+
116
+ for i, q in enumerate(quiz, 1):
117
+ # Check for empty questions
118
+ if not q.get("question", "").strip():
119
+ warnings.append(f"Question {i}: Empty question text")
120
+
121
+ # Check for duplicate options
122
+ options = [opt.strip().lower() for opt in q.get("options", []) if opt.strip()]
123
+ if len(options) != len(set(options)):
124
+ warnings.append(f"Question {i}: Duplicate answer options detected")
125
+
126
+ # Check if answer matches any option
127
+ answer = q.get("answer", "").strip()
128
+ if answer not in q.get("options", []):
129
+ warnings.append(f"Question {i}: Answer '{answer}' not found in options")
130
+
131
+ # Check for very short options
132
+ short_options = [opt for opt in q.get("options", []) if len(opt.strip()) < 2]
133
+ if short_options:
134
+ warnings.append(f"Question {i}: Very short answer options detected")
135
+
136
+ return warnings
137
 
138
 
139
  # -----------------------
140
+ # Enhanced Quiz CSV loader
141
  # -----------------------
142
+ def load_quiz_from_path(path: str) -> Tuple[List[Dict], List[str]]:
143
+ """
144
+ Load quiz from CSV and return quiz data and validation warnings.
145
+ """
146
  df = safe_read_csv(path)
147
+
148
+ # Normalize column names (case-insensitive)
149
+ df.columns = df.columns.str.strip().str.lower()
150
+ cols = set(df.columns)
151
+
152
+ # Check for required columns
153
+ missing = REQUIRED_COLUMNS - cols
154
  if missing:
155
  raise ValueError(f"CSV missing required columns (case-insensitive): {missing}")
156
+
157
+ # Process quiz data
158
  quiz = []
159
+ for idx, row in df.iterrows():
160
+ try:
161
+ question = str(row["question"]).strip()
162
+ options = [
163
+ str(row["option1"]).strip(),
164
+ str(row["option2"]).strip(),
165
+ str(row["option3"]).strip(),
166
+ str(row["option4"]).strip(),
167
+ ]
168
+ answer = str(row["answer"]).strip()
169
+
170
+ # Skip empty questions
171
+ if not question or question.lower() in ['nan', 'none', '']:
172
+ continue
173
+
174
+ quiz.append({
175
+ "question": question,
176
+ "options": options,
177
+ "answer": answer
178
+ })
179
+
180
+ except Exception as e:
181
+ logger.warning(f"Error processing row {idx + 1}: {e}")
182
+ continue
183
+
184
+ # Validate quiz data
185
+ warnings = validate_quiz_data(quiz)
186
+
187
+ return quiz, warnings
188
 
189
 
190
  # -----------------------
191
+ # Enhanced PDF generation
192
  # -----------------------
193
+ def generate_report_pdf(
194
+ user_name: str,
195
+ quiz_title: str,
196
+ history: List[Dict],
197
+ score: int,
198
+ logo_path: Optional[str] = None,
199
+ include_statistics: bool = True
200
+ ) -> str:
201
  """
202
+ Create an enhanced PDF report with statistics and better formatting.
 
203
  """
204
  ts = datetime.now().strftime("%Y%m%d_%H%M%S")
205
+ tmp = tempfile.NamedTemporaryFile(
206
+ prefix=f"CCCPlus_Report_{ts}_",
207
+ suffix=".pdf",
208
+ delete=False
209
+ )
210
  tmp.close()
211
  filename = tmp.name
212
 
213
  c = canvas.Canvas(filename, pagesize=A4)
214
  width, height = A4
215
+ y = height - PDF_MARGIN
 
216
 
217
+ # Draw logo if available
218
  if logo_path and os.path.exists(logo_path):
219
  try:
220
  img_w = 30 * mm
221
  img = Image(logo_path, img_w, img_w)
222
+ img.drawOn(c, PDF_MARGIN, y - img_w)
223
+ x_title = PDF_MARGIN + img_w + 8
224
+ except Exception as e:
225
+ logger.warning(f"Failed to draw logo on PDF: {e}")
226
+ x_title = PDF_MARGIN
227
  else:
228
+ x_title = PDF_MARGIN
229
 
230
  # Header
231
  c.setFont("Helvetica-Bold", 18)
232
  c.drawString(x_title, y - 8, quiz_title)
233
+
234
  c.setFont("Helvetica", 10)
235
  c.drawString(x_title, y - 28, f"Candidate: {user_name}")
236
  c.drawString(x_title, y - 42, f"Date: {datetime.now().strftime('%d %b %Y %H:%M:%S')}")
237
  y -= 60
238
 
239
+ # Score and Statistics
240
  c.setFont("Helvetica-Bold", 14)
241
+ total_questions = len(history)
242
+ percentage = (score / total_questions * 100) if total_questions > 0 else 0
243
+
244
+ c.drawString(PDF_MARGIN, y, f"Final Score: {score} / {total_questions} ({percentage:.1f}%)")
245
  y -= 22
246
 
247
+ if include_statistics and history:
248
+ # Add performance statistics
249
+ c.setFont("Helvetica", 10)
250
+ correct_count = sum(1 for h in history if h.get("correct", False))
251
+ incorrect_count = total_questions - correct_count
252
+
253
+ c.drawString(PDF_MARGIN, y, f"Correct Answers: {correct_count}")
254
+ c.drawString(PDF_MARGIN + 100, y, f"Incorrect Answers: {incorrect_count}")
255
+ y -= 16
256
+
257
+ y -= 10
258
+
259
+ # Results table
260
  table_data = [["#", "Question", "Your Answer", "Correct Answer", "Result"]]
261
+
262
  for i, h in enumerate(history, start=1):
263
+ result = "Correct" if h.get("correct") else "Incorrect"
264
+
265
+ # Truncate long text to prevent layout issues
266
+ question_text = (h.get("question") or "")[:MAX_QUESTION_LENGTH]
267
+ selected_text = (h.get("selected") or "")[:MAX_OPTION_LENGTH]
268
+ answer_text = (h.get("answer") or "")[:MAX_OPTION_LENGTH]
269
+
270
+ table_data.append([
271
+ str(i),
272
+ question_text,
273
+ selected_text,
274
+ answer_text,
275
+ result
276
+ ])
277
+
278
+ # Create and style table
279
  col_widths = [12 * mm, 90 * mm, 40 * mm, 40 * mm, 25 * mm]
280
  table = Table(table_data, colWidths=col_widths)
281
+
282
+ table_style = [
283
+ ("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
284
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#003366")),
285
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
286
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
287
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
288
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
289
+ ]
290
+
291
+ # Add alternating row colors
292
+ for i in range(1, len(table_data)):
293
+ if i % 2 == 0:
294
+ table_style.append(("BACKGROUND", (0, i), (-1, i), colors.HexColor("#f5f5f5")))
295
+
296
+ table.setStyle(TableStyle(table_style))
297
+
298
+ # Layout table with page breaks if needed
299
+ w, h_table = table.wrapOn(c, width - 2 * PDF_MARGIN, y - PDF_MARGIN)
300
+
301
+ if y - h_table < PDF_MARGIN + 30:
302
  c.showPage()
303
+ y = height - PDF_MARGIN
304
+
305
+ table.drawOn(c, PDF_MARGIN, y - h_table)
306
  y = y - h_table - 30
307
 
308
  # Footer
309
  c.setFont("Helvetica", 8)
310
+ footer_text = f"NIELIT Chandigarh — © {datetime.now().year} — Generated automatically"
311
  c.drawCentredString(width / 2, 12 * mm, footer_text)
312
 
313
  c.save()
314
+ logger.info(f"Generated enhanced PDF report: {filename}")
315
  return filename
316
 
317
 
318
  # -----------------------
319
+ # Enhanced Quiz Logic
320
  # -----------------------
321
  def prepare_quiz(csv_file_text: str, csv_upload):
322
  """
323
+ Load questions from CSV with enhanced validation and feedback.
 
 
 
 
 
 
324
  """
 
325
  uploaded_path = extract_file_path(csv_upload)
326
+ path = uploaded_path or (csv_file_text if csv_file_text and os.path.exists(csv_file_text) else None)
327
+
328
  if not path:
329
+ msg = "⚠️ No questions loaded. Upload a CSV file or ensure quiz.csv exists in the app folder."
330
  return "", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), msg, {}
331
 
332
  try:
333
+ quiz, warnings = load_quiz_from_path(path)
334
  except Exception as e:
335
  logger.exception("Failed to load quiz CSV")
336
+ error_msg = f"Error loading CSV: {str(e)}"
337
+ return "", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), error_msg, {}
338
 
339
  if not quiz:
340
+ msg = "❌ No valid questions found in CSV file."
341
+ return "", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), msg, {}
342
 
343
+ # Prepare state
344
+ state = {
345
+ "quiz": quiz,
346
+ "index": 0,
347
+ "score": 0,
348
+ "history": [],
349
+ "started": False,
350
+ "total_questions": len(quiz)
351
+ }
352
+
353
+ first_question = quiz[0]
354
+
355
+ # Create status message with warnings
356
+ msg_parts = [f"✅ Loaded {len(quiz)} questions successfully."]
357
+
358
+ if warnings:
359
+ msg_parts.append(f"\n⚠️ {len(warnings)} validation warning(s):")
360
+ for warning in warnings[:3]: # Show first 3 warnings
361
+ msg_parts.append(f" • {warning}")
362
+ if len(warnings) > 3:
363
+ msg_parts.append(f" • ... and {len(warnings) - 3} more warnings")
364
+
365
+ msg_parts.append("\n🚀 Click 'Start Quiz' to begin!")
366
+ status_msg = "\n".join(msg_parts)
367
+
368
+ return (
369
+ first_question["question"],
370
+ gr.update(choices=first_question["options"], value=None),
371
+ gr.update(visible=True),
372
+ gr.update(visible=False),
373
+ status_msg,
374
+ state
375
+ )
376
 
377
 
378
+ def get_enhanced_progress_html(state) -> str:
379
+ """
380
+ Enhanced progress display with better styling and information.
381
+ """
382
+ if not state or "quiz" not in state or not state["quiz"]:
383
+ return "<div style='text-align: center; color: #666;'>No quiz loaded</div>"
384
+
385
+ total = len(state["quiz"])
386
+ current = min(state.get("index", 0), total)
387
+ score = state.get("score", 0)
388
+
389
+ # Calculate percentage
390
+ progress_pct = int((current / total) * 100) if total > 0 else 0
391
+ score_pct = int((score / total) * 100) if total > 0 else 0
392
+
393
+ # Determine progress color based on score percentage
394
+ if score_pct >= 80:
395
+ score_color = "#28a745" # green
396
+ elif score_pct >= 60:
397
+ score_color = "#ffc107" # yellow
398
+ else:
399
+ score_color = "#dc3545" # red
400
+
401
+ html = f"""
402
+ <div style='background: linear-gradient(135deg, #f8f9fa, #e9ecef);
403
+ padding: 16px; border-radius: 12px; border: 1px solid #dee2e6;'>
404
+ <div style='display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;'>
405
+ <span style='font-weight: 600; color: #495057;'>Question {current} of {total}</span>
406
+ <span style='font-weight: 600; color: {score_color};'>Score: {score}/{total}</span>
407
+ </div>
408
+
409
+ <div style='background: #e9ecef; border-radius: 10px; overflow: hidden; height: 20px; margin-bottom: 8px;'>
410
+ <div style='background: linear-gradient(90deg, #007bff, #0056b3);
411
+ height: 100%; width: {progress_pct}%;
412
+ transition: width 0.3s ease;'></div>
413
+ </div>
414
+
415
+ <div style='display: flex; justify-content: space-between; font-size: 12px; color: #6c757d;'>
416
+ <span>Progress: {progress_pct}%</span>
417
+ <span>Accuracy: {score_pct}%</span>
418
+ </div>
419
+ </div>
420
+ """
421
+
422
+ return html
423
+
424
+
425
+ # -----------------------
426
+ # Existing functions with minor enhancements
427
+ # -----------------------
428
  def start_quiz(state):
429
+ """Start the quiz with enhanced state management."""
430
  if not state or "quiz" not in state or not state["quiz"]:
431
+ return "No quiz loaded. Please load a CSV file first.", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
432
+
433
+ # Reset quiz state
434
+ state.update({
435
+ "index": 0,
436
+ "score": 0,
437
+ "history": [],
438
+ "started": True,
439
+ "start_time": datetime.now()
440
+ })
441
+
442
+ first_question = state["quiz"][0]
443
+ return (
444
+ first_question["question"],
445
+ gr.update(choices=first_question["options"], value=None),
446
+ gr.update(visible=True),
447
+ gr.update(visible=False),
448
+ state
449
+ )
450
 
451
 
452
  def submit_answer(state, selected):
453
+ """Submit answer with enhanced feedback."""
454
  if not state or "quiz" not in state:
455
+ return "No quiz loaded.", gr.update(visible=False), gr.update(visible=False), state
456
+
457
  if not state.get("started"):
458
+ return "Quiz not started. Click 'Start Quiz' first.", gr.update(visible=False), gr.update(visible=False), state
459
+
460
+ current_idx = state["index"]
461
+ current_question = state["quiz"][current_idx]
462
+ selected_answer = selected if selected is not None else ""
463
+
464
+ is_correct = (selected_answer == current_question["answer"])
465
+
466
+ # Store the result temporarily
467
+ state["last_result"] = {
468
+ "question": current_question["question"],
469
+ "selected": selected_answer,
470
+ "answer": current_question["answer"],
471
+ "correct": is_correct
472
+ }
473
+
474
+ # Enhanced feedback
475
+ if is_correct:
476
+ feedback = f"✅ <span style='color: #28a745; font-weight: 600;'>Correct!</span>"
477
+ else:
478
+ feedback = f"❌ <span style='color: #dc3545; font-weight: 600;'>Incorrect.</span> The correct answer is: <strong>{current_question['answer']}</strong>"
479
+
480
  return feedback, gr.update(visible=True), gr.update(visible=True), state
481
 
482
 
483
+ def next_question(state):
484
+ """Move to next question with enhanced state management."""
485
  if not state or "quiz" not in state:
486
+ return "No quiz loaded.", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
487
+
488
+ # Process the last result if it exists
489
+ if "last_result" in state:
490
+ last_result = state.pop("last_result")
491
+ state["history"].append(last_result)
492
+ if last_result.get("correct"):
493
  state["score"] += 1
494
+
495
+ # Move to next question
496
  state["index"] += 1
497
+
498
+ # Check if quiz is complete
499
  if state["index"] >= len(state["quiz"]):
 
500
  state["started"] = False
501
+ state["end_time"] = datetime.now()
502
+
503
+ # Calculate completion time
504
+ if "start_time" in state:
505
+ duration = state["end_time"] - state["start_time"]
506
+ duration_str = f"Completed in {duration.seconds // 60}m {duration.seconds % 60}s"
507
+ else:
508
+ duration_str = ""
509
+
510
+ completion_msg = f"""
511
+ 🎉 <strong>Quiz Completed!</strong><br>
512
+ 📊 Final Score: <strong>{state['score']} / {len(state['quiz'])} ({state['score']/len(state['quiz'])*100:.1f}%)</strong><br>
513
+ ⏱️ {duration_str}
514
+ """
515
+
516
+ return completion_msg, gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
517
+
518
+ # Show next question
519
+ next_q = state["quiz"][state["index"]]
 
520
  return (
521
+ next_q["question"],
522
+ gr.update(choices=next_q["options"], value=None),
523
+ gr.update(visible=True),
524
+ gr.update(visible=False),
525
+ state
526
  )
527
 
528
 
529
  def show_report(state, user_name: str, logo_file):
530
+ """Generate enhanced report with better formatting."""
531
  if not state or "history" not in state:
532
+ return "📝 No quiz results available. Complete a quiz first.", None
533
+
534
  history = list(state["history"])
535
+
536
+ # Include the last result if it exists but wasn't processed yet
537
+ if "last_result" in state:
538
+ history.append(state["last_result"])
539
+
540
+ if not history:
541
+ return "📝 No quiz results available. Complete a quiz first.", None
542
+
543
+ # Generate markdown report
544
+ candidate_name = user_name.strip() if user_name else "Anonymous Candidate"
545
+ total_questions = len(history)
546
+ correct_answers = sum(1 for h in history if h.get("correct", False))
547
+ percentage = (correct_answers / total_questions * 100) if total_questions > 0 else 0
548
+
549
+ # Determine performance level
550
+ if percentage >= 90:
551
+ performance = "🏆 Excellent"
552
+ elif percentage >= 80:
553
+ performance = "👍 Good"
554
+ elif percentage >= 60:
555
+ performance = "👌 Average"
556
+ else:
557
+ performance = "📚 Needs Improvement"
558
+
559
+ md_lines = [
560
+ f"# 📋 Quiz Report Card",
561
+ f"**Candidate:** {candidate_name}",
562
+ f"**Date:** {datetime.now().strftime('%d %B %Y at %H:%M:%S')}",
563
+ f"**Final Score:** **{correct_answers} / {total_questions} ({percentage:.1f}%)**",
564
+ f"**Performance Level:** {performance}",
565
+ "",
566
+ "## 📊 Detailed Results",
567
+ "",
568
+ "| # | Question | Your Answer | Correct Answer | Result |",
569
+ "|---:|---|---|---|:---:|"
570
+ ]
571
+
572
+ for i, result in enumerate(history, start=1):
573
+ status_icon = "✅" if result.get("correct") else "❌"
574
+ question_text = (result.get("question", "") or "").replace("\n", " ")[:120]
575
+ if len(question_text) > 117:
576
+ question_text += "..."
577
+
578
+ md_lines.append(
579
+ f"| {i} | {question_text} | {result.get('selected', 'No answer')} | "
580
+ f"{result.get('answer', 'Unknown')} | {status_icon} |"
581
+ )
582
+
583
+ report_md = "\n".join(md_lines)
584
+
585
+ # Generate PDF
586
+ logo_path = extract_file_path(logo_file) or (
587
+ "nielit_logo.png" if os.path.exists("nielit_logo.png") else None
588
+ )
589
+
590
  try:
591
+ pdf_path = generate_report_pdf(
592
+ candidate_name,
593
+ "CCC Plus - Computer Concepts Plus",
594
+ history,
595
+ correct_answers,
596
+ logo_path=logo_path,
597
+ include_statistics=True
598
+ )
599
+ return report_md, pdf_path
600
+
601
  except Exception as e:
602
+ logger.exception("Failed to generate PDF report")
603
+ error_report = report_md + f"\n\n**⚠️ PDF Generation Failed:** {str(e)}"
604
+ return error_report, None
 
605
 
606
 
607
  # -----------------------
608
+ # Enhanced Gradio Interface
609
  # -----------------------
610
  def build_app():
611
+ """Build the enhanced Gradio interface."""
612
  css = """
613
+ .header {
614
+ display: flex;
615
+ align-items: center;
616
+ gap: 12px;
617
+ }
618
+ .logo-small {
619
+ height: 56px;
620
+ }
621
+ .card {
622
+ border-radius: 12px;
623
+ padding: 20px;
624
+ box-shadow: 0 6px 18px rgba(0,0,0,0.08);
625
+ background: white;
626
+ border: 1px solid #e9ecef;
627
+ }
628
+ .muted {
629
+ color: #6c757d;
630
+ font-size: 14px;
631
+ }
632
+ .small {
633
+ font-size: 13px;
634
+ color: #495057;
635
+ }
636
+ .status-box {
637
+ background: #f8f9fa;
638
+ border: 1px solid #dee2e6;
639
+ border-radius: 8px;
640
+ padding: 12px;
641
+ font-family: monospace;
642
+ font-size: 13px;
643
+ }
644
  """
645
 
646
+ with gr.Blocks(
647
+ title="CCC Plus Quiz App — NIELIT Chandigarh",
648
+ css=css,
649
+ theme=gr.themes.Soft()
650
+ ) as demo:
651
+
652
+ # Header section
653
  with gr.Row():
654
+ with gr.Column(scale=1, min_width=80):
655
+ logo_img = gr.Image(
656
+ value="nielit_logo.png" if os.path.exists("nielit_logo.png") else None,
657
+ visible=True,
658
+ interactive=False,
659
+ height=80,
660
+ show_label=False
661
+ )
662
+
663
  with gr.Column(scale=6):
664
+ gr.Markdown("""
665
+ <div class='header'>
666
+ <h1 style='margin:0; color: #003366;'>🖥️ CCC Plus — Computer Concepts Plus</h1>
667
+ </div>
668
+ <div class='muted' style='margin-top: 8px;'>
669
+ Interactive quiz application for practice & assessment • Upload your CSV file or use the default quiz.csv
670
+ </div>
671
+ """)
672
 
673
  gr.Markdown("---")
674
 
675
+ # Main interface
676
  with gr.Row():
677
+ # Left panel - Controls
678
  with gr.Column(scale=3):
679
+ # Quiz loading section
680
+ with gr.Group():
681
+ gr.Markdown("### 📁 Load Quiz Questions")
682
+ csv_file_text = gr.Textbox(
683
+ value=DEFAULT_CSV_PATH,
684
+ label="Local CSV file path",
685
+ placeholder="Enter path to CSV file...",
686
+ interactive=True
687
+ )
688
+ csv_upload = gr.File(
689
+ label="Or upload CSV file",
690
+ file_count="single",
691
+ file_types=[".csv"]
692
+ )
693
+ load_btn = gr.Button("🔄 Load Quiz", variant="primary")
694
+
695
+ load_status = gr.HTML(
696
+ value="<div class='status-box'>📄 No quiz loaded. Upload a CSV file to begin.</div>",
697
+ label="Status"
698
+ )
699
+
700
+ # Candidate information
701
+ with gr.Group():
702
+ gr.Markdown("### 👤 Candidate Information")
703
+ user_name = gr.Textbox(
704
+ label="Full Name",
705
+ placeholder="Enter your full name for the certificate...",
706
+ value=""
707
+ )
708
+
709
+ # Quiz controls
710
+ with gr.Group():
711
+ gr.Markdown("### 🎯 Quiz Controls")
712
+ start_btn = gr.Button("🚀 Start Quiz", variant="primary", size="lg")
713
+ restart_btn = gr.Button("🔄 Reset All", variant="secondary")
714
+
715
+ # Question controls (hidden initially)
716
+ with gr.Group():
717
+ gr.Markdown("### ✍️ Answer Controls")
718
+ submit_btn = gr.Button("✅ Submit Answer", variant="primary", visible=False)
719
+ next_btn = gr.Button("➡️ Next Question", visible=False)
720
+ feedback_html = gr.HTML(visible=False)
721
+
722
+ # Report section
723
+ with gr.Group():
724
+ gr.Markdown("### 📊 Generate Report")
725
+ show_report_btn = gr.Button("📄 Generate Report")
726
+
727
+ # Right panel - Quiz area
728
  with gr.Column(scale=4):
729
+ # Progress and question area
730
  with gr.Group(elem_classes="card"):
731
+ progress_html = gr.HTML(
732
+ value="<div style='text-align: center; color: #666;'>No quiz loaded</div>"
733
+ )
734
+
735
+ gr.Markdown("### Current Question")
736
+ question_display = gr.Markdown(
737
+ "**Your question will appear here after loading a quiz and clicking Start**"
738
+ )
739
+
740
+ options_radio = gr.Radio(
741
+ choices=[],
742
+ label="Select your answer:",
743
+ interactive=True
744
+ )
745
+
746
+ # Logo upload
747
+ with gr.Group():
748
+ gr.Markdown("### 🎨 Branding (Optional)")
749
+ logo_upload = gr.File(
750
+ label="Upload logo for PDF report (PNG/JPG)",
751
+ file_count="single",
752
+ file_types=[".png", ".jpg", ".jpeg"]
753
+ )
754
+
755
+ # Report display area
756
+ gr.Markdown("---")
757
+ with gr.Row():
758
+ with gr.Column():
759
+ gr.Markdown("### 📋 Quiz Report")
760
+ report_preview = gr.Markdown("Complete a quiz to see your report here.")
761
+ report_download = gr.File(
762
+ label="📥 Download PDF Report",
763
+ visible=False
764
+ )
765
+
766
+ # State management
767
+ quiz_state = gr.State({})
768
+
769
+ # Event handlers
770
+ def handle_load_quiz(csv_path_text, uploaded_file):
771
+ q_text, q_choices, show_submit, show_next, status_msg, new_state = prepare_quiz(csv_path_text, uploaded_file)
772
+
773
+ # Update progress display
774
+ progress = get_enhanced_progress_html(new_state)
775
+
776
+ # Format status message for HTML display
777
+ status_html = f"<div class='status-box'>{status_msg.replace(chr(10), '<br>')}</div>"
778
+
779
+ return (
780
+ q_text, q_choices, show_submit, show_next,
781
+ status_html, new_state, progress
782
+ )
783
 
784
  load_btn.click(
785
+ fn=handle_load_quiz,
786
  inputs=[csv_file_text, csv_upload],
787
+ outputs=[
788
+ question_display, options_radio, submit_btn, next_btn,
789
+ load_status, quiz_state, progress_html
790
+ ]
791
  )
792
 
793
+ def handle_start_quiz(state):
 
794
  q_text, q_choices, show_submit, show_next, new_state = start_quiz(state)
795
+ progress = get_enhanced_progress_html(new_state)
796
+ return q_text, q_choices, show_submit, show_next, new_state, progress
797
+
798
+ start_btn.click(
799
+ fn=handle_start_quiz,
800
+ inputs=[quiz_state],
801
+ outputs=[
802
+ question_display, options_radio, submit_btn, next_btn,
803
+ quiz_state, progress_html
804
+ ]
805
+ )
806
 
807
+ def handle_submit_answer(state, selected):
 
808
  feedback_text, show_feedback, show_next_btn, new_state = submit_answer(state, selected)
809
+ progress = get_enhanced_progress_html(new_state)
810
+ return feedback_text, show_feedback, show_next_btn, new_state, progress
 
 
811
 
812
  submit_btn.click(
813
+ fn=handle_submit_answer,
814
+ inputs=[quiz_state, options_radio],
815
+ outputs=[feedback_html, feedback_html, next_btn, quiz_state, progress_html]
816
  )
817
 
818
+ def handle_next_question(state):
819
+ q_text, q_choices, show_submit, show_next, new_state = next_question(state)
820
+ progress = get_enhanced_progress_html(new_state)
821
+
822
+ # Auto-generate report if quiz is complete
823
+ report_md = "Complete a quiz to see your report here."
824
+ pdf_file = gr.File.update(visible=False)
825
+
826
  if new_state and new_state.get("index", 0) >= len(new_state.get("quiz", [])):
827
+ try:
828
+ report_md, pdf_path = show_report(new_state, user_name.value or "Anonymous", None)
829
+ if pdf_path:
830
+ pdf_file = gr.File.update(value=pdf_path, visible=True)
831
+ except Exception as e:
832
+ logger.exception("Error generating auto-report")
833
+
834
+ return (
835
+ q_text, q_choices, show_submit, show_next, new_state,
836
+ progress, report_md, pdf_file
837
+ )
838
 
839
  next_btn.click(
840
+ fn=handle_next_question,
841
+ inputs=[quiz_state],
842
+ outputs=[
843
+ question_display, options_radio, submit_btn, next_btn,
844
+ quiz_state, progress_html, report_preview, report_download
845
+ ]
846
  )
847
 
848
+ def handle_generate_report(state, name, logo_file):
849
+ report_md, pdf_path = show_report(state, name, logo_file)
850
+ pdf_file = gr.File.update(value=pdf_path, visible=True) if pdf_path else gr.File.update(visible=False)
851
+ return report_md, pdf_file
 
852
 
853
  show_report_btn.click(
854
+ fn=handle_generate_report,
855
+ inputs=[quiz_state, user_name, logo_upload],
856
+ outputs=[report_preview, report_download]
857
  )
858
 
859
+ def handle_reset():
 
 
 
860
  return (
861
+ "**Your question will appear here after loading a quiz and clicking Start**",
862
  gr.update(choices=[]),
863
  gr.update(visible=False),
864
  gr.update(visible=False),
865
+ gr.update(visible=False),
866
+ "<div class='status-box'>📄 Reset complete. Load a CSV file to begin.</div>",
867
  {},
868
+ "<div style='text-align: center; color: #666;'>No quiz loaded</div>",
869
+ "Complete a quiz to see your report here.",
870
+ gr.File.update(visible=False)
 
871
  )
872
 
873
  restart_btn.click(
874
+ fn=handle_reset,
875
  inputs=[],
876
+ outputs=[
877
+ question_display, options_radio, submit_btn, next_btn, feedback_html,
878
+ load_status, quiz_state, progress_html, report_preview, report_download
879
+ ]
880
  )
881
 
882
+ # Auto-update progress when state changes
883
+ def update_progress_display(state):
884
+ return get_enhanced_progress_html(state)
 
 
885
 
886
+ quiz_state.change(
887
+ fn=update_progress_display,
888
+ inputs=[quiz_state],
889
+ outputs=[progress_html]
890
+ )
891
 
892
+ # Footer
893
+ gr.Markdown(f"""
894
+ ---
895
+ <div style='text-align: center; color: #6c757d; font-size: 12px; margin-top: 20px;'>
896
+ <strong>NIELIT Chandigarh</strong> — National Institute of Electronics & Information Technology<br>
897
+ © {datetime.now().year} • Enhanced Quiz Application • Version 2.0
898
+ </div>
899
+ """)
900
 
901
  return demo
902
 
903
 
904
  # -----------------------
905
+ # CLI & Application Entry Point
906
  # -----------------------
907
  def main():
908
+ """Main entry point with enhanced CLI options."""
909
+ parser = argparse.ArgumentParser(
910
+ description="CCC Plus Quiz App Enhanced Gradio Application",
911
+ formatter_class=argparse.RawDescriptionHelpFormatter,
912
+ epilog="""
913
+ Examples:
914
+ %(prog)s --host 0.0.0.0 --port 8080 # Run on all interfaces, port 8080
915
+ %(prog)s --share # Create public share link
916
+ %(prog)s --debug # Enable debug logging
917
+ """
918
+ )
919
+
920
+ parser.add_argument(
921
+ "--host",
922
+ default="127.0.0.1",
923
+ help="Host to bind to (default: 127.0.0.1, use 0.0.0.0 for all interfaces)"
924
+ )
925
+ parser.add_argument(
926
+ "--port",
927
+ type=int,
928
+ default=7860,
929
+ help="Port to serve on (default: 7860)"
930
+ )
931
+ parser.add_argument(
932
+ "--share",
933
+ action="store_true",
934
+ help="Create a public share link via Gradio"
935
+ )
936
+ parser.add_argument(
937
+ "--debug",
938
+ action="store_true",
939
+ help="Enable debug logging"
940
+ )
941
+
942
  args = parser.parse_args()
943
+
944
+ # Configure logging level
945
+ if args.debug:
946
+ logging.getLogger().setLevel(logging.DEBUG)
947
+ logger.debug("Debug logging enabled")
948
+
949
+ # Build and launch application
950
  demo = build_app()
951
+
952
+ logger.info(f"Starting CCC Plus Quiz App on {args.host}:{args.port}")
953
+ if args.share:
954
+ logger.info("Public share link will be created")
955
+
956
+ try:
957
+ demo.launch(
958
+ server_name=args.host,
959
+ server_port=args.port,
960
+ share=args.share,
961
+ show_error=True,
962
+ quiet=False
963
+ )
964
+ except KeyboardInterrupt:
965
+ logger.info("Application stopped by user")
966
+ except Exception as e:
967
+ logger.exception(f"Failed to start application: {e}")
968
+ return 1
969
+
970
+ return 0
971
 
972
 
973
  if __name__ == "__main__":
974
+ # For development/testing - always create public share
975
  demo = build_app()
976
+ demo.launch(share=True, show_error=True)
977
+
978
+ # For production use:
979
+ # exit(main())