dbxdrgsl commited on
Commit
8c8834f
·
verified ·
1 Parent(s): cea52ed

Add gradebook app with full CRUD, batch grading, CSV import/export, and statistics

Browse files
Files changed (1) hide show
  1. app.py +636 -0
app.py ADDED
@@ -0,0 +1,636 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import pandas as pd
3
+ import json
4
+ import os
5
+ import csv
6
+ import io
7
+ from datetime import datetime
8
+
9
+ # --- Persistent Storage ---
10
+ DATA_FILE = "gradebook_data.json"
11
+
12
+ def load_data():
13
+ """Load gradebook data from JSON file."""
14
+ if os.path.exists(DATA_FILE):
15
+ try:
16
+ with open(DATA_FILE, "r") as f:
17
+ return json.load(f)
18
+ except (json.JSONDecodeError, IOError):
19
+ pass
20
+ return {"pupils": [], "assignments": [], "grades": {}}
21
+
22
+ def save_data(data):
23
+ """Save gradebook data to JSON file."""
24
+ with open(DATA_FILE, "w") as f:
25
+ json.dump(data, f, indent=2)
26
+
27
+ # --- Helper Functions ---
28
+
29
+ def get_grade_table():
30
+ """Build the main grades dataframe."""
31
+ data = load_data()
32
+ pupils = data["pupils"]
33
+ assignments = data["assignments"]
34
+ grades = data["grades"]
35
+
36
+ if not pupils:
37
+ return pd.DataFrame({"Info": ["No pupils added yet. Go to 'Manage Pupils' to add some."]})
38
+
39
+ if not assignments:
40
+ return pd.DataFrame({"Pupil": pupils, "Average": ["N/A"] * len(pupils)})
41
+
42
+ rows = []
43
+ for pupil in pupils:
44
+ row = {"Pupil": pupil}
45
+ total = 0
46
+ count = 0
47
+ for assignment in assignments:
48
+ key = f"{pupil}|||{assignment}"
49
+ grade = grades.get(key, "")
50
+ row[assignment] = grade
51
+ if grade != "":
52
+ try:
53
+ total += float(grade)
54
+ count += 1
55
+ except (ValueError, TypeError):
56
+ pass
57
+ row["Average"] = f"{total / count:.1f}" if count > 0 else "N/A"
58
+ rows.append(row)
59
+
60
+ df = pd.DataFrame(rows)
61
+ # Reorder columns: Pupil first, then assignments, then Average
62
+ cols = ["Pupil"] + assignments + ["Average"]
63
+ df = df[cols]
64
+ return df
65
+
66
+ def get_styled_table():
67
+ """Return a styled grade table with color-coded averages."""
68
+ df = get_grade_table()
69
+ if "Average" not in df.columns or "Pupil" not in df.columns:
70
+ return df
71
+
72
+ def color_grades(val):
73
+ try:
74
+ v = float(val)
75
+ if v >= 90:
76
+ return "background-color: #c6efce; color: #006100"
77
+ elif v >= 80:
78
+ return "background-color: #d4edbc; color: #2e6b09"
79
+ elif v >= 70:
80
+ return "background-color: #ffeb9c; color: #9c6500"
81
+ elif v >= 60:
82
+ return "background-color: #ffc7ce; color: #9c0006"
83
+ else:
84
+ return "background-color: #ff9999; color: #800000"
85
+ except (ValueError, TypeError):
86
+ return ""
87
+
88
+ # Apply styling to grade columns (not Pupil)
89
+ grade_cols = [c for c in df.columns if c != "Pupil"]
90
+ styler = df.style.map(color_grades, subset=grade_cols)
91
+ styler = styler.format(precision=1, na_rep="")
92
+ return styler
93
+
94
+ def get_summary_stats():
95
+ """Generate summary statistics."""
96
+ data = load_data()
97
+ pupils = data["pupils"]
98
+ assignments = data["assignments"]
99
+ grades = data["grades"]
100
+
101
+ if not pupils or not assignments:
102
+ return "No data available yet. Add pupils and assignments first."
103
+
104
+ lines = []
105
+ lines.append(f"📊 **Gradebook Summary**")
106
+ lines.append(f"- **Total Pupils:** {len(pupils)}")
107
+ lines.append(f"- **Total Assignments:** {len(assignments)}")
108
+ lines.append("")
109
+
110
+ # Per-assignment stats
111
+ lines.append("### 📝 Assignment Statistics")
112
+ lines.append("| Assignment | Mean | Min | Max | Graded |")
113
+ lines.append("|---|---|---|---|---|")
114
+
115
+ for assignment in assignments:
116
+ scores = []
117
+ for pupil in pupils:
118
+ key = f"{pupil}|||{assignment}"
119
+ grade = grades.get(key, "")
120
+ if grade != "":
121
+ try:
122
+ scores.append(float(grade))
123
+ except (ValueError, TypeError):
124
+ pass
125
+ if scores:
126
+ mean = sum(scores) / len(scores)
127
+ lines.append(f"| {assignment} | {mean:.1f} | {min(scores):.1f} | {max(scores):.1f} | {len(scores)}/{len(pupils)} |")
128
+ else:
129
+ lines.append(f"| {assignment} | — | — | — | 0/{len(pupils)} |")
130
+
131
+ lines.append("")
132
+
133
+ # Per-pupil averages
134
+ lines.append("### 🎓 Pupil Averages (ranked)")
135
+ pupil_avgs = []
136
+ for pupil in pupils:
137
+ scores = []
138
+ for assignment in assignments:
139
+ key = f"{pupil}|||{assignment}"
140
+ grade = grades.get(key, "")
141
+ if grade != "":
142
+ try:
143
+ scores.append(float(grade))
144
+ except (ValueError, TypeError):
145
+ pass
146
+ avg = sum(scores) / len(scores) if scores else None
147
+ pupil_avgs.append((pupil, avg, len(scores)))
148
+
149
+ pupil_avgs.sort(key=lambda x: x[1] if x[1] is not None else -1, reverse=True)
150
+
151
+ lines.append("| Rank | Pupil | Average | Assignments Graded |")
152
+ lines.append("|---|---|---|---|")
153
+ for i, (pupil, avg, graded) in enumerate(pupil_avgs, 1):
154
+ avg_str = f"{avg:.1f}" if avg is not None else "N/A"
155
+ medal = "🥇" if i == 1 else "🥈" if i == 2 else "🥉" if i == 3 else f"{i}."
156
+ lines.append(f"| {medal} | {pupil} | {avg_str} | {graded}/{len(assignments)} |")
157
+
158
+ # Class average
159
+ all_scores = []
160
+ for pupil in pupils:
161
+ for assignment in assignments:
162
+ key = f"{pupil}|||{assignment}"
163
+ grade = grades.get(key, "")
164
+ if grade != "":
165
+ try:
166
+ all_scores.append(float(grade))
167
+ except (ValueError, TypeError):
168
+ pass
169
+
170
+ if all_scores:
171
+ lines.append("")
172
+ lines.append(f"### 📈 Class Overview")
173
+ lines.append(f"- **Class Average:** {sum(all_scores)/len(all_scores):.1f}")
174
+ lines.append(f"- **Highest Grade:** {max(all_scores):.1f}")
175
+ lines.append(f"- **Lowest Grade:** {min(all_scores):.1f}")
176
+ lines.append(f"- **Total Grades Entered:** {len(all_scores)} / {len(pupils) * len(assignments)}")
177
+
178
+ return "\n".join(lines)
179
+
180
+ # --- Event Handlers ---
181
+
182
+ def add_pupil(name):
183
+ """Add a new pupil."""
184
+ name = name.strip()
185
+ if not name:
186
+ return "⚠️ Please enter a name.", get_pupils_list(), get_styled_table(), gr.update(), get_summary_stats()
187
+
188
+ data = load_data()
189
+ if name in data["pupils"]:
190
+ return f"⚠️ '{name}' already exists.", get_pupils_list(), get_styled_table(), gr.update(), get_summary_stats()
191
+
192
+ data["pupils"].append(name)
193
+ data["pupils"].sort()
194
+ save_data(data)
195
+
196
+ pupil_choices = data["pupils"]
197
+ return f"✅ Added '{name}'.", get_pupils_list(), get_styled_table(), gr.update(choices=pupil_choices), get_summary_stats()
198
+
199
+ def remove_pupil(name):
200
+ """Remove a pupil and their grades."""
201
+ name = name.strip()
202
+ data = load_data()
203
+
204
+ if name not in data["pupils"]:
205
+ return f"⚠️ '{name}' not found.", get_pupils_list(), get_styled_table(), gr.update(), get_summary_stats()
206
+
207
+ data["pupils"].remove(name)
208
+ # Remove associated grades
209
+ keys_to_remove = [k for k in data["grades"] if k.startswith(f"{name}|||")]
210
+ for k in keys_to_remove:
211
+ del data["grades"][k]
212
+ save_data(data)
213
+
214
+ pupil_choices = data["pupils"]
215
+ return f"✅ Removed '{name}'.", get_pupils_list(), get_styled_table(), gr.update(choices=pupil_choices), get_summary_stats()
216
+
217
+ def get_pupils_list():
218
+ """Get formatted list of pupils."""
219
+ data = load_data()
220
+ if not data["pupils"]:
221
+ return "No pupils added yet."
222
+ return "\n".join([f"• {p}" for p in data["pupils"]])
223
+
224
+ def add_assignment(name):
225
+ """Add a new assignment."""
226
+ name = name.strip()
227
+ if not name:
228
+ return "⚠️ Please enter an assignment name.", get_assignments_list(), get_styled_table(), gr.update(), gr.update(), get_summary_stats()
229
+
230
+ data = load_data()
231
+ if name in data["assignments"]:
232
+ return f"⚠️ '{name}' already exists.", get_assignments_list(), get_styled_table(), gr.update(), gr.update(), get_summary_stats()
233
+
234
+ data["assignments"].append(name)
235
+ save_data(data)
236
+
237
+ assignment_choices = data["assignments"]
238
+ return f"✅ Added assignment '{name}'.", get_assignments_list(), get_styled_table(), gr.update(choices=assignment_choices), gr.update(choices=assignment_choices), get_summary_stats()
239
+
240
+ def remove_assignment(name):
241
+ """Remove an assignment and its grades."""
242
+ name = name.strip()
243
+ data = load_data()
244
+
245
+ if name not in data["assignments"]:
246
+ return f"⚠️ '{name}' not found.", get_assignments_list(), get_styled_table(), gr.update(), gr.update(), get_summary_stats()
247
+
248
+ data["assignments"].remove(name)
249
+ # Remove associated grades
250
+ keys_to_remove = [k for k in data["grades"] if k.endswith(f"|||{name}")]
251
+ for k in keys_to_remove:
252
+ del data["grades"][k]
253
+ save_data(data)
254
+
255
+ assignment_choices = data["assignments"]
256
+ return f"✅ Removed assignment '{name}'.", get_assignments_list(), get_styled_table(), gr.update(choices=assignment_choices), gr.update(choices=assignment_choices), get_summary_stats()
257
+
258
+ def get_assignments_list():
259
+ """Get formatted list of assignments."""
260
+ data = load_data()
261
+ if not data["assignments"]:
262
+ return "No assignments added yet."
263
+ return "\n".join([f"• {a}" for a in data["assignments"]])
264
+
265
+ def enter_grade(pupil, assignment, grade):
266
+ """Enter or update a grade for a pupil/assignment."""
267
+ data = load_data()
268
+
269
+ if not pupil or not assignment:
270
+ return "⚠️ Please select both a pupil and an assignment.", get_styled_table(), get_summary_stats()
271
+
272
+ grade = grade.strip()
273
+ if grade == "":
274
+ # Allow clearing a grade
275
+ key = f"{pupil}|||{assignment}"
276
+ if key in data["grades"]:
277
+ del data["grades"][key]
278
+ save_data(data)
279
+ return f"✅ Cleared grade for {pupil} on '{assignment}'.", get_styled_table(), get_summary_stats()
280
+ return "ℹ️ No grade to clear.", get_styled_table(), get_summary_stats()
281
+
282
+ try:
283
+ grade_val = float(grade)
284
+ if grade_val < 0 or grade_val > 100:
285
+ return "⚠️ Grade must be between 0 and 100.", get_styled_table(), get_summary_stats()
286
+ except ValueError:
287
+ return "⚠️ Please enter a valid number (0-100).", get_styled_table(), get_summary_stats()
288
+
289
+ key = f"{pupil}|||{assignment}"
290
+ data["grades"][key] = grade_val
291
+ save_data(data)
292
+
293
+ return f"✅ {pupil} → {assignment}: {grade_val}", get_styled_table(), get_summary_stats()
294
+
295
+ def batch_enter_grades(assignment, grades_text):
296
+ """Enter grades for multiple pupils at once for a given assignment."""
297
+ data = load_data()
298
+
299
+ if not assignment:
300
+ return "⚠️ Please select an assignment.", get_styled_table(), get_summary_stats()
301
+
302
+ if not grades_text.strip():
303
+ return "⚠️ Please enter grades.", get_styled_table(), get_summary_stats()
304
+
305
+ lines = grades_text.strip().split("\n")
306
+ success = 0
307
+ errors = []
308
+
309
+ for line in lines:
310
+ line = line.strip()
311
+ if not line:
312
+ continue
313
+ # Support formats: "Name: Grade", "Name, Grade", "Name Grade"
314
+ parts = None
315
+ for sep in [":", ",", "\t"]:
316
+ if sep in line:
317
+ parts = line.split(sep, 1)
318
+ break
319
+ if parts is None:
320
+ parts = line.rsplit(" ", 1)
321
+
322
+ if len(parts) != 2:
323
+ errors.append(f"Could not parse: '{line}'")
324
+ continue
325
+
326
+ pupil_name = parts[0].strip()
327
+ grade_str = parts[1].strip()
328
+
329
+ if pupil_name not in data["pupils"]:
330
+ errors.append(f"Pupil not found: '{pupil_name}'")
331
+ continue
332
+
333
+ try:
334
+ grade_val = float(grade_str)
335
+ if grade_val < 0 or grade_val > 100:
336
+ errors.append(f"Grade out of range for '{pupil_name}': {grade_val}")
337
+ continue
338
+ except ValueError:
339
+ errors.append(f"Invalid grade for '{pupil_name}': '{grade_str}'")
340
+ continue
341
+
342
+ key = f"{pupil_name}|||{assignment}"
343
+ data["grades"][key] = grade_val
344
+ success += 1
345
+
346
+ save_data(data)
347
+
348
+ msg = f"✅ Entered {success} grade(s)."
349
+ if errors:
350
+ msg += f"\n⚠️ {len(errors)} error(s):\n" + "\n".join(f" • {e}" for e in errors)
351
+
352
+ return msg, get_styled_table(), get_summary_stats()
353
+
354
+ def export_csv():
355
+ """Export gradebook as CSV."""
356
+ df = get_grade_table()
357
+ if "Info" in df.columns:
358
+ return None
359
+
360
+ filepath = "gradebook_export.csv"
361
+ df.to_csv(filepath, index=False)
362
+ return filepath
363
+
364
+ def import_csv(file):
365
+ """Import grades from a CSV file."""
366
+ if file is None:
367
+ return "⚠️ No file uploaded.", get_styled_table(), get_pupils_list(), get_assignments_list(), get_summary_stats()
368
+
369
+ try:
370
+ df = pd.read_csv(file.name if hasattr(file, 'name') else file)
371
+ except Exception as e:
372
+ return f"⚠️ Error reading CSV: {e}", get_styled_table(), get_pupils_list(), get_assignments_list(), get_summary_stats()
373
+
374
+ if "Pupil" not in df.columns:
375
+ return "⚠️ CSV must have a 'Pupil' column.", get_styled_table(), get_pupils_list(), get_assignments_list(), get_summary_stats()
376
+
377
+ data = load_data()
378
+
379
+ # Get assignment columns (everything except Pupil and Average)
380
+ assignment_cols = [c for c in df.columns if c not in ("Pupil", "Average")]
381
+
382
+ # Add new pupils and assignments
383
+ for pupil in df["Pupil"]:
384
+ pupil = str(pupil).strip()
385
+ if pupil and pupil not in data["pupils"]:
386
+ data["pupils"].append(pupil)
387
+ data["pupils"].sort()
388
+
389
+ for assignment in assignment_cols:
390
+ assignment = str(assignment).strip()
391
+ if assignment and assignment not in data["assignments"]:
392
+ data["assignments"].append(assignment)
393
+
394
+ # Import grades
395
+ count = 0
396
+ for _, row in df.iterrows():
397
+ pupil = str(row["Pupil"]).strip()
398
+ for assignment in assignment_cols:
399
+ val = row[assignment]
400
+ if pd.notna(val) and str(val).strip() != "":
401
+ try:
402
+ grade_val = float(val)
403
+ key = f"{pupil}|||{assignment}"
404
+ data["grades"][key] = grade_val
405
+ count += 1
406
+ except (ValueError, TypeError):
407
+ pass
408
+
409
+ save_data(data)
410
+ return f"✅ Imported {count} grades from CSV.", get_styled_table(), get_pupils_list(), get_assignments_list(), get_summary_stats()
411
+
412
+ def refresh_pupil_dropdown():
413
+ data = load_data()
414
+ return gr.update(choices=data["pupils"])
415
+
416
+ def refresh_assignment_dropdown():
417
+ data = load_data()
418
+ return gr.update(choices=data["assignments"])
419
+
420
+ def clear_all_data():
421
+ """Reset the entire gradebook."""
422
+ save_data({"pupils": [], "assignments": [], "grades": {}})
423
+ return (
424
+ "✅ All data cleared.",
425
+ get_styled_table(),
426
+ get_pupils_list(),
427
+ get_assignments_list(),
428
+ get_summary_stats(),
429
+ gr.update(choices=[]),
430
+ gr.update(choices=[]),
431
+ gr.update(choices=[]),
432
+ )
433
+
434
+ # --- Build the UI ---
435
+
436
+ css = """
437
+ .gradio-container { max-width: 1200px !important; }
438
+ h1 { text-align: center; margin-bottom: 0.5em; }
439
+ .subtitle { text-align: center; color: #666; margin-bottom: 1.5em; }
440
+ """
441
+
442
+ with gr.Blocks(css=css, title="📚 Gradebook", theme=gr.themes.Soft()) as demo:
443
+ gr.Markdown("# 📚 Gradebook")
444
+ gr.Markdown("<p class='subtitle'>Manage your pupils' grades in one place</p>")
445
+
446
+ with gr.Tabs():
447
+ # ===================== TAB 1: Grades Overview =====================
448
+ with gr.Tab("📊 Grades", id="grades"):
449
+ grades_table = gr.Dataframe(
450
+ value=get_styled_table(),
451
+ label="Grades Table",
452
+ interactive=False,
453
+ wrap=True,
454
+ max_height=600,
455
+ show_search="filter",
456
+ )
457
+ with gr.Row():
458
+ refresh_btn = gr.Button("🔄 Refresh", variant="secondary", scale=1)
459
+ export_btn = gr.Button("📥 Export CSV", variant="secondary", scale=1)
460
+ export_file = gr.File(label="Download Export", visible=False)
461
+
462
+ refresh_btn.click(fn=get_styled_table, outputs=[grades_table])
463
+
464
+ def do_export():
465
+ path = export_csv()
466
+ if path:
467
+ return gr.update(value=path, visible=True)
468
+ return gr.update(visible=False)
469
+
470
+ export_btn.click(fn=do_export, outputs=[export_file])
471
+
472
+ # ===================== TAB 2: Enter Grades =====================
473
+ with gr.Tab("✏️ Enter Grades", id="enter"):
474
+ gr.Markdown("### Enter a Single Grade")
475
+ with gr.Row():
476
+ grade_pupil = gr.Dropdown(
477
+ choices=load_data()["pupils"],
478
+ label="Pupil",
479
+ interactive=True,
480
+ scale=2,
481
+ )
482
+ grade_assignment = gr.Dropdown(
483
+ choices=load_data()["assignments"],
484
+ label="Assignment",
485
+ interactive=True,
486
+ scale=2,
487
+ )
488
+ grade_value = gr.Textbox(
489
+ label="Grade (0-100)",
490
+ placeholder="e.g. 85",
491
+ scale=1,
492
+ )
493
+ grade_btn = gr.Button("💾 Save Grade", variant="primary")
494
+ grade_msg = gr.Markdown("")
495
+
496
+ gr.Markdown("---")
497
+ gr.Markdown("### Batch Enter Grades")
498
+ gr.Markdown("Enter one grade per line in the format: `Pupil Name: Grade` \n"
499
+ "Also supports comma or tab separation.")
500
+ with gr.Row():
501
+ batch_assignment = gr.Dropdown(
502
+ choices=load_data()["assignments"],
503
+ label="Assignment",
504
+ interactive=True,
505
+ scale=1,
506
+ )
507
+ batch_text = gr.Textbox(
508
+ label="Grades",
509
+ placeholder="Alice: 92\nBob: 85\nCharlie: 78",
510
+ lines=8,
511
+ scale=2,
512
+ )
513
+ batch_btn = gr.Button("💾 Save All Grades", variant="primary")
514
+ batch_msg = gr.Markdown("")
515
+
516
+ # Hidden table for updates
517
+ grades_table_hidden = gr.Dataframe(visible=False)
518
+ summary_hidden = gr.Markdown(visible=False)
519
+
520
+ # ===================== TAB 3: Manage Pupils =====================
521
+ with gr.Tab("👩‍🎓 Manage Pupils", id="pupils"):
522
+ with gr.Row():
523
+ with gr.Column(scale=1):
524
+ gr.Markdown("### Add Pupil")
525
+ pupil_name_input = gr.Textbox(label="Pupil Name", placeholder="e.g. Alice Johnson")
526
+ add_pupil_btn = gr.Button("➕ Add Pupil", variant="primary")
527
+
528
+ gr.Markdown("### Remove Pupil")
529
+ remove_pupil_input = gr.Textbox(label="Pupil Name to Remove", placeholder="Exact name")
530
+ remove_pupil_btn = gr.Button("🗑️ Remove Pupil", variant="stop")
531
+
532
+ pupil_msg = gr.Markdown("")
533
+
534
+ with gr.Column(scale=1):
535
+ gr.Markdown("### Current Pupils")
536
+ pupils_list_display = gr.Markdown(get_pupils_list())
537
+
538
+ # ===================== TAB 4: Manage Assignments =====================
539
+ with gr.Tab("📝 Manage Assignments", id="assignments"):
540
+ with gr.Row():
541
+ with gr.Column(scale=1):
542
+ gr.Markdown("### Add Assignment")
543
+ assignment_name_input = gr.Textbox(label="Assignment Name", placeholder="e.g. Midterm Exam")
544
+ add_assignment_btn = gr.Button("➕ Add Assignment", variant="primary")
545
+
546
+ gr.Markdown("### Remove Assignment")
547
+ remove_assignment_input = gr.Textbox(label="Assignment Name to Remove", placeholder="Exact name")
548
+ remove_assignment_btn = gr.Button("🗑️ Remove Assignment", variant="stop")
549
+
550
+ assignment_msg = gr.Markdown("")
551
+
552
+ with gr.Column(scale=1):
553
+ gr.Markdown("### Current Assignments")
554
+ assignments_list_display = gr.Markdown(get_assignments_list())
555
+
556
+ # ===================== TAB 5: Statistics =====================
557
+ with gr.Tab("📈 Statistics", id="stats"):
558
+ stats_display = gr.Markdown(get_summary_stats())
559
+ stats_refresh_btn = gr.Button("🔄 Refresh Statistics", variant="secondary")
560
+ stats_refresh_btn.click(fn=get_summary_stats, outputs=[stats_display])
561
+
562
+ # ===================== TAB 6: Import / Settings =====================
563
+ with gr.Tab("⚙️ Settings", id="settings"):
564
+ gr.Markdown("### Import Data from CSV")
565
+ gr.Markdown("Upload a CSV file with a `Pupil` column and assignment columns. Example:\n\n"
566
+ "| Pupil | Homework 1 | Quiz 1 | Midterm |\n"
567
+ "|---|---|---|---|\n"
568
+ "| Alice | 95 | 88 | 92 |\n"
569
+ "| Bob | 82 | 76 | 85 |")
570
+ import_file = gr.File(label="Upload CSV", file_types=[".csv"])
571
+ import_btn = gr.Button("📤 Import", variant="primary")
572
+ import_msg = gr.Markdown("")
573
+
574
+ gr.Markdown("---")
575
+ gr.Markdown("### ⚠️ Danger Zone")
576
+ clear_btn = gr.Button("🗑️ Clear All Data", variant="stop")
577
+ clear_msg = gr.Markdown("")
578
+
579
+ # --- Wire up events ---
580
+
581
+ # Add pupil
582
+ add_pupil_btn.click(
583
+ fn=add_pupil,
584
+ inputs=[pupil_name_input],
585
+ outputs=[pupil_msg, pupils_list_display, grades_table, grade_pupil, stats_display],
586
+ )
587
+
588
+ # Remove pupil
589
+ remove_pupil_btn.click(
590
+ fn=remove_pupil,
591
+ inputs=[remove_pupil_input],
592
+ outputs=[pupil_msg, pupils_list_display, grades_table, grade_pupil, stats_display],
593
+ )
594
+
595
+ # Add assignment
596
+ add_assignment_btn.click(
597
+ fn=add_assignment,
598
+ inputs=[assignment_name_input],
599
+ outputs=[assignment_msg, assignments_list_display, grades_table, grade_assignment, batch_assignment, stats_display],
600
+ )
601
+
602
+ # Remove assignment
603
+ remove_assignment_btn.click(
604
+ fn=remove_assignment,
605
+ inputs=[remove_assignment_input],
606
+ outputs=[assignment_msg, assignments_list_display, grades_table, grade_assignment, batch_assignment, stats_display],
607
+ )
608
+
609
+ # Enter single grade
610
+ grade_btn.click(
611
+ fn=enter_grade,
612
+ inputs=[grade_pupil, grade_assignment, grade_value],
613
+ outputs=[grade_msg, grades_table, stats_display],
614
+ )
615
+
616
+ # Batch enter grades
617
+ batch_btn.click(
618
+ fn=batch_enter_grades,
619
+ inputs=[batch_assignment, batch_text],
620
+ outputs=[batch_msg, grades_table, stats_display],
621
+ )
622
+
623
+ # Import CSV
624
+ import_btn.click(
625
+ fn=import_csv,
626
+ inputs=[import_file],
627
+ outputs=[import_msg, grades_table, pupils_list_display, assignments_list_display, stats_display],
628
+ )
629
+
630
+ # Clear all data
631
+ clear_btn.click(
632
+ fn=clear_all_data,
633
+ outputs=[clear_msg, grades_table, pupils_list_display, assignments_list_display, stats_display, grade_pupil, grade_assignment, batch_assignment],
634
+ )
635
+
636
+ demo.launch()