Chuyển tới nội dung chính

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ăngMô tả
Thêm sinh viênNhập thông tin và thêm vào danh sách
Sửa sinh viênChọn và chỉnh sửa thông tin
Xóa sinh viênXóa có xác nhận
Tìm kiếmTìm theo tên hoặc mã SV
Thống kêXem điểm trung bình, giỏi/khá/TB
Lưu tự độngDữ liệu lưu file JSON
Xuất danh sáchXuất ra file .txt
Giao diện đẹpTheme 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ảnLabel, Entry, Frame, Button
Bài 3: Layoutpack(), grid(), fill, expand
Bài 4: Sự kiệnbind(), trace_add(), phím tắt
Bài 5: Widget nâng caoCombobox, form nhập liệu
Bài 6: Menu & Dialogmessagebox, filedialog, modal dialog
Bài 7: Canvas(Bài tập nâng cao: vẽ biểu đồ)
Bài 8: ttk & StyleTreeview, Style, dark theme
Bài 9: OOPMVC, custom dialog, class structure

Ý tưởng mở rộng

:::tip Thử thách bản thân

  1. Thêm màn hình đăng nhập — username/password bảo vệ ứng dụng
  2. Biểu đồ thống kê — vẽ biểu đồ cột điểm bằng Canvas
  3. Import CSV — nhập danh sách từ file Excel/CSV
  4. Sắp xếp cột — click tiêu đề Treeview để sắp xếp
  5. Tìm kiếm nâng cao — lọc theo lớp, điểm từ-đến
  6. Backup dữ liệu — lưu nhiều phiên bản JSON
  7. 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.