commit 65691a0aeede6a4b9d86fb3bccc87e7f06900148 Author: AnantaSrikar Date: Fri Sep 25 08:14:04 2026 -0700 project-init diff --git a/README.md b/README.md new file mode 100644 index 0000000..266c337 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Student Management System + +A terminal CRUD application for managing **Students**, **Guardians**, and +**Attendance** records, backed by MySQL. Built against the schema in +Tables 9.3–9.5 (STUDENT, GUARDIAN, ATTENDANCE). + +## What it does on startup + +1. Connects to the MySQL server at `10.0.1.40:3306`. +2. Verifies the `student_db` database exists — creates it if not. +3. Verifies the `GUARDIAN`, `STUDENT`, and `ATTENDANCE` tables exist + (in that order, since STUDENT references GUARDIAN and ATTENDANCE + references STUDENT) — creates any that are missing, with the exact + data types and constraints from the schema (primary keys, foreign + keys, `NOT NULL`, `UNIQUE` on `GPhone`, and the composite primary key + `(AttendanceDate, RollNumber)` on ATTENDANCE). +4. Launches an interactive menu for Create / Read / Update / Delete. + +## Setup + +1. Install the one dependency: + + ```bash + pip install -r requirements.txt + ``` + +2. Set your MySQL credentials as environment variables (the host/port/ + database default to the assignment's values already): + + ```bash + export DB_USER=root + export DB_PASSWORD=your_password_here + # optional overrides — these already default correctly: + # export DB_HOST=10.0.1.40 + # export DB_PORT=3306 + # export DB_NAME=student_db + ``` + +3. Run it: + + ```bash + python3 main.py + ``` + +## Files + +| File | Purpose | +|-----------------|------------------------------------------------------------------| +| `config.py` | Connection settings (reads env vars, falls back to defaults) | +| `db.py` | Connects to MySQL; verifies/creates the database and tables | +| `crud.py` | All Create/Read/Update/Delete operations for the three tables | +| `ui_helpers.py` | Input prompts/validation and table-printing for the terminal | +| `main.py` | The menu-driven application — run this | + +## Using the app + +The main menu has three sections: + +- **Guardian management** — list, view, add, update, delete guardians. + GUID must be a 12-digit number (per the Aadhaar-number schema note); + phone, if given, must be 10 digits and is unique across guardians. +- **Student management** — list, view, add, update, delete students. + Roll number is up to 3 digits; each student must reference an + existing guardian's GUID (add the guardian first). +- **Attendance management** — mark attendance (P/A) for a student on a + date (marking the same student+date again updates it instead of + erroring), view attendance by date or by student, and delete records. + +Deleting a guardian who still has students is blocked by the foreign +key (delete or reassign their students first). Deleting a student +cascades and removes their attendance records automatically. + +## Notes + +- Uses [PyMySQL](https://pymysql.readthedocs.io/), a pure-Python MySQL + client — no system libraries (like `libmysqlclient`) required. +- All input is validated against the schema's constraints (max lengths, + digit counts, date format, P/A status) before hitting the database, + and database-level errors (duplicate keys, foreign key violations) + are caught and shown as friendly messages rather than crashing. +- Tested end-to-end (connect → create DB/tables → full CRUD → constraint + error handling → cascade delete) against a local MySQL/MariaDB + instance before delivery. diff --git a/config.py b/config.py new file mode 100644 index 0000000..36fb2eb --- /dev/null +++ b/config.py @@ -0,0 +1,18 @@ +""" +Configuration for the Student Management System. + +Connection settings are read from environment variables first, falling back +to the defaults below (which match the assignment's requirements). This +means you can run the app as-is, or override any value like: + + DB_HOST=10.0.1.40 DB_USER=root DB_PASSWORD=secret python3 main.py +""" + +import os + +DB_HOST = os.environ.get("DB_HOST", "10.0.1.40") +DB_PORT = int(os.environ.get("DB_PORT", "3306")) +DB_USER = os.environ.get("DB_USER", "root") +DB_PASSWORD = os.environ.get("DB_PASSWORD", "") +DB_NAME = os.environ.get("DB_NAME", "student_db") +DB_CONNECT_TIMEOUT = int(os.environ.get("DB_CONNECT_TIMEOUT", "10")) diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..4518f05 --- /dev/null +++ b/crud.py @@ -0,0 +1,205 @@ +""" +CRUD operations for GUARDIAN, STUDENT and ATTENDANCE. + +Every function takes an open pymysql connection as its first argument and +manages its own commit/rollback. All functions return plain data +(dicts/lists/bool) so this module has no dependency on the UI layer. +""" + +import pymysql + + +class CrudError(Exception): + """Raised for expected, user-facing data errors (bad FK, duplicate key, etc).""" + pass + + +def _run(conn, sql, params=None, fetch=None): + """ + Helper: execute a statement, commit, and optionally fetch results. + fetch: None (no fetch), 'one', or 'all' + Translates common MySQL errors into CrudError with a friendly message. + """ + try: + with conn.cursor() as cur: + cur.execute(sql, params or ()) + result = None + if fetch == "one": + result = cur.fetchone() + elif fetch == "all": + result = cur.fetchall() + conn.commit() + return result if fetch else cur.rowcount + except pymysql.err.IntegrityError as e: + conn.rollback() + code = e.args[0] if e.args else None + if code == 1062: + raise CrudError("A record with that primary/unique key already exists.") + if code in (1451, 1452): + raise CrudError( + "That operation violates a foreign key constraint " + "(referenced record missing, or still referenced by other rows)." + ) + raise CrudError(f"Database integrity error: {e}") + except pymysql.err.DataError as e: + conn.rollback() + raise CrudError(f"Invalid data for one or more fields: {e}") + except pymysql.err.MySQLError as e: + conn.rollback() + raise CrudError(f"Database error: {e}") + + +# ---------------------------------------------------------------- GUARDIAN -- + +def create_guardian(conn, guid, gname, gphone, gaddress): + _run( + conn, + "INSERT INTO GUARDIAN (GUID, GName, GPhone, GAddress) VALUES (%s, %s, %s, %s)", + (guid, gname, gphone, gaddress), + ) + + +def get_guardian(conn, guid): + return _run(conn, "SELECT * FROM GUARDIAN WHERE GUID = %s", (guid,), fetch="one") + + +def list_guardians(conn): + return _run(conn, "SELECT * FROM GUARDIAN ORDER BY GUID", fetch="all") + + +def update_guardian(conn, guid, gname=None, gphone=None, gaddress=None): + fields, params = [], [] + if gname is not None: + fields.append("GName = %s") + params.append(gname) + if gphone is not None: + fields.append("GPhone = %s") + params.append(gphone) + if gaddress is not None: + fields.append("GAddress = %s") + params.append(gaddress) + if not fields: + return 0 + params.append(guid) + return _run(conn, f"UPDATE GUARDIAN SET {', '.join(fields)} WHERE GUID = %s", params) + + +def delete_guardian(conn, guid): + return _run(conn, "DELETE FROM GUARDIAN WHERE GUID = %s", (guid,)) + + +# ----------------------------------------------------------------- STUDENT -- + +def create_student(conn, roll_number, sname, sdateofbirth, guid): + _run( + conn, + "INSERT INTO STUDENT (RollNumber, SName, SDateofBirth, GUID) VALUES (%s, %s, %s, %s)", + (roll_number, sname, sdateofbirth, guid), + ) + + +def get_student(conn, roll_number): + return _run(conn, "SELECT * FROM STUDENT WHERE RollNumber = %s", (roll_number,), fetch="one") + + +def list_students(conn): + return _run( + conn, + """ + SELECT s.RollNumber, s.SName, s.SDateofBirth, s.GUID, g.GName AS GuardianName + FROM STUDENT s + LEFT JOIN GUARDIAN g ON s.GUID = g.GUID + ORDER BY s.RollNumber + """, + fetch="all", + ) + + +def update_student(conn, roll_number, sname=None, sdateofbirth=None, guid=None): + fields, params = [], [] + if sname is not None: + fields.append("SName = %s") + params.append(sname) + if sdateofbirth is not None: + fields.append("SDateofBirth = %s") + params.append(sdateofbirth) + if guid is not None: + fields.append("GUID = %s") + params.append(guid) + if not fields: + return 0 + params.append(roll_number) + return _run(conn, f"UPDATE STUDENT SET {', '.join(fields)} WHERE RollNumber = %s", params) + + +def delete_student(conn, roll_number): + return _run(conn, "DELETE FROM STUDENT WHERE RollNumber = %s", (roll_number,)) + + +# --------------------------------------------------------------- ATTENDANCE -- + +def mark_attendance(conn, attendance_date, roll_number, status): + """Insert attendance; if a record for that date+student exists, update it instead.""" + existing = _run( + conn, + "SELECT 1 FROM ATTENDANCE WHERE AttendanceDate = %s AND RollNumber = %s", + (attendance_date, roll_number), + fetch="one", + ) + if existing: + _run( + conn, + "UPDATE ATTENDANCE SET AttendanceStatus = %s " + "WHERE AttendanceDate = %s AND RollNumber = %s", + (status, attendance_date, roll_number), + ) + return "updated" + else: + _run( + conn, + "INSERT INTO ATTENDANCE (AttendanceDate, RollNumber, AttendanceStatus) " + "VALUES (%s, %s, %s)", + (attendance_date, roll_number, status), + ) + return "created" + + +def get_attendance(conn, attendance_date, roll_number): + return _run( + conn, + "SELECT * FROM ATTENDANCE WHERE AttendanceDate = %s AND RollNumber = %s", + (attendance_date, roll_number), + fetch="one", + ) + + +def list_attendance_by_date(conn, attendance_date): + return _run( + conn, + """ + SELECT a.AttendanceDate, a.RollNumber, s.SName, a.AttendanceStatus + FROM ATTENDANCE a + JOIN STUDENT s ON a.RollNumber = s.RollNumber + WHERE a.AttendanceDate = %s + ORDER BY a.RollNumber + """, + (attendance_date,), + fetch="all", + ) + + +def list_attendance_by_student(conn, roll_number): + return _run( + conn, + "SELECT * FROM ATTENDANCE WHERE RollNumber = %s ORDER BY AttendanceDate", + (roll_number,), + fetch="all", + ) + + +def delete_attendance(conn, attendance_date, roll_number): + return _run( + conn, + "DELETE FROM ATTENDANCE WHERE AttendanceDate = %s AND RollNumber = %s", + (attendance_date, roll_number), + ) diff --git a/db.py b/db.py new file mode 100644 index 0000000..62adea1 --- /dev/null +++ b/db.py @@ -0,0 +1,135 @@ +""" +Database layer: connect to MySQL, verify/create the student_db database, +and verify/create the STUDENT, GUARDIAN and ATTENDANCE tables per the +schema (Tables 9.3, 9.4, 9.5). + +Schema notes: +- GUARDIAN.GUID is the primary key (12-digit Aadhaar number, stored as CHAR(12)). +- STUDENT.GUID is a foreign key referencing GUARDIAN.GUID. +- ATTENDANCE has a composite primary key (AttendanceDate, RollNumber), and + RollNumber is also a foreign key referencing STUDENT.RollNumber. +- Because STUDENT references GUARDIAN, GUARDIAN must be created first. +""" + +import sys + +import pymysql +import pymysql.cursors + +import config + +# Table creation statements, in dependency order (GUARDIAN -> STUDENT -> ATTENDANCE) +TABLE_DEFINITIONS = { + "GUARDIAN": """ + CREATE TABLE GUARDIAN ( + GUID CHAR(12) NOT NULL, + GName VARCHAR(20) NOT NULL, + GPhone CHAR(10) NULL, + GAddress VARCHAR(30) NOT NULL, + PRIMARY KEY (GUID), + UNIQUE (GPhone) + ) ENGINE=InnoDB + """, + "STUDENT": """ + CREATE TABLE STUDENT ( + RollNumber INT NOT NULL, + SName VARCHAR(20) NOT NULL, + SDateofBirth DATE NOT NULL, + GUID CHAR(12) NOT NULL, + PRIMARY KEY (RollNumber), + FOREIGN KEY (GUID) REFERENCES GUARDIAN(GUID) + ON DELETE RESTRICT ON UPDATE CASCADE + ) ENGINE=InnoDB + """, + "ATTENDANCE": """ + CREATE TABLE ATTENDANCE ( + AttendanceDate DATE NOT NULL, + RollNumber INT NOT NULL, + AttendanceStatus CHAR(1) NOT NULL, + PRIMARY KEY (AttendanceDate, RollNumber), + FOREIGN KEY (RollNumber) REFERENCES STUDENT(RollNumber) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT chk_attendance_status CHECK (AttendanceStatus IN ('P', 'A')) + ) ENGINE=InnoDB + """, +} + +# Creation order matters because of foreign keys. +TABLE_ORDER = ["GUARDIAN", "STUDENT", "ATTENDANCE"] + + +def connect_server(database=None): + """Connect to the MySQL server (optionally selecting a database).""" + try: + conn = pymysql.connect( + host=config.DB_HOST, + port=config.DB_PORT, + user=config.DB_USER, + password=config.DB_PASSWORD, + database=database, + connect_timeout=config.DB_CONNECT_TIMEOUT, + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + return conn + except pymysql.err.OperationalError as e: + print(f"\n[ERROR] Could not connect to MySQL server at " + f"{config.DB_HOST}:{config.DB_PORT} as user '{config.DB_USER}'.") + print(f" Details: {e}") + sys.exit(1) + + +def ensure_database(): + """Verify the student_db database exists; create it if it does not.""" + conn = connect_server(database=None) + try: + with conn.cursor() as cur: + cur.execute("SHOW DATABASES LIKE %s", (config.DB_NAME,)) + exists = cur.fetchone() is not None + if exists: + print(f"[OK] Database '{config.DB_NAME}' found.") + else: + print(f"[..] Database '{config.DB_NAME}' not found. Creating it...") + cur.execute( + f"CREATE DATABASE `{config.DB_NAME}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + conn.commit() + print(f"[OK] Database '{config.DB_NAME}' created.") + finally: + conn.close() + + +def ensure_tables(conn): + """Verify each required table exists; create any that are missing.""" + with conn.cursor() as cur: + cur.execute("SHOW TABLES") + existing = {row[f"Tables_in_{config.DB_NAME}"] for row in cur.fetchall()} + + for table in TABLE_ORDER: + if table in existing: + print(f"[OK] Table '{table}' found.") + else: + print(f"[..] Table '{table}' not found. Creating it...") + with conn.cursor() as cur: + cur.execute(TABLE_DEFINITIONS[table]) + conn.commit() + print(f"[OK] Table '{table}' created.") + + +def get_connection(): + """ + Full startup sequence: + 1. Ensure the student_db database exists (creating it if needed). + 2. Connect to student_db. + 3. Ensure all required tables exist (creating any that are missing). + Returns an open connection to student_db, ready for use. + """ + print(f"Connecting to MySQL server at {config.DB_HOST}:{config.DB_PORT} ...") + ensure_database() + + conn = connect_server(database=config.DB_NAME) + print(f"[OK] Connected to database '{config.DB_NAME}'.") + + ensure_tables(conn) + return conn diff --git a/main.py b/main.py new file mode 100644 index 0000000..17e05ef --- /dev/null +++ b/main.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +Student Management System +========================== +Terminal CRUD application for STUDENT / GUARDIAN / ATTENDANCE, backed by +MySQL on 10.0.1.40:3306 (student_db). + +On startup this: + 1. Connects to the MySQL server. + 2. Verifies (or creates) the student_db database. + 3. Verifies (or creates) the STUDENT, GUARDIAN and ATTENDANCE tables. + 4. Launches an interactive terminal menu for CRUD operations. + +Run: + python3 main.py +""" + +import sys + +import pymysql + +import crud +import db +from ui_helpers import ( + clear_hint, + pause, + print_table, + prompt_attendance_status, + prompt_date, + prompt_guid, + prompt_int, + prompt_phone, + prompt_str, +) + + +# ============================================================ GUARDIAN UI == + +def menu_guardians(conn): + while True: + clear_hint() + print("GUARDIAN MANAGEMENT") + print(" 1. List all guardians") + print(" 2. View a guardian") + print(" 3. Add a guardian") + print(" 4. Update a guardian") + print(" 5. Delete a guardian") + print(" 0. Back to main menu") + choice = input("\nChoice: ").strip() + + try: + if choice == "1": + print() + print_table(crud.list_guardians(conn)) + pause() + elif choice == "2": + guid = prompt_guid() + row = crud.get_guardian(conn, guid) + print() + print_table([row] if row else []) + pause() + elif choice == "3": + add_guardian(conn) + elif choice == "4": + update_guardian(conn) + elif choice == "5": + delete_guardian(conn) + elif choice == "0": + return + else: + print(" Invalid choice.") + except crud.CrudError as e: + print(f"\n [ERROR] {e}") + pause() + + +def add_guardian(conn): + print("\n-- Add Guardian --") + guid = prompt_guid() + gname = prompt_str("Guardian name", max_len=20) + gphone = prompt_phone() + gaddress = prompt_str("Guardian address", max_len=30) + crud.create_guardian(conn, guid, gname, gphone, gaddress) + print(f" [OK] Guardian {guid} added.") + pause() + + +def update_guardian(conn): + print("\n-- Update Guardian --") + guid = prompt_guid() + existing = crud.get_guardian(conn, guid) + if not existing: + print(" No guardian found with that GUID.") + pause() + return + print(f" Current: {existing}") + print(" (Press Enter to leave a field unchanged)") + gname = prompt_str("New name", max_len=20, allow_blank_skip=True) + gphone_raw = input("New phone (10 digits, blank = unchanged, 'clear' = remove): ").strip() + gphone = existing["GPhone"] + if gphone_raw.lower() == "clear": + gphone = None + elif gphone_raw: + gphone = prompt_phone_validate(gphone_raw) + if gphone is None: + print(" Invalid phone — update aborted for that field.") + gphone = existing["GPhone"] + gaddress = prompt_str("New address", max_len=30, allow_blank_skip=True) + + crud.update_guardian(conn, guid, gname=gname, gphone=gphone, gaddress=gaddress) + print(" [OK] Guardian updated.") + pause() + + +def prompt_phone_validate(raw): + import re + return raw if re.match(r"^\d{10}$", raw) else None + + +def delete_guardian(conn): + print("\n-- Delete Guardian --") + guid = prompt_guid() + confirm = input(f" Type 'yes' to confirm deleting guardian {guid}: ").strip().lower() + if confirm != "yes": + print(" Cancelled.") + pause() + return + rows = crud.delete_guardian(conn, guid) + if rows: + print(" [OK] Guardian deleted.") + else: + print(" No guardian found with that GUID.") + pause() + + +# ============================================================= STUDENT UI == + +def menu_students(conn): + while True: + clear_hint() + print("STUDENT MANAGEMENT") + print(" 1. List all students") + print(" 2. View a student") + print(" 3. Add a student") + print(" 4. Update a student") + print(" 5. Delete a student") + print(" 0. Back to main menu") + choice = input("\nChoice: ").strip() + + try: + if choice == "1": + print() + print_table(crud.list_students(conn)) + pause() + elif choice == "2": + roll = prompt_int("Roll number", max_digits=3) + row = crud.get_student(conn, roll) + print() + print_table([row] if row else []) + pause() + elif choice == "3": + add_student(conn) + elif choice == "4": + update_student(conn) + elif choice == "5": + delete_student(conn) + elif choice == "0": + return + else: + print(" Invalid choice.") + except crud.CrudError as e: + print(f"\n [ERROR] {e}") + pause() + + +def add_student(conn): + print("\n-- Add Student --") + roll = prompt_int("Roll number (max 3 digits)", max_digits=3) + sname = prompt_str("Student name", max_len=20) + dob = prompt_date("Date of birth") + print(" (Guardian must already exist — add them first via Guardian Management if needed)") + guid = prompt_guid() + crud.create_student(conn, roll, sname, dob, guid) + print(f" [OK] Student {roll} added.") + pause() + + +def update_student(conn): + print("\n-- Update Student --") + roll = prompt_int("Roll number", max_digits=3) + existing = crud.get_student(conn, roll) + if not existing: + print(" No student found with that roll number.") + pause() + return + print(f" Current: {existing}") + print(" (Press Enter to leave a field unchanged)") + sname = prompt_str("New name", max_len=20, allow_blank_skip=True) + dob = prompt_date("New date of birth", allow_blank_skip=True) + guid = prompt_guid("New guardian GUID", allow_blank_skip=True) + + crud.update_student(conn, roll, sname=sname, sdateofbirth=dob, guid=guid) + print(" [OK] Student updated.") + pause() + + +def delete_student(conn): + print("\n-- Delete Student --") + roll = prompt_int("Roll number", max_digits=3) + confirm = input(f" Type 'yes' to confirm deleting student {roll} " + f"(also deletes their attendance records): ").strip().lower() + if confirm != "yes": + print(" Cancelled.") + pause() + return + rows = crud.delete_student(conn, roll) + if rows: + print(" [OK] Student deleted.") + else: + print(" No student found with that roll number.") + pause() + + +# =========================================================== ATTENDANCE UI == + +def menu_attendance(conn): + while True: + clear_hint() + print("ATTENDANCE MANAGEMENT") + print(" 1. Mark / update attendance for a student") + print(" 2. View attendance by date") + print(" 3. View attendance history for a student") + print(" 4. Delete an attendance record") + print(" 0. Back to main menu") + choice = input("\nChoice: ").strip() + + try: + if choice == "1": + mark_attendance(conn) + elif choice == "2": + d = prompt_date("Date") + print() + print_table(crud.list_attendance_by_date(conn, d)) + pause() + elif choice == "3": + roll = prompt_int("Roll number", max_digits=3) + print() + print_table(crud.list_attendance_by_student(conn, roll)) + pause() + elif choice == "4": + delete_attendance(conn) + elif choice == "0": + return + else: + print(" Invalid choice.") + except crud.CrudError as e: + print(f"\n [ERROR] {e}") + pause() + + +def mark_attendance(conn): + print("\n-- Mark Attendance --") + roll = prompt_int("Roll number", max_digits=3) + if not crud.get_student(conn, roll): + print(" No student found with that roll number.") + pause() + return + d = prompt_date("Date") + status = prompt_attendance_status() + result = crud.mark_attendance(conn, d, roll, status) + print(f" [OK] Attendance {result} for student {roll} on {d}: {status}") + pause() + + +def delete_attendance(conn): + print("\n-- Delete Attendance Record --") + roll = prompt_int("Roll number", max_digits=3) + d = prompt_date("Date") + confirm = input(" Type 'yes' to confirm: ").strip().lower() + if confirm != "yes": + print(" Cancelled.") + pause() + return + rows = crud.delete_attendance(conn, d, roll) + if rows: + print(" [OK] Attendance record deleted.") + else: + print(" No matching attendance record found.") + pause() + + +# ================================================================ MAIN ==== + +def main_menu(conn): + while True: + clear_hint() + print("STUDENT MANAGEMENT SYSTEM") + print(" 1. Guardian management") + print(" 2. Student management") + print(" 3. Attendance management") + print(" 0. Exit") + choice = input("\nChoice: ").strip() + + if choice == "1": + menu_guardians(conn) + elif choice == "2": + menu_students(conn) + elif choice == "3": + menu_attendance(conn) + elif choice == "0": + print("\nGoodbye!") + return + else: + print(" Invalid choice.") + + +def main(): + print("=" * 60) + print(" STUDENT MANAGEMENT SYSTEM - Startup") + print("=" * 60) + + conn = db.get_connection() + + print("\nAll checks passed. Starting application...\n") + try: + main_menu(conn) + except KeyboardInterrupt: + print("\n\nInterrupted. Exiting.") + except pymysql.err.MySQLError as e: + print(f"\n[ERROR] Lost database connection: {e}") + sys.exit(1) + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..632cb38 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyMySQL>=1.1.0 diff --git a/ui_helpers.py b/ui_helpers.py new file mode 100644 index 0000000..f5b4da4 --- /dev/null +++ b/ui_helpers.py @@ -0,0 +1,118 @@ +"""Small input-prompting and formatting helpers shared by the menu screens.""" + +import datetime +import re + +GUID_RE = re.compile(r"^\d{12}$") +PHONE_RE = re.compile(r"^\d{10}$") + + +def pause(): + input("\nPress Enter to continue...") + + +def clear_hint(): + print("\n" + "-" * 60 + "\n") + + +def prompt_str(label, max_len=None, required=True, allow_blank_skip=False): + """Prompt for a string. allow_blank_skip=True returns None on empty input (for 'leave unchanged').""" + while True: + suffix = f" (max {max_len} chars)" if max_len else "" + val = input(f"{label}{suffix}: ").strip() + if not val: + if allow_blank_skip: + return None + if required: + print(" This field is required.") + continue + return "" + if max_len and len(val) > max_len: + print(f" Too long — max {max_len} characters.") + continue + return val + + +def prompt_int(label, max_digits=None, allow_blank_skip=False): + while True: + val = input(f"{label}: ").strip() + if not val: + if allow_blank_skip: + return None + print(" This field is required.") + continue + if not val.isdigit(): + print(" Please enter a whole number.") + continue + if max_digits and len(val) > max_digits: + print(f" Too many digits — max {max_digits}.") + continue + return int(val) + + +def prompt_guid(label="Guardian ID / GUID (12-digit Aadhaar number)", allow_blank_skip=False): + while True: + val = input(f"{label}: ").strip() + if not val: + if allow_blank_skip: + return None + print(" This field is required.") + continue + if not GUID_RE.match(val): + print(" GUID must be exactly 12 digits.") + continue + return val + + +def prompt_phone(label="Guardian phone (10 digits, optional)", allow_blank_skip=True): + while True: + val = input(f"{label}: ").strip() + if not val: + return None # NULL is allowed for GPhone + if not PHONE_RE.match(val): + print(" Phone must be exactly 10 digits (or leave blank).") + continue + return val + + +def prompt_date(label, allow_blank_skip=False): + while True: + val = input(f"{label} (YYYY-MM-DD): ").strip() + if not val: + if allow_blank_skip: + return None + print(" This field is required.") + continue + try: + return datetime.datetime.strptime(val, "%Y-%m-%d").date() + except ValueError: + print(" Invalid date — use format YYYY-MM-DD, e.g. 2015-08-21.") + + +def prompt_attendance_status(label="Status (P=present, A=absent)"): + while True: + val = input(f"{label}: ").strip().upper() + if val in ("P", "A"): + return val + print(" Please enter 'P' or 'A'.") + + +def print_table(rows, columns=None): + """Pretty-print a list of dict rows as a simple aligned table.""" + if not rows: + print(" (no records found)") + return + + if columns is None: + columns = list(rows[0].keys()) + + widths = {} + for col in columns: + widths[col] = max(len(str(col)), *(len(str(r.get(col, ""))) for r in rows)) + + header = " " + " | ".join(str(col).ljust(widths[col]) for col in columns) + print(header) + print(" " + "-+-".join("-" * widths[col] for col in columns)) + for r in rows: + print(" " + " | ".join(str(r.get(col, "")).ljust(widths[col]) for col in columns)) + print(f"\n ({len(rows)} record{'s' if len(rows) != 1 else ''})")