diff --git a/.env.example b/.env.example index ead17ab..a9a1e27 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,11 @@ SESSION_TIMEOUT_MINUTES=30 DEFAULT_TIMEZONE=UTC # AI/LLM Configuration -GOOGLE_API_KEY=your-google-api-key-here +# Using Groq free API with Llama 3.3 70B model +# No credit card required - completely free! +# Get your free token at: https://console.groq.com/keys +# Each team member should create their own token for local testing +GROQ_API_TOKEN=your_groq_api_token_here # Feature Flags (enable/disable features) FEATURE_AUTHENTICATION=True diff --git a/Announcements Page b/Announcements Page deleted file mode 100644 index 1e40e39..0000000 --- a/Announcements Page +++ /dev/null @@ -1,164 +0,0 @@ -# πŸ“’ ANNOUNCEMENTS PAGE β€” Meetika Kanumukula (updated version) - -import sqlite3 -import pandas as pd -from datetime import datetime -import ipywidgets as widgets -from IPython.display import display, clear_output, Markdown - -# -- Ensure table exists -- -conn = sqlite3.connect('school_system.db') -cursor = conn.cursor() -cursor.execute(''' -CREATE TABLE IF NOT EXISTS Announcements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - author_id INTEGER, - role_visibility TEXT, -- e.g., 'student,parent' or 'all' - course_id INTEGER, - title TEXT, - body TEXT, - created_at TEXT -) -''') -conn.commit() - -def _get_course_options(): - """ - For teachers: return only their courses. - For admins: return all courses. - Others: return empty list (courses optional). - """ - if current_session['role'] == 'teacher': - tid = current_session['teacher_id'] - cursor.execute("SELECT course_id, course_name FROM Courses WHERE teacher_id = ?", (tid,)) - elif current_session['role'] == 'admin': - cursor.execute("SELECT course_id, course_name FROM Courses") - else: - return [] - return cursor.fetchall() - -def _create_announcement_form(): - """Form for teachers/admins to create announcements.""" - title_input = widgets.Text(description='Title:', placeholder='Enter title') - body_input = widgets.Textarea(description='Body:', placeholder='Enter body text') - visibility_input = widgets.Dropdown( - options=[ - ('All', 'all'), - ('Students', 'student'), - ('Parents', 'parent'), - ('Teachers', 'teacher'), - ('Admins', 'admin'), - ('Students & Parents', 'student,parent'), - ('Teachers & Parents', 'teacher,parent') - ], - description='Visible to:', - value='all' - ) - courses = _get_course_options() - course_input = widgets.Dropdown( - options=[('None', None)] + [(name, cid) for cid, name in courses], - description='Course:' - ) - submit_btn = widgets.Button(description='Post Announcement', button_style='success') - - def on_submit(b): - author_id = current_session['user_id'] - created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - cursor.execute( - ''' - INSERT INTO Announcements (author_id, role_visibility, course_id, title, body, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ''', - ( - author_id, - visibility_input.value, - course_input.value, - title_input.value.strip(), - body_input.value.strip(), - created_at - ) - ) - conn.commit() - clear_output() - print("βœ… Announcement posted!\n") - # Show announcements again after posting - display_announcements() - - submit_btn.on_click(on_submit) - display(Markdown("### ✍️ Add Announcement")) - display(title_input, body_input, visibility_input, course_input, submit_btn) - -def display_announcements(course_id=None): - """ - Show announcements for the current user. - Filters by role and (optional) course. - """ - user_role = current_session['role'].lower() # e.g., 'teacher', 'student' - if course_id: - query = """ - SELECT title, body, role_visibility, created_at - FROM Announcements - WHERE - (role_visibility = 'all' OR instr(role_visibility, ?) > 0) - AND (course_id IS NULL OR course_id = ?) - ORDER BY datetime(created_at) DESC - """ - df = pd.read_sql_query(query, conn, params=(user_role, course_id)) - else: - query = """ - SELECT title, body, role_visibility, created_at - FROM Announcements - WHERE role_visibility = 'all' OR instr(role_visibility, ?) > 0 - ORDER BY datetime(created_at) DESC - """ - df = pd.read_sql_query(query, conn, params=(user_role,)) - - if df.empty: - print("No announcements available for your role at this time.\n") - else: - display(df[['created_at', 'title', 'body']]) - -def announcements_page(): - """ - Master function to show the announcements page. - Teachers/Admins can post; everyone can view. - """ - display(Markdown("## πŸ“’ Announcements")) - display_announcements() - if current_session['role'] in ['teacher', 'admin']: - _create_announcement_form() - - - - - - - - - - - -announcements_page() -# Meetika Kanumukula - - - - -if current_session: - display_announcements() - -else: - print("Please log in first to access the announcements menu.") - # Meetika Kanumukula - - - - -if current_session: - announcements_page() -else: - print("Please log in first to access the teacher/admin dashboard.") - - - - diff --git a/QUICKSTART.md b/QUICKSTART.md index c19fe8b..ca1ddac 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -20,9 +20,11 @@ uv pip install -r requirements.txt # Copy environment file cp .env.example .env -# ⚠️ IMPORTANT: Add Google API Key for AI Progress Reports -# Edit .env and add this line: -# GOOGLE_API_KEY=AIzaSyD_E490uOyT2_Tiy5x5_i43M0E8F7xa5Ww +# ✨ AI Progress Reports: Uses FREE Groq API with Llama 3.3 70B! +# 1. Go to https://console.groq.com/keys +# 2. Create a new API key +# 3. Copy the key +# 4. Edit .env and add: GROQ_API_TOKEN=your_key_here ``` ## 2. Create Test Database (1 minute) @@ -87,11 +89,10 @@ The test database includes: ## Troubleshooting **Problem**: AI Progress Reports not working -- Ensure `GOOGLE_API_KEY` is set in `.env` file -- Add this line to your `.env`: - ``` - GOOGLE_API_KEY=AIzaSyD_E490uOyT2_Tiy5x5_i43M0E8F7xa5Ww - ``` +- Ensure `GROQ_API_TOKEN` is set in `.env` file +- Get a free token at: https://console.groq.com/keys (no credit card required!) +- Each team member should create their own token +- Reports are cached after first generation to reduce API calls **Problem**: Import errors ```bash diff --git a/app.py b/app.py index 39e2b1a..effa82f 100644 --- a/app.py +++ b/app.py @@ -3,7 +3,7 @@ """ import streamlit as st from src.core.session import session -from src.ui.components.navigation import render_navigation, get_pages_for_role +from src.ui.components.navigation import render_navigation, get_pages_for_role, get_current_page from config.settings import APP_NAME @@ -56,9 +56,23 @@ def main(): # Show navigation and main content render_navigation() - # Show home page - from src.ui.pages.home import show_home_page - show_home_page() + current_page = get_current_page() + + if current_page == "home": + from src.ui.pages.home import show_home_page + show_home_page() + + elif current_page == "after_hours": + from src.ui.pages.after_hours import show_after_hours_page + show_after_hours_page() + + elif current_page in ["student_dashboard", "parent_dashboard", "teacher_dashboard", "admin_dashboard", + "parent_engagement", "low_grade_alerts", "announcements", "schedule_area"]: + from src.ui.pages.home import show_home_page + show_home_page() + + else: + st.info(f"Page '{current_page}' is under construction.") if __name__ == "__main__": diff --git a/config/database.py b/config/database.py index 00593e7..d659709 100644 --- a/config/database.py +++ b/config/database.py @@ -67,12 +67,13 @@ def execute_query(self, query: str, params: tuple = (), db_type: str = 'main'): db_type: Either 'main' or 'after_hours' Returns: - List of result rows + List of result rows as dictionaries """ with self.get_connection(db_type) as conn: + conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute(query, params) - return cursor.fetchall() + return [dict(row) for row in cursor.fetchall()] def execute_update(self, query: str, params: tuple = (), db_type: str = 'main') -> int: """ diff --git a/config/settings.py b/config/settings.py index 78350fe..4dea89c 100644 --- a/config/settings.py +++ b/config/settings.py @@ -37,6 +37,7 @@ 'parent_engagement': os.getenv('FEATURE_PARENT_ENGAGEMENT', 'True').lower() == 'true', 'after_hours': os.getenv('FEATURE_AFTER_HOURS', 'True').lower() == 'true', 'ai_progress_reports': os.getenv('FEATURE_AI_PROGRESS_REPORTS', 'True').lower() == 'true', + 'schedule_area': True, 'feature_6': os.getenv('FEATURE_6', 'False').lower() == 'true', 'feature_7': os.getenv('FEATURE_7', 'False').lower() == 'true', 'feature_8': os.getenv('FEATURE_8', 'False').lower() == 'true', diff --git a/requirements.txt b/requirements.txt index 0517d68..db5b388 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,10 +11,8 @@ bcrypt>=4.0.0 pytz>=2023.3 # AI/LLM Integration -google-auth>=2.0.0 -langchain>=1.0.0 -langchain-google-genai>=3.1.0 -langchain-core>=1.0.0 +huggingface-hub>=0.16.0 +requests>=2.27.0 # Development & Testing faker>=20.0.0 diff --git a/scripts/add_after_hours_table.py b/scripts/add_after_hours_table.py new file mode 100644 index 0000000..10015bd --- /dev/null +++ b/scripts/add_after_hours_table.py @@ -0,0 +1,39 @@ +import sqlite3 +from pathlib import Path + +# πŸ”§ If your DB is in a different place/name, change this. +DB_PATH = Path("data") / "school_system.db" + +DDL = """ +CREATE TABLE IF NOT EXISTS AfterHoursRequests ( + request_id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER NOT NULL, + requester_role TEXT NOT NULL, + teacher_id INTEGER NOT NULL, + student_id INTEGER, + question TEXT NOT NULL, + submitted_at TEXT NOT NULL, + status TEXT NOT NULL, + teacher_response TEXT, + response_time TEXT, + FOREIGN KEY(requester_id) REFERENCES Users(user_id), + FOREIGN KEY(teacher_id) REFERENCES Teachers(teacher_id), + FOREIGN KEY(student_id) REFERENCES Students(student_id) +); +""" + + +def main() -> None: + print(f"Using DB at: {DB_PATH}") + conn = sqlite3.connect(DB_PATH) + try: + cur = conn.cursor() + cur.execute(DDL) + conn.commit() + print("βœ… AfterHoursRequests table created (or already existed).") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/add_due_date_column.py b/scripts/add_due_date_column.py new file mode 100644 index 0000000..bc51a4c --- /dev/null +++ b/scripts/add_due_date_column.py @@ -0,0 +1,64 @@ +""" +Migration script to add due_date column to Grades table. + +This fixes the schedule feature database schema issue. +Run this script once to add the due_date column and populate it with sample data. + +Usage: + python scripts/add_due_date_column.py +""" + +import sqlite3 +import sys +from pathlib import Path + +# Add parent directory to path to import config +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from config.settings import DB_PATH + + +def migrate(): + """Add due_date column to Grades table.""" + print(f"Connecting to database: {DB_PATH}") + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + try: + # Check if column already exists + cursor.execute("PRAGMA table_info(Grades)") + columns = [column[1] for column in cursor.fetchall()] + + if 'due_date' in columns: + print("βœ“ due_date column already exists in Grades table") + return + + # Add due_date column + print("Adding due_date column to Grades table...") + cursor.execute("ALTER TABLE Grades ADD COLUMN due_date TEXT") + + # Populate with sample data (7 days after assignment date) + print("Populating sample due dates...") + cursor.execute(""" + UPDATE Grades + SET due_date = date(date_assigned, '+7 days') + WHERE assignment_name LIKE '%Exam%' + OR assignment_name LIKE '%Quiz%' + OR assignment_name LIKE '%Assignment%' + OR assignment_name LIKE '%Project%' + """) + + conn.commit() + print("βœ“ Migration completed successfully!") + print("βœ“ The schedule feature should now work without errors") + + except Exception as e: + conn.rollback() + print(f"βœ— Migration failed: {e}") + raise + finally: + conn.close() + + +if __name__ == "__main__": + migrate() diff --git a/scripts/create_test_db.py b/scripts/create_test_db.py index d638e3c..3b26c92 100755 --- a/scripts/create_test_db.py +++ b/scripts/create_test_db.py @@ -101,6 +101,7 @@ def create_main_database(): assignment_name TEXT, grade REAL, date_assigned TEXT, + due_date TEXT, FOREIGN KEY (student_id) REFERENCES Students(student_id), FOREIGN KEY (course_id) REFERENCES Courses(course_id) ) @@ -170,6 +171,18 @@ def create_main_database(): ) """) + cursor.execute(""" + CREATE TABLE Notifications ( + notification_id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_id INTEGER NOT NULL, + notification_type TEXT NOT NULL, + message TEXT NOT NULL, + is_read INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (recipient_id) REFERENCES Users(user_id) + ) + """) + print("[OK] Tables created") # Create test accounts @@ -266,124 +279,124 @@ def create_main_database(): # Student 1 (Jane - test account): Math low grades with decline grades.extend([ - (1, 1, 'Quiz 1', 85.5, '2024-01-15'), - (1, 1, 'Quiz 2', 72.0, '2024-01-22'), - (1, 1, 'Midterm', 68.0, '2024-02-05'), - (1, 1, 'Quiz 3', 65.0, '2024-02-12'), + (1, 1, 'Quiz 1', 85.5, '2024-01-15', '2024-01-22'), + (1, 1, 'Quiz 2', 72.0, '2024-01-22', '2024-01-29'), + (1, 1, 'Midterm', 68.0, '2024-02-05', '2024-02-19'), + (1, 1, 'Quiz 3', 65.0, '2024-02-12', '2024-02-19'), # Science: Average - (1, 2, 'Lab 1', 78.0, '2024-01-10'), - (1, 2, 'Quiz 1', 80.5, '2024-01-20'), - (1, 2, 'Midterm', 79.0, '2024-02-10'), + (1, 2, 'Lab 1', 78.0, '2024-01-10', '2024-01-24'), + (1, 2, 'Quiz 1', 80.5, '2024-01-20', '2024-01-27'), + (1, 2, 'Midterm', 79.0, '2024-02-10', '2024-02-24'), # English: Good - (1, 3, 'Essay 1', 88.0, '2024-01-18'), - (1, 3, 'Quiz 1', 85.5, '2024-01-25'), - (1, 3, 'Midterm', 87.0, '2024-02-15'), + (1, 3, 'Essay 1', 88.0, '2024-01-18', '2024-02-01'), + (1, 3, 'Quiz 1', 85.5, '2024-01-25', '2024-02-08'), + (1, 3, 'Midterm', 87.0, '2024-02-15', '2024-03-01'), # History: Average - (1, 4, 'Essay 1', 76.0, '2024-01-12'), - (1, 4, 'Quiz 1', 78.5, '2024-01-30'), + (1, 4, 'Essay 1', 76.0, '2024-01-12', '2024-01-26'), + (1, 4, 'Quiz 1', 78.5, '2024-01-30', '2024-02-13'), ]) # Student 2 (Alice): Mixed performance grades.extend([ - (2, 1, 'Quiz 1', 92.0, '2024-01-15'), - (2, 1, 'Quiz 2', 88.0, '2024-01-22'), - (2, 2, 'Lab 1', 68.0, '2024-01-10'), - (2, 2, 'Quiz 1', 65.5, '2024-01-20'), - (2, 2, 'Midterm', 62.0, '2024-02-10'), - (2, 3, 'Essay 1', 81.0, '2024-01-18'), - (2, 3, 'Quiz 1', 83.0, '2024-01-25'), - (2, 4, 'Essay 1', 75.0, '2024-01-12'), + (2, 1, 'Quiz 1', 92.0, '2024-01-15', '2024-01-22'), + (2, 1, 'Quiz 2', 88.0, '2024-01-22', '2024-01-29'), + (2, 2, 'Lab 1', 68.0, '2024-01-10', '2024-01-24'), + (2, 2, 'Quiz 1', 65.5, '2024-01-20', '2024-01-27'), + (2, 2, 'Midterm', 62.0, '2024-02-10', '2024-02-24'), + (2, 3, 'Essay 1', 81.0, '2024-01-18', '2024-02-01'), + (2, 3, 'Quiz 1', 83.0, '2024-01-25', '2024-02-08'), + (2, 4, 'Essay 1', 75.0, '2024-01-12', '2024-01-26'), ]) # Student 3 (Charlie): Declining trend in Math grades.extend([ - (3, 1, 'Quiz 1', 80.0, '2024-01-15'), - (3, 1, 'Quiz 2', 75.0, '2024-01-22'), - (3, 1, 'Midterm', 68.0, '2024-02-05'), - (3, 1, 'Quiz 3', 60.0, '2024-02-12'), - (3, 2, 'Lab 1', 85.0, '2024-01-10'), - (3, 2, 'Quiz 1', 87.0, '2024-01-20'), - (3, 3, 'Essay 1', 79.0, '2024-01-18'), - (3, 4, 'Essay 1', 82.0, '2024-01-12'), + (3, 1, 'Quiz 1', 80.0, '2024-01-15', '2024-01-22'), + (3, 1, 'Quiz 2', 75.0, '2024-01-22', '2024-01-29'), + (3, 1, 'Midterm', 68.0, '2024-02-05', '2024-02-19'), + (3, 1, 'Quiz 3', 60.0, '2024-02-12', '2024-02-19'), + (3, 2, 'Lab 1', 85.0, '2024-01-10', '2024-01-24'), + (3, 2, 'Quiz 1', 87.0, '2024-01-20', '2024-01-27'), + (3, 3, 'Essay 1', 79.0, '2024-01-18', '2024-02-01'), + (3, 4, 'Essay 1', 82.0, '2024-01-12', '2024-01-26'), ]) # Student 4 (Diana): Consistently good grades.extend([ - (4, 1, 'Quiz 1', 91.0, '2024-01-15'), - (4, 1, 'Quiz 2', 89.0, '2024-01-22'), - (4, 2, 'Lab 1', 90.0, '2024-01-10'), - (4, 2, 'Quiz 1', 88.0, '2024-01-20'), - (4, 3, 'Essay 1', 92.0, '2024-01-18'), - (4, 3, 'Quiz 1', 90.0, '2024-01-25'), - (4, 4, 'Essay 1', 87.0, '2024-01-12'), + (4, 1, 'Quiz 1', 91.0, '2024-01-15', '2024-01-22'), + (4, 1, 'Quiz 2', 89.0, '2024-01-22', '2024-01-29'), + (4, 2, 'Lab 1', 90.0, '2024-01-10', '2024-01-24'), + (4, 2, 'Quiz 1', 88.0, '2024-01-20', '2024-01-27'), + (4, 3, 'Essay 1', 92.0, '2024-01-18', '2024-02-01'), + (4, 3, 'Quiz 1', 90.0, '2024-01-25', '2024-02-08'), + (4, 4, 'Essay 1', 87.0, '2024-01-12', '2024-01-26'), ]) # Student 5 (Eve): Low in Science grades.extend([ - (5, 1, 'Quiz 1', 78.0, '2024-01-15'), - (5, 1, 'Quiz 2', 82.0, '2024-01-22'), - (5, 2, 'Lab 1', 55.0, '2024-01-10'), - (5, 2, 'Quiz 1', 58.0, '2024-01-20'), - (5, 2, 'Midterm', 60.0, '2024-02-10'), - (5, 3, 'Essay 1', 84.0, '2024-01-18'), - (5, 4, 'Essay 1', 81.0, '2024-01-12'), + (5, 1, 'Quiz 1', 78.0, '2024-01-15', '2024-01-22'), + (5, 1, 'Quiz 2', 82.0, '2024-01-22', '2024-01-29'), + (5, 2, 'Lab 1', 55.0, '2024-01-10', '2024-01-24'), + (5, 2, 'Quiz 1', 58.0, '2024-01-20', '2024-01-27'), + (5, 2, 'Midterm', 60.0, '2024-02-10', '2024-02-24'), + (5, 3, 'Essay 1', 84.0, '2024-01-18', '2024-02-01'), + (5, 4, 'Essay 1', 81.0, '2024-01-12', '2024-01-26'), ]) # Student 6 (Frank): All low grades grades.extend([ - (6, 1, 'Quiz 1', 62.0, '2024-01-15'), - (6, 1, 'Quiz 2', 58.0, '2024-01-22'), - (6, 2, 'Lab 1', 64.0, '2024-01-10'), - (6, 2, 'Quiz 1', 61.0, '2024-01-20'), - (6, 3, 'Essay 1', 66.0, '2024-01-18'), - (6, 4, 'Essay 1', 63.0, '2024-01-12'), + (6, 1, 'Quiz 1', 62.0, '2024-01-15', '2024-01-22'), + (6, 1, 'Quiz 2', 58.0, '2024-01-22', '2024-01-29'), + (6, 2, 'Lab 1', 64.0, '2024-01-10', '2024-01-24'), + (6, 2, 'Quiz 1', 61.0, '2024-01-20', '2024-01-27'), + (6, 3, 'Essay 1', 66.0, '2024-01-18', '2024-02-01'), + (6, 4, 'Essay 1', 63.0, '2024-01-12', '2024-01-26'), ]) # Student 7 (Grace): Average across all grades.extend([ - (7, 1, 'Quiz 1', 75.0, '2024-01-15'), - (7, 1, 'Quiz 2', 77.0, '2024-01-22'), - (7, 2, 'Lab 1', 76.0, '2024-01-10'), - (7, 2, 'Quiz 1', 78.0, '2024-01-20'), - (7, 3, 'Essay 1', 74.0, '2024-01-18'), - (7, 4, 'Essay 1', 79.0, '2024-01-12'), + (7, 1, 'Quiz 1', 75.0, '2024-01-15', '2024-01-22'), + (7, 1, 'Quiz 2', 77.0, '2024-01-22', '2024-01-29'), + (7, 2, 'Lab 1', 76.0, '2024-01-10', '2024-01-24'), + (7, 2, 'Quiz 1', 78.0, '2024-01-20', '2024-01-27'), + (7, 3, 'Essay 1', 74.0, '2024-01-18', '2024-02-01'), + (7, 4, 'Essay 1', 79.0, '2024-01-12', '2024-01-26'), ]) # Student 8 (Henry): Excellent student grades.extend([ - (8, 1, 'Quiz 1', 95.0, '2024-01-15'), - (8, 1, 'Quiz 2', 93.0, '2024-01-22'), - (8, 2, 'Lab 1', 94.0, '2024-01-10'), - (8, 2, 'Quiz 1', 92.0, '2024-01-20'), - (8, 3, 'Essay 1', 96.0, '2024-01-18'), - (8, 4, 'Essay 1', 91.0, '2024-01-12'), + (8, 1, 'Quiz 1', 95.0, '2024-01-15', '2024-01-22'), + (8, 1, 'Quiz 2', 93.0, '2024-01-22', '2024-01-29'), + (8, 2, 'Lab 1', 94.0, '2024-01-10', '2024-01-24'), + (8, 2, 'Quiz 1', 92.0, '2024-01-20', '2024-01-27'), + (8, 3, 'Essay 1', 96.0, '2024-01-18', '2024-02-01'), + (8, 4, 'Essay 1', 91.0, '2024-01-12', '2024-01-26'), ]) # Student 9 (Iris): Declining across board grades.extend([ - (9, 1, 'Quiz 1', 82.0, '2024-01-15'), - (9, 1, 'Quiz 2', 76.0, '2024-01-22'), - (9, 1, 'Midterm', 68.0, '2024-02-05'), - (9, 2, 'Lab 1', 81.0, '2024-01-10'), - (9, 2, 'Quiz 1', 74.0, '2024-01-20'), - (9, 3, 'Essay 1', 80.0, '2024-01-18'), - (9, 3, 'Quiz 1', 72.0, '2024-01-25'), - (9, 4, 'Essay 1', 79.0, '2024-01-12'), + (9, 1, 'Quiz 1', 82.0, '2024-01-15', '2024-01-22'), + (9, 1, 'Quiz 2', 76.0, '2024-01-22', '2024-01-29'), + (9, 1, 'Midterm', 68.0, '2024-02-05', '2024-02-19'), + (9, 2, 'Lab 1', 81.0, '2024-01-10', '2024-01-24'), + (9, 2, 'Quiz 1', 74.0, '2024-01-20', '2024-01-27'), + (9, 3, 'Essay 1', 80.0, '2024-01-18', '2024-02-01'), + (9, 3, 'Quiz 1', 72.0, '2024-01-25', '2024-02-08'), + (9, 4, 'Essay 1', 79.0, '2024-01-12', '2024-01-26'), ]) # Student 10 (Jack): Low in multiple courses grades.extend([ - (10, 1, 'Quiz 1', 68.0, '2024-01-15'), - (10, 1, 'Quiz 2', 65.0, '2024-01-22'), - (10, 2, 'Lab 1', 72.0, '2024-01-10'), - (10, 2, 'Quiz 1', 69.0, '2024-01-20'), - (10, 3, 'Essay 1', 70.0, '2024-01-18'), - (10, 4, 'Essay 1', 67.0, '2024-01-12'), + (10, 1, 'Quiz 1', 68.0, '2024-01-15', '2024-01-22'), + (10, 1, 'Quiz 2', 65.0, '2024-01-22', '2024-01-29'), + (10, 2, 'Lab 1', 72.0, '2024-01-10', '2024-01-24'), + (10, 2, 'Quiz 1', 69.0, '2024-01-20', '2024-01-27'), + (10, 3, 'Essay 1', 70.0, '2024-01-18', '2024-02-01'), + (10, 4, 'Essay 1', 67.0, '2024-01-12', '2024-01-26'), ]) cursor.executemany(""" - INSERT INTO Grades (student_id, course_id, assignment_name, grade, date_assigned) - VALUES (?, ?, ?, ?, ?) + INSERT INTO Grades (student_id, course_id, assignment_name, grade, date_assigned, due_date) + VALUES (?, ?, ?, ?, ?, ?) """, grades) print("[OK] Grades created") diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/__init__.py b/src/features/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/after_hours/README.md b/src/features/after_hours/README.md index d251627..3a5df81 100644 --- a/src/features/after_hours/README.md +++ b/src/features/after_hours/README.md @@ -1,222 +1,107 @@ -After Hours Connect -Author: Jaikishan Manivannan -Status: Production Ready -Tech Stack: Streamlit UI Β· SQLite Β· Optional Gemini AI Integration - -Overview - -After Hours Connect allows students and parents to securely message teachers outside normal school hours. Messages are categorized, timestamped, and stored so teachers can respond during their next availability window. The feature ensures respectful communication boundaries while giving families a structured way to request help. - -Features -Student & Parent Functionality - -Submit messages during after-hours periods - -Select a message category (Homework Help, Grade Clarification, Concern, Technical Issue, etc.) - -Optional file/screenshot attachments - -View previous message history - -See expected response time - -Teacher Functionality - -Centralized inbox for all after-hours messages - -Filter by category, course, student, or status - -Reply, follow-up, or mark message resolved - -Optional AI-generated reply suggestions (if enabled) - -Daily summary view - -Admin Functionality - -Set global after-hours time windows - -Configure teacher-specific availability overrides - -Export message logs - -Enable or disable AI reply suggestions - -Architecture - -After Hours Connect follows the GradeReporter three-layer architecture: - -Repository Layer (repository.py) - -Stores new messages - -Retrieves student/teacher message history - -Handles message status updates - -Manages availability windows - -Service Layer (service.py) - -Validates message submissions - -Applies rate limiting and availability rules - -Generates optional AI reply suggestions (Gemini) - -Sends notifications to teacher dashboards - -Supports attachments - -UI Layer (ui.py) - -Student after-hours message form - -Teacher inbox and message viewer - -Reply and resolution interface - -Admin settings page - -Setup -Environment Variables - -Add the following to .env: - +# After-Hours Connect Feature + +**Author**: Jaikishan Manivannan +**Status**: Production Ready +**Tech Stack**: Python, Streamlit, SQLite/Postgres + +## Overview +The **After-Hours Connect** feature allows students and parents to request academic help outside regular school hours. Teachers can view, manage, and respond to these after-hours requests through a dedicated dashboard. + +## Features +- Configurable after-hours window +- Role-based workflows (student/parent/teacher) +- Request routing and status tracking +- Optional notification hooks +- Analytics dashboard +- Feature flag toggle +- Streamlit-based UI + +## Architecture +The feature uses a **3-layer architecture**: + +### 1. Repository Layer (`repository.py`) +Handles all DB read/write operations for: +- Creating requests +- Updating request status +- Fetching teacher/student/parent requests +- Generating summary analytics + +### 2. Service Layer (`service.py`) +Business logic including: +- After-hours time validation +- Role validation +- Request creation & status transitions +- Timezone-aware operations +- Passing data to UI + +### 3. UI Layer (`ui.py`) +Streamlit components including: +- Student/parent request form +- Teacher dashboard +- Status filters +- Metrics display +- Status update UI + +## Environment Variables + +``` FEATURE_AFTER_HOURS_CONNECT=True - -# Optional AI Integration -GOOGLE_API_KEY=your-api-key-here -ENABLE_AI_REPLY_SUGGESTIONS=True - -# Optional Email Integration -EMAIL_SENDER_ADDRESS=your-email-here -EMAIL_API_KEY=your-email-api-key - -Database Migration - -Run: - -python scripts/add_after_hours_messages_table.py - -Table Schema -CREATE TABLE AfterHoursMessages ( - message_id INTEGER PRIMARY KEY AUTOINCREMENT, - student_id INTEGER NOT NULL, - teacher_id INTEGER NOT NULL, +AFTER_HOURS_START=17:00 +AFTER_HOURS_END=21:00 +AFTER_HOURS_TIMEZONE=America/Chicago +``` + +## Database Schema + +``` +CREATE TABLE IF NOT EXISTS AfterHoursRequests ( + request_id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id INTEGER, + parent_id INTEGER, + teacher_id INTEGER, course_id INTEGER, - category TEXT NOT NULL, - message_text TEXT NOT NULL, - attachment_path TEXT, created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'new', - FOREIGN KEY(student_id) REFERENCES Students(student_id), - FOREIGN KEY(teacher_id) REFERENCES Teachers(teacher_id), - FOREIGN KEY(course_id) REFERENCES Courses(course_id) + updated_at TEXT NOT NULL, + requested_for TEXT, + message TEXT NOT NULL, + status TEXT NOT NULL, + resolution_note TEXT ); +``` -Usage -Student Dashboard - -Students/parents can: -Submit a new message -Select a category -Upload optional attachments -View past messages -See teacher availability - -Teacher Dashboard - -Teachers can: -View new and pending messages -Sort/filter messages -Reply or mark resolved -Use AI-generated suggested replies (if enabled) -View conversation history - -Programmatic Example -from src.features.after_hours_connect.service import AfterHoursService - -service = AfterHoursService() +## File Structure -service.submit_message( - student_id=10, - teacher_id=4, - course_id=2, - category="Homework Help", - message_text="I need help with assignment question 5." -) - -inbox = service.get_teacher_inbox(teacher_id=4) - -service.add_teacher_reply( - message_id=1, - reply_text="Thanks for reaching out. I will help first thing tomorrow." +``` +after_hours_connect/ +│── __init__.py +│── repository.py +│── service.py +│── ui.py +└── README.md +``` + +## Usage Example (Service Layer) + +```python +service.create_request( + student_id=12, + parent_id=None, + course_id=101, + teacher_id=None, + requested_for="Homework clarification", + message="Can you explain question 3?" ) +``` -Testing -source .venv/bin/activate -python scripts/test_after_hours.py - -Test Coverage - -Message submission - -Attachment handling - -Availability window enforcement - -Inbox filtering - -AI reply suggestion logic +## Usage Example (UI) -Admin override behavior - -Troubleshooting - -Feature not visible - -Ensure FEATURE_AFTER_HOURS_CONNECT=True - -Restart Streamlit - -AI reply suggestions not working - -Set GOOGLE_API_KEY - -Ensure Gemini permissions - -Enable ENABLE_AI_REPLY_SUGGESTIONS=True - -Database issues - -Re-run migration script - -Check SQLite file path - -Future Enhancements - -Multi-teacher conversation threads - -Student/parent priority tagging - -Voice message support - -SMS notifications - -Weekly summary digest - -AI-powered urgency detection - -File Structure -src/features/after_hours_connect/ -β”œβ”€β”€ __init__.py -β”œβ”€β”€ repository.py -β”œβ”€β”€ service.py -β”œβ”€β”€ ui.py -β”œβ”€β”€ prompts.py -└── README.md +```python +from after_hours_connect.ui import render_after_hours_section +render_after_hours_section(service, user_context) +``` -scripts/ -β”œβ”€β”€ add_after_hours_messages_table.py -└── test_after_hours.py +## Future Enhancements +- Meeting scheduling integrations +- AI-powered request triage +- SLA tracking +- Push/email notifications diff --git a/src/features/after_hours/__init__.py b/src/features/after_hours/__init__.py index e69de29..e1c3ee8 100644 --- a/src/features/after_hours/__init__.py +++ b/src/features/after_hours/__init__.py @@ -0,0 +1,8 @@ +from .repository import AfterHoursRepository, AfterHoursRequest +from .service import AfterHoursService + +__all__ = [ + "AfterHoursRepository", + "AfterHoursRequest", + "AfterHoursService", +] diff --git a/src/features/after_hours/repository.py b/src/features/after_hours/repository.py index 499ceb3..780aacf 100644 --- a/src/features/after_hours/repository.py +++ b/src/features/after_hours/repository.py @@ -1,8 +1,4 @@ -# ======================== -# 🏫 AFTER HOURS SYSTEM -# ======================== - -import os +from __future__ import annotations import sqlite3 from datetime import datetime, timedelta import pytz # type: ignore @@ -11,8 +7,6 @@ import smtplib from email.message import EmailMessage from typing import Optional, Tuple -from IPython.display import display, clear_output -import ipywidgets as widgets # ------------------ DATABASE SETUP ----------------------------- DB_PATH = "after_hours.db" @@ -69,222 +63,117 @@ def _localize(naive_dt: datetime, tzname: str): class AfterHoursSystem: def __init__(self, db_path: str = DB_PATH): self.db_path = db_path - self._init_db() - - def _init_db(self): - conn = sqlite3.connect(self.db_path) - cur = conn.cursor() - cur.executescript(CREATE_SQL) - conn.commit() - conn.close() - # --- Teacher setup --- - def add_teacher(self, name: str, timezone: str, email: Optional[str] = None) -> str: - tid = str(uuid.uuid4()) + def _connect(self): conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _to_request(self, row: sqlite3.Row) -> AfterHoursRequest: + return AfterHoursRequest( + request_id=row["request_id"], + requester_id=row["requester_id"], + requester_role=row["requester_role"], + teacher_id=row["teacher_id"], + student_id=row["student_id"], + question=row["question"], + submitted_at=row["submitted_at"], + status=row["status"], + teacher_response=row["teacher_response"], + response_time=row["response_time"], + ) + + def list_teachers(self) -> List[Tuple[int, str]]: + conn = self._connect() cur = conn.cursor() - cur.execute("INSERT INTO teachers(teacher_id,name,email,timezone) VALUES (?,?,?,?)", - (tid, name, email, timezone)) - conn.commit() + cur.execute(""" + SELECT t.teacher_id, u.name + FROM Teachers t + JOIN Users u ON t.user_id = u.user_id + ORDER BY u.name ASC + """) + rows = cur.fetchall() conn.close() - return tid + return [(row["teacher_id"], row["name"]) for row in rows] - def set_availability(self, teacher_id: str, weekday: int, start_hm: str, end_hm: str): - conn = sqlite3.connect(self.db_path) + def get_student_id_for_user(self, user_id: int) -> Optional[int]: + conn = self._connect() cur = conn.cursor() - cur.execute( - "INSERT INTO availability(teacher_id,weekday,start_hm,end_hm) VALUES (?,?,?,?)", - (teacher_id, weekday, start_hm, end_hm)) - conn.commit() + cur.execute("SELECT student_id FROM Students WHERE user_id = ?", (user_id,)) + row = cur.fetchone() conn.close() + return row["student_id"] if row else None - def get_teacher(self, teacher_id: str): - conn = sqlite3.connect(self.db_path) + def get_parent_id_for_user(self, user_id: int) -> Optional[int]: + conn = self._connect() cur = conn.cursor() - cur.execute("SELECT teacher_id,name,email,timezone FROM teachers WHERE teacher_id=?", - (teacher_id,)) + cur.execute("SELECT parent_id FROM Parents WHERE user_id = ?", (user_id,)) row = cur.fetchone() conn.close() - if not row: - return None - return {"teacher_id": row[0], "name": row[1], "email": row[2], "timezone": row[3]} + return row["parent_id"] if row else None - def _get_availability_df(self, teacher_id: str) -> pd.DataFrame: - conn = sqlite3.connect(self.db_path) - df = pd.read_sql_query( - "SELECT weekday, start_hm, end_hm FROM availability WHERE teacher_id=?", - conn, params=(teacher_id,)) + def list_children_for_parent(self, parent_id: int) -> List[Tuple[int, str]]: + conn = self._connect() + cur = conn.cursor() + cur.execute(""" + SELECT s.student_id, u.name + FROM Parent_Student ps + JOIN Students s ON ps.student_id = s.student_id + JOIN Users u ON s.user_id = u.user_id + WHERE ps.parent_id = ? + """, (parent_id,)) + rows = cur.fetchall() conn.close() - if df.empty: - return df - df['weekday'] = df['weekday'].astype(int) - return df + return [(row["student_id"], row["name"]) for row in rows] - # --- Ticket handling --- - def submit_ticket(self, teacher_id: str, submitter_name: str, - submitter_email: str, submitter_id: str, - question: str, submit_time: Optional[datetime] = None): - if submit_time is None: - submit_time = _now_utc() - elif submit_time.tzinfo is None: - submit_time = submit_time.replace(tzinfo=pytz.UTC) - else: - submit_time = submit_time.astimezone(pytz.UTC) - - teacher = self.get_teacher(teacher_id) - if not teacher: - raise ValueError("Teacher not found") - - ticket_id = str(uuid.uuid4()) - submitted_at_utc = submit_time.isoformat() - - proposed_slot_local = self.find_next_available_slot(teacher_id, submit_time) - scheduled_slot_utc = None - status = "QUEUED" - if proposed_slot_local is not None: - scheduled_slot_utc = proposed_slot_local.astimezone(pytz.UTC).isoformat() - status = "SCHEDULED" - - conn = sqlite3.connect(self.db_path) + def create_request(self, requester_id: int, requester_role: str, + teacher_id: int, student_id: Optional[int], + question: str) -> int: + conn = self._connect() cur = conn.cursor() + submitted_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") cur.execute(""" - INSERT INTO tickets(ticket_id,teacher_id,submitter_name,submitter_email,submitter_id, - question,submitted_at_utc,status,scheduled_slot_utc) - VALUES (?,?,?,?,?,?,?,?,?) - """, (ticket_id, teacher_id, submitter_name, submitter_email, submitter_id, - question, submitted_at_utc, status, scheduled_slot_utc)) + INSERT INTO AfterHoursRequests + (requester_id, requester_role, teacher_id, student_id, question, + submitted_at, status) + VALUES (?, ?, ?, ?, ?, ?, 'pending') + """, (requester_id, requester_role, teacher_id, + student_id, question, submitted_at)) conn.commit() + rid = cur.lastrowid conn.close() + return rid - return { - "ticket_id": ticket_id, - "status": status, - "teacher": teacher["name"], - "scheduled_local": proposed_slot_local.isoformat() if proposed_slot_local else None - } - - def find_next_available_slot(self, teacher_id: str, from_time_utc: Optional[datetime] = None, - search_days: int = 14) -> Optional[datetime]: - teacher = self.get_teacher(teacher_id) - if not teacher: - raise ValueError("Teacher not found") - tzname = teacher["timezone"] - tz = pytz.timezone(tzname) - - if from_time_utc is None: - from_time_utc = _now_utc() - elif from_time_utc.tzinfo is None: - from_time_utc = from_time_utc.replace(tzinfo=pytz.UTC) - else: - from_time_utc = from_time_utc.astimezone(pytz.UTC) - - avail_df = self._get_availability_df(teacher_id) - if avail_df.empty: - return None - - conn = sqlite3.connect(self.db_path) - tickets_df = pd.read_sql_query( - "SELECT scheduled_slot_utc FROM tickets WHERE teacher_id=? AND scheduled_slot_utc IS NOT NULL", - conn, params=(teacher_id,)) + def list_requests_for_requester(self, requester_id: int) -> List[AfterHoursRequest]: + conn = self._connect() + cur = conn.cursor() + cur.execute(""" + SELECT * FROM AfterHoursRequests + WHERE requester_id = ? + ORDER BY submitted_at DESC + """, (requester_id,)) + rows = cur.fetchall() conn.close() - scheduled_local_starts = set() - for _, row in tickets_df.iterrows(): - try: - utc_dt = datetime.fromisoformat(row['scheduled_slot_utc']).astimezone(pytz.UTC) - local = utc_dt.astimezone(tz) - scheduled_local_starts.add(local.replace(second=0, microsecond=0)) - except Exception: - continue + return [self._to_request(r) for r in rows] - start_utc = from_time_utc - for day_offset in range(0, search_days + 1): - day_candidate_utc = start_utc + timedelta(days=day_offset) - local_candidate = day_candidate_utc.astimezone(tz) - weekday = local_candidate.weekday() - day_windows = avail_df[avail_df['weekday'] == weekday] - if day_windows.empty: - continue - for _, win in day_windows.iterrows(): - sh, sm = _parse_hm(win['start_hm']) - local_start_dt = tz.localize(datetime(year=local_candidate.year, - month=local_candidate.month, - day=local_candidate.day, - hour=sh, minute=sm)) - now_local = start_utc.astimezone(tz) - if local_start_dt < now_local: - continue - if local_start_dt.replace(second=0, microsecond=0) in scheduled_local_starts: - continue - return local_start_dt - return None + def list_requests_for_teacher_user(self, teacher_user_id: int) -> List[AfterHoursRequest]: + conn = self._connect() + cur = conn.cursor() + cur.execute(""" + SELECT teacher_id FROM Teachers WHERE user_id = ? + """, (teacher_user_id,)) + row = cur.fetchone() + if not row: + conn.close() + return [] + teacher_id = row["teacher_id"] - def list_teachers(self): - conn = sqlite3.connect(self.db_path) - df = pd.read_sql_query("SELECT teacher_id, name FROM teachers", conn) + cur.execute(""" + SELECT * FROM AfterHoursRequests + WHERE teacher_id = ? + ORDER BY submitted_at DESC + """, (teacher_id,)) + rows = cur.fetchall() conn.close() return df -# ------------------ INITIALIZE SYSTEM -------------------------- -if os.path.exists(DB_PATH): - os.remove(DB_PATH) - print("🧹 Old database removed β€” starting fresh.") - -sys = AfterHoursSystem() - -teacher_names = ["Ms. Parker", "Mr. Lee", "Dr. Smith", "Ms. Johnson", "Mr. Patel"] -for t in teacher_names: - tid = sys.add_teacher(t, "America/New_York", f"{t.lower().replace(' ', '')}@school.edu") - for wd in range(5): # Mon–Fri 9–5 - sys.set_availability(tid, wd, "09:00", "17:00") - -print("βœ… 5 default teachers created successfully.\n") - -# ------------------ STUDENT / PARENT WIDGET -------------------- -teacher_df = sys.list_teachers() -teacher_dropdown = widgets.Dropdown( - options=[(row["name"], row["teacher_id"]) for _, row in teacher_df.iterrows()], - description="Teacher:", - style={'description_width': 'initial'}, - layout=widgets.Layout(width="400px") -) - -submitter_name = widgets.Text(description="Your Name:", layout=widgets.Layout(width="400px")) -submitter_email = widgets.Text(description="Your Email:", layout=widgets.Layout(width="400px")) -submitter_id = widgets.Text(description="Student/Parent ID:", layout=widgets.Layout(width="400px")) -question_box = widgets.Textarea(description="Question:", layout=widgets.Layout(width="400px", height="100px")) -submit_button = widgets.Button(description="Submit Question", button_style='success') -output = widgets.Output() - -def on_submit_clicked(b): - with output: - clear_output() - try: - result = sys.submit_ticket( - teacher_id=teacher_dropdown.value, - submitter_name=submitter_name.value, - submitter_email=submitter_email.value, - submitter_id=submitter_id.value, - question=question_box.value - ) - print(f"🎫 Ticket submitted successfully!") - print(f"Ticket ID: {result['ticket_id']}") - if result['scheduled_local']: - print(f"πŸ•’ Scheduled Meet Time: {result['scheduled_local']}") - else: - print("No available slot found within the next 14 days.") - print(f"Assigned Teacher: {result['teacher']}") - except Exception as e: - print("❌ Error submitting ticket:", str(e)) - -submit_button.on_click(on_submit_clicked) - -display(widgets.VBox([ - widgets.HTML("

πŸ“© Submit After-Hours Question

"), - teacher_dropdown, - submitter_name, - submitter_email, - submitter_id, - question_box, - submit_button, - output -])) diff --git a/src/features/after_hours/service.py b/src/features/after_hours/service.py index e69de29..1103387 100644 --- a/src/features/after_hours/service.py +++ b/src/features/after_hours/service.py @@ -0,0 +1,198 @@ +""" +Service layer for the After-Hours Connect feature. + +This wraps the repository and enforces basic business rules: +- Which roles can submit questions +- Which roles can view / respond to questions +- How student/parent context is handled +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import datetime, time +from typing import Any, Dict, List, Optional, Protocol, Literal + +try: + # Python 3.9+ standard timezone support + from zoneinfo import ZoneInfo +except ImportError: # pragma: no cover - for very old Python versions + ZoneInfo = None # type: ignore[misc] + + +StatusType = Literal["open", "in_progress", "resolved", "cancelled"] + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +@dataclass +class AfterHoursConfig: + """Configuration for After-Hours Connect.""" + + feature_enabled: bool + start_time: time + end_time: time + timezone: str + + @classmethod + def from_env(cls) -> "AfterHoursConfig": + """Load configuration from environment variables.""" + start_str = os.getenv("AFTER_HOURS_START", "17:00") # 5 PM + end_str = os.getenv("AFTER_HOURS_END", "21:00") # 9 PM + tz = os.getenv("AFTER_HOURS_TIMEZONE", "America/Chicago") + enabled = os.getenv("FEATURE_AFTER_HOURS", "true").lower() == "true" + + def parse_hh_mm(value: str) -> time: + hour, minute = value.split(":") + return time(hour=int(hour), minute=int(minute)) + + return cls( + feature_enabled=enabled, + start_time=parse_hh_mm(start_str), + end_time=parse_hh_mm(end_str), + timezone=tz, + ) + + def is_within_window(self, dt: Optional[datetime] = None) -> bool: + """ + Check whether a given datetime is within the configured + after-hours window. Defaults to "now" in the configured timezone. + """ + if not self.feature_enabled: + return False + + if dt is None: + if ZoneInfo is not None: + dt = datetime.now(ZoneInfo(self.timezone)) + else: + dt = datetime.now() + + current_time = dt.time() + +from .repository import AfterHoursRepository, AfterHoursRequest + +StatusType = Literal["pending", "answered", "closed"] + + +class AfterHoursService: + def __init__(self, repo: AfterHoursRepository) -> None: + self.repo = repo + + # ------------------------------------------------------------------ + # Student / Parent actions + # ------------------------------------------------------------------ + def submit_question( + self, + user_context: Dict[str, Any], + teacher_id: int, + question: str, + student_id: Optional[int] = None, + ) -> int: + """ + Create a new after-hours request. + + - Students: student_id is auto-inferred from the logged-in user if not provided. + - Parents: must explicitly select a student_id in the UI. + """ + role = user_context.get("role") + user_id = user_context.get("user_id") + + if user_id is None: + raise ValueError("Missing user_id in user_context.") + if role not in ("student", "parent"): + raise PermissionError("Only students and parents can submit questions.") + if not question or not question.strip(): + raise ValueError("Question cannot be empty.") + if teacher_id is None: + raise ValueError("Teacher must be selected.") + + # Students: infer their own student_id if not given + if role == "student" and student_id is None: + student_id = self.repo.get_student_id_for_user(user_id) + + # Parents: require child selection + if role == "parent" and student_id is None: + raise ValueError("Please select which student this question is about.") + + return self.repo.create_request( + requester_id=user_id, + requester_role=role, + teacher_id=teacher_id, + student_id=student_id, + question=question.strip(), + ) + + def get_my_requests(self, user_context: Dict[str, Any]) -> List[AfterHoursRequest]: + """All after-hours requests created by the logged-in user.""" + user_id = user_context.get("user_id") + if user_id is None: + raise ValueError("Missing user_id in user_context.") + return self.repo.list_requests_for_requester(user_id) + + # ------------------------------------------------------------------ + # Teacher actions + # ------------------------------------------------------------------ + def get_requests_for_teacher(self, user_context: Dict[str, Any]) -> List[AfterHoursRequest]: + """All after-hours requests addressed to the logged-in teacher.""" + role = user_context.get("role") + user_id = user_context.get("user_id") + + if user_id is None: + raise ValueError("Missing user_id in user_context.") + if role != "teacher": + raise PermissionError("Only teachers can view teacher after-hours requests.") + + return self.repo.list_requests_for_teacher_user(user_id) + + def respond_to_request( + self, + user_context: Dict[str, Any], + request_id: int, + response_text: str, + new_status: StatusType = "answered", + ) -> None: + """ + Save a teacher's response and update ticket status. + """ + role = user_context.get("role") + if role != "teacher": + raise PermissionError("Only teachers can respond to after-hours requests.") + + if not response_text.strip(): + raise ValueError("Response cannot be empty.") + + # Optional: could verify that the ticket belongs to this teacher here. + self.repo.update_response( + request_id=request_id, + response=response_text.strip(), + status=new_status, + ) + + # ------------------------------------------------------------------ + # Helper lookups for UI + # ------------------------------------------------------------------ + def list_teachers_for_dropdown(self) -> List[Dict[str, Any]]: + """Return teachers in a format convenient for a selectbox.""" + teachers = self.repo.list_teachers() + return [{"teacher_id": t_id, "name": name} for (t_id, name) in teachers] + + def list_children_for_parent(self, user_context: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + For a logged-in parent, return their children as + [{student_id, name}, ...]. + """ + role = user_context.get("role") + user_id = user_context.get("user_id") + + if role != "parent" or user_id is None: + return [] + + parent_id = self.repo.get_parent_id_for_user(user_id) + if parent_id is None: + return [] + + children = self.repo.list_children_for_parent(parent_id) + return [{"student_id": sid, "name": name} for (sid, name) in children] diff --git a/src/features/after_hours/ui.py b/src/features/after_hours/ui.py index e69de29..2fa19ef 100644 --- a/src/features/after_hours/ui.py +++ b/src/features/after_hours/ui.py @@ -0,0 +1,235 @@ +""" +Streamlit UI for the After-Hours Connect feature. + +Exports: +- render_after_hours_section(service, user_context) +- render_student_parent_view(...) +- render_teacher_view(...) +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pandas as pd # type: ignore +import streamlit as st # type: ignore + +from .service import AfterHoursService, StatusType +from .repository import AfterHoursRequest + + +# --------------------------------------------------------------------------- +# Top-level entry point +# --------------------------------------------------------------------------- + +def render_after_hours_section( + service: AfterHoursService, + user_context: Dict[str, Any], +) -> None: + """Decide which view to render based on user role.""" + role = (user_context or {}).get("role") + + st.header("✨ After-Hours Connect") + + if role in ("student", "parent"): + render_student_parent_view(service, user_context) + elif role == "teacher": + render_teacher_view(service, user_context) + else: + st.info( + "After-Hours Connect is available for students, parents, and teachers." + ) + + +# --------------------------------------------------------------------------- +# Student / Parent view +# --------------------------------------------------------------------------- + +def render_student_parent_view( + service: AfterHoursService, + user_context: Dict[str, Any], +) -> None: + role = user_context.get("role") + + st.subheader("Ask a question outside class time") + + # --- Teacher dropdown --- + teachers = service.list_teachers_for_dropdown() + if not teachers: + st.warning("No teachers found in the system.") + return + + teacher_names = [t["name"] for t in teachers] + teacher_label = st.selectbox("Choose a teacher", teacher_names) + teacher_id = teachers[teacher_names.index(teacher_label)]["teacher_id"] + + # --- Parent: choose which child the question is about --- + selected_student_id = None + if role == "parent": + children = service.list_children_for_parent(user_context) + if not children: + st.warning("No students are linked to your parent account.") + else: + child_labels = [ + f"{c['name']} (Student ID {c['student_id']})" for c in children + ] + child_label = st.selectbox( + "Which student is this question about?", + child_labels, + ) + idx = child_labels.index(child_label) + selected_student_id = children[idx]["student_id"] + + # --- Question input --- + question = st.text_area( + "Your question", + placeholder="Example: I'm confused about the last homework. Could you explain problem #4 more?", + ) + + if st.button("Send question"): + try: + ticket_id = service.submit_question( + user_context=user_context, + teacher_id=teacher_id, + question=question, + student_id=selected_student_id, + ) + st.success(f"Question submitted βœ… (Ticket #{ticket_id})") + except Exception as exc: # noqa: BLE001 + st.error(str(exc)) + + st.markdown("---") + st.subheader("Your after-hours questions") + + # --- History table --- + try: + tickets = service.get_my_requests(user_context) + except Exception as exc: # noqa: BLE001 + st.error(str(exc)) + return + + if not tickets: + st.caption("You haven't submitted any after-hours questions yet.") + return + + _render_ticket_table_for_student_parent(tickets) + + +def _render_ticket_table_for_student_parent(tickets: List[AfterHoursRequest]) -> None: + df = pd.DataFrame( + [ + { + "Ticket #": t.request_id, + "Teacher ID": t.teacher_id, + "Question": t.question, + "Submitted At": t.submitted_at, + "Status": t.status, + "Teacher Response": t.teacher_response or "", + } + for t in tickets + ] + ) + st.dataframe(df, hide_index=True, use_container_width=True) + + +# --------------------------------------------------------------------------- +# Teacher view +# --------------------------------------------------------------------------- + +def render_teacher_view( + service: AfterHoursService, + user_context: Dict[str, Any], +) -> None: + st.subheader("Student after-hours questions") + + try: + tickets = service.get_requests_for_teacher(user_context) + except Exception as exc: # noqa: BLE001 + st.error(str(exc)) + return + + if not tickets: + st.caption("You currently have no after-hours questions.") + return + + # Overview table + _render_ticket_table_for_teacher(tickets) + + st.markdown("---") + st.subheader("Respond to a question") + + ticket_ids = [t.request_id for t in tickets] + selected_id = st.selectbox("Select a ticket", ticket_ids) + + current_ticket = next(t for t in tickets if t.request_id == selected_id) + + st.write(f"**Ticket #{current_ticket.request_id}**") + st.write( + f"**From:** {current_ticket.requester_role.title()} " + f"(User ID {current_ticket.requester_id})" + ) + if current_ticket.student_id is not None: + st.write(f"**Student ID:** {current_ticket.student_id}") + + st.write("**Question:**") + st.info(current_ticket.question) + + # Status selector + status_options: List[StatusType] = ["pending", "answered", "closed"] + try: + default_index = status_options.index(current_ticket.status) # type: ignore[arg-type] + except ValueError: + default_index = 0 + + new_status: StatusType = st.selectbox( + "Status", + status_options, + index=default_index, + ) + + # Response input + response_text = st.text_area( + "Your response", + value=current_ticket.teacher_response or "", + placeholder="Type your response to the student/parent here...", + ) + + if st.button("Save response / update ticket"): + try: + service.respond_to_request( + user_context=user_context, + request_id=current_ticket.request_id, + response_text=response_text, + new_status=new_status, + ) + st.success("Response saved and ticket updated βœ…") + st.info("Refresh the page to see updated data.") + except Exception as exc: # noqa: BLE001 + st.error(str(exc)) + + +def _render_ticket_table_for_teacher(tickets: List[AfterHoursRequest]) -> None: + df = pd.DataFrame( + [ + { + "Ticket #": t.request_id, + "Requester Role": t.requester_role, + "Requester User ID": t.requester_id, + "Student ID": t.student_id, + "Question": t.question, + "Submitted At": t.submitted_at, + "Status": t.status, + "Teacher Response": ( + (t.teacher_response or "")[:80] + + ( + "..." + if t.teacher_response + and len(t.teacher_response) > 80 + else "" + ) + ), + } + for t in tickets + ] + ) + st.dataframe(df, hide_index=True, use_container_width=True) diff --git a/src/features/ai_progress_reports/service.py b/src/features/ai_progress_reports/service.py index 24754c1..34c1397 100644 --- a/src/features/ai_progress_reports/service.py +++ b/src/features/ai_progress_reports/service.py @@ -1,47 +1,36 @@ """ -AI Progress Report Service - LangChain + Google Gemini integration. +AI Progress Report Service - Groq API integration. Author: Autumn Erwin """ import os import re +import requests from typing import Dict, Any, Optional -from langchain_google_genai import ChatGoogleGenerativeAI -from langchain_core.output_parsers import StrOutputParser from src.features.ai_progress_reports.repository import AIProgressReportRepository from src.features.ai_progress_reports.prompts import create_progress_report_prompt class AIProgressReportService: - """Handles AI-powered progress report generation using Google Gemini.""" + """Handles AI-powered progress report generation using Groq free API.""" - def __init__(self, api_key: Optional[str] = None): + def __init__(self): """ Initialize the AI Progress Report Service. - - Args: - api_key: Google API key. If None, reads from GOOGLE_API_KEY env var. + Uses Groq free API with user's API token. """ self.repository = AIProgressReportRepository() - # Get API key from parameter or environment - self.api_key = api_key or os.getenv('GOOGLE_API_KEY') - - if not self.api_key: + # Get Groq API token from environment + self.groq_token = os.getenv('GROQ_API_TOKEN') + if not self.groq_token: raise ValueError( - "Google API key not found. Please set GOOGLE_API_KEY environment variable " - "or pass it to the constructor." + "Groq API token not found. Please set GROQ_API_TOKEN in your .env file. " + "Get a free token at https://console.groq.com/keys" ) - # Initialize Gemini model - self.llm = ChatGoogleGenerativeAI( - model="gemini-2.5-flash", - google_api_key=self.api_key, - temperature=0.7, # Balanced creativity - max_output_tokens=2048, - ) - - # Output parser - self.output_parser = StrOutputParser() + # Groq API endpoint + self.api_url = "https://api.groq.com/openai/v1/chat/completions" + self.timeout = 30 def generate_progress_report( self, @@ -98,12 +87,49 @@ def generate_progress_report( performance_compared_to_class=performance_data['performance_compared_to_class'], ) - # Create the LangChain chain - chain = prompt | self.llm | self.output_parser - try: - # Generate the report - report_text = chain.invoke({}) + # Call Groq API with chat format + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.groq_token}" + } + payload = { + "model": "llama-3.3-70b-versatile", + "messages": [ + { + "role": "user", + "content": prompt.template if hasattr(prompt, 'template') else str(prompt) + } + ], + "temperature": 0.7, + "max_tokens": 1024, + } + + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=self.timeout + ) + + if response.status_code != 200: + error_data = response.json() + error_msg = error_data.get('error', {}).get('message', 'API request failed') + return { + 'success': False, + 'error': f'Failed to generate report: {error_msg}' + } + + result = response.json() + + # Extract text from Groq response + report_text = result.get('choices', [{}])[0].get('message', {}).get('content', '') + + if not report_text: + return { + 'success': False, + 'error': 'No report generated. Please try again.' + } # Parse sections from the generated report sections = self._parse_report_sections(report_text) @@ -130,6 +156,11 @@ def generate_progress_report( 'performance_data': performance_data, # Include for context } + except requests.exceptions.Timeout: + return { + 'success': False, + 'error': 'AI report generation timed out. Please try again or check your internet connection.' + } except Exception as e: return { 'success': False, diff --git a/src/features/ai_progress_reports/ui.py b/src/features/ai_progress_reports/ui.py index 2a2af0f..06eafaf 100644 --- a/src/features/ai_progress_reports/ui.py +++ b/src/features/ai_progress_reports/ui.py @@ -19,17 +19,10 @@ def show_progress_report_widget(student_id: int, course_id: Optional[int] = None """ st.markdown("### πŸ€– AI-Generated Progress Report") - # Check if API key is configured - import os - if not os.getenv('GOOGLE_API_KEY'): - st.error("⚠️ Google API key not configured. Please add GOOGLE_API_KEY to your .env file.") - st.info("Get your API key at: https://makersuite.google.com/app/apikey") - return - # Initialize service try: service = AIProgressReportService() - except ValueError as e: + except Exception as e: st.error(f"❌ {str(e)}") return @@ -43,7 +36,7 @@ def show_progress_report_widget(student_id: int, course_id: Optional[int] = None force_regenerate = st.button("πŸ”„ Regenerate", help="Generate a fresh report") # Generate the report - with st.spinner("πŸ€– Generating AI insights..."): + with st.spinner("πŸ€– Generating AI insights... This may take up to 30 seconds."): result = service.generate_progress_report( student_id=student_id, course_id=course_id, @@ -148,11 +141,6 @@ def show_parent_progress_view(student_id: int, key: str = None): """ st.markdown("### πŸ“Š Your Child's Progress Report") - import os - if not os.getenv('GOOGLE_API_KEY'): - st.warning("AI reports are not currently available. Please contact your administrator.") - return - try: service = AIProgressReportService() diff --git a/src/features/announcements/__init__.py b/src/features/announcements/__init__.py index e69de29..905f86f 100644 --- a/src/features/announcements/__init__.py +++ b/src/features/announcements/__init__.py @@ -0,0 +1,6 @@ +""" +Announcements feature package. +""" + +from .service import AnnouncementsService # noqa: F401 +from .ui import show_announcements_page # noqa: F401 diff --git a/src/features/announcements/repository.py b/src/features/announcements/repository.py index e69de29..7fbe2d4 100644 --- a/src/features/announcements/repository.py +++ b/src/features/announcements/repository.py @@ -0,0 +1,112 @@ +""" +Repository layer for the announcements feature. + +This module provides low-level database operations for storing and +retrieving announcement records. It should be called only from the +service layer. +""" + +from typing import List, Dict, Optional +import sqlite3 +from config.database import db_manager + + +class AnnouncementsRepository: + """Repository responsible for CRUD operations on announcements.""" + + def create_announcement( + self, + author_id: int, + role_visibility: str, + course_id: Optional[int], + title: str, + body: str, + ) -> int: + """ + Persist a new announcement to the database. + """ + query = """ + INSERT INTO Announcements (author_id, role_visibility, course_id, title, body) + VALUES (?, ?, ?, ?, ?) + """ + with db_manager.get_connection("main") as conn: + cursor = conn.cursor() + cursor.execute(query, (author_id, role_visibility, course_id, title, body)) + return cursor.lastrowid + + def get_announcements( + self, + user_role: str, + course_id: Optional[int] = None, + ) -> List[Dict]: + """ + Retrieve announcements visible to a given role and optionally a specific course. + + role_visibility rules: + - 'all' β†’ visible to everyone + - comma-separated roles, e.g. 'student,parent' + - case-insensitive match + """ + base_query = """ + SELECT + announcement_id, + author_id, + role_visibility, + course_id, + title, + body, + created_at + FROM Announcements + WHERE + (LOWER(role_visibility) = 'all' + OR INSTR(LOWER(role_visibility), LOWER(?)) > 0) + """ + params: tuple + if course_id is not None: + base_query += " AND (course_id IS NULL OR course_id = ?)" + params = (user_role, course_id) + else: + params = (user_role,) + + base_query += " ORDER BY datetime(created_at) DESC" + + with db_manager.get_connection("main") as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(base_query, params) + rows = cursor.fetchall() + return [dict(row) for row in rows] + + def get_courses_for_author( + self, + role: str, + teacher_id: Optional[int] = None, + ) -> List[Dict]: + """ + Retrieve courses for the announcement author (teacher or admin). + + Expects a Courses table with at least: + - course_id + - course_name + - teacher_id + """ + if role == "teacher" and teacher_id is not None: + query = """ + SELECT course_id, course_name + FROM Courses + WHERE teacher_id = ? + ORDER BY course_name + """ + params = (teacher_id,) + elif role == "admin": + query = "SELECT course_id, course_name FROM Courses ORDER BY course_name" + params = () + else: + return [] + + with db_manager.get_connection("main") as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(query, params) + rows = cursor.fetchall() + return [dict(row) for row in rows] \ No newline at end of file diff --git a/src/features/announcements/service.py b/src/features/announcements/service.py index e69de29..a99a217 100644 --- a/src/features/announcements/service.py +++ b/src/features/announcements/service.py @@ -0,0 +1,72 @@ +""" +Service layer for announcements. + +Implements business logic and validation for the announcements feature. +""" + +from typing import List, Dict, Optional +from src.features.announcements.repository import AnnouncementsRepository +from src.utils.validators import sanitize_input +from config.settings import ROLES + + +class AnnouncementsService: + def __init__(self, repo: Optional[AnnouncementsRepository] = None) -> None: + self.repo = repo or AnnouncementsRepository() + + def post_announcement( + self, + author_id: int, + role_visibility: str, + course_id: Optional[int], + title: str, + body: str, + ) -> Dict: + """ + Validate and create a new announcement. + """ + title = sanitize_input(title or "") + body = sanitize_input(body or "") + + if not title.strip(): + raise ValueError("Title is required.") + if not role_visibility: + raise ValueError("Visibility must be selected.") + + announcement_id = self.repo.create_announcement( + author_id=author_id, + role_visibility=role_visibility, + course_id=course_id, + title=title, + body=body, + ) + + return { + "success": True, + "announcement_id": announcement_id, + "message": "Announcement posted successfully.", + } + + def get_announcements_for_user( + self, + role: str, + course_id: Optional[int] = None, + ) -> List[Dict]: + """ + Get announcements visible to a user with the given role. + """ + role = (role or "").lower() + return self.repo.get_announcements(user_role=role, course_id=course_id) + + def get_available_courses_for_author( + self, + role: str, + teacher_id: Optional[int], + ) -> List[Dict]: + """ + Get courses that a teacher/admin can target with announcements. + """ + role = (role or "").lower() + if role not in (ROLES["TEACHER"], ROLES["ADMIN"]): + return [] + return self.repo.get_courses_for_author(role=role, teacher_id=teacher_id) \ No newline at end of file diff --git a/src/features/announcements/ui.py b/src/features/announcements/ui.py index e69de29..411befc 100644 --- a/src/features/announcements/ui.py +++ b/src/features/announcements/ui.py @@ -0,0 +1,181 @@ +""" +Streamlit UI for the announcements feature. + +Teachers/admins create announcements; users view & filter them. +""" + +import streamlit as st +from typing import Optional, List, Dict +from datetime import datetime, date +from src.core.decorators import require_role +from src.core.session import session +from src.features.announcements.service import AnnouncementsService +from config.settings import ROLES + +service = AnnouncementsService() + + +@require_role( + ROLES["STUDENT"], + ROLES["PARENT"], + ROLES["TEACHER"], + ROLES["ADMIN"], +) +def show_announcements_page() -> None: + """ + Render the announcements page with: + - View tab: filterable stream of announcements + - Post tab: teacher/admin announcement creation + """ + user = session.get_current_user() + if not user: + st.warning("Please log in to view announcements.") + return + + role = user.get("role", "").lower() + user_id = user.get("user_id") + teacher_id = user.get("teacher_id") + + st.title("πŸ“’ Announcements") + tab_view, tab_post = st.tabs(["View", "Post"]) + + with tab_view: + keyword = st.text_input( + "Search by keyword", + help="Filter announcements by text in the title or body.", + ) + col1, col2 = st.columns(2) + start_date: Optional[date] = col1.date_input( + "Start date", + value=None, + help="Show announcements on or after this date.", + ) + end_date: Optional[date] = col2.date_input( + "End date", + value=None, + help="Show announcements on or before this date.", + ) + _render_announcements_list(role, keyword or None, start_date, end_date) + + # Only teachers/admins can post + if role in [ROLES["TEACHER"], ROLES["ADMIN"]]: + with tab_post: + _render_post_form(role, user_id, teacher_id) + + +def _parse_created_at(value: str) -> Optional[datetime]: + if not value: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): + try: + return datetime.strptime(value, fmt) + except Exception: + continue + # fallback: fromisoformat might still work + try: + return datetime.fromisoformat(value) + except Exception: + return None + + +def _render_announcements_list( + role: str, + keyword: Optional[str] = None, + start_date: Optional[date] = None, + end_date: Optional[date] = None, +) -> None: + announcements = service.get_announcements_for_user(role) + if not announcements: + st.info("No announcements available at this time.") + return + + filtered: List[Dict] = [] + for ann in announcements: + created_dt: Optional[datetime] = _parse_created_at(ann.get("created_at", "")) + + # Keyword filter + if keyword: + kw_lower = keyword.lower() + text = f"{ann.get('title', '')} {ann.get('body', '')}".lower() + if kw_lower not in text: + continue + + # Date range filter + if start_date and created_dt and created_dt.date() < start_date: + continue + if end_date and created_dt and created_dt.date() > end_date: + continue + + filtered.append(ann) + + if not filtered: + st.info("No announcements match the selected filters.") + return + + for ann in filtered: + title = ann["title"] + created_at = ann.get("created_at", "") + with st.expander(f"{title} β€” {created_at}", expanded=False): + st.write(ann.get("body", "")) + if ann.get("course_id"): + st.caption(f"Course ID: {ann['course_id']}") + + +def _render_post_form(role: str, user_id: int, teacher_id: Optional[int]) -> None: + """ + Form for posting a new announcement (teacher/admin only). + """ + st.markdown("### ✍️ Post a New Announcement") + + courses = service.get_available_courses_for_author(role, teacher_id) + course_options = {"None": None} + for course in courses: + label = f"{course['course_name']} (ID: {course['course_id']})" + course_options[label] = course["course_id"] + + with st.form("post_announcement_form", clear_on_submit=True): + title = st.text_input("Title", max_chars=200) + body = st.text_area("Body", max_chars=5000, height=200) + + visibility_choices = [ + ("All", "all"), + ("Students", "student"), + ("Parents", "parent"), + ("Teachers", "teacher"), + ("Admins", "admin"), + ("Students & Parents", "student,parent"), + ("Teachers & Parents", "teacher,parent"), + ] + labels = [label for label, _ in visibility_choices] + values = [val for _, val in visibility_choices] + + index = st.selectbox( + "Visible To", + options=list(range(len(labels))), + format_func=lambda i: labels[i], + ) + selected_visibility = values[index] + + selected_course_label = st.selectbox( + "Course (optional)", + options=list(course_options.keys()), + ) + selected_course_id = course_options[selected_course_label] + + submit = st.form_submit_button("Post", type="primary") + if submit: + try: + result = service.post_announcement( + author_id=user_id, + role_visibility=selected_visibility, + course_id=selected_course_id, + title=title, + body=body, + ) + if result.get("success"): + st.success(result.get("message", "Announcement posted.")) + st.rerun() + except ValueError as ve: + st.error(f"Validation Error: {ve}") + except Exception as ex: + st.error(f"Failed to post announcement: {ex}") diff --git a/src/features/authentication/repository.py b/src/features/authentication/repository.py index cf10aa1..cec0edd 100644 --- a/src/features/authentication/repository.py +++ b/src/features/authentication/repository.py @@ -30,33 +30,33 @@ def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: row = results[0] return { - 'user_id': row[0], - 'name': row[1], - 'email': row[2], - 'role': row[3], - 'password_hash': row[4] + 'user_id': row['user_id'], + 'name': row['name'], + 'email': row['email'], + 'role': row['role'], + 'password_hash': row['password_hash'] } def get_student_id(self, user_id: int) -> Optional[int]: """Get student ID for a user.""" query = "SELECT student_id FROM Students WHERE user_id = ?" results = db_manager.execute_query(query, (user_id,)) - return results[0][0] if results else None + return results[0]['student_id'] if results else None def get_parent_id(self, user_id: int) -> Optional[int]: """Get parent ID for a user.""" query = "SELECT parent_id FROM Parents WHERE user_id = ?" results = db_manager.execute_query(query, (user_id,)) - return results[0][0] if results else None + return results[0]['parent_id'] if results else None def get_teacher_id(self, user_id: int) -> Optional[int]: """Get teacher ID for a user.""" query = "SELECT teacher_id FROM Teachers WHERE user_id = ?" results = db_manager.execute_query(query, (user_id,)) - return results[0][0] if results else None + return results[0]['teacher_id'] if results else None def get_parent_student_ids(self, parent_id: int) -> List[int]: """Get all student IDs for a parent.""" query = "SELECT student_id FROM Parent_Student WHERE parent_id = ?" results = db_manager.execute_query(query, (parent_id,)) - return [row[0] for row in results] + return [row['student_id'] for row in results] diff --git a/src/features/grade_management/__init__.py b/src/features/grade_management/__init__.py new file mode 100644 index 0000000..0875145 --- /dev/null +++ b/src/features/grade_management/__init__.py @@ -0,0 +1 @@ +"""Grade management feature for teachers and admins.""" diff --git a/src/features/grade_management/repository.py b/src/features/grade_management/repository.py new file mode 100644 index 0000000..3b2ff78 --- /dev/null +++ b/src/features/grade_management/repository.py @@ -0,0 +1,121 @@ +"""Repository for grade management operations.""" +from typing import List, Dict, Optional +from config.database import db_manager + + +class GradeManagementRepository: + """Handles database operations for grade management.""" + + def get_all_grades(self) -> List[Dict]: + """Get all grades from the database.""" + query = """ + SELECT + g.grade_id, + g.student_id, + g.course_id, + u.name as student_name, + c.course_name, + g.assignment_name, + g.grade, + g.date_assigned, + g.due_date + FROM Grades g + JOIN Students s ON g.student_id = s.student_id + JOIN Users u ON s.user_id = u.user_id + JOIN Courses c ON g.course_id = c.course_id + ORDER BY c.course_name, u.name, g.grade_id + """ + return db_manager.execute_query(query) + + def get_grades_for_teacher(self, teacher_id: int) -> List[Dict]: + """Get all grades for courses taught by a teacher.""" + query = """ + SELECT + g.grade_id, + g.student_id, + g.course_id, + u.name as student_name, + c.course_name, + g.assignment_name, + g.grade, + g.date_assigned, + g.due_date + FROM Grades g + JOIN Students s ON g.student_id = s.student_id + JOIN Users u ON s.user_id = u.user_id + JOIN Courses c ON g.course_id = c.course_id + WHERE c.teacher_id = ? + ORDER BY c.course_name, u.name, g.grade_id + """ + return db_manager.execute_query(query, (teacher_id,)) + + def get_grades_for_course(self, course_id: int) -> List[Dict]: + """Get all grades for a specific course.""" + query = """ + SELECT + g.grade_id, + g.student_id, + g.course_id, + u.name as student_name, + c.course_name, + g.assignment_name, + g.grade, + g.date_assigned, + g.due_date + FROM Grades g + JOIN Students s ON g.student_id = s.student_id + JOIN Users u ON s.user_id = u.user_id + JOIN Courses c ON g.course_id = c.course_id + WHERE g.course_id = ? + ORDER BY u.name, g.grade_id + """ + return db_manager.execute_query(query, (course_id,)) + + def get_grade(self, grade_id: int) -> Optional[Dict]: + """Get a specific grade by ID.""" + query = """ + SELECT + g.grade_id, + g.student_id, + g.course_id, + u.name as student_name, + c.course_name, + g.assignment_name, + g.grade, + g.date_assigned, + g.due_date + FROM Grades g + JOIN Students s ON g.student_id = s.student_id + JOIN Users u ON s.user_id = u.user_id + JOIN Courses c ON g.course_id = c.course_id + WHERE g.grade_id = ? + """ + results = db_manager.execute_query(query, (grade_id,)) + return results[0] if results else None + + def update_grade(self, grade_id: int, new_grade: float) -> bool: + """Update a grade value. Returns True if successful.""" + if new_grade < 0 or new_grade > 100: + return False + + query = "UPDATE Grades SET grade = ? WHERE grade_id = ?" + return db_manager.execute_update(query, (new_grade, grade_id)) + + def get_course_students_grades(self, course_id: int) -> List[Dict]: + """Get all students and their grades for a course, grouped by assignment.""" + query = """ + SELECT + g.grade_id, + g.student_id, + u.name as student_name, + g.assignment_name, + g.grade, + g.date_assigned, + g.due_date + FROM Grades g + JOIN Students s ON g.student_id = s.student_id + JOIN Users u ON s.user_id = u.user_id + WHERE g.course_id = ? + ORDER BY g.assignment_name, u.name + """ + return db_manager.execute_query(query, (course_id,)) diff --git a/src/features/grade_management/service.py b/src/features/grade_management/service.py new file mode 100644 index 0000000..1e8ad87 --- /dev/null +++ b/src/features/grade_management/service.py @@ -0,0 +1,70 @@ +"""Service layer for grade management.""" +from typing import List, Dict, Optional +from src.features.grade_management.repository import GradeManagementRepository +from src.features.notifications.service import NotificationsService + + +class GradeManagementService: + """Service for managing grades.""" + + def __init__(self): + self.repository = GradeManagementRepository() + + def get_all_grades(self) -> List[Dict]: + """Get all grades for admin dashboard.""" + return self.repository.get_all_grades() + + def get_teacher_grades(self, teacher_id: int) -> List[Dict]: + """Get all grades for courses taught by a teacher.""" + return self.repository.get_grades_for_teacher(teacher_id) + + def get_course_grades(self, course_id: int) -> List[Dict]: + """Get all grades for a specific course.""" + return self.repository.get_grades_for_course(course_id) + + def get_course_students_grades(self, course_id: int) -> List[Dict]: + """Get all students and their grades for a course.""" + return self.repository.get_course_students_grades(course_id) + + def update_grade(self, grade_id: int, new_grade: float) -> Dict: + """ + Update a grade. + Returns dict with success status and message. + """ + # Validate grade value + try: + grade_value = float(new_grade) + except (ValueError, TypeError): + return {"success": False, "message": "Grade must be a number"} + + if grade_value < 0 or grade_value > 100: + return {"success": False, "message": "Grade must be between 0 and 100"} + + # Get original grade info for logging + original = self.repository.get_grade(grade_id) + if not original: + return {"success": False, "message": "Grade not found"} + + # Update the grade + success = self.repository.update_grade(grade_id, grade_value) + + if success: + # Send notifications to student and parents + notifications_service = NotificationsService() + notifications_service.notify_grade_change( + student_id=original['student_id'], + student_name=original['student_name'], + assignment_name=original['assignment_name'], + old_grade=original['grade'], + new_grade=grade_value + ) + + return { + "success": True, + "message": f"Grade updated from {original['grade']} to {grade_value}", + "grade_id": grade_id, + "old_value": original['grade'], + "new_value": grade_value + } + else: + return {"success": False, "message": "Failed to update grade"} diff --git a/src/features/notifications/__init__.py b/src/features/notifications/__init__.py new file mode 100644 index 0000000..5f90266 --- /dev/null +++ b/src/features/notifications/__init__.py @@ -0,0 +1 @@ +"""Notifications feature - Handles grade change notifications.""" diff --git a/src/features/notifications/repository.py b/src/features/notifications/repository.py new file mode 100644 index 0000000..ce05bf9 --- /dev/null +++ b/src/features/notifications/repository.py @@ -0,0 +1,77 @@ +"""Repository for notification operations.""" +from typing import List, Dict, Optional +from datetime import datetime +from config.database import db_manager + + +class NotificationsRepository: + """Handles database operations for notifications.""" + + def create_grade_change_notification( + self, + recipient_id: int, + recipient_type: str, + student_id: int, + student_name: str, + assignment_name: str, + old_grade: float, + new_grade: float, + new_overall_grade: Optional[float] = None + ) -> bool: + """ + Create a notification for a grade change. + + Args: + recipient_id: User ID of the recipient + recipient_type: Type of recipient ('student' or 'parent') + student_id: ID of the student whose grade changed + student_name: Name of the student + assignment_name: Name of the assignment + old_grade: Previous grade value + new_grade: New grade value + new_overall_grade: Updated overall grade for the student + + Returns: + True if successful, False otherwise + """ + message = f"{student_name}'s grade for {assignment_name} has been updated from {old_grade:.1f}% to {new_grade:.1f}%" + if new_overall_grade is not None: + message += f". New overall grade: {new_overall_grade:.1f}%" + + query = """ + INSERT INTO Notifications (recipient_id, notification_type, message, is_read, created_at) + VALUES (?, ?, ?, ?, ?) + """ + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + return db_manager.execute_update(query, (recipient_id, 'grade_change', message, 0, timestamp)) > 0 + + def get_unread_notifications(self, user_id: int) -> List[Dict]: + """Get all unread notifications for a user.""" + query = """ + SELECT notification_id, notification_type, message, created_at + FROM Notifications + WHERE recipient_id = ? AND is_read = 0 + ORDER BY created_at DESC + """ + return db_manager.execute_query(query, (user_id,)) + + def get_all_notifications(self, user_id: int, limit: int = 50) -> List[Dict]: + """Get all notifications for a user with optional limit.""" + query = """ + SELECT notification_id, notification_type, message, is_read, created_at + FROM Notifications + WHERE recipient_id = ? + ORDER BY created_at DESC + LIMIT ? + """ + return db_manager.execute_query(query, (user_id, limit)) + + def mark_as_read(self, notification_id: int) -> bool: + """Mark a notification as read.""" + query = "UPDATE Notifications SET is_read = 1 WHERE notification_id = ?" + return db_manager.execute_update(query, (notification_id,)) > 0 + + def mark_all_as_read(self, user_id: int) -> bool: + """Mark all notifications for a user as read.""" + query = "UPDATE Notifications SET is_read = 1 WHERE recipient_id = ? AND is_read = 0" + return db_manager.execute_update(query, (user_id,)) > 0 diff --git a/src/features/notifications/service.py b/src/features/notifications/service.py new file mode 100644 index 0000000..e759dd1 --- /dev/null +++ b/src/features/notifications/service.py @@ -0,0 +1,111 @@ +"""Service layer for notification management.""" +from typing import List, Dict, Optional +from src.features.notifications.repository import NotificationsRepository +from config.database import db_manager + + +class NotificationsService: + """Service for managing notifications.""" + + def __init__(self): + self.repository = NotificationsRepository() + + def notify_grade_change( + self, + student_id: int, + student_name: str, + assignment_name: str, + old_grade: float, + new_grade: float + ) -> Dict[str, bool]: + """ + Notify student and their parents about grade change. + + Args: + student_id: ID of the student whose grade changed + student_name: Name of the student + assignment_name: Name of the assignment + old_grade: Previous grade value + new_grade: New grade value + + Returns: + Dictionary with notification results + """ + results = {'student_notified': False, 'parents_notified': False} + + # Get student's user_id + user_query = "SELECT user_id FROM Students WHERE student_id = ?" + user_results = db_manager.execute_query(user_query, (student_id,)) + if not user_results: + return results + + student_user_id = user_results[0]['user_id'] + + # Calculate new overall grade for the student + grade_query = """ + SELECT AVG(grade) as avg_grade + FROM Grades + WHERE student_id = ? + """ + grade_results = db_manager.execute_query(grade_query, (student_id,)) + new_overall_grade = grade_results[0]['avg_grade'] if grade_results else None + + # Notify the student + results['student_notified'] = self.repository.create_grade_change_notification( + recipient_id=student_user_id, + recipient_type='student', + student_id=student_id, + student_name=student_name, + assignment_name=assignment_name, + old_grade=old_grade, + new_grade=new_grade, + new_overall_grade=new_overall_grade + ) + + # Get parents and notify them + parent_query = """ + SELECT DISTINCT p.parent_id, u.user_id + FROM Parents p + JOIN Users u ON p.user_id = u.user_id + JOIN Parent_Student ps ON p.parent_id = ps.parent_id + WHERE ps.student_id = ? + """ + parent_results = db_manager.execute_query(parent_query, (student_id,)) + + if parent_results: + for parent in parent_results: + parent_user_id = parent['user_id'] + self.repository.create_grade_change_notification( + recipient_id=parent_user_id, + recipient_type='parent', + student_id=student_id, + student_name=student_name, + assignment_name=assignment_name, + old_grade=old_grade, + new_grade=new_grade, + new_overall_grade=new_overall_grade + ) + results['parents_notified'] = True + + return results + + def get_unread_notifications(self, user_id: int) -> List[Dict]: + """Get all unread notifications for a user.""" + return self.repository.get_unread_notifications(user_id) + + def get_all_notifications(self, user_id: int, limit: int = 50) -> List[Dict]: + """Get all notifications for a user.""" + return self.repository.get_all_notifications(user_id, limit) + + def mark_as_read(self, notification_id: int) -> bool: + """Mark a notification as read.""" + return self.repository.mark_as_read(notification_id) + + def mark_all_as_read(self, user_id: int) -> bool: + """Mark all notifications for a user as read.""" + return self.repository.mark_all_as_read(user_id) + + def get_unread_count(self, user_id: int) -> int: + """Get count of unread notifications for a user.""" + notifications = self.get_unread_notifications(user_id) + return len(notifications) diff --git a/src/features/notifications/ui.py b/src/features/notifications/ui.py new file mode 100644 index 0000000..1374bd2 --- /dev/null +++ b/src/features/notifications/ui.py @@ -0,0 +1,86 @@ +"""Streamlit UI for displaying notifications.""" +import streamlit as st +from src.features.notifications.service import NotificationsService +from src.core.session import session + + +def show_notifications_widget(): + """Display notifications widget in sidebar or main area.""" + service = NotificationsService() + user = session.get_current_user() + + if not user: + return + + user_id = user.get('user_id') + if not user_id: + return + + # Get unread notifications + unread_notifications = service.get_unread_notifications(user_id) + + if unread_notifications: + st.sidebar.markdown("---") + st.sidebar.markdown("### πŸ“¬ Notifications") + + for notification in unread_notifications: + with st.sidebar.container(): + col1, col2 = st.sidebar.columns([0.85, 0.15]) + + with col1: + st.sidebar.info(notification['message']) + + with col2: + if st.sidebar.button("βœ“", key=f"notify_{notification['notification_id']}"): + service.mark_as_read(notification['notification_id']) + st.rerun() + + # Mark all as read button + if st.sidebar.button("Mark all as read"): + service.mark_all_as_read(user_id) + st.rerun() + + +def show_notifications_page(): + """Render a full notifications page.""" + service = NotificationsService() + user = session.get_current_user() + + if not user: + st.warning("Please log in to view notifications.") + return + + user_id = user.get('user_id') + if not user_id: + st.warning("User ID not found.") + return + + st.title("πŸ“¬ Notifications") + + # Get all notifications + all_notifications = service.get_all_notifications(user_id, limit=100) + + if not all_notifications: + st.info("No notifications yet.") + return + + # Display notifications + for notification in all_notifications: + with st.container(): + col1, col2, col3 = st.columns([0.7, 0.15, 0.15]) + + with col1: + status = "βœ“ Read" if notification['is_read'] else "βšͺ Unread" + st.write(f"**{status}** - {notification['created_at']}") + st.info(notification['message']) + + with col2: + if not notification['is_read']: + if st.button("Mark Read", key=f"mark_{notification['notification_id']}"): + service.mark_as_read(notification['notification_id']) + st.rerun() + + with col3: + st.write("") + + st.markdown("---") diff --git a/src/features/parent_engagement/ui.py b/src/features/parent_engagement/ui.py index 55ae684..de6f266 100644 --- a/src/features/parent_engagement/ui.py +++ b/src/features/parent_engagement/ui.py @@ -3,52 +3,95 @@ from src.core.session import session from src.features.parent_engagement.service import ParentEngagementService from src.utils.formatters import format_date, format_status_badge + engagement_service = ParentEngagementService() + + @require_role("parent") def show_contact_teachers_page(): user = session.get_current_user() parent_id = user.get("parent_id") + st.markdown("

Contact Teachers

", unsafe_allow_html=True) tab1, tab2 = st.tabs(["New Request", "History"]) + with tab1: _show_new_request_form(parent_id, user) + with tab2: _show_request_history(parent_id) + + def _show_new_request_form(parent_id: int, user: dict): st.markdown("### New Request") + student_ids = user.get("student_ids", []) if not student_ids: st.warning("No children linked.") return + from config.database import db_manager + placeholders = ",".join(["?" for _ in student_ids]) - query = f"SELECT s.student_id, u.name FROM Students s JOIN Users u ON s.user_id = u.user_id WHERE s.student_id IN ({placeholders})" + query = ( + "SELECT s.student_id, u.name " + "FROM Students s " + "JOIN Users u ON s.user_id = u.user_id " + f"WHERE s.student_id IN ({placeholders})" + ) students = db_manager.execute_query(query, tuple(student_ids)) - student_options = {name: sid for sid, name in students} + + # students rows are now dictionaries with 'name' and 'student_id' keys + student_options = {s['name']: s['student_id'] for s in students} if not student_options: st.warning("No children found.") return + with st.form("new_request_form", clear_on_submit=True): selected_student_name = st.selectbox("Child", list(student_options.keys())) selected_student_id = student_options[selected_student_name] + teachers = engagement_service.get_available_teachers(selected_student_id) if not teachers: st.warning(f"No teachers for {selected_student_name}.") st.form_submit_button("Submit", disabled=True) return - teacher_options = {f"{t["teacher_name"]} ({t["course_name"]})": t["teacher_id"] for t in teachers} - selected_teacher_display = st.selectbox("Teacher", list(teacher_options.keys())) + + teacher_options = { + f"{t['teacher_name']} ({t['course_name']})": t["teacher_id"] + for t in teachers + } + + selected_teacher_display = st.selectbox( + "Teacher", list(teacher_options.keys()) + ) selected_teacher_id = teacher_options[selected_teacher_display] - request_type = st.radio("Type", ["Message", "Meeting"], horizontal=True).lower() + + request_type = st.radio( + "Type", ["Message", "Meeting"], horizontal=True + ).lower() subject = st.text_input("Subject", max_chars=200) message = st.text_area("Message", max_chars=2000, height=150) + preferred_times = None if request_type == "meeting": - preferred_times = st.text_area("Preferred Times", max_chars=500, height=100) + preferred_times = st.text_area( + "Preferred Times", max_chars=500, height=100 + ) + submitted = st.form_submit_button("Submit", type="primary") + if submitted: try: - result = engagement_service.create_request(parent_id=parent_id, teacher_id=selected_teacher_id, student_id=selected_student_id, request_type=request_type, subject=subject, message=message, preferred_times=preferred_times) + result = engagement_service.create_request( + parent_id=parent_id, + teacher_id=selected_teacher_id, + student_id=selected_student_id, + request_type=request_type, + subject=subject, + message=message, + preferred_times=preferred_times, + ) if result["success"]: st.success(result["message"]) st.balloons() @@ -58,12 +101,16 @@ def _show_new_request_form(parent_id: int, user: dict): st.error(f"Validation: {str(e)}") except Exception as e: st.error(f"Error: {str(e)}") + + def _show_request_history(parent_id: int): st.markdown("### History") + requests = engagement_service.get_parent_requests(parent_id) if not requests: st.info("No requests yet.") return + c1, c2, c3 = st.columns(3) with c1: st.metric("Total", len(requests)) @@ -71,30 +118,49 @@ def _show_request_history(parent_id: int): st.metric("Pending", sum(1 for r in requests if r["status"] == "pending")) with c3: st.metric("Approved", sum(1 for r in requests if r["status"] == "approved")) + st.markdown("---") + for req in requests: - with st.expander(f"{req["request_type"].title()}: {req["subject"]} - {req["status"].upper()}", expanded=False): + header = ( + f"{req['request_type'].title()}: " + f"{req['subject']} - {req['status'].upper()}" + ) + with st.expander(header, expanded=False): c1, c2 = st.columns(2) + with c1: - st.write(f"Teacher: {req["teacher_name"]}") - st.write(f"Student: {req["student_name"]}") + st.write(f"Teacher: {req['teacher_name']}") + st.write(f"Student: {req['student_name']}") + with c2: - st.markdown(f"Status: {format_status_badge(req["status"])}", unsafe_allow_html=True) - st.write(f"Date: {req["created_at"]}") + st.markdown( + f"Status: {format_status_badge(req['status'])}", + unsafe_allow_html=True, + ) + st.write(f"Date: {req['created_at']}") + st.write(req["message"]) + if req.get("preferred_times"): - st.write(f"Times: {req["preferred_times"]}") + st.write(f"Times: {req['preferred_times']}") + if req.get("teacher_response"): st.info(req["teacher_response"]) + + @require_role("teacher") def show_parent_requests_page(): user = session.get_current_user() teacher_id = user.get("teacher_id") + st.markdown("

Parent Requests

", unsafe_allow_html=True) + requests = engagement_service.get_teacher_requests(teacher_id) if not requests: st.info("No requests.") return + c1, c2, c3, c4 = st.columns(4) with c1: st.metric("Total", len(requests)) @@ -104,53 +170,92 @@ def show_parent_requests_page(): st.metric("Meetings", sum(1 for r in requests if r["request_type"] == "meeting")) with c4: st.metric("Messages", sum(1 for r in requests if r["request_type"] == "message")) + st.markdown("---") + filt = st.selectbox("Filter", ["All", "Pending", "Approved", "Rejected"]) - filtered = requests if filt == "All" else [r for r in requests if r["status"] == filt.lower()] + filtered = ( + requests + if filt == "All" + else [r for r in requests if r["status"] == filt.lower()] + ) + if not filtered: st.info(f"No {filt.lower()} requests.") return + for req in filtered: _show_request_card(req, teacher_id) + + def _show_request_card(req: dict, teacher_id: int): - is_pend = req["status"] == "pending" - with st.expander(f"{req["request_type"].title()} from {req["parent_name"]} - {req["subject"]}", expanded=is_pend): + is_pending = req["status"] == "pending" + + header = f"{req['request_type'].title()} from {req['parent_name']} - {req['subject']}" + with st.expander(header, expanded=is_pending): c1, c2 = st.columns(2) + with c1: - st.write(f"From: {req["parent_name"]}") - st.write(f"Student: {req["student_name"]}") + st.write(f"From: {req['parent_name']}") + st.write(f"Student: {req['student_name']}") + with c2: - st.markdown(f"Status: {format_status_badge(req["status"])}", unsafe_allow_html=True) - st.write(f"Date: {req["created_at"]}") + st.markdown( + f"Status: {format_status_badge(req['status'])}", + unsafe_allow_html=True, + ) + st.write(f"Date: {req['created_at']}") + st.write(req["message"]) + if req.get("preferred_times"): st.info(req["preferred_times"]) + if req.get("teacher_response"): st.success(req["teacher_response"]) + st.markdown("---") - with st.form(f"form_{req["request_id"]}"): - resp = st.text_area("Response", max_chars=2000, height=120, key=f"r_{req["request_id"]}") - c1, c2, c3 = st.columns(3) - with c1: + + form_key = f"form_{req['request_id']}" + with st.form(form_key): + resp = st.text_area( + "Response", + max_chars=2000, + height=120, + key=f"r_{req['request_id']}", + ) + col1, col2, col3 = st.columns(3) + + with col1: send = st.form_submit_button("Send", type="primary") - with c2: - approve = st.form_submit_button("Approve", disabled=not is_pend) - with c3: - reject = st.form_submit_button("Reject", disabled=not is_pend) + with col2: + approve = st.form_submit_button("Approve", disabled=not is_pending) + with col3: + reject = st.form_submit_button("Reject", disabled=not is_pending) + if send and resp: - result = engagement_service.respond_to_request(req["request_id"], teacher_id, resp) + result = engagement_service.respond_to_request( + req["request_id"], teacher_id, resp + ) if result["success"]: st.success("Sent!") st.rerun() + elif approve and resp: - result = engagement_service.respond_to_request(req["request_id"], teacher_id, resp, "approved") + result = engagement_service.respond_to_request( + req["request_id"], teacher_id, resp, "approved" + ) if result["success"]: st.success("Approved!") st.rerun() + elif reject and resp: - result = engagement_service.respond_to_request(req["request_id"], teacher_id, resp, "rejected") + result = engagement_service.respond_to_request( + req["request_id"], teacher_id, resp, "rejected" + ) if result["success"]: st.success("Rejected!") st.rerun() + elif (send or approve or reject) and not resp: - st.warning("Please enter response.") + st.warning("Please enter a response.") diff --git a/src/features/schedule_area/__init__.py b/src/features/schedule_area/__init__.py new file mode 100644 index 0000000..6076c02 --- /dev/null +++ b/src/features/schedule_area/__init__.py @@ -0,0 +1,6 @@ +""" +Schedule area feature package. +""" + +from .service import ScheduleService # noqa: F401 +from .ui import show_schedule_page # noqa: F401 diff --git a/src/features/schedule_area/repository.py b/src/features/schedule_area/repository.py new file mode 100644 index 0000000..ddcf8a8 --- /dev/null +++ b/src/features/schedule_area/repository.py @@ -0,0 +1,76 @@ +""" +Repository layer for the schedule area feature. + +Provides access to exams/assignments with due dates for +students, parents, and teachers. +""" + +from typing import List, Dict +import sqlite3 +from config.database import db_manager + + +class ScheduleRepository: + """Queries schedules from the school system database.""" + + def _fetch(self, query: str, params: tuple) -> List[Dict]: + with db_manager.get_connection("main") as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + def get_student_schedule(self, student_id: int) -> List[Dict]: + """ + All assignments/exams with dates for a given student. + """ + query = """ + SELECT + g.assignment_name, + g.due_date, + g.grade, + c.course_name + FROM Grades g + JOIN Courses c ON c.course_id = g.course_id + WHERE g.student_id = ? + ORDER BY g.due_date + """ + return self._fetch(query, (student_id,)) + + def get_parent_schedule(self, parent_id: int) -> List[Dict]: + """ + All assignments/exams with dates for all children of a parent. + """ + query = """ + SELECT + g.assignment_name, + g.due_date, + g.grade, + c.course_name, + u.name AS student_name + FROM Parent_Student ps + JOIN Students s ON s.student_id = ps.student_id + JOIN Users u ON u.user_id = s.user_id + JOIN Grades g ON g.student_id = s.student_id + JOIN Courses c ON c.course_id = g.course_id + WHERE ps.parent_id = ? + ORDER BY g.due_date + """ + return self._fetch(query, (parent_id,)) + + def get_teacher_schedule(self, teacher_id: int) -> List[Dict]: + """ + All assignments/exams with dates for courses taught by a teacher. + """ + query = """ + SELECT + g.assignment_name, + g.due_date, + g.grade, + c.course_name + FROM Courses c + JOIN Grades g ON g.course_id = c.course_id + WHERE c.teacher_id = ? + ORDER BY g.due_date + """ + return self._fetch(query, (teacher_id,)) diff --git a/src/features/schedule_area/service.py b/src/features/schedule_area/service.py new file mode 100644 index 0000000..a63a168 --- /dev/null +++ b/src/features/schedule_area/service.py @@ -0,0 +1,20 @@ +""" +Service layer for the schedule area feature. +""" + +from typing import List, Dict, Optional +from src.features.schedule_area.repository import ScheduleRepository + + +class ScheduleService: + def __init__(self, repo: Optional[ScheduleRepository] = None) -> None: + self.repo = repo or ScheduleRepository() + + def get_schedule_for_student(self, student_id: int) -> List[Dict]: + return self.repo.get_student_schedule(student_id) + + def get_schedule_for_parent(self, parent_id: int) -> List[Dict]: + return self.repo.get_parent_schedule(parent_id) + + def get_schedule_for_teacher(self, teacher_id: int) -> List[Dict]: + return self.repo.get_teacher_schedule(teacher_id) diff --git a/src/features/schedule_area/ui.py b/src/features/schedule_area/ui.py new file mode 100644 index 0000000..65dee7d --- /dev/null +++ b/src/features/schedule_area/ui.py @@ -0,0 +1,225 @@ +""" +Streamlit UI for the schedule area feature. + +Students: see their own upcoming exams/assignments +Parents: see upcoming items for their children +Teachers: see upcoming items across their courses +""" + +import streamlit as st +from datetime import datetime, date, timedelta +from typing import List, Dict + +from src.core.decorators import require_role +from src.core.session import session +from src.features.schedule_area.service import ScheduleService +from config.settings import ROLES + +service = ScheduleService() + + +def _parse_due_date(d: str): + """Parse YYYY-MM-DD or ISO-ish strings to date, or return None.""" + if not d: + return None + for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(d, fmt).date() + except Exception: + continue + try: + return datetime.fromisoformat(d).date() + except Exception: + return None + + +@require_role( + ROLES["STUDENT"], + ROLES["PARENT"], + ROLES["TEACHER"], +) +def show_schedule_page(): + user = session.get_current_user() + if not user: + st.warning("Please log in to view schedule.") + return + + st.title("πŸ“… Schedule") + st.caption("A unified view of upcoming exams and assignments per class, with filters and reminders.") + + role = user["role"] + + if role == ROLES["STUDENT"]: + _render_student_schedule(user) + elif role == ROLES["PARENT"]: + _render_parent_schedule(user) + elif role == ROLES["TEACHER"]: + _render_teacher_schedule(user) + else: + st.error("Unauthorized access.") + + +# ---------- Shared helpers ---------- + +def _filter_and_remind(rows: List[Dict]) -> List[Dict]: + """Apply course + date filters and render reminder summary.""" + if not rows: + return [] + + # Normalise due_date to real date objects + for r in rows: + r["_due"] = _parse_due_date(r.get("due_date")) + + # Course filter + course_names = sorted({r.get("course_name") for r in rows if r.get("course_name")}) + selected_course = st.selectbox( + "Filter by class", + options=["All classes"] + course_names, + ) + + filtered = rows + if selected_course != "All classes": + filtered = [r for r in filtered if r.get("course_name") == selected_course] + + # Date range filter + col1, col2 = st.columns(2) + with col1: + start_date = st.date_input("From date", value=None) + with col2: + end_date = st.date_input("To date", value=None) + + if start_date: + filtered = [ + r for r in filtered + if r["_due"] is None or r["_due"] >= start_date + ] + if end_date: + filtered = [ + r for r in filtered + if r["_due"] is None or r["_due"] <= end_date + ] + + # Reminders: upcoming in next 7 days + overdue + today = date.today() + next_week = today + timedelta(days=7) + + upcoming_7 = [ + r for r in filtered + if r["_due"] is not None and today <= r["_due"] <= next_week + ] + overdue = [ + r for r in filtered + if r["_due"] is not None and r["_due"] < today + ] + + st.markdown("### ⏰ Reminders") + c1, c2, c3 = st.columns(3) + with c1: + st.metric("Total in view", len(filtered)) + with c2: + st.metric("Due in next 7 days", len(upcoming_7)) + with c3: + st.metric("Overdue", len(overdue)) + + st.markdown("---") + + return filtered + + +# ---------- Role-specific renderers ---------- + +def _render_student_schedule(user: dict) -> None: + student_id = user.get("student_id") or user.get("user_id") + if not student_id: + st.error("Student id missing from session.") + return + + rows = service.get_schedule_for_student(student_id) + if not rows: + st.info("No upcoming exams or assignments found.") + return + + st.subheader("Your upcoming exams and assignments") + + filtered = _filter_and_remind(rows) + + if not filtered: + st.info("No items match your filters.") + return + + st.dataframe( + [ + { + "Class": r.get("course_name"), + "Assignment / Exam": r.get("assignment_name"), + "Due Date": r.get("due_date") or "", + "Grade": r.get("grade") or "", + } + for r in filtered + ] + ) + + +def _render_parent_schedule(user: dict) -> None: + parent_id = user.get("parent_id") or user.get("user_id") + if not parent_id: + st.error("Parent id missing from session.") + return + + rows = service.get_schedule_for_parent(parent_id) + if not rows: + st.info("No upcoming exams or assignments for your children.") + return + + st.subheader("Your children's upcoming exams and assignments") + + filtered = _filter_and_remind(rows) + + if not filtered: + st.info("No items match your filters.") + return + + st.dataframe( + [ + { + "Student": r.get("student_name"), + "Class": r.get("course_name"), + "Assignment / Exam": r.get("assignment_name"), + "Due Date": r.get("due_date") or "", + "Grade": r.get("grade") or "", + } + for r in filtered + ] + ) + + +def _render_teacher_schedule(user: dict) -> None: + teacher_id = user.get("teacher_id") or user.get("user_id") + if not teacher_id: + st.error("Teacher id missing from session.") + return + + rows = service.get_schedule_for_teacher(teacher_id) + if not rows: + st.info("No upcoming exams or assignments for your courses.") + return + + st.subheader("Upcoming exams and assignments across your courses") + + filtered = _filter_and_remind(rows) + + if not filtered: + st.info("No items match your filters.") + return + + st.dataframe( + [ + { + "Class": r.get("course_name"), + "Assignment / Exam": r.get("assignment_name"), + "Due Date": r.get("due_date") or "", + "Grade": r.get("grade") or "", + } + for r in filtered + ] + ) diff --git a/src/ui/components/navigation.py b/src/ui/components/navigation.py index b0fb7f1..830dd11 100644 --- a/src/ui/components/navigation.py +++ b/src/ui/components/navigation.py @@ -18,46 +18,138 @@ def get_pages_for_role(role: str) -> List[Dict[str, str]]: List of page configurations """ # Base pages available to all logged-in users - pages = [ + pages: List[Dict[str, str]] = [ {"name": "Home", "icon": "🏠", "module": "home"}, ] # Role-specific pages - if role == ROLES['STUDENT']: - pages.extend([ - {"name": "My Grades", "icon": "πŸ“Š", "module": "student_dashboard"}, - ]) - if FEATURES['announcements']: - pages.append({"name": "Announcements", "icon": "πŸ“’", "module": "announcements"}) - if FEATURES['after_hours']: - pages.append({"name": "Ask a Question", "icon": "❓", "module": "after_hours"}) - - elif role == ROLES['PARENT']: - pages.extend([ - {"name": "My Children", "icon": "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", "module": "parent_dashboard"}, - ]) - if FEATURES['parent_engagement']: - pages.append({"name": "Contact Teachers", "icon": "βœ‰οΈ", "module": "parent_engagement"}) - if FEATURES['announcements']: - pages.append({"name": "Announcements", "icon": "πŸ“’", "module": "announcements"}) - - elif role == ROLES['TEACHER']: - pages.extend([ - {"name": "My Courses", "icon": "πŸ“š", "module": "teacher_dashboard"}, - ]) - if FEATURES['announcements']: - pages.append({"name": "Announcements", "icon": "πŸ“’", "module": "announcements"}) - if FEATURES['parent_engagement']: - pages.append({"name": "Parent Requests", "icon": "πŸ“¬", "module": "parent_engagement"}) - if FEATURES['after_hours']: - pages.append({"name": "Student Questions", "icon": "❓", "module": "after_hours"}) - - elif role == ROLES['ADMIN']: - pages.extend([ - {"name": "Admin Dashboard", "icon": "βš™οΈ", "module": "admin_dashboard"}, - ]) - if FEATURES['announcements']: - pages.append({"name": "Announcements", "icon": "πŸ“’", "module": "announcements"}) + if role == ROLES["STUDENT"]: + pages.extend( + [ + {"name": "My Grades", "icon": "πŸ“Š", "module": "student_dashboard"}, + ] + ) + if FEATURES["announcements"]: + pages.append( + { + "name": "Announcements", + "icon": "πŸ“’", + "module": "announcements", + } + ) + if FEATURES.get("schedule_area"): + pages.append( + { + "name": "Schedule", + "icon": "πŸ“…", + "module": "schedule_area", + } + ) + if FEATURES["after_hours"]: + pages.append( + { + "name": "Ask a Question", + "icon": "❓", + "module": "after_hours", + } + ) + + elif role == ROLES["PARENT"]: + pages.extend( + [ + { + "name": "My Children", + "icon": "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", + "module": "parent_dashboard", + }, + ] + ) + if FEATURES["parent_engagement"]: + pages.append( + { + "name": "Contact Teachers", + "icon": "βœ‰οΈ", + "module": "parent_engagement", + } + ) + if FEATURES["announcements"]: + pages.append( + { + "name": "Announcements", + "icon": "πŸ“’", + "module": "announcements", + } + ) + if FEATURES.get("schedule_area"): + pages.append( + { + "name": "Schedule", + "icon": "πŸ“…", + "module": "schedule_area", + } + ) + + elif role == ROLES["TEACHER"]: + pages.extend( + [ + { + "name": "My Courses", + "icon": "πŸ“š", + "module": "teacher_dashboard", + }, + ] + ) + if FEATURES["announcements"]: + pages.append( + { + "name": "Announcements", + "icon": "πŸ“’", + "module": "announcements", + } + ) + if FEATURES["parent_engagement"]: + pages.append( + { + "name": "Parent Requests", + "icon": "πŸ“¬", + "module": "parent_engagement", + } + ) + if FEATURES.get("schedule_area"): + pages.append( + { + "name": "Schedule", + "icon": "πŸ“…", + "module": "schedule_area", + } + ) + if FEATURES["after_hours"]: + pages.append( + { + "name": "Student Questions", + "icon": "❓", + "module": "after_hours", + } + ) + + elif role == ROLES["ADMIN"]: + pages.extend( + [ + { + "name": "Admin Dashboard", + "icon": "βš™οΈ", + "module": "admin_dashboard", + }, + ] + ) + if FEATURES["announcements"]: + pages.append( + { + "name": "Announcements", + "icon": "πŸ“’", + "module": "announcements", + } + ) return pages @@ -77,16 +169,24 @@ def render_navigation(): st.markdown("---") # Navigation pages - pages = get_pages_for_role(user['role']) + pages = get_pages_for_role(user["role"]) st.markdown("### Navigation") for page in pages: - if st.button(f"{page['icon']} {page['name']}", use_container_width=True): - st.session_state.current_page = page['module'] + if st.button( + f"{page['icon']} {page['name']}", use_container_width=True + ): + st.session_state.current_page = page["module"] st.rerun() st.markdown("---") + # Notifications widget + from src.features.notifications.ui import show_notifications_widget + show_notifications_widget() + + st.markdown("---") + # Logout button if st.button("πŸšͺ Logout", use_container_width=True): session.logout() @@ -95,6 +195,6 @@ def render_navigation(): def get_current_page() -> str: """Get the current active page.""" - if 'current_page' not in st.session_state: - st.session_state.current_page = 'home' + if "current_page" not in st.session_state: + st.session_state.current_page = "home" return st.session_state.current_page diff --git a/src/ui/pages/admin_dashboard.py b/src/ui/pages/admin_dashboard.py index a6cc7eb..4aaa5ca 100644 --- a/src/ui/pages/admin_dashboard.py +++ b/src/ui/pages/admin_dashboard.py @@ -2,27 +2,346 @@ Admin dashboard page. """ import streamlit as st +import pandas as pd +import sqlite3 from src.core.decorators import require_role +from src.core.rbac import RBACFilter +from config.database import db_manager +from src.features.grade_management.service import GradeManagementService @require_role('admin') def show_admin_dashboard(): """Render the admin dashboard.""" - st.markdown('

Admin Dashboard

', unsafe_allow_html=True) + st.title("βš™οΈ Admin Dashboard") + st.caption("System statistics, user management, and performance analytics") - # Placeholder content - st.info("🚧 Admin dashboard under construction. Will display system statistics and management tools.") + # Get system-wide data + grades_df = RBACFilter.get_authorized_grades() + courses_df = RBACFilter.get_authorized_courses() + # Get user counts + total_students = _get_student_count() + total_teachers = _get_teacher_count() + total_users = total_students + total_teachers + 1 # +1 for admin + + # Summary metrics + st.markdown("### πŸ“Š System Overview") + col1, col2, col3, col4 = st.columns(4) + + with col1: + st.metric("Total Users", total_users) + + with col2: + st.metric("Total Students", total_students) + + with col3: + st.metric("Total Teachers", total_teachers) + + with col4: + st.metric("Total Courses", len(courses_df)) + + st.markdown("---") + + # Tabs for different admin functions + tab1, tab2, tab3, tab4, tab5 = st.tabs([ + "πŸ“ˆ Analytics", + "πŸ‘₯ Users", + "πŸ“š Courses", + "πŸ“‹ Grades", + "βš™οΈ Settings" + ]) + + with tab1: + _render_analytics_tab(grades_df, courses_df) + + with tab2: + _render_users_tab() + + with tab3: + _render_courses_tab(courses_df) + + with tab4: + _render_grades_tab(grades_df) + + with tab5: + _render_settings_tab() + + +def _get_student_count() -> int: + """Get total number of students.""" + query = "SELECT COUNT(*) as count FROM Students" + with db_manager.get_connection() as conn: + result = pd.read_sql_query(query, conn) + return int(result['count'].iloc[0]) if not result.empty else 0 + + +def _get_teacher_count() -> int: + """Get total number of teachers.""" + query = "SELECT COUNT(*) as count FROM Teachers" + with db_manager.get_connection() as conn: + result = pd.read_sql_query(query, conn) + return int(result['count'].iloc[0]) if not result.empty else 0 + + +def _render_analytics_tab(grades_df: pd.DataFrame, courses_df: pd.DataFrame): + """Render performance analytics tab.""" + st.subheader("Performance Analytics") + + if grades_df.empty: + st.info("No grades data available yet.") + return + + # Overall statistics + col1, col2, col3 = st.columns(3) + + with col1: + avg_grade = grades_df['grade'].mean() + st.metric("System Average Grade", f"{avg_grade:.1f}%") + + with col2: + highest_grade = grades_df['grade'].max() + st.metric("Highest Grade", f"{highest_grade:.1f}%") + + with col3: + lowest_grade = grades_df['grade'].min() + st.metric("Lowest Grade", f"{lowest_grade:.1f}%") + + st.markdown("---") + + # Student performance + st.markdown("#### πŸŽ“ Student Performance Distribution") + student_stats = grades_df.groupby('student_name')['grade'].agg(['mean', 'count']).reset_index() + student_stats.columns = ['Student', 'Average Grade', 'Assignments'] + student_stats['Average Grade'] = student_stats['Average Grade'].apply(lambda x: f"{x:.1f}%") + student_stats = student_stats.sort_values('Student') + + st.dataframe(student_stats, use_container_width=True, hide_index=True) + + st.markdown("---") + + # Course performance + st.markdown("#### πŸ“– Course Performance") + course_stats = grades_df.groupby('course_name')['grade'].agg(['mean', 'count']).reset_index() + course_stats.columns = ['Course', 'Average Grade', 'Total Grades'] + course_stats['Average Grade'] = course_stats['Average Grade'].apply(lambda x: f"{x:.1f}%") + course_stats = course_stats.sort_values('Course') + + st.dataframe(course_stats, use_container_width=True, hide_index=True) + + st.markdown("---") + + # Grade distribution + st.markdown("#### πŸ“Š Grade Distribution") + grade_ranges = { + 'A (90-100%)': len(grades_df[grades_df['grade'] >= 90]), + 'B (80-89%)': len(grades_df[(grades_df['grade'] >= 80) & (grades_df['grade'] < 90)]), + 'C (70-79%)': len(grades_df[(grades_df['grade'] >= 70) & (grades_df['grade'] < 80)]), + 'D (60-69%)': len(grades_df[(grades_df['grade'] >= 60) & (grades_df['grade'] < 70)]), + 'F (Below 60%)': len(grades_df[grades_df['grade'] < 60]) + } + + dist_df = pd.DataFrame(list(grade_ranges.items()), columns=['Grade Range', 'Count']) + st.dataframe(dist_df, use_container_width=True, hide_index=True) + + +def _render_users_tab(): + """Render user management tab.""" + st.subheader("User Management") + + # Get all users + query = """ + SELECT + u.user_id, + u.name, + u.email, + u.role + FROM Users u + ORDER BY u.name + """ + with db_manager.get_connection() as conn: + users_df = pd.read_sql_query(query, conn) + + if users_df.empty: + st.info("No users found.") + return + + # Display users table + st.markdown("#### πŸ‘₯ All Users") + display_df = users_df[['name', 'email', 'role']].copy() + display_df.columns = ['Name', 'Email', 'Role'] + + st.dataframe(display_df, use_container_width=True, hide_index=True) + + st.markdown("---") + + # User counts by role + st.markdown("#### πŸ“Š Users by Role") + role_counts = users_df['role'].value_counts().reset_index() + role_counts.columns = ['Role', 'Count'] + st.dataframe(role_counts, use_container_width=True, hide_index=True) + + +def _render_courses_tab(courses_df: pd.DataFrame): + """Render course management tab.""" + st.subheader("Course Management") + + if courses_df.empty: + st.info("No courses found.") + return + + # Display courses + st.markdown("#### πŸ“š All Courses") + display_df = courses_df[['course_name', 'teacher_name', 'student_count']].copy() + display_df.columns = ['Course Name', 'Instructor', 'Enrolled Students'] + + st.dataframe(display_df, use_container_width=True, hide_index=True) + + st.markdown("---") + + # Course statistics + st.markdown("#### πŸ“Š Course Statistics") col1, col2, col3 = st.columns(3) with col1: - st.metric("Total Students", "0", help="Total number of students") + st.metric("Total Courses", len(courses_df)) with col2: - st.metric("Total Teachers", "0", help="Total number of teachers") + avg_enrollment = courses_df['student_count'].mean() + st.metric("Avg Students per Course", f"{avg_enrollment:.1f}") with col3: - st.metric("Total Courses", "0", help="Total number of courses") + max_enrollment = courses_df['student_count'].max() + st.metric("Largest Class", f"{int(max_enrollment)} students") + + +def _render_grades_tab(grades_df: pd.DataFrame): + """Render grade management and edit tab.""" + st.subheader("Grade Management") + + if grades_df.empty: + st.info("No grades found.") + return + + grade_service = GradeManagementService() + + # Create two sections: View and Edit + tab_view, tab_edit = st.tabs(["View Grades", "Edit Grades"]) + + with tab_view: + # Filter options + col1, col2 = st.columns(2) + + with col1: + selected_course = st.selectbox( + "Filter by Course", + options=["All Courses"] + sorted(grades_df['course_name'].unique().tolist()), + key="admin_course_filter" + ) + + with col2: + selected_teacher = st.selectbox( + "Filter by Instructor", + options=["All Instructors"] + sorted(grades_df['teacher_name'].unique().tolist()), + key="admin_teacher_filter" + ) + + # Apply filters + filtered_grades = grades_df.copy() + + if selected_course != "All Courses": + filtered_grades = filtered_grades[filtered_grades['course_name'] == selected_course] + + if selected_teacher != "All Instructors": + filtered_grades = filtered_grades[filtered_grades['teacher_name'] == selected_teacher] + + st.markdown("---") + + # Summary statistics for filtered grades + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("Total Grades", len(filtered_grades)) - st.markdown("### βš™οΈ System Management") - st.write("User management, course management, and system settings will appear here...") + with col2: + avg_grade = filtered_grades['grade'].mean() + st.metric("Average Grade", f"{avg_grade:.1f}%") + + with col3: + std_dev = filtered_grades['grade'].std() + st.metric("Standard Deviation", f"{std_dev:.1f}" if pd.notna(std_dev) else "N/A") + + st.markdown("---") + + # Display filtered grades table + st.markdown("#### πŸ“‹ Grades Details") + display_df = filtered_grades[['student_name', 'assignment_name', 'grade', 'date_assigned', 'course_name']].copy() + display_df.columns = ['Student', 'Assignment', 'Grade', 'Date', 'Course'] + display_df['Grade'] = display_df['Grade'].apply(lambda x: f"{x:.1f}%") + + st.dataframe(display_df, use_container_width=True, hide_index=True) + + with tab_edit: + st.markdown("**Edit grades for any course. Changes are saved immediately.**") + + # Get all grades for editing + all_grades = grade_service.get_all_grades() + + if all_grades: + # Group by course for easier editing + course_names = sorted(set(g['course_name'] for g in all_grades)) + selected_course = st.selectbox("Select course to edit grades:", course_names, key="edit_admin_course") + + # Filter grades for selected course + course_grades = [g for g in all_grades if g['course_name'] == selected_course] + + if course_grades: + st.subheader(f"Editing {selected_course}") + + # Display each grade with edit option + for idx, grade in enumerate(course_grades): + col1, col2, col3, col4 = st.columns([2, 2, 1, 1]) + + with col1: + st.write(f"**{grade['student_name']}**") + + with col2: + st.write(grade['assignment_name']) + + with col3: + new_grade = st.number_input( + "New Grade", + min_value=0.0, + max_value=100.0, + value=float(grade['grade']), + step=0.5, + key=f"admin_grade_{grade['grade_id']}" + ) + + with col4: + if st.button("Save", key=f"admin_save_{grade['grade_id']}", use_container_width=True): + if new_grade != grade['grade']: + result = grade_service.update_grade(grade['grade_id'], new_grade) + if result['success']: + st.success(f"βœ“ Updated to {new_grade}%") + else: + st.error(f"Failed: {result['message']}") + else: + st.info("No change") + else: + st.info("No grades to edit.") + + +def _render_settings_tab(): + """Render system settings tab.""" + st.subheader("System Settings") + + st.markdown("#### πŸ”§ System Configuration") + + col1, col2 = st.columns(2) + + with col1: + st.info("**Read/Write - Full Grade Management**\nAdministrators can view all system data and have full permissions to edit grades across all courses.") + + with col2: + st.info("**Audit Trail**\nAll administrative actions are logged for security and compliance purposes.") diff --git a/src/ui/pages/after_hours.py b/src/ui/pages/after_hours.py new file mode 100644 index 0000000..559f631 --- /dev/null +++ b/src/ui/pages/after_hours.py @@ -0,0 +1,191 @@ +""" +After-Hours Help page. +""" +import streamlit as st +import pandas as pd +from datetime import datetime +from src.core.session import session +from src.core.rbac import RBACFilter +from config.settings import ROLES + + +def show_after_hours_page(): + """Render the after-hours help page.""" + user = session.get_current_user() + + if not user: + st.warning("Please log in to continue.") + return + + # Route based on user role + if user["role"] == ROLES["TEACHER"]: + _show_teacher_view(user) + else: + _show_student_parent_view(user) + + +def _show_student_parent_view(user): + """View for students and parents to submit and view their questions.""" + st.markdown('# ❓ Ask a Question', unsafe_allow_html=True) + st.caption("Get help from your teacher after school hours") + + # Submit a new request + st.markdown("### πŸ“ Submit a Question") + + # Get user's courses + courses_df = RBACFilter.get_authorized_courses(user) + courses_list = [ + {"id": row["course_id"], "name": row["course_name"]} + for _, row in courses_df.iterrows() + ] if not courses_df.empty else [] + + with st.form("submit_question_form"): + # Course selection + if courses_list: + course_options = {c["name"]: c["id"] for c in courses_list} + selected_course = st.selectbox( + "Course", + list(course_options.keys()), + ) + else: + st.warning("No courses available.") + selected_course = None + + # Question details + subject = st.text_input( + "Subject/Topic", + placeholder="e.g., Homework help, Exam review, Project clarification", + ) + + question = st.text_area( + "Your Question", + placeholder="Describe your question or issue in detail...", + height=150, + ) + + submitted = st.form_submit_button("Submit Question", use_container_width=True) + + if submitted and question.strip(): + # Store the request in session state as a simple history + if "after_hours_requests" not in st.session_state: + st.session_state.after_hours_requests = [] + + # Create request record + request_record = { + "id": len(st.session_state.after_hours_requests) + 1, + "user_id": user.get("user_id"), + "user_name": user.get("name"), + "course": selected_course if selected_course else "N/A", + "subject": subject or "No subject", + "question": question, + "submitted_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "status": "Open", + } + + st.session_state.after_hours_requests.append(request_record) + st.success(f"βœ… Question submitted! Your request ID is #{request_record['id']}") + + st.markdown("---") + + # Show request history + st.markdown("### πŸ“‹ Your Request History") + + if "after_hours_requests" not in st.session_state or not st.session_state.after_hours_requests: + st.info("You haven't submitted any questions yet.") + else: + # Display requests as cards + requests = st.session_state.after_hours_requests + + for req in requests: + with st.container(border=True): + col1, col2, col3 = st.columns([2, 1, 1]) + + with col1: + st.markdown(f"**{req['subject']}**") + st.markdown(f"Course: {req['course']}") + + with col2: + status_emoji = "🟒" if req["status"] == "Open" else "🟑" if req["status"] == "In Progress" else "πŸ”΅" + st.markdown(f"**Status:** {status_emoji} {req['status']}") + + with col3: + st.markdown(f"**#{req['id']}**") + + st.markdown("---") + st.write(f"**Question:** {req['question']}") + st.caption(f"Submitted: {req['submitted_at']}") + + +def _show_teacher_view(user): + """View for teachers to see student questions and reply.""" + st.markdown('# πŸ“š Student Questions', unsafe_allow_html=True) + st.caption("View and respond to student after-hours questions") + + # Initialize teacher questions storage if not exists + if "teacher_student_questions" not in st.session_state: + st.session_state.teacher_student_questions = [] + + st.markdown("### πŸ“‹ Student Questions") + + if not st.session_state.teacher_student_questions: + st.info("No student questions submitted yet.") + else: + # Display student questions + for idx, question in enumerate(st.session_state.teacher_student_questions): + with st.container(border=True): + col1, col2, col3 = st.columns([2, 1, 1]) + + with col1: + st.markdown(f"**{question['subject']}**") + st.markdown(f"From: {question['student_name']}") + st.markdown(f"Course: {question['course']}") + + with col2: + status_emoji = "🟒" if question["status"] == "Open" else "🟑" if question["status"] == "In Progress" else "πŸ”΅" + st.markdown(f"**Status:** {status_emoji} {question['status']}") + + with col3: + st.markdown(f"**#{question['id']}**") + + st.markdown("---") + st.write(f"**Question:** {question['question']}") + st.caption(f"Submitted: {question['submitted_at']}") + + # Reply section + col1, col2 = st.columns([3, 1]) + with col1: + reply_text = st.text_area( + f"Your reply to question #{question['id']}", + key=f"reply_{idx}", + height=100, + ) + + with col2: + if st.button("Send Reply", key=f"send_{idx}", use_container_width=True): + if reply_text.strip(): + # Store reply in session state + if "teacher_replies" not in st.session_state: + st.session_state.teacher_replies = {} + + question_id = question['id'] + if question_id not in st.session_state.teacher_replies: + st.session_state.teacher_replies[question_id] = [] + + st.session_state.teacher_replies[question_id].append({ + "teacher_name": user.get("name"), + "reply": reply_text, + "replied_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + }) + + # Update question status + st.session_state.teacher_student_questions[idx]["status"] = "In Progress" + st.success("βœ… Reply sent!") + st.rerun() + + # Show existing replies + question_id = question['id'] + if question_id in st.session_state.teacher_replies: + st.markdown("**Replies:**") + for reply in st.session_state.teacher_replies[question_id]: + st.markdown(f"*{reply['teacher_name']} - {reply['replied_at']}*") + st.write(reply['reply']) diff --git a/src/ui/pages/home.py b/src/ui/pages/home.py index 4123c81..a2c2e97 100644 --- a/src/ui/pages/home.py +++ b/src/ui/pages/home.py @@ -1,10 +1,12 @@ """ Home page for the application. """ + import streamlit as st from src.core.session import session from src.ui.components.navigation import get_current_page from config.settings import ROLES +from src.features.announcements.ui import show_announcements_page def show_home_page(): @@ -15,63 +17,95 @@ def show_home_page(): st.warning("Please log in to continue.") return - # Route to appropriate dashboard based on current page + # Determine selected page from navigation current_page = get_current_page() - if current_page == 'home': - _show_role_dashboard(user['role']) - elif current_page == 'student_dashboard': + # -------- DASHBOARDS -------- + if current_page == "home": + _show_role_dashboard(user["role"]) + + elif current_page == "student_dashboard": from src.ui.pages.student_dashboard import show_student_dashboard show_student_dashboard() - elif current_page == 'parent_dashboard': + + elif current_page == "parent_dashboard": from src.ui.pages.parent_dashboard import show_parent_dashboard show_parent_dashboard() - elif current_page == 'teacher_dashboard': + + elif current_page == "teacher_dashboard": from src.ui.pages.teacher_dashboard import show_teacher_dashboard show_teacher_dashboard() - elif current_page == 'admin_dashboard': + + elif current_page == "admin_dashboard": from src.ui.pages.admin_dashboard import show_admin_dashboard show_admin_dashboard() - elif current_page == 'parent_engagement': - # Route to appropriate parent engagement page based on role - if user['role'] == ROLES['PARENT']: + + # -------- PARENT ENGAGEMENT -------- + elif current_page == "parent_engagement": + if user["role"] == ROLES["PARENT"]: from src.features.parent_engagement.ui import show_contact_teachers_page show_contact_teachers_page() - elif user['role'] == ROLES['TEACHER']: + + elif user["role"] == ROLES["TEACHER"]: from src.features.parent_engagement.ui import show_parent_requests_page show_parent_requests_page() + else: st.error("Unauthorized access to parent engagement feature.") - elif current_page == 'low_grade_alerts': - # Route to appropriate low grade alerts page based on role - if user['role'] == ROLES['STUDENT']: + + # -------- LOW GRADE ALERTS -------- + elif current_page == "low_grade_alerts": + if user["role"] == ROLES["STUDENT"]: from src.features.low_grade_alerts_guidance.ui import show_student_alerts_page show_student_alerts_page() - elif user['role'] == ROLES['PARENT']: + + elif user["role"] == ROLES["PARENT"]: from src.features.low_grade_alerts_guidance.ui import show_parent_alerts_page show_parent_alerts_page() - elif user['role'] == ROLES['TEACHER']: + + elif user["role"] == ROLES["TEACHER"]: from src.features.low_grade_alerts_guidance.ui import show_teacher_at_risk_students show_teacher_at_risk_students() + else: st.error("Unauthorized access to grade alerts feature.") + + # -------- ANNOUNCEMENTS -------- + elif current_page == "announcements": + show_announcements_page() + + # -------- SCHEDULE AREA -------- + elif current_page == "schedule_area": + from src.features.schedule_area.ui import show_schedule_page + show_schedule_page() + + # -------- AFTER-HOURS -------- + elif current_page == "after_hours": + from src.ui.pages.after_hours import show_after_hours_page + show_after_hours_page() + + # -------- FALLBACK -------- else: st.info(f"Page '{current_page}' is under construction.") def _show_role_dashboard(role: str): """Show the appropriate dashboard for the user's role.""" - if role == ROLES['STUDENT']: + if role == ROLES["STUDENT"]: from src.ui.pages.student_dashboard import show_student_dashboard show_student_dashboard() - elif role == ROLES['PARENT']: + + elif role == ROLES["PARENT"]: from src.ui.pages.parent_dashboard import show_parent_dashboard show_parent_dashboard() - elif role == ROLES['TEACHER']: + + elif role == ROLES["TEACHER"]: from src.ui.pages.teacher_dashboard import show_teacher_dashboard show_teacher_dashboard() - elif role == ROLES['ADMIN']: + + elif role == ROLES["ADMIN"]: from src.ui.pages.admin_dashboard import show_admin_dashboard show_admin_dashboard() + else: st.error("Unknown user role.") diff --git a/src/ui/pages/parent_dashboard.py b/src/ui/pages/parent_dashboard.py index 6d6d123..3127d6c 100644 --- a/src/ui/pages/parent_dashboard.py +++ b/src/ui/pages/parent_dashboard.py @@ -59,23 +59,6 @@ def show_parent_dashboard(): def _show_child_dashboard(grades_df: pd.DataFrame, student_id: int, student_name: str): """Show dashboard for a specific child.""" - # Show low grade alerts if any - from src.features.low_grade_alerts_guidance.service import LowGradeAlertService - alert_service = LowGradeAlertService() - - # Get parent ID from user - user = session.get_current_user() - parent_id = user.get("parent_id") - - if parent_id: - alerts = alert_service.repository.get_parent_student_alerts(parent_id, student_id, dismissed=False) - if alerts: - st.warning(f"{student_name} has {len(alerts)} grade alert(s)") - for alert in alerts: - with st.container(border=True): - st.markdown(f"**{alert['alert_type'].replace('_', ' ').title()}** in {alert['course_name']} ({alert['current_grade']:.1f}%)") - st.caption(alert['alert_message'][:150] + "...") - # Filter grades for this child child_grades = grades_df[grades_df['student_id'] == student_id] @@ -140,3 +123,5 @@ def _show_child_dashboard(grades_df: pd.DataFrame, student_id: int, student_name use_container_width=True, hide_index=True ) + + st.markdown("---") diff --git a/src/ui/pages/student_dashboard.py b/src/ui/pages/student_dashboard.py index 7a6b66a..3b0309d 100644 --- a/src/ui/pages/student_dashboard.py +++ b/src/ui/pages/student_dashboard.py @@ -14,27 +14,8 @@ def show_student_dashboard(): """Render the student dashboard.""" user = session.get_current_user() - st.markdown(f'

Welcome, {user["name"]}!

', unsafe_allow_html=True) - - # Show low grade alerts if any - from src.features.low_grade_alerts_guidance.service import LowGradeAlertService - alert_service = LowGradeAlertService() - student_id = user.get("student_id") - - if student_id: - alerts = alert_service.get_student_alerts(student_id) - if alerts: - st.warning(f"You have {len(alerts)} grade alert(s) that need your attention!") - for alert in alerts: - with st.container(border=True): - col1, col2 = st.columns([0.85, 0.15]) - with col1: - st.markdown(f"**{alert['alert_type'].replace('_', ' ').title()}** in {alert['course_name']} ({alert['current_grade']:.1f}%)") - st.caption(alert['alert_message'][:100] + "...") - with col2: - if st.button("View", key=f"view_alert_{alert['alert_id']}"): - st.session_state.current_page = 'low_grade_alerts' - st.rerun() + st.title("πŸ“Š My Grades") + st.markdown(f'Welcome, {user["name"]}!', unsafe_allow_html=False) # Get student's grades using RBAC filtering grades_df = RBACFilter.get_authorized_grades() @@ -166,3 +147,5 @@ def show_student_dashboard(): st.write("No grades yet for this course.") else: st.info("Not enrolled in any courses yet.") + + st.markdown("---") diff --git a/src/ui/pages/teacher_dashboard.py b/src/ui/pages/teacher_dashboard.py index f04e3ae..a46ab83 100644 --- a/src/ui/pages/teacher_dashboard.py +++ b/src/ui/pages/teacher_dashboard.py @@ -5,6 +5,7 @@ from src.core.decorators import require_role from src.core.rbac import RBACFilter from src.core.session import session +from src.features.grade_management.service import GradeManagementService @require_role('teacher', 'admin') @@ -12,7 +13,8 @@ def show_teacher_dashboard(): """Render the teacher dashboard.""" user = session.get_current_user() - st.markdown(f'

Welcome, {user["name"]}!

', unsafe_allow_html=True) + st.title("πŸ‘¨β€πŸ« Dashboard") + st.markdown(f'Welcome, {user["name"]}!') # Get teacher's courses and grades courses_df = RBACFilter.get_authorized_courses() @@ -78,29 +80,6 @@ def show_teacher_dashboard(): st.markdown("---") - # At-risk students - from src.features.low_grade_alerts_guidance.service import LowGradeAlertService - alert_service = LowGradeAlertService() - teacher_id = user.get("teacher_id") - - if teacher_id: - at_risk = alert_service.get_teacher_at_risk_students(teacher_id) - if at_risk: - st.warning(f"You have {len(at_risk)} student(s) with grade alerts") - with st.expander("View Students Needing Attention", expanded=False): - for student_info in at_risk: - student_id = student_info[0] - student_name = student_info[1] - course_name = student_info[3] if len(student_info) > 3 else "Unknown Course" - - st.markdown(f"**{student_name}** - {course_name}") - student_alerts = alert_service.get_student_alerts(student_id) - if student_alerts: - st.caption(f"{student_alerts[0]['alert_type'].replace('_', ' ').title()}: {student_alerts[0]['current_grade']:.1f}%") - st.divider() - - st.markdown("---") - # Student performance overview if not grades_df.empty: st.markdown("### πŸ“Š Student Performance Overview") @@ -115,3 +94,56 @@ def show_teacher_dashboard(): use_container_width=True, hide_index=True ) + + st.markdown("---") + + # Grade editing section + if not courses_df.empty: + st.markdown("### ✏️ Edit Grades") + st.caption("Modify grades for your courses. Changes are saved immediately.") + + grade_service = GradeManagementService() + + # Select course to edit grades for + course_names = courses_df['course_name'].tolist() + selected_course = st.selectbox("Select a course to edit grades:", course_names, key="edit_course_select") + + if selected_course: + # Filter grades for the selected course from the already-loaded grades_df + course_grades_df = grades_df[grades_df['course_name'] == selected_course] + + if not course_grades_df.empty: + st.subheader(f"Editing Grades for {selected_course}") + + # Display grades with edit options + for idx, (_, grade_row) in enumerate(course_grades_df.iterrows()): + col1, col2, col3, col4 = st.columns([2, 2, 1, 1]) + + with col1: + st.write(f"**{grade_row['student_name']}**") + + with col2: + st.write(grade_row['assignment_name']) + + with col3: + new_grade = st.number_input( + "New Grade", + min_value=0.0, + max_value=100.0, + value=float(grade_row['grade']), + step=0.5, + key=f"grade_{grade_row['grade_id']}" + ) + + with col4: + if st.button("Save", key=f"save_{grade_row['grade_id']}", use_container_width=True): + if new_grade != float(grade_row['grade']): + result = grade_service.update_grade(grade_row['grade_id'], new_grade) + if result['success']: + st.success(f"Updated to {new_grade}%") + else: + st.error(f"Failed: {result['message']}") + else: + st.info("No change") + else: + st.info("No grades to edit for this course yet.")