From 06ebab16762f1564500ce5c54edc46d749d3edf6 Mon Sep 17 00:00:00 2001 From: obotu okwori Date: Tue, 11 Aug 2026 14:49:11 +0100 Subject: [PATCH 1/4] Add .gitignore for Python artifacts and local databases --- .gitignore | 22 + .../Student_management_system.py | 626 ------------------ Student Management System/app.db | Bin 24576 -> 0 bytes Student Management System/login.db | Bin 12288 -> 0 bytes Student Management System/sms.db | Bin 16384 -> 0 bytes Student Management System/student.db | Bin 20480 -> 0 bytes Student Management System/students.db | Bin 12288 -> 0 bytes Student Management System/users.db | Bin 12288 -> 0 bytes 8 files changed, 22 insertions(+), 626 deletions(-) create mode 100644 .gitignore delete mode 100644 Student Management System/Student_management_system.py delete mode 100644 Student Management System/app.db delete mode 100644 Student Management System/login.db delete mode 100644 Student Management System/sms.db delete mode 100644 Student Management System/student.db delete mode 100644 Student Management System/students.db delete mode 100644 Student Management System/users.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3899d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +__pycache__/ +*.pyc +*.pyo +*.db +*.sqlite +*.sqlite3 + +.idea/ +.vscode/ +*.iml + +venv/ +env/ +.venv/ +ENV/ + +build/ +dist/ +*.spec + +.DS_Store +Thumbs.db diff --git a/Student Management System/Student_management_system.py b/Student Management System/Student_management_system.py deleted file mode 100644 index 6b7e38e..0000000 --- a/Student Management System/Student_management_system.py +++ /dev/null @@ -1,626 +0,0 @@ -import tkinter as tk -from tkinter import ttk, filedialog, messagebox -import sqlite3 -import csv -from reportlab.pdfgen import canvas -from reportlab.lib.pagesizes import A4 - -DB_FILE = "app.db" - -# ------------------------ DB Bootstrap ------------------------ -def db_connect(): - return sqlite3.connect(DB_FILE) - -def init_db(): - conn = db_connect() - c = conn.cursor() - - # Users table - c.execute(""" - CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - password TEXT NOT NULL, - role TEXT NOT NULL CHECK(role IN ('admin','staff','student')) - ) - """) - - # Students table (username is UNIQUE so one login links to at most one record) - c.execute(""" - CREATE TABLE IF NOT EXISTS students ( - roll_no TEXT PRIMARY KEY, - name TEXT, - email TEXT, - gender TEXT, - contact TEXT, - dob TEXT, - address TEXT, - username TEXT UNIQUE - ) - """) - - # Seed admin if missing - c.execute("SELECT 1 FROM users WHERE username='admin'") - if not c.fetchone(): - c.execute("INSERT INTO users (username, password, role) VALUES (?,?,?)", - ("admin", "admin123", "admin")) - conn.commit() - conn.close() - - -# ------------------------ Login Window ------------------------ -class LoginWindow: - def __init__(self, root): - self.root = root - self.root.title("Student Management - Login") - self.root.geometry("1200x600") - self.root.config(bg="#ecf0f1") - - self.username = tk.StringVar() - self.password = tk.StringVar() - self.show_password = tk.BooleanVar(value=False) - - login_frame = tk.Frame(root, bg="white", bd=5, relief=tk.RIDGE) - login_frame.place(relx=0.5, rely=0.5, anchor="center", width=420, height=380) - - tk.Label(login_frame, text="Login", font=("Helvetica", 22, "bold"), - bg="white", fg="#2c3e50").pack(pady=18) - - tk.Label(login_frame, text="Username", font=("Arial", 12), - bg="white", anchor="w").pack(padx=40, anchor="w") - tk.Entry(login_frame, textvariable=self.username, - font=("Arial", 12), width=30).pack(pady=6) - - tk.Label(login_frame, text="Password", font=("Arial", 12), - bg="white", anchor="w").pack(padx=40, anchor="w") - self.password_entry = tk.Entry(login_frame, textvariable=self.password, - font=("Arial", 12), width=30, show="*") - self.password_entry.pack(pady=6) - - tk.Checkbutton(login_frame, text="Show Password", variable=self.show_password, - command=self.toggle_password, bg="white").pack(pady=6) - - row = tk.Frame(login_frame, bg="white") - row.pack(pady=14) - tk.Button(row, text="Login", command=self.login, font=("Arial", 12), width=15, - bg="#3498db", fg="white").grid(row=0, column=0, padx=6) - - tk.Label(login_frame, text="Default admin: admin / admin123", - font=("Arial", 9), bg="white", fg="gray").pack(pady=6) - - def toggle_password(self): - self.password_entry.config(show="" if self.show_password.get() else "*") - - def login(self): - user = self.username.get().strip() - pwd = self.password.get() - - if not user or not pwd: - messagebox.showerror("Error", "Enter username and password.") - return - - conn = db_connect() - c = conn.cursor() - c.execute("SELECT role FROM users WHERE username=? AND password=?", (user, pwd)) - row = c.fetchone() - conn.close() - - if row: - role = row[0] - self.root.destroy() - main_root = tk.Tk() - StudentManagementSystem(main_root, user_role=role, username=user) - main_root.mainloop() - else: - messagebox.showerror("Access Denied", "Invalid username or password!") - - -# ------------------------ Main App ------------------------ -class StudentManagementSystem: - def __init__(self, root, user_role, username): - self.root = root - self.role = user_role.lower() - self.username = username - self.dark_mode = False - - self.root.title(f"{self.role.capitalize()} Dashboard - Student Management System") - self.root.geometry("1600x600") - self.root.config(bg="white") - - # Title bar - self.title_bar = tk.Label(self.root, text="Student Management System", - font=("Arial", 20, "bold"), bg="#34495e", fg="white") - self.title_bar.pack(side=tk.TOP, fill=tk.X) - - # Welcome + theme toggle - top_row = tk.Frame(self.root, bg="white") - top_row.pack(fill="x") - tk.Label(top_row, text=f"Welcome, {self.username} 👋 (Role: {self.role})", - font=("Helvetica", 13, "bold"), - bg="white", fg="#2c3e50").pack(side="left", padx=10, pady=8) - self.theme_btn = tk.Button(top_row, text="🌙 Dark Mode", font=("Arial", 10), - command=self.toggle_theme, bg="#34495e", fg="white") - self.theme_btn.pack(side="right", padx=10) - - # Left: Table (list) - self.table_frame = tk.Frame(self.root, bd=4, relief=tk.RIDGE, bg="white") - self.table_frame.place(x=20, y=105, width=550, height=470) - - # Right: Manage student data panel - self.manage_frame = tk.Frame(self.root, bd=4, relief=tk.RIDGE, bg="white") - self.manage_frame.place(x=590, y=75, width=670, height=500) - - # ---- Manage panel contents ---- - tk.Label(self.manage_frame, text="Manage Students", bg="white", - fg="black", font=("Arial", 15, "bold")).grid(row=0, column=0, columnspan=2, pady=10) - - # Vars - self.roll_no_var = tk.StringVar() - self.name_var = tk.StringVar() - self.email_var = tk.StringVar() - self.gender_var = tk.StringVar(value="Male") - self.contact_var = tk.StringVar() - self.dob_var = tk.StringVar() - self.username_var = tk.StringVar() # <-- used to link a student record to a login - self.search_by = tk.StringVar(value="roll_no") - self.search_txt = tk.StringVar() - - # Fields - row_i = 1 - def add_row(label, widget): - nonlocal row_i - tk.Label(self.manage_frame, text=label, bg="white", font=("Arial", 11)).grid(row=row_i, column=0, padx=8, pady=6, sticky="w") - widget.grid(row=row_i, column=1, padx=8, pady=6, sticky="w") - row_i += 1 - - add_row("Roll No", tk.Entry(self.manage_frame, textvariable=self.roll_no_var, width=34)) - add_row("Name", tk.Entry(self.manage_frame, textvariable=self.name_var, width=34)) - add_row("Email", tk.Entry(self.manage_frame, textvariable=self.email_var, width=34)) - - gender_combo = ttk.Combobox(self.manage_frame, textvariable=self.gender_var, - values=("Male", "Female", "Other"), state="readonly", width=31) - gender_combo.current(0) - add_row("Gender", gender_combo) - - add_row("Contact", tk.Entry(self.manage_frame, textvariable=self.contact_var, width=34)) - add_row("D.O.B", tk.Entry(self.manage_frame, textvariable=self.dob_var, width=34)) - - # Username link (admin/staff can set; students fixed to their own) - uname_entry = tk.Entry(self.manage_frame, textvariable=self.username_var, width=34) - add_row("Username (link)", uname_entry) - - tk.Label(self.manage_frame, text="Address", bg="white", font=("Arial", 11)).grid(row=row_i, column=0, padx=8, pady=6, sticky="nw") - self.address_txt = tk.Text(self.manage_frame, width=33, height=4) - self.address_txt.grid(row=row_i, column=1, padx=8, pady=6, sticky="w") - row_i += 1 - - # If student, lock username field to own login and disable editing - if self.role == "student": - self.username_var.set(self.username) - uname_entry.config(state="disabled") - - # Buttons row inside manage panel - btn_frame = tk.Frame(self.manage_frame, bg="white") - btn_frame.grid(row=row_i, column=0, columnspan=2, pady=10) - if self.role in ("admin", "staff"): - self.btn_add = tk.Button(btn_frame, text="Add", width=10, command=self.add_student, - bg="#27ae60", fg="white", font=("Arial", 11)) - self.btn_add.grid(row=0, column=0, padx=6) - - self.btn_update = tk.Button(btn_frame, text="Update", width=10, command=self.update_student, - bg="#2980b9", fg="white", font=("Arial", 11)) - self.btn_update.grid(row=0, column=1, padx=6) - - self.btn_delete = tk.Button(btn_frame, text="Delete", width=10, command=self.delete_student, - bg="#c0392b", fg="white", font=("Arial", 11)) - self.btn_delete.grid(row=0, column=2, padx=6) - - self.btn_csv = tk.Button(btn_frame, text="Export CSV", width=10, command=self.export_csv, - bg="#f39c12", fg="white", font=("Arial", 11)) - self.btn_csv.grid(row=0, column=3, padx=6) - - self.btn_pdf = tk.Button(btn_frame, text="Export PDF", width=10, command=self.export_pdf, - bg="#d35400", fg="white", font=("Arial", 11)) - self.btn_pdf.grid(row=0, column=4, padx=6) - - # Admin-only: Add User (popup) - if self.role == "admin": - self.btn_add_user = tk.Button(btn_frame, text="Add User", width=10, - command=self.open_add_user_popup, bg="#6c5ce7", fg="white", - font=("Arial", 11)) - self.btn_add_user.grid(row=0, column=5, padx=6) - - # Search area above table (left side) - search_frame = tk.Frame(self.root, bd=4, relief=tk.RIDGE, bg="white") - search_frame.place(x=20, y=75, width=550, height=36) - - ttk.Combobox(search_frame, textvariable=self.search_by, - values=("roll_no", "name", "contact", "username"), - state="readonly", width=14).grid(row=0, column=0, padx=6, pady=3) - - tk.Entry(search_frame, textvariable=self.search_txt, width=30).grid(row=0, column=1, padx=6) - tk.Button(search_frame, text="Search", command=self.search_student, width=10, - bg="#8e44ad", fg="white").grid(row=0, column=2, padx=4) - tk.Button(search_frame, text="Reset", command=self.fetch_data, width=10, - bg="#95a5a6", fg="black").grid(row=0, column=3, padx=4) - - # Table - self.student_table = ttk.Treeview(self.table_frame, - columns=("roll", "name", "email", "gender", "contact", "dob", "address", "username"), - show="headings") - for col, w in zip(("roll", "name", "email", "gender", "contact", "dob", "address", "username"), - (80, 140, 180, 100, 120, 110, 220, 130)): - self.student_table.heading(col, text=col.title()) - self.student_table.column(col, width=w, anchor="w") - self.student_table.pack(fill=tk.BOTH, expand=1) - self.student_table.bind("", self.get_cursor) - - # Role restrictions (student = read-only + own records only) - if self.role == "student": - # Only disable if buttons exist (they don't for student) - for attr in ("btn_add", "btn_update", "btn_delete", "btn_add_user"): - if hasattr(self, attr): - getattr(self, attr).config(state="disabled") - - # Staff can manage students but not add users - if self.role == "staff" and hasattr(self, "btn_add_user"): - self.btn_add_user.config(state="disabled") - - self.fetch_data() - - # ------------------------ CRUD ------------------------ - def add_student(self): - if not self.roll_no_var.get() or not self.name_var.get(): - messagebox.showerror("Error", "Roll No and Name are required.") - return - - # Map empty username to None so SQLite stores NULL (UNIQUE allows multiple NULLs) - link_username = self.username_var.get().strip() - link_username = link_username if link_username else None - - address = self.address_txt.get("1.0", tk.END).strip() - conn = db_connect() - c = conn.cursor() - try: - c.execute(""" - INSERT INTO students (roll_no, name, email, gender, contact, dob, address, username) - VALUES (?,?,?,?,?,?,?,?) - """, ( - self.roll_no_var.get().strip(), - self.name_var.get().strip(), - self.email_var.get().strip(), - self.gender_var.get().strip(), - self.contact_var.get().strip(), - self.dob_var.get().strip(), - address, - link_username - )) - conn.commit() - messagebox.showinfo("Success", "Student added successfully.") - self.clear_fields() - self.fetch_data() - except sqlite3.IntegrityError as e: - messagebox.showerror("Error", f"Duplicate Roll No / Username conflict.\n{e}") - finally: - conn.close() - - def update_student(self): - if not self.roll_no_var.get(): - messagebox.showerror("Error", "Select a student from the list.") - return - - # Allow updating the link (admin/staff); students can't edit (field is disabled) - link_username = self.username_var.get().strip() - link_username = link_username if link_username else None - - address = self.address_txt.get("1.0", tk.END).strip() - conn = db_connect() - c = conn.cursor() - try: - c.execute(""" - UPDATE students SET - name=?, email=?, gender=?, contact=?, dob=?, address=?, username=? - WHERE roll_no=? - """, (self.name_var.get().strip(), self.email_var.get().strip(), self.gender_var.get().strip(), - self.contact_var.get().strip(), self.dob_var.get().strip(), address, link_username, - self.roll_no_var.get().strip())) - conn.commit() - messagebox.showinfo("Updated", "Student record updated.") - self.fetch_data() - self.clear_fields() - except sqlite3.IntegrityError as e: - messagebox.showerror("Error", f"Username is already linked to another student.\n{e}") - finally: - conn.close() - - def delete_student(self): - if not self.roll_no_var.get(): - messagebox.showerror("Error", "Select a student from the list.") - return - if not messagebox.askyesno("Confirm", "Delete this student?"): - return - - conn = db_connect() - c = conn.cursor() - c.execute("DELETE FROM students WHERE roll_no=?", (self.roll_no_var.get().strip(),)) - conn.commit() - conn.close() - messagebox.showinfo("Deleted", "Student record deleted.") - self.fetch_data() - self.clear_fields() - - def get_cursor(self, _event): - sel = self.student_table.focus() - vals = self.student_table.item(sel, "values") - if not vals: - return - self.roll_no_var.set(vals[0]) - self.name_var.set(vals[1]) - self.email_var.set(vals[2]) - self.gender_var.set(vals[3]) - self.contact_var.set(vals[4]) - self.dob_var.set(vals[5]) - self.address_txt.delete("1.0", tk.END) - self.address_txt.insert(tk.END, vals[6]) - self.username_var.set(vals[7] if len(vals) > 7 else "") - - def clear_fields(self): - self.roll_no_var.set("") - self.name_var.set("") - self.email_var.set("") - self.gender_var.set("Male") - self.contact_var.set("") - self.dob_var.set("") - self.username_var.set("" if self.role != "student" else self.username) - self.address_txt.delete("1.0", tk.END) - - # ------------------------ Search & Load ------------------------ - def fetch_data(self): - conn = db_connect() - c = conn.cursor() - if self.role == "student": - # Only show the logged-in student's record - c.execute("SELECT roll_no, name, email, gender, contact, dob, address, username " - "FROM students WHERE username=? ORDER BY name", (self.username,)) - else: - c.execute("SELECT roll_no, name, email, gender, contact, dob, address, username " - "FROM students ORDER BY name") - rows = c.fetchall() - conn.close() - - self.student_table.delete(*self.student_table.get_children()) - for r in rows: - self.student_table.insert("", tk.END, values=r) - - def search_student(self): - field = self.search_by.get() - val = self.search_txt.get().strip() - if not val: - messagebox.showerror("Error", "Enter a value to search.") - return - - valid = {"roll_no","name","contact","username"} - if field not in valid: - field = "name" - - conn = db_connect() - c = conn.cursor() - if self.role == "student": - c.execute(f"SELECT roll_no, name, email, gender, contact, dob, address, username " - f"FROM students WHERE username=? AND {field} LIKE ?", - (self.username, f"%{val}%")) - else: - c.execute(f"SELECT roll_no, name, email, gender, contact, dob, address, username " - f"FROM students WHERE {field} LIKE ?", (f"%{val}%",)) - rows = c.fetchall() - conn.close() - - self.student_table.delete(*self.student_table.get_children()) - for r in rows: - self.student_table.insert("", tk.END, values=r) - - # ------------------------ Export ------------------------ - def export_csv(self): - rows = self.student_table.get_children() - if not rows: - messagebox.showwarning("No Data", "No data to export.") - return - - path = filedialog.asksaveasfilename(defaultextension=".csv", - filetypes=[("CSV files", "*.csv")], - title="Save CSV") - if not path: - return - - with open(path, "w", newline="", encoding="utf-8") as f: - wr = csv.writer(f) - wr.writerow(["Roll No", "Name", "Email", "Gender", "Contact", "D.O.B", "Address", "Username"]) - for r in rows: - wr.writerow(self.student_table.item(r)["values"]) - messagebox.showinfo("Exported", f"CSV saved to:\n{path}") - - def export_pdf(self): - rows = self.student_table.get_children() - if not rows: - messagebox.showwarning("No Data", "No data to export.") - return - - path = filedialog.asksaveasfilename(defaultextension=".pdf", - filetypes=[("PDF files", "*.pdf")], - title="Save PDF") - if not path: - return - - pdf = canvas.Canvas(path, pagesize=A4) - w, h = A4 - y = h - 50 - - pdf.setTitle("Student Records") - pdf.setFont("Helvetica-Bold", 14) - pdf.drawCentredString(w / 2, y, "Student Records") - y -= 24 - pdf.setFont("Helvetica", 9) - - headers = ["Roll No", "Name", "Email", "Gender", "Contact", "D.O.B", "Address", "Username"] - x = [20, 80, 150, 270, 320, 380, 440, 515] - - for i, hdr in enumerate(headers): - pdf.drawString(x[i], y, hdr) - y -= 16 - - for r in rows: - data = list(map(str, self.student_table.item(r)["values"])) - for i, val in enumerate(data): - pdf.drawString(x[i], y, val[:35]) - y -= 14 - if y < 40: - pdf.showPage() - y = h - 50 - pdf.setFont("Helvetica-Bold", 14) - pdf.drawCentredString(w / 2, y, "Student Records (cont.)") - y -= 24 - pdf.setFont("Helvetica", 9) - for i, hdr in enumerate(headers): - pdf.drawString(x[i], y, hdr) - y -= 16 - - pdf.save() - messagebox.showinfo("Exported", f"PDF saved to:\n{path}") - - # ------------------------ Admin: Add User (Popup) ------------------------ - def open_add_user_popup(self): - win = tk.Toplevel(self.root) - win.title("Add New User") - win.geometry("360x260") - win.resizable(False, False) - win.config(bg="white") - win.transient(self.root) - win.grab_set() - - tk.Label(win, text="Add New User", font=("Arial", 14, "bold"), - bg="white").pack(pady=10) - - frm = tk.Frame(win, bg="white") - frm.pack(pady=6) - - tk.Label(frm, text="Username:", bg="white").grid(row=0, column=0, sticky="w", padx=8, pady=6) - uname = tk.Entry(frm, width=24) - uname.grid(row=0, column=1, padx=8) - - tk.Label(frm, text="Password:", bg="white").grid(row=1, column=0, sticky="w", padx=8, pady=6) - pwd = tk.Entry(frm, width=24, show="*") - pwd.grid(row=1, column=1, padx=8) - - tk.Label(frm, text="Role:", bg="white").grid(row=2, column=0, sticky="w", padx=8, pady=6) - role_var = tk.StringVar(value="student") - ttk.Combobox(frm, textvariable=role_var, values=["admin", "staff", "student"], - state="readonly", width=21).grid(row=2, column=1, padx=8) - - def save_user(): - u = uname.get().strip() - p = pwd.get() - r = role_var.get() - if not u or not p: - messagebox.showerror("Error", "All fields are required.", parent=win) - return - - conn = db_connect() - c = conn.cursor() - try: - c.execute("INSERT INTO users (username, password, role) VALUES (?,?,?)", (u, p, r)) - conn.commit() - messagebox.showinfo("Success", f"User '{u}' added as {r}.", parent=win) - win.destroy() - except sqlite3.IntegrityError: - messagebox.showerror("Error", "Username already exists.", parent=win) - finally: - conn.close() - - tk.Button(win, text="Add User", command=save_user, width=12, - bg="#2ecc71", fg="white").pack(pady=12) - - if self.dark_mode: - self._apply_popup_theme(win) - - # ------------------------ Theme ------------------------ - def toggle_theme(self): - self.dark_mode = not self.dark_mode - if self.dark_mode: - bg = "#1f2937" # dark slate - fg = "white" - card = "#111827" - btn = "#374151" - self.theme_btn.config(text="☀️ Light Mode") - else: - bg = "white" - fg = "black" - card = "white" - btn = "#f5f5f5" - self.theme_btn.config(text="🌙 Dark Mode") - - # Root + title - self.root.config(bg=bg) - self.title_bar.config(bg="#0f172a" if self.dark_mode else "#34495e", fg="white") - - # Walk and recolor - def paint(widget): - try: - if isinstance(widget, (tk.Frame, tk.LabelFrame)): - widget.config(bg=card) - elif isinstance(widget, tk.Label): - widget.config(bg=card, fg=fg) - elif isinstance(widget, tk.Button): - widget.config(bg=btn, fg=fg, activebackground=btn, activeforeground=fg) - elif isinstance(widget, (tk.Entry, tk.Text)): - widget.config(bg="#111827" if self.dark_mode else "white", - fg=fg, insertbackground=fg) - except Exception: - pass - for ch in widget.winfo_children(): - paint(ch) - - paint(self.root) - - # ttk styles (Treeview/Combobox) - style = ttk.Style(self.root) - if self.dark_mode: - style.theme_use("clam") - style.configure("Treeview", - background="#0b1220", fieldbackground="#0b1220", - foreground="white") - style.configure("TCombobox", - fieldbackground="#111827", - foreground="white") - else: - style.theme_use("default") - style.configure("Treeview", - background="white", fieldbackground="white", - foreground="black") - style.configure("TCombobox", - fieldbackground="white", - foreground="black") - - def _apply_popup_theme(self, win): - if not self.dark_mode: - return - def paint(widget): - try: - if isinstance(widget, (tk.Frame, tk.LabelFrame, tk.Toplevel)): - widget.config(bg="#111827") - elif isinstance(widget, tk.Label): - widget.config(bg="#111827", fg="white") - elif isinstance(widget, tk.Entry): - widget.config(bg="#0b1220", fg="white", insertbackground="white") - elif isinstance(widget, tk.Button): - widget.config(bg="#374151", fg="white", activebackground="#374151", activeforeground="white") - except Exception: - pass - for ch in widget.winfo_children(): - paint(ch) - paint(win) - - -if __name__ == "__main__": - init_db() - root = tk.Tk() - LoginWindow(root) - root.mainloop() diff --git a/Student Management System/app.db b/Student Management System/app.db deleted file mode 100644 index c4a197f7cdc5b95abd4e1c8d22b09aafaa86dbc5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24576 zcmeI(Z)?*)90%~ENxOB>o}<)bNbx|SD;rz?Y-HYxL| zW3R#oz6ZgV;8XF5;4}AV=o8r^QSgbEtLfI+f{%piH#E8HU4;@QHB5y;-;dkMe zgQ}w(0uX=z1Rwwb2tWV=5P-nHEpSAY5gLoh$FigCGO^&e(Tgz%TtER;&#fKGke{*Ghd2^es6}P8*YMGj$iz>avx}i0ieaiQAv#z)L zoTyo*qt%=~XX@5YpHi(}Z|Sz(=SSFM$e6PaFTcuL(1>je&b6vXUS>bN6ur?`9td!YgT&o)mGd>;XaqjMlRQyWfiNrZq z!WW>6bP$&qLp3E5h5w^Q-G5bx?|$+^BA>|z@`k+PS!@u100bZa0SG_<0uX=z1Rwwb z2>dmHs9&Y@nyt08{m!8?Ei|sG(I_<<2fDk9*?@6HjYN1&!~Jw>5g#w22fE2WqRv%W zr78ypu(mIe#AC>1d{A8KSdnGM8VNpUb3E=~Qyg>&)0x zf`m>@3~YOczk9($8PHmDcoc^{(eUK}t8$P;6Yij*DQ_XbrD$@=Y%ZU3!{MXkxz5m< zZN6gQd}wZ4_xZm<&Lr}KeB}u?2tWV=5P$##AOHafKmY;|fB*!BU%;;rIitA8v`p)KmY;|fB*y_009U<00Izz00jP9fnTfteccxW_1k>E?dBXGm7BgR8{J;p@#PwsC6tmKDG4D=O;gR` zpehK?4M&jwH5ggFq|^M17<`8C8UFm&4X8r^0uX=z1Rwwb2tWV=5P$##{$Jp3i;fE+ z=>4hhT(sQ2=k`2(FbY*GZ(GvN@0Km8PsvnNh9k&3PTQ5Xb!N-MYH>ebJ(nfxJo9tO zmE(Cg-CligN#($nm6LM$x8Ccv!rO;6S=h4*rIaodD>9XI>g{GHnUP7)cN&dB(6754 zKbcPJ=cLodnAjGSG&^l>STou^EM1)XPeYkG~U?}_jy!aw;3f8uZab>tt2 zKZF1TAOHafKmY;|fB*y_009U2BF^c=Zpzc HOzQ9(v*?N) diff --git a/Student Management System/sms.db b/Student Management System/sms.db deleted file mode 100644 index dd3a30346774ca306eb56c72f7612836e02d54ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI(&rZTH90%}rLj)2S35Q;Lgg{J;XA`Gs%;Fd@w?s~4LP?0QDI3wN@lkvpU&Wh5 zZZ&w{6*V#~vk;52NWMNcagU6H3V$=Y$YV)r_j^pz371zaIOm z)yPqw7L8A$mtINf!+28-0s;_#00bZa0SG_<0uX=z1R$_Wpsi=?U(c=eFQmbE{!6kqx!7_UzL`#u$Ccr+dy~IgQhUwy{3F#H$7V zD@FruIi9!_E^oV5({#K1N_4sDdo9aRtDC~{8XGGXyDaX^J#pi4-?2Kr;7-fqj&IwW zQ}2T~evGDr<<#ACrqOt{)q4ErZnc`0Wv7giQ8e%!*qsbjiIZSBEU3Jw@kLZYKmY;| zfB*y_009U<00Izz00bbgZvt7pl240M}ThygL^^% diff --git a/Student Management System/student.db b/Student Management System/student.db deleted file mode 100644 index e5fd93d35a968f1fba6a9977a0e5c376dcab67c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmeI&O>fgM7zc2>O}8r#`{bnm%6pVfdKmY;|fB*y_009U< z00I#B7lCWLy6!j*z5b;6X)Gp6WU4q=2P1djxy&0pAG)mQVh=ajwB+Y0PXzPacOH8^ zdT}@yov@eg$>Ukb2d>nG%);LxN3T3~^maJB9nWMuy}TSNE^RigHD}MEBuYbZsXoT$ zh12H~EsJx%n26sls@msgblqt*=$E}>@=8xak!n@el|ND_d*`w!v&m~So!C$1l7!}? zXSv_zGe0a69>v8^{e65aOmf+hfIz022YN|FC{LG+@-WPVQbok;^J9;HKFU*Q{!)F1!>2tWV=5P$##AOHafKmY

Wb(^GTB39P_~CWEu&5$>8oj7! zrWTupX&h-e)thP-PuAJ_Wu{OWR5_cQt#8R|VD9w3T*%0Sh-b*Iy6I!>qA uY}eb}?twj&z5jpj)amfE5P$##AOHafKmY;|fB*y_009X6O(2&Q`;8x1{84HE diff --git a/Student Management System/users.db b/Student Management System/users.db deleted file mode 100644 index e7202870a523ab9feb2e86cfc9d235af167c330b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI#&r8EF6u|M9w)hJ|y!9mH=wJxqS#U}Z;s#oKaHmpTg+aG!+o4zgK>r^R52lfw zcXoUqd3}9J6X@rb`@8YHEA4aLEQ-#a$)PAE7uJeM;4*Z%4&`2=b>nK^Z;C)po|HCU z5|~O%Wj;4;U>yMj5I_I{1Q0*~0R#|00D=E5kgMG|j@8FeS3Fkbsx6z=KZ2_?8D)vh zMwjEn`YC(dmu0aiZI(P__BOqqjMAySNv1>lQnc+`-OT*b&^C4T_tjt!?8N7>lKFC0 zzPGQHyV>W(s;hmU`-AgSU;2CfUhk=0D)TLF5C|ZE00IagfB*srAb Date: Tue, 11 Aug 2026 14:49:32 +0100 Subject: [PATCH 2/4] Restructure into a modular app package with a clear entry point Split the single Student_management_system.py file into main.py plus app/database.py, login_window.py, dashboard.py, theme.py, export.py, and validators.py. The database and its tables are now created at runtime via CREATE TABLE IF NOT EXISTS instead of committing .db files. Passwords are now stored as SHA-256 hashes instead of plaintext. --- app/__init__.py | 1 + app/dashboard.py | 433 ++++++++++++++++++++++++++++++++++++++++++++ app/database.py | 197 ++++++++++++++++++++ app/export.py | 60 ++++++ app/login_window.py | 76 ++++++++ app/theme.py | 116 ++++++++++++ app/validators.py | 44 +++++ main.py | 21 +++ requirements.txt | 10 + 9 files changed, 958 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/dashboard.py create mode 100644 app/database.py create mode 100644 app/export.py create mode 100644 app/login_window.py create mode 100644 app/theme.py create mode 100644 app/validators.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ade5200 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Student Management System application package.""" diff --git a/app/dashboard.py b/app/dashboard.py new file mode 100644 index 0000000..d5b15c7 --- /dev/null +++ b/app/dashboard.py @@ -0,0 +1,433 @@ +"""Main application window: student record table + manage/search panels. + +Behaviour matches the original single-file version (login roles, CRUD, +search, CSV/PDF export, dark mode) but the UI is rebuilt with ttk widgets, +a grid layout that resizes properly, a sortable/striped data table, and +user-facing validation instead of silent failures. +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox + +from app import database, export, validators +from app.theme import apply_theme + +COLUMNS = ("roll", "name", "email", "gender", "contact", "dob", "address", "username") +COLUMN_LABELS = { + "roll": "Roll No", "name": "Name", "email": "Email", "gender": "Gender", + "contact": "Contact", "dob": "D.O.B", "address": "Address", "username": "Username", +} +COLUMN_WIDTHS = { + "roll": 90, "name": 150, "email": 190, "gender": 90, + "contact": 120, "dob": 100, "address": 220, "username": 130, +} + + +class Dashboard: + def __init__(self, root, user_role, username): + self.root = root + self.role = user_role.lower() + self.username = username + self.dark_mode = False + self.sort_state = {} # column -> ascending bool + + self.root.title(f"{self.role.capitalize()} Dashboard - Student Management System") + self.root.geometry("1440x760") + self.root.minsize(1080, 620) + + self.colors = apply_theme(self.root, dark_mode=False) + + self.root.columnconfigure(0, weight=3) + self.root.columnconfigure(1, weight=2) + self.root.rowconfigure(2, weight=1) + + self._build_header() + self._build_search_and_table() + self._build_manage_panel() + self._apply_role_restrictions() + + self.fetch_data() + + # ------------------------ Layout ------------------------ + def _build_header(self): + header = ttk.Frame(self.root, style="HeaderBar.TFrame", padding=(20, 14)) + header.grid(row=0, column=0, columnspan=2, sticky="ew") + header.columnconfigure(0, weight=1) + + ttk.Label(header, text="Student Management System", style="Header.TLabel").grid( + row=0, column=0, sticky="w") + + self.theme_btn = ttk.Button(header, text="Dark Mode", style="Secondary.TButton", + command=self.toggle_theme) + self.theme_btn.grid(row=0, column=1, sticky="e") + + sub = ttk.Frame(self.root, style="Page.TFrame", padding=(20, 10, 20, 4)) + sub.grid(row=1, column=0, columnspan=2, sticky="ew") + ttk.Label(sub, text=f"Signed in as {self.username} ({self.role.capitalize()})", + style="Sub.TLabel").pack(anchor="w") + + def _build_search_and_table(self): + left = ttk.Frame(self.root, style="Page.TFrame", padding=(20, 10, 10, 20)) + left.grid(row=2, column=0, sticky="nsew") + left.rowconfigure(1, weight=1) + left.columnconfigure(0, weight=1) + + # --- search bar --- + search_card = ttk.Frame(left, style="Card.TFrame", padding=12) + search_card.grid(row=0, column=0, sticky="ew", pady=(0, 12)) + search_card.columnconfigure(1, weight=1) + + self.search_by = tk.StringVar(value="roll_no") + self.search_txt = tk.StringVar() + + ttk.Label(search_card, text="Search by", style="Field.TLabel").grid(row=0, column=0, padx=(0, 8)) + ttk.Combobox(search_card, textvariable=self.search_by, + values=database.SEARCHABLE_FIELDS, state="readonly", width=12).grid( + row=0, column=1, sticky="w") + entry = ttk.Entry(search_card, textvariable=self.search_txt) + entry.grid(row=0, column=2, sticky="ew", padx=8) + search_card.columnconfigure(2, weight=1) + entry.bind("", lambda _e: self.search_student()) + + ttk.Button(search_card, text="Search", style="Accent.TButton", + command=self.search_student).grid(row=0, column=3, padx=(0, 6)) + ttk.Button(search_card, text="Reset", style="Secondary.TButton", + command=self.fetch_data).grid(row=0, column=4) + + # --- table --- + table_card = ttk.Frame(left, style="Card.TFrame", padding=8) + table_card.grid(row=1, column=0, sticky="nsew") + table_card.rowconfigure(0, weight=1) + table_card.columnconfigure(0, weight=1) + + self.student_table = ttk.Treeview(table_card, columns=COLUMNS, show="headings") + for col in COLUMNS: + self.student_table.heading(col, text=COLUMN_LABELS[col], + command=lambda c=col: self.sort_by(c)) + self.student_table.column(col, width=COLUMN_WIDTHS[col], anchor="w") + self.student_table.grid(row=0, column=0, sticky="nsew") + + vsb = ttk.Scrollbar(table_card, orient="vertical", command=self.student_table.yview) + vsb.grid(row=0, column=1, sticky="ns") + self.student_table.configure(yscrollcommand=vsb.set) + + self.student_table.tag_configure("oddrow", background=self.colors["row_alt"]) + self.student_table.tag_configure("evenrow", background=self.colors["row"]) + self.student_table.bind("<>", self.get_cursor) + + def _build_manage_panel(self): + right = ttk.Frame(self.root, style="Page.TFrame", padding=(10, 10, 20, 20)) + right.grid(row=2, column=1, sticky="nsew") + right.columnconfigure(0, weight=1) + + panel = ttk.Frame(right, style="Card.TFrame", padding=20) + panel.grid(row=0, column=0, sticky="new") + panel.columnconfigure(1, weight=1) + + ttk.Label(panel, text="Manage Students", style="SectionTitle.TLabel").grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 14)) + + self.roll_no_var = tk.StringVar() + self.name_var = tk.StringVar() + self.email_var = tk.StringVar() + self.gender_var = tk.StringVar(value="Male") + self.contact_var = tk.StringVar() + self.dob_var = tk.StringVar() + self.username_var = tk.StringVar() + + self._row = 1 + + def add_row(label, widget): + ttk.Label(panel, text=label, style="Field.TLabel").grid( + row=self._row, column=0, sticky="w", padx=(0, 10), pady=6) + widget.grid(row=self._row, column=1, sticky="ew", pady=6) + self._row += 1 + + add_row("Roll No", ttk.Entry(panel, textvariable=self.roll_no_var)) + add_row("Name", ttk.Entry(panel, textvariable=self.name_var)) + add_row("Email", ttk.Entry(panel, textvariable=self.email_var)) + + gender_combo = ttk.Combobox(panel, textvariable=self.gender_var, + values=("Male", "Female", "Other"), state="readonly") + gender_combo.current(0) + add_row("Gender", gender_combo) + + add_row("Contact", ttk.Entry(panel, textvariable=self.contact_var)) + add_row("D.O.B (YYYY-MM-DD)", ttk.Entry(panel, textvariable=self.dob_var)) + + self.uname_entry = ttk.Entry(panel, textvariable=self.username_var) + add_row("Username (link)", self.uname_entry) + + ttk.Label(panel, text="Address", style="Field.TLabel").grid( + row=self._row, column=0, sticky="nw", padx=(0, 10), pady=6) + self.address_txt = tk.Text(panel, height=4, wrap="word", relief="flat", + bg=self.colors["surface"], fg=self.colors["text"], + insertbackground=self.colors["text"], font=("Segoe UI", 10)) + self.address_txt.grid(row=self._row, column=1, sticky="ew", pady=6) + self._row += 1 + + if self.role == "student": + self.username_var.set(self.username) + self.uname_entry.config(state="disabled") + + btn_frame = ttk.Frame(panel, style="Card.TFrame") + btn_frame.grid(row=self._row, column=0, columnspan=2, pady=(16, 0), sticky="w") + self._row += 1 + + col = 0 + if self.role in ("admin", "staff"): + self.btn_add = ttk.Button(btn_frame, text="Add", style="Success.TButton", + command=self.add_student) + self.btn_add.grid(row=0, column=col, padx=4, pady=4); col += 1 + + self.btn_update = ttk.Button(btn_frame, text="Update", style="Primary.TButton", + command=self.update_student) + self.btn_update.grid(row=0, column=col, padx=4, pady=4); col += 1 + + self.btn_delete = ttk.Button(btn_frame, text="Delete", style="Danger.TButton", + command=self.delete_student) + self.btn_delete.grid(row=0, column=col, padx=4, pady=4); col += 1 + + self.btn_clear = ttk.Button(btn_frame, text="Clear", style="Secondary.TButton", + command=self.clear_fields) + self.btn_clear.grid(row=0, column=col, padx=4, pady=4); col += 1 + + self.btn_csv = ttk.Button(btn_frame, text="Export CSV", style="Warning.TButton", + command=self.export_csv) + self.btn_csv.grid(row=1, column=0, padx=4, pady=4) + + self.btn_pdf = ttk.Button(btn_frame, text="Export PDF", style="Warning.TButton", + command=self.export_pdf) + self.btn_pdf.grid(row=1, column=1, padx=4, pady=4) + + if self.role == "admin": + self.btn_add_user = ttk.Button(btn_frame, text="Add User", style="Accent.TButton", + command=self.open_add_user_popup) + self.btn_add_user.grid(row=1, column=2, padx=4, pady=4) + + def _apply_role_restrictions(self): + if self.role == "student": + for attr in ("btn_add", "btn_update", "btn_delete", "btn_clear", "btn_add_user"): + if hasattr(self, attr): + getattr(self, attr).config(state="disabled") + if self.role == "staff" and hasattr(self, "btn_add_user"): + self.btn_add_user.config(state="disabled") + + # ------------------------ CRUD ------------------------ + def add_student(self): + errors = validators.validate_student_form( + self.roll_no_var.get(), self.name_var.get(), self.email_var.get(), + self.contact_var.get(), self.dob_var.get()) + if errors: + messagebox.showerror("Please fix the following", "\n".join(f"- {e}" for e in errors)) + return + + address = self.address_txt.get("1.0", tk.END).strip() + try: + database.add_student( + self.roll_no_var.get().strip(), self.name_var.get().strip(), + self.email_var.get().strip(), self.gender_var.get().strip(), + self.contact_var.get().strip(), self.dob_var.get().strip(), + address, self.username_var.get()) + messagebox.showinfo("Success", "Student added successfully.") + self.clear_fields() + self.fetch_data() + except database.DuplicateError as e: + messagebox.showerror("Error", str(e)) + + def update_student(self): + if not self.roll_no_var.get(): + messagebox.showerror("Error", "Select a student from the list first.") + return + + errors = validators.validate_student_form( + self.roll_no_var.get(), self.name_var.get(), self.email_var.get(), + self.contact_var.get(), self.dob_var.get()) + if errors: + messagebox.showerror("Please fix the following", "\n".join(f"- {e}" for e in errors)) + return + + address = self.address_txt.get("1.0", tk.END).strip() + try: + updated = database.update_student( + self.roll_no_var.get().strip(), self.name_var.get().strip(), + self.email_var.get().strip(), self.gender_var.get().strip(), + self.contact_var.get().strip(), self.dob_var.get().strip(), + address, self.username_var.get()) + if not updated: + messagebox.showwarning("Not Found", "No student with that Roll No exists.") + return + messagebox.showinfo("Updated", "Student record updated.") + self.fetch_data() + self.clear_fields() + except database.DuplicateError as e: + messagebox.showerror("Error", str(e)) + + def delete_student(self): + if not self.roll_no_var.get(): + messagebox.showerror("Error", "Select a student from the list first.") + return + if not messagebox.askyesno("Confirm", "Delete this student record?"): + return + + deleted = database.delete_student(self.roll_no_var.get().strip()) + if not deleted: + messagebox.showwarning("Not Found", "No student with that Roll No exists.") + return + messagebox.showinfo("Deleted", "Student record deleted.") + self.fetch_data() + self.clear_fields() + + def get_cursor(self, _event): + sel = self.student_table.focus() + vals = self.student_table.item(sel, "values") + if not vals: + return + self.roll_no_var.set(vals[0]) + self.name_var.set(vals[1]) + self.email_var.set(vals[2]) + self.gender_var.set(vals[3] or "Male") + self.contact_var.set(vals[4]) + self.dob_var.set(vals[5]) + self.address_txt.delete("1.0", tk.END) + self.address_txt.insert(tk.END, vals[6]) + if self.role != "student": + self.username_var.set(vals[7] if len(vals) > 7 else "") + + def clear_fields(self): + self.roll_no_var.set("") + self.name_var.set("") + self.email_var.set("") + self.gender_var.set("Male") + self.contact_var.set("") + self.dob_var.set("") + self.username_var.set("" if self.role != "student" else self.username) + self.address_txt.delete("1.0", tk.END) + + # ------------------------ Search & Load ------------------------ + def _populate(self, rows): + self.student_table.delete(*self.student_table.get_children()) + for i, r in enumerate(rows): + tag = "evenrow" if i % 2 == 0 else "oddrow" + # Tk's Treeview stringifies values via Tcl, turning a Python + # None (e.g. an unlinked student's username) into the literal + # text "None". Store "" instead so it round-trips correctly + # when a row is re-read for editing. + clean = tuple("" if v is None else v for v in r) + self.student_table.insert("", tk.END, values=clean, tags=(tag,)) + + def fetch_data(self): + self._populate(database.fetch_students(self.role, self.username)) + + def search_student(self): + val = self.search_txt.get().strip() + if not val: + messagebox.showerror("Error", "Enter a value to search for.") + return + rows = database.search_students(self.role, self.username, self.search_by.get(), val) + self._populate(rows) + if not rows: + messagebox.showinfo("No Results", "No matching student records were found.") + + def sort_by(self, column): + ascending = self.sort_state.get(column, True) + items = [(self.student_table.set(k, column), k) for k in self.student_table.get_children("")] + items.sort(key=lambda t: t[0].lower(), reverse=not ascending) + for index, (_, k) in enumerate(items): + self.student_table.move(k, "", index) + for index, (_, k) in enumerate(items): + tag = "evenrow" if index % 2 == 0 else "oddrow" + self.student_table.item(k, tags=(tag,)) + self.sort_state[column] = not ascending + + # ------------------------ Export ------------------------ + def export_csv(self): + rows = self.student_table.get_children() + if not rows: + messagebox.showwarning("No Data", "No data to export.") + return + path = filedialog.asksaveasfilename(defaultextension=".csv", + filetypes=[("CSV files", "*.csv")], title="Save CSV") + if not path: + return + data = [self.student_table.item(r)["values"] for r in rows] + try: + export.export_csv(data, path) + messagebox.showinfo("Exported", f"CSV saved to:\n{path}") + except OSError as e: + messagebox.showerror("Export Failed", f"Could not write file:\n{e}") + + def export_pdf(self): + rows = self.student_table.get_children() + if not rows: + messagebox.showwarning("No Data", "No data to export.") + return + path = filedialog.asksaveasfilename(defaultextension=".pdf", + filetypes=[("PDF files", "*.pdf")], title="Save PDF") + if not path: + return + data = [self.student_table.item(r)["values"] for r in rows] + try: + export.export_pdf(data, path) + messagebox.showinfo("Exported", f"PDF saved to:\n{path}") + except ImportError as e: + messagebox.showerror("Missing Dependency", str(e)) + except OSError as e: + messagebox.showerror("Export Failed", f"Could not write file:\n{e}") + + # ------------------------ Admin: Add User ------------------------ + def open_add_user_popup(self): + win = tk.Toplevel(self.root) + win.title("Add New User") + win.geometry("380x300") + win.resizable(False, False) + win.configure(bg=self.colors["bg"]) + win.transient(self.root) + win.grab_set() + + frm = ttk.Frame(win, style="Card.TFrame", padding=20) + frm.pack(fill="both", expand=True, padx=16, pady=16) + frm.columnconfigure(1, weight=1) + + ttk.Label(frm, text="Add New User", style="SectionTitle.TLabel").grid( + row=0, column=0, columnspan=2, pady=(0, 14), sticky="w") + + ttk.Label(frm, text="Username", style="Field.TLabel").grid(row=1, column=0, sticky="w", pady=6) + uname = ttk.Entry(frm) + uname.grid(row=1, column=1, sticky="ew", pady=6) + + ttk.Label(frm, text="Password", style="Field.TLabel").grid(row=2, column=0, sticky="w", pady=6) + pwd = ttk.Entry(frm, show="*") + pwd.grid(row=2, column=1, sticky="ew", pady=6) + + ttk.Label(frm, text="Role", style="Field.TLabel").grid(row=3, column=0, sticky="w", pady=6) + role_var = tk.StringVar(value="student") + ttk.Combobox(frm, textvariable=role_var, values=list(database.VALID_ROLES), + state="readonly").grid(row=3, column=1, sticky="ew", pady=6) + + def save_user(): + u, p, r = uname.get(), pwd.get(), role_var.get() + errors = validators.validate_new_user(u, p) + if errors: + messagebox.showerror("Please fix the following", + "\n".join(f"- {e}" for e in errors), parent=win) + return + try: + database.add_user(u.strip(), p, r) + messagebox.showinfo("Success", f"User '{u.strip()}' added as {r}.", parent=win) + win.destroy() + except database.DuplicateError as e: + messagebox.showerror("Error", str(e), parent=win) + + ttk.Button(frm, text="Add User", style="Success.TButton", + command=save_user).grid(row=4, column=0, columnspan=2, pady=(16, 0), sticky="ew") + + # ------------------------ Theme ------------------------ + def toggle_theme(self): + self.dark_mode = not self.dark_mode + self.colors = apply_theme(self.root, dark_mode=self.dark_mode) + self.theme_btn.config(text="Light Mode" if self.dark_mode else "Dark Mode") + self.address_txt.config(bg=self.colors["surface"], fg=self.colors["text"], + insertbackground=self.colors["text"]) + self.student_table.tag_configure("oddrow", background=self.colors["row_alt"]) + self.student_table.tag_configure("evenrow", background=self.colors["row"]) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..1f0a406 --- /dev/null +++ b/app/database.py @@ -0,0 +1,197 @@ +"""Data access layer for the Student Management System. + +Everything that talks to SQLite lives here so the UI code stays free of +raw SQL. The database file is created automatically the first time the +application runs (see init_db) and is never committed to version control. +""" + +import hashlib +import os +import sqlite3 + +# The database lives next to this package so the app works no matter +# what directory it is launched from. +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DB_FILE = os.path.join(BASE_DIR, "app.db") + +VALID_ROLES = ("admin", "staff", "student") +SEARCHABLE_FIELDS = ("roll_no", "name", "contact", "username") + + +class DuplicateError(Exception): + """Raised when an insert/update violates a uniqueness constraint.""" + + +def db_connect(): + conn = sqlite3.connect(DB_FILE) + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def hash_password(password: str) -> str: + """One-way hash for storing passwords (no plaintext in the database).""" + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + +def init_db(): + """Create tables if they do not exist yet and seed a default admin.""" + conn = db_connect() + try: + c = conn.cursor() + + c.execute(""" + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','staff','student')) + ) + """) + + # username is UNIQUE so one login links to at most one student record + c.execute(""" + CREATE TABLE IF NOT EXISTS students ( + roll_no TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + gender TEXT, + contact TEXT, + dob TEXT, + address TEXT, + username TEXT UNIQUE, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE SET NULL + ) + """) + + c.execute("SELECT 1 FROM users WHERE username='admin'") + if not c.fetchone(): + c.execute( + "INSERT INTO users (username, password, role) VALUES (?,?,?)", + ("admin", hash_password("admin123"), "admin"), + ) + conn.commit() + finally: + conn.close() + + +# ------------------------ Auth ------------------------ +def verify_login(username: str, password: str): + """Return the user's role if credentials match, else None.""" + conn = db_connect() + try: + c = conn.cursor() + c.execute("SELECT role FROM users WHERE username=? AND password=?", + (username, hash_password(password))) + row = c.fetchone() + return row[0] if row else None + finally: + conn.close() + + +def add_user(username: str, password: str, role: str): + if role not in VALID_ROLES: + raise ValueError(f"Invalid role: {role}") + conn = db_connect() + try: + c = conn.cursor() + try: + c.execute("INSERT INTO users (username, password, role) VALUES (?,?,?)", + (username, hash_password(password), role)) + conn.commit() + except sqlite3.IntegrityError as exc: + raise DuplicateError("Username already exists.") from exc + finally: + conn.close() + + +# ------------------------ Student CRUD ------------------------ +def _normalize_username(username: str): + username = (username or "").strip() + return username or None + + +def add_student(roll_no, name, email, gender, contact, dob, address, username): + conn = db_connect() + try: + c = conn.cursor() + try: + c.execute(""" + INSERT INTO students (roll_no, name, email, gender, contact, dob, address, username) + VALUES (?,?,?,?,?,?,?,?) + """, (roll_no, name, email, gender, contact, dob, address, + _normalize_username(username))) + conn.commit() + except sqlite3.IntegrityError as exc: + raise DuplicateError(f"Duplicate Roll No / Username conflict.\n{exc}") from exc + finally: + conn.close() + + +def update_student(roll_no, name, email, gender, contact, dob, address, username): + conn = db_connect() + try: + c = conn.cursor() + try: + c.execute(""" + UPDATE students SET + name=?, email=?, gender=?, contact=?, dob=?, address=?, username=? + WHERE roll_no=? + """, (name, email, gender, contact, dob, address, + _normalize_username(username), roll_no)) + conn.commit() + return c.rowcount + except sqlite3.IntegrityError as exc: + raise DuplicateError(f"Username is already linked to another student.\n{exc}") from exc + finally: + conn.close() + + +def delete_student(roll_no): + conn = db_connect() + try: + c = conn.cursor() + c.execute("DELETE FROM students WHERE roll_no=?", (roll_no,)) + conn.commit() + return c.rowcount + finally: + conn.close() + + +def fetch_students(role, username): + """Return all students, or just the caller's own record for the 'student' role.""" + conn = db_connect() + try: + c = conn.cursor() + if role == "student": + c.execute( + "SELECT roll_no, name, email, gender, contact, dob, address, username " + "FROM students WHERE username=? ORDER BY name", (username,)) + else: + c.execute( + "SELECT roll_no, name, email, gender, contact, dob, address, username " + "FROM students ORDER BY name") + return c.fetchall() + finally: + conn.close() + + +def search_students(role, username, field, value): + if field not in SEARCHABLE_FIELDS: + field = "name" + + conn = db_connect() + try: + c = conn.cursor() + like_val = f"%{value}%" + if role == "student": + c.execute( + f"SELECT roll_no, name, email, gender, contact, dob, address, username " + f"FROM students WHERE username=? AND {field} LIKE ? ORDER BY name", + (username, like_val)) + else: + c.execute( + f"SELECT roll_no, name, email, gender, contact, dob, address, username " + f"FROM students WHERE {field} LIKE ? ORDER BY name", + (like_val,)) + return c.fetchall() + finally: + conn.close() diff --git a/app/export.py b/app/export.py new file mode 100644 index 0000000..59f72de --- /dev/null +++ b/app/export.py @@ -0,0 +1,60 @@ +"""CSV / PDF export helpers, kept separate from the UI code. + +PDF export depends on the optional third-party 'reportlab' package. If it +is not installed, export_pdf raises ImportError with a clear message that +the UI layer turns into a friendly dialog instead of a crash. +""" + +import csv + +HEADERS = ["Roll No", "Name", "Email", "Gender", "Contact", "D.O.B", "Address", "Username"] + + +def export_csv(rows, path): + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(HEADERS) + for row in rows: + writer.writerow(row) + + +def export_pdf(rows, path): + try: + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + except ImportError as exc: + raise ImportError( + "PDF export requires the 'reportlab' package. Install it with:\n" + " pip install reportlab" + ) from exc + + pdf = canvas.Canvas(path, pagesize=A4) + w, h = A4 + y = h - 50 + + pdf.setTitle("Student Records") + x_positions = [20, 80, 150, 270, 320, 380, 440, 515] + + def draw_headers(): + nonlocal y + pdf.setFont("Helvetica-Bold", 14) + pdf.drawCentredString(w / 2, y, "Student Records") + y -= 24 + pdf.setFont("Helvetica", 9) + for i, hdr in enumerate(HEADERS): + pdf.drawString(x_positions[i], y, hdr) + y -= 16 + + draw_headers() + + for row in rows: + data = [str(v) if v is not None else "" for v in row] + for i, val in enumerate(data): + pdf.drawString(x_positions[i], y, val[:35]) + y -= 14 + if y < 40: + pdf.showPage() + y = h - 50 + draw_headers() + + pdf.save() diff --git a/app/login_window.py b/app/login_window.py new file mode 100644 index 0000000..ec8f615 --- /dev/null +++ b/app/login_window.py @@ -0,0 +1,76 @@ +"""Login screen: authenticates against the users table and hands off +control to the main dashboard on success.""" + +import tkinter as tk +from tkinter import ttk, messagebox + +from app import database +from app.theme import apply_theme + + +class LoginWindow: + def __init__(self, root): + self.root = root + self.root.title("Student Management System - Login") + self.root.geometry("1000x620") + self.root.minsize(760, 520) + + self.colors = apply_theme(root, dark_mode=False) + + self.username = tk.StringVar() + self.password = tk.StringVar() + self.show_password = tk.BooleanVar(value=False) + + outer = ttk.Frame(root, style="Page.TFrame") + outer.pack(fill="both", expand=True) + outer.rowconfigure(0, weight=1) + outer.columnconfigure(0, weight=1) + + card = ttk.Frame(outer, style="Card.TFrame", padding=36) + card.place(relx=0.5, rely=0.5, anchor="center", width=420) + + ttk.Label(card, text="Student Management System", style="SectionTitle.TLabel", + font=("Segoe UI", 16, "bold")).pack(pady=(0, 4)) + ttk.Label(card, text="Sign in to continue", style="Field.TLabel").pack(pady=(0, 20)) + + ttk.Label(card, text="Username", style="Field.TLabel").pack(anchor="w") + ttk.Entry(card, textvariable=self.username, width=34).pack(fill="x", pady=(2, 14)) + + ttk.Label(card, text="Password", style="Field.TLabel").pack(anchor="w") + self.password_entry = ttk.Entry(card, textvariable=self.password, width=34, show="*") + self.password_entry.pack(fill="x", pady=(2, 6)) + + ttk.Checkbutton(card, text="Show password", variable=self.show_password, + command=self.toggle_password).pack(anchor="w", pady=(0, 18)) + + ttk.Button(card, text="Login", style="Primary.TButton", + command=self.login).pack(fill="x", pady=(0, 10)) + + ttk.Label(card, text="Default admin login: admin / admin123", + style="Field.TLabel").pack(pady=(6, 0)) + + self.password_entry.bind("", lambda _e: self.login()) + self.root.bind("", lambda _e: self.login()) + + def toggle_password(self): + self.password_entry.config(show="" if self.show_password.get() else "*") + + def login(self): + user = self.username.get().strip() + pwd = self.password.get() + + if not user or not pwd: + messagebox.showerror("Error", "Enter username and password.") + return + + role = database.verify_login(user, pwd) + + if role: + self.root.destroy() + main_root = tk.Tk() + # Imported here to avoid a circular import (dashboard imports theme only). + from app.dashboard import Dashboard + Dashboard(main_root, user_role=role, username=user) + main_root.mainloop() + else: + messagebox.showerror("Access Denied", "Invalid username or password.") diff --git a/app/theme.py b/app/theme.py new file mode 100644 index 0000000..2deba17 --- /dev/null +++ b/app/theme.py @@ -0,0 +1,116 @@ +"""Centralized ttk styling for a consistent, modern look across the app.""" + +from tkinter import ttk + +# ---- Palette ---- +LIGHT = { + "bg": "#f4f6f9", + "surface": "#ffffff", + "border": "#d7dde5", + "text": "#1f2933", + "muted": "#5f6b7a", + "header_bg": "#1f2d3d", + "header_fg": "#ffffff", + "primary": "#2563eb", + "primary_fg": "#ffffff", + "success": "#15803d", + "danger": "#b91c1c", + "warning": "#c2650a", + "accent": "#6d28d9", + "row_alt": "#eef2f8", + "row": "#ffffff", +} + +DARK = { + "bg": "#111827", + "surface": "#1a2333", + "border": "#2c3a52", + "text": "#e5e9f0", + "muted": "#9aa7b8", + "header_bg": "#0b1220", + "header_fg": "#ffffff", + "primary": "#3b82f6", + "primary_fg": "#ffffff", + "success": "#22c55e", + "danger": "#ef4444", + "warning": "#f59e0b", + "accent": "#a78bfa", + "row_alt": "#202b40", + "row": "#1a2333", +} + +FONT_FAMILY = "Segoe UI" + + +def palette(dark_mode: bool): + return DARK if dark_mode else LIGHT + + +def apply_theme(root, dark_mode: bool = False): + """Configure ttk.Style with a custom themed look. Returns the palette used.""" + colors = palette(dark_mode) + style = ttk.Style(root) + style.theme_use("clam") + + root.configure(bg=colors["bg"]) + + style.configure(".", font=(FONT_FAMILY, 10), background=colors["bg"], foreground=colors["text"]) + + style.configure("Header.TLabel", font=(FONT_FAMILY, 18, "bold"), + background=colors["header_bg"], foreground=colors["header_fg"]) + style.configure("HeaderBar.TFrame", background=colors["header_bg"]) + + style.configure("Sub.TLabel", font=(FONT_FAMILY, 11), + background=colors["bg"], foreground=colors["muted"]) + + style.configure("SectionTitle.TLabel", font=(FONT_FAMILY, 13, "bold"), + background=colors["surface"], foreground=colors["text"]) + + style.configure("Card.TFrame", background=colors["surface"], relief="flat") + style.configure("Page.TFrame", background=colors["bg"]) + + style.configure("TLabel", background=colors["surface"], foreground=colors["text"]) + style.configure("Field.TLabel", background=colors["surface"], foreground=colors["muted"], + font=(FONT_FAMILY, 10)) + + style.configure("TEntry", fieldbackground=colors["surface"], foreground=colors["text"], + bordercolor=colors["border"], lightcolor=colors["border"], + darkcolor=colors["border"], padding=6) + style.configure("TCombobox", fieldbackground=colors["surface"], foreground=colors["text"], + padding=6) + + # Buttons + style.configure("TButton", font=(FONT_FAMILY, 10, "bold"), padding=(14, 8), borderwidth=0) + + style.configure("Primary.TButton", background=colors["primary"], foreground=colors["primary_fg"]) + style.map("Primary.TButton", background=[("active", colors["primary"]), ("disabled", colors["border"])]) + + style.configure("Success.TButton", background=colors["success"], foreground="#ffffff") + style.map("Success.TButton", background=[("active", colors["success"]), ("disabled", colors["border"])]) + + style.configure("Danger.TButton", background=colors["danger"], foreground="#ffffff") + style.map("Danger.TButton", background=[("active", colors["danger"]), ("disabled", colors["border"])]) + + style.configure("Warning.TButton", background=colors["warning"], foreground="#ffffff") + style.map("Warning.TButton", background=[("active", colors["warning"]), ("disabled", colors["border"])]) + + style.configure("Accent.TButton", background=colors["accent"], foreground="#ffffff") + style.map("Accent.TButton", background=[("active", colors["accent"]), ("disabled", colors["border"])]) + + style.configure("Secondary.TButton", background=colors["border"], foreground=colors["text"]) + style.map("Secondary.TButton", background=[("active", colors["border"])]) + + # Treeview (data table) + style.configure("Treeview", + background=colors["row"], fieldbackground=colors["row"], + foreground=colors["text"], rowheight=26, borderwidth=0) + style.configure("Treeview.Heading", + font=(FONT_FAMILY, 10, "bold"), + background=colors["header_bg"], foreground=colors["header_fg"], + relief="flat", padding=6) + style.map("Treeview.Heading", background=[("active", colors["header_bg"])]) + style.map("Treeview", + background=[("selected", colors["primary"])], + foreground=[("selected", colors["primary_fg"])]) + + return colors diff --git a/app/validators.py b/app/validators.py new file mode 100644 index 0000000..a8511a9 --- /dev/null +++ b/app/validators.py @@ -0,0 +1,44 @@ +"""Small, dependency-free input validation helpers for the UI layer. + +Each function returns an error message string when the input is invalid, +or None when it is fine. Keeping this separate from the widgets makes the +rules easy to unit test and reuse. +""" + +import re + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +CONTACT_RE = re.compile(r"^[0-9+\-\s()]{6,20}$") + + +def validate_student_form(roll_no, name, email, contact, dob): + """Validate the Manage Students form. Returns a list of error strings.""" + errors = [] + + if not roll_no.strip(): + errors.append("Roll No is required.") + if not name.strip(): + errors.append("Name is required.") + + if email.strip() and not EMAIL_RE.match(email.strip()): + errors.append("Email address is not valid.") + + if contact.strip() and not CONTACT_RE.match(contact.strip()): + errors.append("Contact number looks invalid (use digits, spaces, + or -).") + + if dob.strip() and not DATE_RE.match(dob.strip()): + errors.append("D.O.B must be in YYYY-MM-DD format.") + + return errors + + +def validate_new_user(username, password): + errors = [] + if not username.strip(): + errors.append("Username is required.") + if not password: + errors.append("Password is required.") + elif len(password) < 4: + errors.append("Password must be at least 4 characters.") + return errors diff --git a/main.py b/main.py new file mode 100644 index 0000000..bf12026 --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +"""Entry point for the Student Management System. + +Run with: + python main.py +""" + +import tkinter as tk + +from app import database +from app.login_window import LoginWindow + + +def main(): + database.init_db() + root = tk.Tk() + LoginWindow(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c8de585 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Core application (login, CRUD, search, dark mode, CSV export) uses only +# the Python standard library: tkinter, sqlite3, hashlib, csv, re. +# No install is required beyond a standard Python 3 installation with +# Tk support. +# +# PDF export (File > Export PDF) uses reportlab, which is not part of the +# standard library. It is optional: if it is not installed, the app still +# runs and every other feature works, and PDF export shows a clear message +# instead of crashing. +reportlab>=4.0 From 9f33af2883bfd348637c0f0c55066dd8fc7bd4a7 Mon Sep 17 00:00:00 2001 From: obotu okwori Date: Tue, 11 Aug 2026 14:49:38 +0100 Subject: [PATCH 3/4] Add automated tests for the database layer and the live UI test_database.py covers auth, CRUD, role-scoped queries, search, and validators against a throwaway sqlite file. test_gui.py drives the real Tkinter widgets end to end (login, add/update/delete, search, sort, export, theme toggle, role restrictions) to catch UI-level bugs that unit tests on the data layer alone would miss. --- tests/test_database.py | 115 ++++++++++++++++++++++++ tests/test_gui.py | 195 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/test_database.py create mode 100644 tests/test_gui.py diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..7968992 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,115 @@ +"""Standalone verification of the database layer. + +Run with: python tests/test_database.py +Runs against a throwaway sqlite file in this folder, so it never touches +the real app.db. +""" +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +from app import database + +TEST_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_app.db") +if os.path.exists(TEST_DB): + os.remove(TEST_DB) +database.DB_FILE = TEST_DB + +failures = [] + + +def check(label, cond): + status = "PASS" if cond else "FAIL" + print(f"[{status}] {label}") + if not cond: + failures.append(label) + + +# init + seeded admin (hashed, not plaintext) +database.init_db() +import sqlite3 +conn = sqlite3.connect(TEST_DB) +row = conn.execute("SELECT password FROM users WHERE username='admin'").fetchone() +conn.close() +check("admin seeded", row is not None) +check("admin password is hashed, not plaintext", row[0] != "admin123" and len(row[0]) == 64) + +# login +check("admin login with correct password", database.verify_login("admin", "admin123") == "admin") +check("admin login with wrong password fails", database.verify_login("admin", "wrong") is None) +check("login with unknown user fails", database.verify_login("nobody", "x") is None) + +# add_user + duplicate +database.add_user("alice", "pass123", "staff") +check("verify_login works for new user", database.verify_login("alice", "pass123") == "staff") +try: + database.add_user("alice", "other", "student") + check("duplicate username raises DuplicateError", False) +except database.DuplicateError: + check("duplicate username raises DuplicateError", True) + +# student CRUD +database.add_student("R001", "John Doe", "john@example.com", "Male", "12345", "2000-01-01", "123 St", None) +rows = database.fetch_students("admin", "admin") +check("fetch_students returns inserted row", len(rows) == 1 and rows[0][1] == "John Doe") + +database.add_student("R002", "Jane Roe", "jane@example.com", "Female", "54321", "2001-02-02", "456 Ave", "alice") +rows = database.fetch_students("admin", "admin") +check("fetch_students returns both rows ordered by name", [r[1] for r in rows] == ["Jane Roe", "John Doe"]) + +# role scoping: student only sees own linked record +rows = database.fetch_students("student", "alice") +check("student role sees only own record", len(rows) == 1 and rows[0][7] == "alice") + +# duplicate roll_no +try: + database.add_student("R001", "Dup", "", "", "", "", "", None) + check("duplicate roll_no raises DuplicateError", False) +except database.DuplicateError: + check("duplicate roll_no raises DuplicateError", True) + +# update +updated = database.update_student("R001", "John Updated", "john2@example.com", "Male", + "999", "2000-01-01", "New Addr", None) +check("update_student reports 1 row updated", updated == 1) +rows = database.fetch_students("admin", "admin") +names = {r[0]: r[1] for r in rows} +check("update_student actually changed the name", names["R001"] == "John Updated") + +updated_missing = database.update_student("R999", "Nobody", "", "", "", "", "", None) +check("update_student on missing roll_no returns 0", updated_missing == 0) + +# search +results = database.search_students("admin", "admin", "name", "Jane") +check("search_students finds partial match", len(results) == 1 and results[0][1] == "Jane Roe") + +results = database.search_students("admin", "admin", "not_a_real_field", "Jane") +check("search_students falls back to 'name' for invalid field", len(results) == 1) + +# delete +deleted = database.delete_student("R001") +check("delete_student reports 1 row deleted", deleted == 1) +deleted_again = database.delete_student("R001") +check("deleting again reports 0 rows deleted", deleted_again == 0) +rows = database.fetch_students("admin", "admin") +check("record actually removed", len(rows) == 1) + +# validators +from app import validators +check("valid form passes", validators.validate_student_form("R1", "Name", "a@b.com", "123456", "2020-01-01") == []) +check("invalid email caught", any("Email" in e for e in validators.validate_student_form("R1", "Name", "bad", "", ""))) +check("invalid date caught", any("D.O.B" in e for e in validators.validate_student_form("R1", "Name", "", "", "01/01/2020"))) +check("missing roll/name caught", len(validators.validate_student_form("", "", "", "", "")) == 2) + +os.remove(TEST_DB) + +print() +if failures: + print(f"{len(failures)} check(s) FAILED:") + for f in failures: + print(" -", f) + sys.exit(1) +else: + print("All checks passed.") diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..c9c65b1 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,195 @@ +"""Drives the actual GUI via real Tk widgets. + +Run with: python tests/test_gui.py +Requires a display (this is a Tkinter app, so it cannot run in a fully +headless CI environment without a virtual display such as Xvfb). + +This is a genuine functional smoke test: it creates the real LoginWindow +and Dashboard widget trees on a live Tk root, exercises their callbacks +directly (no mainloop needed since we call root.update() ourselves), and +checks real widget/database state at each step. It never calls +mainloop(), so it terminates on its own instead of blocking forever. +""" +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +import tkinter as tk +from tkinter import messagebox +from app import database, export +from app.login_window import LoginWindow +from app.dashboard import Dashboard + +# This environment has no real interactive desktop, so modal dialogs +# (messagebox.*) cannot be clicked through by a human. Stub them so the +# test exercises the exact same code paths a user's click would trigger, +# without hanging or silently defaulting (e.g. askyesno() returns False +# with no user present, which would make delete_student() a no-op). +messagebox.showinfo = lambda *a, **k: None +messagebox.showerror = lambda *a, **k: None +messagebox.showwarning = lambda *a, **k: None +messagebox.askyesno = lambda *a, **k: True + +TEST_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gui_test_app.db") +if os.path.exists(TEST_DB): + os.remove(TEST_DB) +database.DB_FILE = TEST_DB +database.init_db() + +results = [] + + +def check(label, cond): + results.append((label, bool(cond))) + print(f"[{'PASS' if cond else 'FAIL'}] {label}") + + +# ---- Login window builds without error ---- +login_root = tk.Tk() +login = LoginWindow(login_root) +login_root.update() +check("LoginWindow builds all widgets without raising", True) + +# Wrong password path (does not destroy window) +login.username.set("admin") +login.password.set("wrongpass") +login.login() +login_root.update() +check("LoginWindow still open after failed login", bool(login_root.winfo_exists())) + +login_root.destroy() + +# ---- Dashboard: admin role ---- +root = tk.Tk() +dash = Dashboard(root, user_role="admin", username="admin") +root.update() +check("Dashboard builds for admin without raising", True) +check("Admin sees Add/Update/Delete/Add User buttons enabled", + str(dash.btn_add["state"]) == "normal" and str(dash.btn_add_user["state"]) == "normal") + +# Add a student through the real UI fields + button handler +dash.roll_no_var.set("R100") +dash.name_var.set("Test Student") +dash.email_var.set("test@example.com") +dash.contact_var.set("5551234") +dash.dob_var.set("2005-05-05") +dash.address_txt.insert("1.0", "1 Test Way") +dash.add_student() +root.update() +check("Student appears in Treeview after add_student()", + len(dash.student_table.get_children()) == 1) + +# Validation: missing name should NOT insert and should not raise +dash.clear_fields() +dash.roll_no_var.set("R101") +dash.name_var.set("") +dash.add_student() +root.update() +check("Validation blocks add with missing name (row count still 1)", + len(dash.student_table.get_children()) == 1) + +# Bad email should be rejected +dash.clear_fields() +dash.roll_no_var.set("R102") +dash.name_var.set("Bad Email") +dash.email_var.set("not-an-email") +dash.add_student() +root.update() +check("Validation blocks invalid email (row count still 1)", + len(dash.student_table.get_children()) == 1) + +# Search +dash.search_by.set("name") +dash.search_txt.set("Test") +dash.search_student() +root.update() +check("Search finds the added student", len(dash.student_table.get_children()) == 1) + +dash.search_txt.set("Nonexistent") +dash.search_student() +root.update() +check("Search with no match empties the table", len(dash.student_table.get_children()) == 0) + +dash.fetch_data() +root.update() +check("Reset (fetch_data) restores the row", len(dash.student_table.get_children()) == 1) + +# Sort +dash.sort_by("name") +root.update() +check("sort_by runs without raising", True) + +# Update via selecting row then editing +first_item = dash.student_table.get_children()[0] +dash.student_table.selection_set(first_item) +dash.student_table.focus(first_item) +dash.get_cursor(None) +root.update() +check("get_cursor populated form from selection", dash.roll_no_var.get() == "R100") + +dash.name_var.set("Updated Name") +dash.update_student() +root.update() +rows_after = database.fetch_students("admin", "admin") +check("update_student persisted the new name", rows_after[0][1] == "Updated Name") + +# CSV export (no dialog, call export module directly with real table data) +csv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gui_test_export.csv") +data = [dash.student_table.item(r)["values"] for r in dash.student_table.get_children()] +export.export_csv(data, csv_path) +check("CSV export writes a file", os.path.exists(csv_path) and os.path.getsize(csv_path) > 0) +os.remove(csv_path) + +# PDF export (reportlab is installed in this test env) +pdf_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gui_test_export.pdf") +try: + export.export_pdf(data, pdf_path) + check("PDF export writes a file", os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0) + os.remove(pdf_path) +except ImportError: + check("PDF export raises friendly ImportError when reportlab missing", True) + +# Dark mode toggle +dash.toggle_theme() +root.update() +check("Dark mode toggle runs without raising and flips flag", dash.dark_mode is True) +dash.toggle_theme() +root.update() +check("Toggling back to light mode works", dash.dark_mode is False) + +# Delete (reselect the row since update_student() clears the form on success) +row_to_delete = dash.student_table.get_children()[0] +dash.student_table.selection_set(row_to_delete) +dash.student_table.focus(row_to_delete) +dash.get_cursor(None) +root.update() +dash.delete_student() +root.update() +check("delete_student removes the row", len(dash.student_table.get_children()) == 0) + +root.destroy() + +# ---- Dashboard: student role restrictions ---- +database.add_user("bob", "pass1234", "student") +database.add_student("R200", "Bob Student", "", "", "", "", "", "bob") + +root2 = tk.Tk() +dash2 = Dashboard(root2, user_role="student", username="bob") +root2.update() +check("Student dashboard shows only own record", len(dash2.student_table.get_children()) == 1) +check("Student role has no Add/Update/Delete buttons at all", not hasattr(dash2, "btn_add")) +check("Student role has username field disabled", str(dash2.uname_entry["state"]) == "disabled") +root2.destroy() + +os.remove(TEST_DB) + +print() +failed = [label for label, ok in results if not ok] +if failed: + print(f"{len(failed)} check(s) FAILED:") + for f in failed: + print(" -", f) + sys.exit(1) +print(f"All {len(results)} GUI-driven checks passed.") From 11f9135e3439eafa52b252058e9f5811a4f846c6 Mon Sep 17 00:00:00 2001 From: obotu okwori Date: Tue, 11 Aug 2026 14:49:43 +0100 Subject: [PATCH 4/4] Rewrite README for the new structure and add MIT license --- LICENSE | 21 +++++++++ README.md | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c952ab5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jenks00 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8e13164..1c85c77 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,127 @@ -# Student-Management-System -A desktop-based School Management System built with Python, Tkinter, and SQLite +# Student Management System + +A desktop student records manager built with Python and Tkinter, backed by +a local SQLite database. It supports role-based logins (admin, staff, +student), full CRUD on student records, search, CSV/PDF export, and a +light/dark theme. + + + +## Features + +- **Role-based login** — three roles with different permissions: + - `admin` — full access: manage students, manage users, export data. + - `staff` — manage students and export data, cannot create new users. + - `student` — read-only access to their own linked record only. +- **Student records CRUD** — add, update, delete, and browse student + records (roll number, name, email, gender, contact, date of birth, + address, and an optional link to a login username). +- **Search** — search by roll number, name, contact, or username, with + partial matching. +- **Sortable, striped data table** — click any column header to sort; + alternating row colors for readability. +- **Export** — export the current table view to CSV, or to a formatted + PDF report. +- **Light/dark theme** — toggle between a light and dark UI at runtime. +- **Input validation** — required fields, email format, phone format, and + date format are all validated in the UI with clear error messages + instead of raw exceptions or silent failures. +- **Password hashing** — user passwords are stored as SHA-256 hashes, not + plaintext. +- **Self-initializing database** — the SQLite database and its tables are + created automatically on first run; nothing needs to be set up by hand. + +## Tech stack + +Everything the core application needs is in the Python standard library: + +- **Python 3** — application language +- **Tkinter / ttk** — GUI toolkit (themed widgets, `ttk.Treeview` data + table, `ttk.Style` for the custom color palette) +- **sqlite3** — embedded database, no server required +- **hashlib / csv / re** — password hashing, CSV export, and input + validation + +The only optional, non-standard-library dependency is **reportlab**, +used solely for the "Export PDF" button. If it is not installed, every +other feature (including CSV export) still works normally, and clicking +"Export PDF" shows a clear message telling you how to install it instead +of crashing. + +## Project structure + +``` +Student-Management-System/ +├── main.py # Entry point — run this +├── app/ +│ ├── database.py # SQLite access layer (schema, CRUD, auth) +│ ├── login_window.py # Login screen +│ ├── dashboard.py # Main dashboard window (table + manage panel) +│ ├── theme.py # ttk.Style color palette and widget styling +│ ├── export.py # CSV / PDF export helpers +│ └── validators.py # Input validation rules +├── tests/ +│ ├── test_database.py # Standalone tests for the database layer +│ └── test_gui.py # End-to-end test driving the real Tk widgets +├── requirements.txt +└── README.md +``` + +## Getting started + +### Requirements + +- Python 3.9 or later, with Tk support (this ships with the standard + Windows/macOS installers from python.org; on Linux you may need to + install `python3-tk` via your package manager). + +### Setup + +```bash +git clone https://github.com/Jenks00/Student-Management-System.git +cd Student-Management-System +pip install -r requirements.txt # optional, only needed for PDF export +python main.py +``` + +On first launch, the app creates `app.db` in the project root and seeds +a default administrator account: + +``` +Username: admin +Password: admin123 +``` + +Change or remove this account after your first login if you plan to use +the app beyond local testing. + +### Running the tests + +```bash +python tests/test_database.py # database layer, no display required +python tests/test_gui.py # drives the real UI, requires a display +``` + +Both scripts run against a temporary throwaway database and never touch +`app.db`. + +## Packaging as a standalone executable + +Since this is a desktop application, you can package it into a single +`.exe` (Windows), `.app` (macOS), or Linux binary using +[PyInstaller](https://pyinstaller.org/), so end users don't need Python +installed: + +```bash +pip install pyinstaller +pyinstaller --onefile --windowed --name StudentManagementSystem main.py +``` + +The resulting executable will be in the `dist/` folder. This step is +optional and not required to run or develop the app. + +## License + +Released under the MIT License — see [LICENSE](LICENSE).