Bài 10: Dự Án Tổng Hợp — App Quản Lý Sinh Viên
Giai đoạn 3 – Dự Án Thực Tế · Mục tiêu: Tổng hợp toàn bộ kiến thức để xây dựng ứng dụng desktop hoàn chỉnh.
Tổng quan dự án
Tính năng
| Chức năng | Mô tả |
|---|---|
| Thêm sinh viên | Nhập thông tin và thêm vào danh sách |
| Sửa sinh viên | Chọn và chỉnh sửa thông tin |
| Xóa sinh viên | Xóa có xác nhận |
| Tìm kiếm | Tìm theo tên hoặc mã SV |
| Thống kê | Xem điểm trung bình, giỏi/khá/TB |
| Lưu tự động | Dữ liệu lưu file JSON |
| Xuất danh sách | Xuất ra file .txt |
| Giao diện đẹp | Theme tối, ttk Treeview, Style |
Cấu trúc file
quan_ly_sinh_vien/
├── main.py ← Entry point
├── model.py ← StudentModel (dữ liệu + JSON)
├── views/
│ ├── main_view.py ← Giao diện chính
│ └── dialogs.py ← Hộp thoại thêm/sửa
├── controller.py ← StudentController
└── data/
└── students.json ← Dữ liệu
model.py — Model dữ liệu
# model.py
import json
import os
from typing import List, Dict, Optional
class StudentModel:
"""Quản lý dữ liệu sinh viên và lưu trữ JSON."""
DATA_FILE = "data/students.json"
def __init__(self):
os.makedirs("data", exist_ok=True)
self._students: List[Dict] = self._load()
def _load(self) -> List[Dict]:
if os.path.exists(self.DATA_FILE):
try:
with open(self.DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return []
return self._sample_data()
def _sample_data(self) -> List[Dict]:
return [
{"ma_sv": "SV001", "ten": "Nguyễn Văn An", "lop": "CNTT-01", "diem": 8.5, "email": "an@sv.edu.vn"},
{"ma_sv": "SV002", "ten": "Trần Thị Bích", "lop": "CNTT-01", "diem": 9.2, "email": "bich@sv.edu.vn"},
{"ma_sv": "SV003", "ten": "Lê Văn Cường", "lop": "CNTT-02", "diem": 7.8, "email": "cuong@sv.edu.vn"},
{"ma_sv": "SV004", "ten": "Phạm Thị Dung", "lop": "CNTT-02", "diem": 6.5, "email": "dung@sv.edu.vn"},
{"ma_sv": "SV005", "ten": "Hoàng Văn Em", "lop": "CNTT-03", "diem": 5.0, "email": "em@sv.edu.vn"},
{"ma_sv": "SV006", "ten": "Vũ Thị Phương", "lop": "CNTT-03", "diem": 9.8, "email": "phuong@sv.edu.vn"},
{"ma_sv": "SV007", "ten": "Đinh Văn Giang", "lop": "CNTT-01", "diem": 4.2, "email": "giang@sv.edu.vn"},
{"ma_sv": "SV008", "ten": "Bùi Thị Hương", "lop": "CNTT-02", "diem": 7.0, "email": "huong@sv.edu.vn"},
]
def save(self):
with open(self.DATA_FILE, "w", encoding="utf-8") as f:
json.dump(self._students, f, ensure_ascii=False, indent=2)
def get_all(self) -> List[Dict]:
return self._students.copy()
def add(self, student: Dict) -> bool:
if self.find_by_ma(student["ma_sv"]):
return False # Mã SV đã tồn tại
self._students.append(student)
self.save()
return True
def update(self, ma_sv: str, new_data: Dict) -> bool:
for i, s in enumerate(self._students):
if s["ma_sv"] == ma_sv:
self._students[i].update(new_data)
self.save()
return True
return False
def delete(self, ma_sv: str) -> bool:
for i, s in enumerate(self._students):
if s["ma_sv"] == ma_sv:
self._students.pop(i)
self.save()
return True
return False
def find_by_ma(self, ma_sv: str) -> Optional[Dict]:
return next((s for s in self._students if s["ma_sv"] == ma_sv), None)
def search(self, keyword: str) -> List[Dict]:
kw = keyword.strip().lower()
if not kw: return self.get_all()
return [s for s in self._students
if kw in s["ten"].lower() or kw in s["ma_sv"].lower()
or kw in s.get("lop", "").lower()]
def get_stats(self) -> Dict:
if not self._students:
return {"total": 0, "avg": 0, "gioi": 0, "kha": 0, "tb": 0, "yeu": 0}
scores = [float(s["diem"]) for s in self._students]
return {
"total": len(scores),
"avg": round(sum(scores) / len(scores), 2),
"gioi": sum(1 for d in scores if d >= 8.0),
"kha": sum(1 for d in scores if 6.5 <= d < 8.0),
"tb": sum(1 for d in scores if 5.0 <= d < 6.5),
"yeu": sum(1 for d in scores if d < 5.0),
}
def export_txt(self, filepath: str):
with open(filepath, "w", encoding="utf-8") as f:
f.write("DANH SÁCH SINH VIÊN\n")
f.write("=" * 60 + "\n")
f.write(f"{'STT':<5} {'Mã SV':<8} {'Họ tên':<22} {'Lớp':<10} {'Điểm'}\n")
f.write("-" * 60 + "\n")
for i, s in enumerate(self._students, 1):
f.write(f"{i:<5} {s['ma_sv']:<8} {s['ten']:<22} {s.get('lop',''):<10} {s['diem']}\n")
stats = self.get_stats()
f.write("=" * 60 + "\n")
f.write(f"Tổng: {stats['total']} sinh viên | ĐTB: {stats['avg']}\n")
views/dialogs.py — Hộp thoại thêm/sửa
# views/dialogs.py
import tkinter as tk
from tkinter import ttk, messagebox
class StudentDialog(tk.Toplevel):
"""Hộp thoại thêm/sửa sinh viên."""
CLASSES = ["CNTT-01", "CNTT-02", "CNTT-03", "CNTT-04", "KTPM-01", "KTPM-02"]
def __init__(self, parent, title="Thêm sinh viên", student=None):
super().__init__(parent)
self.title(title)
self.geometry("400x320")
self.resizable(False, False)
self.grab_set() # Modal
self.transient(parent)
self.result: dict = None
self._student = student
self._build()
if student:
self._fill(student)
self.wait_window()
def _build(self):
main = tk.Frame(self, padx=20, pady=15)
main.pack(fill="both", expand=True)
fields = [
("Mã sinh viên: *", "ma_sv", "entry"),
("Họ và tên: *", "ten", "entry"),
("Lớp: *", "lop", "combo"),
("Điểm TB: *", "diem", "entry"),
("Email:", "email", "entry"),
]
self._vars = {}
for row, (label, key, wtype) in enumerate(fields):
tk.Label(main, text=label, anchor="e", width=17).grid(
row=row, column=0, sticky="e", pady=5)
var = tk.StringVar()
self._vars[key] = var
if wtype == "combo":
w = ttk.Combobox(main, textvariable=var,
values=self.CLASSES, state="readonly", width=22)
w.set(self.CLASSES[0])
else:
w = tk.Entry(main, textvariable=var, width=24)
w.grid(row=row, column=1, sticky="ew", padx=8, pady=5)
if row == 0 and self._student:
w.config(state="disabled") # Không cho sửa Mã SV
main.columnconfigure(1, weight=1)
# Buttons
btn_frame = tk.Frame(self)
btn_frame.pack(pady=10)
ttk.Button(btn_frame, text=" Xác nhận", command=self._confirm).pack(side="left", padx=8)
ttk.Button(btn_frame, text=" Hủy", command=self.destroy).pack(side="left", padx=8)
self.bind("<Return>", lambda e: self._confirm())
self.bind("<Escape>", lambda e: self.destroy())
def _fill(self, student: dict):
for key, var in self._vars.items():
var.set(student.get(key, ""))
def _confirm(self):
data = {k: v.get().strip() for k, v in self._vars.items()}
if not data["ma_sv"] or not data["ten"] or not data["lop"]:
messagebox.showwarning("Thiếu dữ liệu", "Vui lòng điền các trường (*)", parent=self)
return
try:
diem = float(data["diem"])
if not 0 <= diem <= 10:
raise ValueError
data["diem"] = diem
except ValueError:
messagebox.showerror("Lỗi", "Điểm phải là số từ 0 đến 10!", parent=self)
return
self.result = data
self.destroy()
views/main_view.py — Giao diện chính
# views/main_view.py
import tkinter as tk
from tkinter import ttk
COLORS = {
"bg": "#1E1E2E",
"surface": "#2A2A3E",
"primary": "#7C3AED",
"text": "#CDD6F4",
"muted": "#6C7086",
"success": "#A6E3A1",
"warning": "#F9E2AF",
"danger": "#F38BA8",
"border": "#45475A",
}
class MainView(tk.Frame):
def __init__(self, parent):
super().__init__(parent, bg=COLORS["bg"])
self._setup_style()
self._build()
def _setup_style(self):
s = ttk.Style()
s.theme_use("clam")
s.configure(".", background=COLORS["bg"], foreground=COLORS["text"],
fieldbackground=COLORS["surface"])
s.configure("Treeview",
background=COLORS["surface"],
foreground=COLORS["text"],
fieldbackground=COLORS["surface"],
rowheight=28,
font=("Segoe UI", 10)
)
s.configure("Treeview.Heading",
background=COLORS["primary"],
foreground="white",
font=("Segoe UI", 10, "bold")
)
s.map("Treeview", background=[("selected", COLORS["primary"])])
s.configure("TButton", font=("Segoe UI", 9, "bold"), padding=(10, 5))
s.configure("TEntry", fieldbackground=COLORS["surface"],
foreground=COLORS["text"], insertcolor=COLORS["text"])
def _build(self):
# ── HEADER ──────────────────────────────────────────────
header = tk.Frame(self, bg=COLORS["primary"], pady=12)
header.pack(fill="x")
tk.Label(header, text=" Quản Lý Sinh Viên",
font=("Segoe UI", 16, "bold"),
fg="white", bg=COLORS["primary"]).pack(side="left", padx=20)
self.lbl_count = tk.Label(header, text="0 sinh viên",
font=("Segoe UI", 10),
fg="white", bg=COLORS["primary"])
self.lbl_count.pack(side="right", padx=20)
# ── STATS BAR ────────────────────────────────────────────
self.stats_frame = tk.Frame(self, bg=COLORS["surface"], pady=8)
self.stats_frame.pack(fill="x")
self.stat_labels = {}
for key, label, color in [
("total", "Tổng", COLORS["text"]),
("avg", "ĐTB", COLORS["warning"]),
("gioi", "Giỏi", COLORS["success"]),
("kha", "Khá", COLORS["primary"]),
("tb", "TB", COLORS["muted"]),
("yeu", "Yếu", COLORS["danger"]),
]:
frame = tk.Frame(self.stats_frame, bg=COLORS["surface"])
frame.pack(side="left", padx=20)
tk.Label(frame, text=label, bg=COLORS["surface"],
fg=COLORS["muted"], font=("Segoe UI", 8)).pack()
lbl = tk.Label(frame, text="—", bg=COLORS["surface"],
fg=color, font=("Segoe UI", 14, "bold"))
lbl.pack()
self.stat_labels[key] = lbl
# ── TOOLBAR ──────────────────────────────────────────────
toolbar = tk.Frame(self, bg=COLORS["bg"], pady=6)
toolbar.pack(fill="x", padx=10)
# Search
tk.Label(toolbar, text="", bg=COLORS["bg"], fg=COLORS["muted"]).pack(side="left")
self.search_var = tk.StringVar()
self.entry_search = tk.Entry(toolbar, textvariable=self.search_var,
width=28, bg=COLORS["surface"],
fg=COLORS["text"], insertbackground=COLORS["text"],
relief="flat", font=("Segoe UI", 10))
self.entry_search.pack(side="left", ipady=5, padx=(2,10))
# Action buttons
self.btn_add = ttk.Button(toolbar, text=" Thêm")
self.btn_edit = ttk.Button(toolbar, text=" Sửa")
self.btn_delete = ttk.Button(toolbar, text=" Xóa")
self.btn_export = ttk.Button(toolbar, text=" Xuất")
for btn in (self.btn_add, self.btn_edit, self.btn_delete, self.btn_export):
btn.pack(side="left", padx=3)
# ── TREEVIEW ─────────────────────────────────────────────
tree_frame = tk.Frame(self, bg=COLORS["bg"])
tree_frame.pack(fill="both", expand=True, padx=10, pady=5)
cols = ("stt", "ma_sv", "ten", "lop", "diem", "xep_loai", "email")
self.tree = ttk.Treeview(tree_frame, columns=cols, show="headings")
col_config = [
("stt", "STT", 50, "center"),
("ma_sv", "Mã SV", 90, "center"),
("ten", "Họ tên", 180, "w"),
("lop", "Lớp", 90, "center"),
("diem", "Điểm TB", 75, "center"),
("xep_loai", "Xếp loại",85, "center"),
("email", "Email", 160, "w"),
]
for col, heading, width, anchor in col_config:
self.tree.heading(col, text=heading)
self.tree.column(col, width=width, anchor=anchor)
# Tags màu theo xếp loại
self.tree.tag_configure("gioi", foreground=COLORS["success"])
self.tree.tag_configure("kha", foreground="#89DCEB")
self.tree.tag_configure("tb", foreground=COLORS["text"])
self.tree.tag_configure("yeu", foreground=COLORS["danger"])
self.tree.tag_configure("odd", background="#252535")
self.tree.tag_configure("even", background=COLORS["surface"])
sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=sb.set)
self.tree.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
# ── STATUS BAR ───────────────────────────────────────────
self.status_bar = tk.Label(self, text="Sẵn sàng",
bg=COLORS["surface"], fg=COLORS["muted"],
anchor="w", padx=10, pady=4)
self.status_bar.pack(fill="x", side="bottom")
def refresh_tree(self, students: list):
self.tree.delete(*self.tree.get_children())
def xep_loai(d):
d = float(d)
if d >= 8.0: return "Giỏi ", "gioi"
if d >= 6.5: return "Khá ", "kha"
if d >= 5.0: return "Trung bình", "tb"
return "Yếu ", "yeu"
for i, s in enumerate(students):
xl_text, xl_tag = xep_loai(s["diem"])
row_tag = "odd" if i % 2 else "even"
self.tree.insert("", tk.END,
values=(i+1, s["ma_sv"], s["ten"], s.get("lop",""),
f"{float(s['diem']):.1f}", xl_text, s.get("email","")),
tags=(xl_tag, row_tag))
self.lbl_count.config(text=f"{len(students)} sinh viên")
def refresh_stats(self, stats: dict):
label_map = {
"total": str(stats["total"]),
"avg": str(stats["avg"]),
"gioi": str(stats["gioi"]),
"kha": str(stats["kha"]),
"tb": str(stats["tb"]),
"yeu": str(stats["yeu"]),
}
for key, val in label_map.items():
self.stat_labels[key].config(text=val)
def get_selected_ma(self):
sel = self.tree.selection()
if sel:
return self.tree.item(sel[0])["values"][1]
return None
def set_status(self, msg: str):
self.status_bar.config(text=f" {msg}")
controller.py — Controller
# controller.py
import tkinter as tk
from tkinter import messagebox, filedialog
class StudentController:
def __init__(self, root, model, view):
self.root = root
self.model = model
self.view = view
self._bind_events()
self.refresh()
def _bind_events(self):
v = self.view
v.btn_add.config( command=self.add)
v.btn_edit.config( command=self.edit)
v.btn_delete.config(command=self.delete)
v.btn_export.config(command=self.export)
v.search_var.trace_add("write", lambda *_: self.search())
v.tree.bind("<Double-Button-1>", lambda e: self.edit())
self.root.bind("<Control-n>", lambda e: self.add())
self.root.bind("<Delete>", lambda e: self.delete())
self.root.bind("<F5>", lambda e: self.refresh())
def refresh(self, students=None):
data = students or self.model.get_all()
self.view.refresh_tree(data)
self.view.refresh_stats(self.model.get_stats())
def search(self):
kw = self.view.search_var.get()
results = self.model.search(kw)
self.view.refresh_tree(results)
def add(self):
from views.dialogs import StudentDialog
dialog = StudentDialog(self.root, " Thêm sinh viên")
if dialog.result:
if self.model.add(dialog.result):
self.refresh()
self.view.set_status(f"Đã thêm sinh viên {dialog.result['ten']}")
else:
messagebox.showerror("Lỗi", f"Mã SV '{dialog.result['ma_sv']}' đã tồn tại!")
def edit(self):
from views.dialogs import StudentDialog
ma = self.view.get_selected_ma()
if not ma:
messagebox.showinfo("Thông báo", "Chọn sinh viên cần sửa!")
return
student = self.model.find_by_ma(ma)
dialog = StudentDialog(self.root, " Sửa sinh viên", student=student)
if dialog.result:
self.model.update(ma, dialog.result)
self.refresh()
self.view.set_status(f"Đã cập nhật {dialog.result['ten']}")
def delete(self):
ma = self.view.get_selected_ma()
if not ma:
messagebox.showinfo("Thông báo", "Chọn sinh viên cần xóa!")
return
sv = self.model.find_by_ma(ma)
if messagebox.askyesno("Xác nhận xóa",
f"Xóa sinh viên '{sv['ten']}' ({ma})?",
icon="warning"):
self.model.delete(ma)
self.refresh()
self.view.set_status(f"Đã xóa {sv['ten']}")
def export(self):
path = filedialog.asksaveasfilename(
title="Xuất danh sách",
defaultextension=".txt",
filetypes=[("Text file", "*.txt"), ("All files", "*.*")]
)
if path:
self.model.export_txt(path)
self.view.set_status(f"Đã xuất: {path}")
if messagebox.askyesno("Thành công", "Xuất file thành công! Mở file?"):
import os
os.startfile(path)
main.py — Entry point
# main.py
import tkinter as tk
from model import StudentModel
from views.main_view import MainView
from controller import StudentController
def main():
root = tk.Tk()
root.title(" Quản Lý Sinh Viên — v1.0")
root.geometry("860x580")
root.minsize(700, 450)
# Căn giữa màn hình
root.update_idletasks()
w, h = 860, 580
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
# Khởi tạo MVC
model = StudentModel()
view = MainView(root)
view.pack(fill="both", expand=True)
controller = StudentController(root, model, view)
root.mainloop()
if __name__ == "__main__":
main()
Hướng dẫn chạy
# 1. Tạo thư mục dự án
mkdir quan_ly_sinh_vien
cd quan_ly_sinh_vien
mkdir views data
# 2. Tạo file __init__.py trong views/
echo "" > views/__init__.py
# 3. Tạo các file Python theo code trên
# model.py, views/dialogs.py, views/main_view.py,
# controller.py, main.py
# 4. Chạy ứng dụng
python main.py
Tổng kết — Kiến thức đã áp dụng
| Bài học | Áp dụng trong dự án |
|---|---|
| Bài 1: Cửa sổ | Tk(), căn giữa, minsize |
| Bài 2: Widget cơ bản | Label, Entry, Frame, Button |
| Bài 3: Layout | pack(), grid(), fill, expand |
| Bài 4: Sự kiện | bind(), trace_add(), phím tắt |
| Bài 5: Widget nâng cao | Combobox, form nhập liệu |
| Bài 6: Menu & Dialog | messagebox, filedialog, modal dialog |
| Bài 7: Canvas | (Bài tập nâng cao: vẽ biểu đồ) |
| Bài 8: ttk & Style | Treeview, Style, dark theme |
| Bài 9: OOP | MVC, custom dialog, class structure |
Ý tưởng mở rộng
:::tip Thử thách bản thân
- Thêm màn hình đăng nhập — username/password bảo vệ ứng dụng
- Biểu đồ thống kê — vẽ biểu đồ cột điểm bằng Canvas
- Import CSV — nhập danh sách từ file Excel/CSV
- Sắp xếp cột — click tiêu đề Treeview để sắp xếp
- Tìm kiếm nâng cao — lọc theo lớp, điểm từ-đến
- Backup dữ liệu — lưu nhiều phiên bản JSON
- SQLite — nâng cấp từ JSON sang cơ sở dữ liệu thật :::
Bài trước: Bài 9 – OOP với Tkinter · ← Về Roadmap
Chúc mừng! Bạn đã hoàn thành toàn bộ giáo trình Python Tkinter! Bây giờ bạn có đủ kiến thức để xây dựng các ứng dụng desktop Python chuyên nghiệp.