LovnishVerma commited on
Commit
39c78e1
·
verified ·
1 Parent(s): 57efc7f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +431 -304
app.py CHANGED
@@ -1,413 +1,540 @@
 
1
  """
2
- CCC Plus Quiz App — Gradio
3
- Features:
4
- - Load quiz from CSV (upload or default `quiz.csv`)
5
- - Interactive Q/A with immediate feedback
6
- - Progress bar, score tracking
7
- - Final report card with per-question breakdown
8
- - Generate & download an "official-looking" PDF report (reportlab)
9
- - Display NIELIT logo (if provided) and NIELIT Chandigarh copyright footer
 
 
 
10
  """
11
 
12
- import gradio as gr
13
- import pandas as pd
14
  import os
 
15
  from datetime import datetime
16
- from reportlab.lib.pagesizes import A4
 
 
 
 
17
  from reportlab.lib import colors
 
18
  from reportlab.lib.units import mm
19
  from reportlab.pdfgen import canvas
20
- from reportlab.platypus import Table, TableStyle, Paragraph, Image, Spacer
21
- from reportlab.lib.styles import getSampleStyleSheet
22
-
23
- # ---------------------------
24
- # Helper: Load quiz from CSV
25
- # ---------------------------
26
- def load_quiz_from_path(path):
27
- df = pd.read_csv(path)
28
- # Basic validation and normalization
29
- required = {"question", "option1", "option2", "option3", "option4", "answer"}
30
- if not required.issubset(set(df.columns)):
31
- raise ValueError(f"CSV must contain columns: {required}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  quiz = []
33
  for _, r in df.iterrows():
34
- quiz.append({
35
- "question": str(r["question"]),
36
- "options": [str(r["option1"]), str(r["option2"]), str(r["option3"]), str(r["option4"])],
37
- "answer": str(r["answer"])
38
- })
 
 
 
 
39
  return quiz
40
 
41
- # ---------------------------
42
- # PDF Generation
43
- # ---------------------------
44
- def generate_report_pdf(user_name, quiz_title, history, score, logo_path=None):
 
45
  """
46
- history: list of dicts: [{"question":..., "selected":..., "answer":..., "correct": True/False}, ...]
 
47
  """
48
  ts = datetime.now().strftime("%Y%m%d_%H%M%S")
49
- filename = f"/tmp/CCCPlus_Report_{ts}.pdf"
 
 
 
50
  c = canvas.Canvas(filename, pagesize=A4)
51
  width, height = A4
52
- margin = 25 * mm
53
- styles = getSampleStyleSheet()
54
- normal = styles["Normal"]
55
- title_style = styles["Title"]
56
- small = styles["BodyText"]
57
- small.fontSize = 9
58
-
59
  y = height - margin
60
 
61
- # If logo exists, draw it at top-left
62
  if logo_path and os.path.exists(logo_path):
63
  try:
64
- img_w = 35*mm
65
  img = Image(logo_path, img_w, img_w)
66
  img.drawOn(c, margin, y - img_w)
67
- # Title at top center-right
68
- x_title = margin + img_w + 10
69
  except Exception:
 
70
  x_title = margin
71
  else:
72
  x_title = margin
73
 
74
- # Header text
75
  c.setFont("Helvetica-Bold", 18)
76
- c.drawString(x_title, y - 15, quiz_title)
77
- c.setFont("Helvetica", 11)
78
- c.drawString(x_title, y - 35, f"Candidate: {user_name}")
79
- c.drawString(x_title, y - 50, f"Date: {datetime.now().strftime('%d %b %Y %H:%M:%S')}")
80
- y -= 70
81
 
82
- # Score big
83
  c.setFont("Helvetica-Bold", 14)
84
  c.drawString(margin, y, f"Final Score: {score} / {len(history)}")
85
- y -= 25
86
 
87
- # Build table data
88
  table_data = [["#", "Question", "Your Answer", "Correct Answer", "Result"]]
89
  for i, h in enumerate(history, start=1):
90
- result = "Correct" if h["correct"] else "Incorrect"
91
- table_data.append([str(i), h["question"], h["selected"], h["answer"], result])
92
-
93
- # Make a table with ReportLab
94
- table = Table(table_data, colWidths=[15*mm, 85*mm, 40*mm, 40*mm, 25*mm])
95
- tblstyle = TableStyle([
96
- ("GRID", (0,0), (-1,-1), 0.25, colors.grey),
97
- ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#003366")),
98
- ("TEXTCOLOR", (0,0), (-1,0), colors.white),
99
- ("ALIGN", (0,0), (-1,0), "CENTER"),
100
- ("VALIGN", (0,0), (-1,-1), "TOP"),
101
- ("FONTSIZE", (0,0), (-1,-1), 9),
102
- ])
103
- table.setStyle(tblstyle)
104
-
105
- # Calculate table height place and wrap to next page if needed
106
- w, h_table = table.wrapOn(c, width - 2*margin, y - margin)
107
- if y - h_table < margin + 60:
108
- # not enough space, start new page for table
 
 
 
 
109
  c.showPage()
110
  y = height - margin
111
- if logo_path and os.path.exists(logo_path):
112
- try:
113
- img_w = 25*mm
114
- img = Image(logo_path, img_w, img_w)
115
- img.drawOn(c, margin, y - img_w)
116
- except Exception:
117
- pass
118
- c.setFont("Helvetica-Bold", 14)
119
- c.drawString(margin, y, quiz_title)
120
- y -= 40
121
-
122
  table.drawOn(c, margin, y - h_table)
123
- y = y - h_table - 40
124
 
125
- # Footer copyright
 
126
  footer_text = f"NIELIT Chandigarh — © {datetime.now().year}"
127
- c.setFont("Helvetica", 9)
128
- c.drawCentredString(width / 2, 15 * mm, footer_text)
129
 
130
  c.save()
 
131
  return filename
132
 
133
- # ---------------------------
134
- # App logic functions
135
- # ---------------------------
136
- def prepare_quiz(csv_file, csv_upload):
 
137
  """
138
- Called when user uploads CSV or hits 'Load CSV'.
139
- Returns initial UI state values.
 
 
 
 
 
140
  """
141
  # prefer uploaded file if present
142
- path = None
143
- if csv_upload is not None:
144
- path = csv_upload.name
145
- else:
146
- path = csv_file if os.path.exists(csv_file) else None
147
-
148
- if path is None:
149
- return gr.update(value=""), gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), "No questions loaded. Upload a CSV or place quiz.csv in the app folder."
150
 
151
  try:
152
  quiz = load_quiz_from_path(path)
153
  except Exception as e:
154
- return gr.update(value=""), gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), f"Error loading CSV: {e}"
 
155
 
156
- # initialize state store for quiz
157
- store = {
158
- "quiz": quiz,
159
- "index": 0,
160
- "score": 0,
161
- "history": [], # will contain dicts {"question","selected","answer","correct"}
162
- "started": True
163
- }
164
 
 
165
  first = quiz[0]
166
- msg = f"Loaded {len(quiz)} questions. Ready to start!"
167
- # Return question text, options choices, show controls, and message; store must be assigned to gr.State externally
168
- return first["question"], gr.update(choices=first["options"], value=None), gr.update(visible=True), gr.update(visible=True), msg, store
169
 
170
  def start_quiz(state):
171
- # state passed in from gr.State (may be None if not loaded)
172
- if not state or "quiz" not in state:
173
- return "No quiz loaded. Upload a CSV.", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
174
  state["index"] = 0
175
  state["score"] = 0
176
  state["history"] = []
 
177
  first = state["quiz"][0]
178
- return first["question"], gr.update(choices=first["options"], value=None), gr.update(visible=True), gr.update(visible=True), state
 
 
179
 
180
  def submit_answer(state, selected):
181
- """
182
- When user submits an answer, show feedback but do NOT move to next question until user clicks Next.
183
- We'll return feedback text and set a temporary "last" in state to hold selected answer and correctness.
184
- """
185
  if not state or "quiz" not in state:
186
  return "No quiz loaded.", gr.update(visible=False), gr.update(visible=False), state
 
 
187
 
188
  idx = state["index"]
189
  q = state["quiz"][idx]
190
  selected_val = selected if selected is not None else ""
191
  correct = (selected_val == q["answer"])
192
- # store last attempt but do not increment index yet
193
- state["last"] = {"question": q["question"], "selected": selected_val, "answer": q["answer"], "correct": correct}
194
-
195
  feedback = "✅ Correct!" if correct else f"❌ Incorrect. Correct answer: {q['answer']}"
196
- # Show feedback area and enable Next button
197
  return feedback, gr.update(visible=True), gr.update(visible=True), state
198
 
 
199
  def next_q(state):
200
- """
201
- Advance to next question using the 'last' stored in state from submit_answer.
202
- """
203
  if not state or "quiz" not in state:
204
  return "No quiz loaded.", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
205
-
206
- # If user didn't submit before pressing next, just ignore
207
  last = state.get("last")
208
  if last:
209
  state["history"].append(last)
210
- if last["correct"]:
211
  state["score"] += 1
212
-
213
- # move index
214
  state["index"] += 1
215
  state.pop("last", None)
216
 
217
  if state["index"] >= len(state["quiz"]):
218
- # Quiz complete
219
  finished_msg = f"Quiz Completed! Final Score: {state['score']} / {len(state['quiz'])}"
220
- # hide question/option controls
 
221
  return finished_msg, gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
222
 
223
- # next question
224
  q = state["quiz"][state["index"]]
225
- progress = f"Question {state['index'] + 1} of {len(state['quiz'])} — Score: {state['score']}"
226
- return q["question"], gr.update(choices=q["options"], value=None), gr.update(visible=True), gr.update(visible=True), state
227
 
228
- def get_progress_text(state):
229
- if not state or "quiz" not in state:
230
- return "No quiz loaded."
231
- return f"Question {state['index'] + 1} of {len(state['quiz'])} — Score: {state['score']}"
232
 
233
- def show_report(state, user_name, logo_file):
234
  """
235
- Prepare a report summary and generate a PDF for download.
236
- Returns: report_markdown, pdf_file_component_value
237
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  if not state or "history" not in state:
239
  return "No results to show.", None
240
-
241
- history = state["history"].copy()
242
- # If last submitted but user hasn't pressed Next, include it too
243
  if "last" in state:
244
  history.append(state["last"])
245
-
246
- # Prepare a markdown summary (display in UI)
247
- lines = []
248
- lines.append(f"## Report Card — {user_name if user_name else 'Candidate'}")
249
- lines.append(f"**Date:** {datetime.now().strftime('%d %b %Y %H:%M:%S')}")
250
- lines.append(f"**Score:** **{state['score']} / {len(state['quiz'])}**")
251
- lines.append("")
252
- lines.append("| # | Question | Your Answer | Correct Answer | Result |")
253
- lines.append("|---:|---|---|---|---|")
254
  for i, h in enumerate(history, start=1):
255
  res = "✅" if h["correct"] else "❌"
256
- qtext = h["question"].replace("\n", " ")
257
- lines.append(f"| {i} | {qtext[:80]} | {h['selected']} | {h['answer']} | {res} |")
 
258
 
259
- md = "\n".join(lines)
 
260
 
261
- # Generate PDF
262
- # If user uploaded a logo, use that; else try default path nielit_logo.png if present
263
- logo_path = None
264
- if logo_file is not None:
265
- logo_path = logo_file.name
266
- elif os.path.exists("nielit_logo.png"):
267
- logo_path = "nielit_logo.png"
268
 
269
- pdf_path = generate_report_pdf(user_name or "Candidate", "CCC Plus - Computer Concepts Plus", history, state["score"], logo_path=logo_path)
270
  return md, pdf_path
271
 
272
- # ---------------------------
273
- # Build Gradio UI
274
- # ---------------------------
275
- with gr.Blocks(title="CCC Plus Quiz App — NIELIT Style", css="""
 
 
276
  .header {display:flex; align-items:center; gap:12px;}
277
  .logo-small {height:56px;}
278
  .card {border-radius:12px; padding:14px; box-shadow: 0 6px 18px rgba(0,0,0,0.06);}
279
  .muted {color: #666; font-size: 13px;}
280
- """) as demo:
281
-
282
- # top row: logo (if available) + title
283
- with gr.Row():
284
- with gr.Column(scale=1):
285
- logo_img = gr.Image(value="nielit_logo.png" if os.path.exists("nielit_logo.png") else None, visible=True, elem_id="logo-img")
286
- with gr.Column(scale=6):
287
- gr.Markdown("<div class='header'><h2 style='margin:0'>🖥️ CCC Plus — Computer Concepts Plus</h2></div>")
288
- gr.Markdown("<div class='muted'>Interactive quiz for practice & assessment. Upload CSV or use default quiz.csv.</div>")
289
-
290
- gr.Markdown("---")
291
-
292
- with gr.Row():
293
- # Left column: controls & quiz
294
- with gr.Column(scale=3):
295
- gr.Markdown("### Load Questions")
296
- csv_file_text = gr.Textbox(value="quiz.csv", label="Local CSV path (default)", interactive=True)
297
- csv_upload = gr.File(label="Or upload CSV file", file_count="single", file_types=[".csv"])
298
- load_btn = gr.Button("Load CSV")
299
- load_msg = gr.Textbox(label="Status", interactive=False, lines=2)
300
-
301
- gr.Markdown("### Candidate Info")
302
- user_name = gr.Textbox(label="Candidate Name (to appear on report)", placeholder="Your name here")
303
-
304
- # Start & control buttons
305
- start_btn = gr.Button("Start / Restart Quiz", elem_id="start-btn")
306
- restart_btn = gr.Button("Reset All", elem_id="reset-btn", visible=True)
307
-
308
- gr.Markdown("### Controls")
309
- submit_btn = gr.Button("Submit Answer", variant="primary", visible=True)
310
- next_btn = gr.Button("Next Question ➡️", visible=False)
311
- feedback = gr.Textbox(label="Feedback", interactive=False, visible=False)
312
-
313
- # Hidden area for downloading pdf
314
- report_download = gr.File(label="Download PDF Report", visible=False)
315
-
316
- # Right column: question card and progress
317
- with gr.Column(scale=4):
318
- with gr.Card():
319
- question_txt = gr.Markdown("**Question will appear here after you start the quiz**")
320
- options = gr.Radio(choices=[], label="Choose one option", interactive=True)
321
- progress = gr.Textbox(label="Progress", interactive=False)
322
- score_display = gr.Textbox(label="Score", interactive=False)
323
-
324
- # Report preview area
325
- gr.Markdown("### Report Preview")
326
- report_preview = gr.Markdown("No report yet.")
327
-
328
- # State store
329
- state_store = gr.State({})
330
-
331
- # --- Event wiring ---
332
-
333
- # Load CSV button -> loads quiz and returns initial UI values and store
334
- def on_load(csv_path_text, uploaded_file):
335
- # uploaded_file is a temporary file with .name attr pointing to uploaded file path
336
- q_text, q_choices, show_submit, show_next, msg, store = prepare_quiz(csv_path_text, uploaded_file)
337
- # We must return many outputs; order must match click outputs
338
- return q_text, q_choices, show_submit, show_next, msg, store
339
-
340
- load_btn.click(fn=on_load,
341
- inputs=[csv_file_text, csv_upload],
342
- outputs=[question_txt, options, submit_btn, next_btn, load_msg, state_store])
343
-
344
- # Start quiz (when user clicks Start/Restart) -> reset state and show first question
345
- def on_start(state):
346
- if not state or "quiz" not in state:
347
- return "No quiz loaded. Load CSV first.", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), state
348
- q_text, q_choices, show_submit, show_next, new_state = start_quiz(state)
349
- # update progress & score
350
- return q_text, q_choices, show_submit, show_next, new_state
351
-
352
- start_btn.click(fn=on_start, inputs=[state_store], outputs=[question_txt, options, submit_btn, next_btn, state_store])
353
-
354
- # Submit answer -> produce feedback, show Next button
355
- def on_submit(state, selected):
356
- feedback_text, show_feedback, show_next_btn, new_state = submit_answer(state, selected)
357
- return feedback_text, show_feedback, show_next_btn, new_state
358
-
359
- submit_btn.click(fn=on_submit, inputs=[state_store, options], outputs=[feedback, feedback, next_btn, state_store])
360
-
361
- # Next question -> advance
362
- def on_next(state):
363
- q_text, q_choices, show_submit, show_next, new_state = next_q(state)
364
- # update progress and score displays
365
- progress_text = get_progress_text(new_state)
366
- score_text = f"{new_state['score']} / {len(new_state['quiz'])}"
367
- # if quiz finished (no controls visible), also prepare report preview
368
- report_md = ""
369
- pdf_path = None
370
- if new_state["index"] >= len(new_state["quiz"]):
371
- # prepare a small report preview
372
- report_md, pdf_path = show_report(new_state, "", None)
373
- return q_text, q_choices, show_submit, show_next, new_state, progress_text, score_text, report_md, pdf_path
374
-
375
- # Wire Next: multiple outputs
376
- next_btn.click(fn=on_next,
377
- inputs=[state_store],
378
- outputs=[question_txt, options, submit_btn, next_btn, state_store, progress, score_display, report_preview, report_download])
379
-
380
- # Show report & enable PDF generation and download button explicitly (user can press after completion)
381
- def on_show_report(state, name, logo_file):
382
- md, pdf_path = show_report(state, name, logo_file)
383
- rfile = gr.File.update(value=pdf_path) if pdf_path else gr.File.update(visible=False)
384
- # show file component and markdown preview
385
- return md, rfile
386
-
387
- # Button to explicitly create & show report (user may want to add name/logo)
388
- show_report_btn = gr.Button("Generate / Refresh Report")
389
- show_report_btn.click(fn=on_show_report, inputs=[state_store, user_name, logo_img], outputs=[report_preview, report_download])
390
-
391
- # Reset: clear state & UI
392
- def on_reset():
393
- state = {}
394
- return "", gr.update(choices=[]), gr.update(visible=False), gr.update(visible=False), "", state, "", "", "", None
395
-
396
- restart_btn.click(fn=on_reset, inputs=[], outputs=[question_txt, options, submit_btn, next_btn, load_msg, state_store, progress, score_display, report_preview, report_download])
397
-
398
- # Update progress/score when state changes (optional)
399
- def update_progress(state):
400
- if not state or "quiz" not in state:
401
- return "No quiz loaded.", "0 / 0"
402
- return get_progress_text(state), f"{state['score']} / {len(state['quiz'])}"
403
-
404
- # Connect state change to progress box (so user sees live progress)
405
- state_store.change(fn=update_progress, inputs=[state_store], outputs=[progress, score_display])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- # Footer
408
- gr.Markdown("---")
409
- gr.Markdown("NIELIT Chandigarh — © " + str(datetime.now().year))
410
 
411
- # Run the app
412
  if __name__ == "__main__":
413
- demo.launch()
 
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
26
  from reportlab.lib import colors
27
+ from reportlab.lib.pagesizes import A4
28
  from reportlab.lib.units import mm
29
  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,
37
+ format="%(asctime)s [%(levelname)s] %(message)s",
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"):
62
+ p = uploaded.get(key)
63
+ if isinstance(p, str) and os.path.exists(p):
64
+ return p
65
+ # sometimes 'name' is only filename but 'tempfile' is full path
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)
75
+ if os.path.exists(s):
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
+ main()