From a92991a5e145298ed9d106d98b53c88869b51fec Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:09:57 -0700 Subject: [PATCH 1/4] test: demo media build pipeline (#48) Assisted-by: Claude Code (sonnet subagent) Co-Authored-By: Claude Fable 5 --- tests/test_build_demo_media.py | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_build_demo_media.py diff --git a/tests/test_build_demo_media.py b/tests/test_build_demo_media.py new file mode 100644 index 0000000..6ee453e --- /dev/null +++ b/tests/test_build_demo_media.py @@ -0,0 +1,71 @@ +"""Tests for scripts/build_demo_media.py. + +Validates the screenshots -> mp4/gif pipeline against synthetic frames +(solid-color PNGs), since real app screenshots require a live browser +session. See scripts/build_demo_media.py and _docs/DEMO-script.md. +""" + +import sys +from pathlib import Path + +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from build_demo_media import build_demo_media, load_frame_paths # noqa: E402 + + +def _make_frames(tmp_path, count=3, size=(1280, 720)): + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + colors = [(200, 0, 0), (0, 200, 0), (0, 0, 200)] + for i in range(count): + img = Image.new("RGB", size, colors[i % len(colors)]) + img.save(frames_dir / f"{i:02d}-frame.png") + return frames_dir + + +def test_build_demo_media_produces_mp4_and_gif(tmp_path): + frames_dir = _make_frames(tmp_path, count=3) + out_dir = tmp_path / "out" + + mp4_path, gif_path = build_demo_media( + frames_dir, out_dir, seconds_per_frame=0.2, fps=10, gif_width=400 + ) + + assert mp4_path.exists() + assert mp4_path.stat().st_size > 0 + + assert gif_path.exists() + assert gif_path.stat().st_size > 0 + + with Image.open(gif_path) as gif: + assert gif.is_animated + assert gif.n_frames == 3 + + +def test_build_demo_media_handles_odd_dimensions(tmp_path): + frames_dir = _make_frames(tmp_path, count=2, size=(1281, 721)) + out_dir = tmp_path / "out" + + mp4_path, gif_path = build_demo_media( + frames_dir, out_dir, seconds_per_frame=0.2, fps=10, gif_width=400 + ) + + assert mp4_path.exists() + assert mp4_path.stat().st_size > 0 + assert gif_path.exists() + assert gif_path.stat().st_size > 0 + + +def test_load_frame_paths_missing_dir_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_frame_paths(tmp_path / "does-not-exist") + + +def test_load_frame_paths_empty_dir_raises(tmp_path): + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + with pytest.raises(FileNotFoundError): + load_frame_paths(empty_dir) From e1b1fc0bb3581ec9a48574b4a854f962075919e5 Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:10:01 -0700 Subject: [PATCH 2/4] feat: demo walkthrough script + screenshots->mp4/gif pipeline (#48) Assisted-by: Claude Code (sonnet subagent) Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + _docs/DEMO-script.md | 88 +++++++++++++++++ scripts/build_demo_media.py | 158 ++++++++++++++++++++++++++++++ scripts/demo_capture_checklist.md | 71 ++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 _docs/DEMO-script.md create mode 100644 scripts/build_demo_media.py create mode 100644 scripts/demo_capture_checklist.md diff --git a/.gitignore b/.gitignore index 66e6755..efeedc7 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,8 @@ scripts/setup_goal_complete_demo.py # Local-only docs (not committed) _docs/local/ + +# Demo media build output (screenshots -> mp4/gif) - see scripts/build_demo_media.py +demo-media/ +*.mp4 +*.gif diff --git a/_docs/DEMO-script.md b/_docs/DEMO-script.md new file mode 100644 index 0000000..5478374 --- /dev/null +++ b/_docs/DEMO-script.md @@ -0,0 +1,88 @@ +# Demo Walkthrough Script (#48) + +A narratable, screenshot-by-screenshot demo of the live app. Matches the +actual routes in `examples/frontend-starter/src/App.jsx` and the actual +page components — no invented features. + +- **Frontend**: https://elevareai-frontend.onrender.com +- **API**: https://elevareai-api.onrender.com +- **Demo account**: `demo@elevare.ai` — password is the `DEMO_PASSWORD` + value (never write the literal password anywhere; see README "Set Manual + Secrets" / Render dashboard). + +## Pre-demo warm-up (do this first, every time) + +Render free-tier web services spin down after ~15 min idle; the first +request after idle takes ~50s. Wake the API before the audience is watching: + +```bash +curl https://elevareai-api.onrender.com/health +# wait for {"status":"healthy","database":"connected"} - may take ~50s +curl https://elevareai-api.onrender.com/health +# second call should return near-instantly once warm +``` + +Then load the frontend once yourself (not screenshotted) so its own cold +start (static site, usually fast) is also out of the way before recording. + +## Demo beats + +Each beat = one screenshot. Capture in order; filenames are consumed +in sorted order by `scripts/build_demo_media.py`, so the numeric prefix +controls playback order. + +| # | Filename | Route | Action | What to show | Narration line | +|---|----------|-------|--------|---------------|-----------------| +| 1 | `01-login.png` | `/login` | Land on the login page | ElevareAI logo, tagline "Lift your learning, gently.", email/password form | "This is ElevareAI - an AI study companion that lives between tutoring sessions." | +| 2 | `02-dashboard.png` | `/dashboard` | Log in as `demo@elevare.ai` | The goals pie chart, nudges (if any) | "After logging in, the student lands on their dashboard - goals and progress at a glance." | +| 3 | `03-qa-math.png` | `/qa` | Ask a math question, e.g. "How do I solve x squared plus 5x plus 6 equals 0?" | The rendered answer with KaTeX-formatted math (equations, not raw LaTeX text) and the Confidence badge | "Students can ask questions any time - answers render real math notation, not plain text, and each answer carries a confidence rating." | +| 4 | `04-qa-history.png` | `/qa` (reload or revisit) | Show the conversation history loading in | Prior Q&A pairs above the live one, "Showing conversation history" banner | "The AI remembers previous questions - this is persistent memory, not a one-off chatbot." | +| 5 | `05-practice.png` | `/practice` | Pick a subject (from the student's goals) and generate a practice set | The AI-generated question list for that subject | "Practice questions are generated per-subject from the student's active goals." | +| 6 | `06-practice-answer.png` | `/practice` | Answer a practice question | The "Correct!" / feedback state with explanation | "Immediate feedback with an explanation - not just right/wrong." | +| 7 | `07-goals.png` | `/goals` | View goals list | Active/completed goals, subjects, target dates | "Goals drive everything - practice subjects and suggestions all come from here." | +| 8 | `08-progress.png` | `/progress` | View progress page | Elo ratings per goal, completion dates, related-subject suggestions | "Progress tracks skill level (Elo) per subject and suggests what to learn next - this is what keeps students engaged after they finish a goal." | + +Eight beats is the target; drop 4 (history) or 6 (practice-answer) if +time is short, but keep 1, 2, 3, 5, 7, 8 as the minimum spine (login, +dashboard, QA math, practice, goals, progress). + +## Capture checklist + +See `scripts/demo_capture_checklist.md` for the full step-by-step capture +process (manual screenshot vs. chrome-devtools MCP automation) and the +frame naming convention. + +## Building the video/gif from frames + +1. Save the 8 (or however many) PNGs above into a local, **gitignored** + frames directory, e.g. `_docs/local/demo-frames/`, using exactly the + filenames from the table so sort order matches the beat order. +2. Install the two build dependencies (not in `requirements.txt` - + they're only needed for this one-off media build): + ```bash + pip install imageio-ffmpeg Pillow + ``` +3. Run the build script: + ```bash + python scripts/build_demo_media.py \ + --frames-dir _docs/local/demo-frames \ + --out-dir demo-media \ + --seconds-per-frame 2.5 \ + --fps 30 \ + --gif-width 800 + ``` +4. Output lands in `demo-media/demo.mp4` and `demo-media/demo.gif`. Both + `demo-media/` and `_docs/local/` are gitignored - **do not commit the + frames or the rendered media.** + +## What's owner-dependent (not done here) + +- **Real screenshots**: not captured in this change - the browser profile + used by this session is locked, so only synthetic (solid-color) + placeholder frames were used to prove the build pipeline works. The + owner needs to capture the real 8 frames per the checklist. +- **Whether to commit final media**: this script and this doc keep + `demo.mp4`/`demo.gif` out of git entirely (gitignored `demo-media/` + output dir). If the finished demo video should ship in the repo or as a + GitHub Release asset, that's an owner decision to make later - attach + it to a release rather than committing a binary to `main`. diff --git a/scripts/build_demo_media.py b/scripts/build_demo_media.py new file mode 100644 index 0000000..63bd196 --- /dev/null +++ b/scripts/build_demo_media.py @@ -0,0 +1,158 @@ +"""Build demo.mp4 and demo.gif from an ordered directory of PNG screenshot frames. + +Reads every ``*.png`` file in --frames-dir (sorted by filename, so name +frames ``01-login.png``, ``02-qa-math.png``, ... to control order), holds +each frame on screen for --seconds-per-frame, and writes: + + - /demo.mp4 (via imageio-ffmpeg's bundled ffmpeg binary) + - /demo.gif (via Pillow, downscaled to --gif-width) + +Dependencies (not in requirements.txt - install before running): + pip install imageio-ffmpeg Pillow + +Usage: + python scripts/build_demo_media.py --frames-dir _docs/local/demo-frames --out-dir demo-media +""" + +import argparse +import sys +from pathlib import Path + +import imageio_ffmpeg +from PIL import Image + +DEFAULT_SECONDS_PER_FRAME = 2.5 +DEFAULT_FPS = 30 +DEFAULT_GIF_WIDTH = 800 +DEFAULT_OUT_DIR = "demo-media" + + +def _round_to_even(value): + """ffmpeg's yuv420p output needs even width/height.""" + return value if value % 2 == 0 else value - 1 + + +def load_frame_paths(frames_dir): + frames_dir = Path(frames_dir) + if not frames_dir.exists(): + raise FileNotFoundError(f"Frames directory does not exist: {frames_dir}") + paths = sorted(frames_dir.glob("*.png")) + if not paths: + raise FileNotFoundError(f"No PNG frames found in {frames_dir}") + return paths + + +def build_mp4(frame_paths, out_path, seconds_per_frame, fps): + first = Image.open(frame_paths[0]).convert("RGB") + width = max(2, _round_to_even(first.width)) + height = max(2, _round_to_even(first.height)) + + writer = imageio_ffmpeg.write_frames( + str(out_path), + (width, height), + fps=fps, + codec="libx264", + pix_fmt_out="yuv420p", + ) + writer.send(None) # seed the generator + hold_frames = max(1, round(seconds_per_frame * fps)) + try: + for path in frame_paths: + img = Image.open(path).convert("RGB") + if img.size != (width, height): + img = img.resize((width, height)) + frame_bytes = img.tobytes() + for _ in range(hold_frames): + writer.send(frame_bytes) + finally: + writer.close() + + +def build_gif(frame_paths, out_path, seconds_per_frame, gif_width): + frames = [] + for path in frame_paths: + img = Image.open(path).convert("RGB") + if gif_width and img.width > gif_width: + ratio = gif_width / img.width + img = img.resize((gif_width, max(1, round(img.height * ratio)))) + frames.append(img) + + duration_ms = int(seconds_per_frame * 1000) + frames[0].save( + out_path, + save_all=True, + append_images=frames[1:], + duration=duration_ms, + loop=0, + optimize=True, + ) + + +def build_demo_media( + frames_dir, + out_dir=DEFAULT_OUT_DIR, + seconds_per_frame=DEFAULT_SECONDS_PER_FRAME, + fps=DEFAULT_FPS, + gif_width=DEFAULT_GIF_WIDTH, +): + frame_paths = load_frame_paths(frames_dir) + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + mp4_path = out_dir / "demo.mp4" + gif_path = out_dir / "demo.gif" + build_mp4(frame_paths, mp4_path, seconds_per_frame, fps) + build_gif(frame_paths, gif_path, seconds_per_frame, gif_width) + return mp4_path, gif_path + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--frames-dir", required=True, help="Directory of ordered PNG frames" + ) + parser.add_argument( + "--out-dir", + default=DEFAULT_OUT_DIR, + help=f"Output directory for demo.mp4/demo.gif (default: {DEFAULT_OUT_DIR})", + ) + parser.add_argument( + "--seconds-per-frame", + type=float, + default=DEFAULT_SECONDS_PER_FRAME, + help=f"How long each frame is held (default: {DEFAULT_SECONDS_PER_FRAME})", + ) + parser.add_argument( + "--fps", + type=int, + default=DEFAULT_FPS, + help=f"MP4 frame rate (default: {DEFAULT_FPS})", + ) + parser.add_argument( + "--gif-width", + type=int, + default=DEFAULT_GIF_WIDTH, + help=f"GIF output width in pixels, downscaled proportionally (default: {DEFAULT_GIF_WIDTH})", + ) + args = parser.parse_args() + + try: + mp4_path, gif_path = build_demo_media( + args.frames_dir, + args.out_dir, + args.seconds_per_frame, + args.fps, + args.gif_width, + ) + except FileNotFoundError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + print(f"Wrote {mp4_path} ({mp4_path.stat().st_size} bytes)") + print(f"Wrote {gif_path} ({gif_path.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/scripts/demo_capture_checklist.md b/scripts/demo_capture_checklist.md new file mode 100644 index 0000000..2ae39f4 --- /dev/null +++ b/scripts/demo_capture_checklist.md @@ -0,0 +1,71 @@ +# Demo Capture Checklist (#48) + +Ordered screenshots to capture for the demo video/gif. Pairs with +`_docs/DEMO-script.md` (the narration + what each beat shows) and +`scripts/build_demo_media.py` (the frames -> mp4/gif build). + +## Frame naming convention + +`NN-short-name.png` — two-digit zero-padded prefix controls playback +order (frames are sorted by filename). Use the exact names below so they +line up with the DEMO-script beats. + +## Frames to capture + +- [ ] `01-login.png` — `/login`, logged out +- [ ] `02-dashboard.png` — `/dashboard`, logged in as `demo@elevare.ai` +- [ ] `03-qa-math.png` — `/qa`, after asking a math question, answer + visible with KaTeX-rendered equations and a Confidence badge +- [ ] `04-qa-history.png` — `/qa`, revisited/reloaded showing prior + conversation history above the live answer +- [ ] `05-practice.png` — `/practice`, a subject selected and its + AI-generated question list showing +- [ ] `06-practice-answer.png` — `/practice`, after answering a question, + "Correct!" / feedback + explanation visible +- [ ] `07-goals.png` — `/goals`, goals list showing subjects/dates +- [ ] `08-progress.png` — `/progress`, Elo ratings + suggestions visible + +## Before capturing + +1. Run the pre-demo warm-up (two `curl .../health` calls — see + `_docs/DEMO-script.md`) so pages don't load half-rendered while the + API cold-starts. +2. Use a clean browser window sized consistently for every frame (e.g. + 1280x720 or 1920x1080) — the build script handles arbitrary/odd sizes, + but consistent framing looks better in the final video. +3. Save all PNGs into one local, gitignored directory, e.g. + `_docs/local/demo-frames/` — do not commit frames or rendered media. + +## Capture options + +**Option A — manual OS screenshot** +Use the OS screenshot tool (Win+Shift+S on Windows) against the live +frontend at https://elevareai-frontend.onrender.com, logged in as +`demo@elevare.ai`. Crop to the browser viewport for consistency. + +**Option B — automated via chrome-devtools MCP** +When the browser automation tooling (chrome-devtools MCP `take_screenshot`) +has a free/unlocked browser profile available, drive the same 8 beats +programmatically: navigate to each route, wait for content to load +(especially the QA answer and Practice question generation, which call +the AI backend and can take up to ~20s), then screenshot. This wasn't +usable in this session because the local browser profile was locked by +another process — left for the owner to run when free. + +## After capturing + +Run the build: + +```bash +pip install imageio-ffmpeg Pillow +python scripts/build_demo_media.py \ + --frames-dir _docs/local/demo-frames \ + --out-dir demo-media \ + --seconds-per-frame 2.5 \ + --fps 30 \ + --gif-width 800 +``` + +Check `demo-media/demo.mp4` and `demo-media/demo.gif`. Both are +gitignored — decide separately (see `_docs/DEMO-script.md`) whether final +media ships as a repo commit or a release asset. From 47117da8f95e2081aee695529dadd66a966d7994 Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:17:06 -0700 Subject: [PATCH 3/4] fix: demo pipeline CI-skip on missing tooling deps, gif canvas normalization, stronger tests (#48) - Add pytest.importorskip() at top of test_build_demo_media.py to skip gracefully when PIL/imageio_ffmpeg aren't installed (CI without demo tooling deps won't error on collection, just skip the tests) - Normalize all gif frames to first frame's canvas size before downscaling by gif_width, preventing silent corruption on mismatched frame sizes - Strengthen mp4 test assertions to verify decodability by reading back metadata (source_size, fps) via imageio-ffmpeg - Add test_build_demo_media_handles_mismatched_frame_sizes() to verify gif builds with two different-sized frames (1280x720 and 1000x700) normalized to common canvas size - Scope .gitignore mp4/gif patterns to demo directories only instead of repo-wide blanket patterns (demo-media/, _docs/local/demo-frames/) Assisted-by: Claude Code (haiku subagent) Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +- scripts/build_demo_media.py | 8 + scripts/collect_feedback.py | 104 +++++++----- scripts/create_staging_env.py | 45 ++--- scripts/demo_auth.py | 8 +- scripts/deployment/update-task-def-image.py | 24 ++- scripts/get_demo_uuids.py | 7 +- scripts/run_migrations_aws.py | 77 +++++---- scripts/seed_demo_data.py | 8 +- scripts/setup_beta_testing.py | 84 +++++----- scripts/setup_db.py | 153 +++++++++-------- scripts/test_demo_scenarios.py | 175 ++++++++++---------- scripts/verify_all_demo_accounts.py | 83 ++++++---- scripts/verify_complete_system.py | 86 ++++++---- scripts/verify_demo_data.py | 32 ++-- scripts/verify_demo_users.py | 142 ++++++++++------ tests/test_build_demo_media.py | 54 ++++++ 17 files changed, 629 insertions(+), 464 deletions(-) diff --git a/.gitignore b/.gitignore index efeedc7..dab821e 100644 --- a/.gitignore +++ b/.gitignore @@ -141,5 +141,4 @@ _docs/local/ # Demo media build output (screenshots -> mp4/gif) - see scripts/build_demo_media.py demo-media/ -*.mp4 -*.gif +_docs/local/demo-frames/ diff --git a/scripts/build_demo_media.py b/scripts/build_demo_media.py index 63bd196..cddcdd9 100644 --- a/scripts/build_demo_media.py +++ b/scripts/build_demo_media.py @@ -69,9 +69,17 @@ def build_mp4(frame_paths, out_path, seconds_per_frame, fps): def build_gif(frame_paths, out_path, seconds_per_frame, gif_width): + first = Image.open(frame_paths[0]).convert("RGB") + canvas_width = first.width + canvas_height = first.height + frames = [] for path in frame_paths: img = Image.open(path).convert("RGB") + # Normalize to canvas size first (like build_mp4) + if img.size != (canvas_width, canvas_height): + img = img.resize((canvas_width, canvas_height)) + # Then downscale by gif_width if gif_width and img.width > gif_width: ratio = gif_width / img.width img = img.resize((gif_width, max(1, round(img.height * ratio)))) diff --git a/scripts/collect_feedback.py b/scripts/collect_feedback.py index af77d80..14e437b 100644 --- a/scripts/collect_feedback.py +++ b/scripts/collect_feedback.py @@ -4,101 +4,120 @@ Collects and analyzes user feedback from beta testing """ -import sys import os +import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import json +from datetime import datetime, timedelta + from sqlalchemy.orm import Session + from src.config.database import get_db -from src.models.user import User from src.models.practice import PracticeAssignment from src.models.qa import QAInteraction from src.models.session import Session as SessionModel -from datetime import datetime, timedelta -import json +from src.models.user import User def analyze_user_engagement(db: Session, days: int = 7): """Analyze user engagement metrics""" cutoff_date = datetime.utcnow() - timedelta(days=days) - + # Get active users - active_students = db.query(User).filter( - User.role == "student" - ).all() - + active_students = db.query(User).filter(User.role == "student").all() + engagement_data = { "total_students": len(active_students), "active_students": 0, "sessions_completed": 0, "practice_completed": 0, "qa_queries": 0, - "engagement_rate": 0.0 + "engagement_rate": 0.0, } - + for student in active_students: # Check recent activity - recent_sessions = db.query(SessionModel).filter( - SessionModel.student_id == student.id, - SessionModel.session_date >= cutoff_date - ).count() - - recent_practice = db.query(PracticeAssignment).filter( - PracticeAssignment.student_id == student.id, - PracticeAssignment.completed_at >= cutoff_date - ).count() - - recent_qa = db.query(QAInteraction).filter( - QAInteraction.student_id == student.id, - QAInteraction.created_at >= cutoff_date - ).count() - + recent_sessions = ( + db.query(SessionModel) + .filter( + SessionModel.student_id == student.id, + SessionModel.session_date >= cutoff_date, + ) + .count() + ) + + recent_practice = ( + db.query(PracticeAssignment) + .filter( + PracticeAssignment.student_id == student.id, + PracticeAssignment.completed_at >= cutoff_date, + ) + .count() + ) + + recent_qa = ( + db.query(QAInteraction) + .filter( + QAInteraction.student_id == student.id, + QAInteraction.created_at >= cutoff_date, + ) + .count() + ) + if recent_sessions > 0 or recent_practice > 0 or recent_qa > 0: engagement_data["active_students"] += 1 - + engagement_data["sessions_completed"] += recent_sessions engagement_data["practice_completed"] += recent_practice engagement_data["qa_queries"] += recent_qa - + if engagement_data["total_students"] > 0: engagement_data["engagement_rate"] = ( engagement_data["active_students"] / engagement_data["total_students"] ) * 100 - + return engagement_data def generate_feedback_report(db: Session): """Generate comprehensive feedback report""" print("[REPORT] Generating Feedback Report...\n") - + # Engagement metrics engagement_7d = analyze_user_engagement(db, days=7) engagement_30d = analyze_user_engagement(db, days=30) - + report = { "engagement_metrics": { "last_7_days": engagement_7d, - "last_30_days": engagement_30d + "last_30_days": engagement_30d, }, "feature_usage": { "sessions": engagement_7d["sessions_completed"], "practice": engagement_7d["practice_completed"], - "qa": engagement_7d["qa_queries"] + "qa": engagement_7d["qa_queries"], }, - "recommendations": [] + "recommendations": [], } - + # Generate recommendations if engagement_7d["engagement_rate"] < 50: - report["recommendations"].append("Low engagement - consider improving onboarding or feature discoverability") - + report["recommendations"].append( + "Low engagement - consider improving onboarding or feature discoverability" + ) + if engagement_7d["practice_completed"] == 0: - report["recommendations"].append("No practice completion - check practice assignment flow") - + report["recommendations"].append( + "No practice completion - check practice assignment flow" + ) + if engagement_7d["qa_queries"] == 0: - report["recommendations"].append("No Q&A usage - check Q&A interface accessibility") - + report["recommendations"].append( + "No Q&A usage - check Q&A interface accessibility" + ) + print(json.dumps(report, indent=2)) return report @@ -106,7 +125,7 @@ def generate_feedback_report(db: Session): def main(): """Main function""" db = next(get_db()) - + try: generate_feedback_report(db) except Exception as e: @@ -118,4 +137,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/scripts/create_staging_env.py b/scripts/create_staging_env.py index 46254f4..2665a6d 100644 --- a/scripts/create_staging_env.py +++ b/scripts/create_staging_env.py @@ -4,8 +4,8 @@ Creates a staging environment configuration for testing before production """ -import os import json +import os from pathlib import Path @@ -15,41 +15,34 @@ def create_staging_env_file(): "ENVIRONMENT": "staging", "LOG_LEVEL": "INFO", "API_VERSION": "v1", - # Database (use staging database) "DATABASE_URL": "postgresql://user:password@staging-db:5432/pennygadget_staging", "DATABASE_POOL_SIZE": "10", - # AWS (staging credentials) "AWS_REGION": "us-east-1", "COGNITO_USER_POOL_ID": "us-east-1_STAGING_POOL_ID", "COGNITO_CLIENT_ID": "staging_client_id", - # OpenAI (staging key) "OPENAI_API_KEY": "sk-staging-key-here", - # Email (staging SES) "AWS_SES_REGION": "us-east-1", "AWS_SES_FROM_EMAIL": "staging@yourdomain.com", - # Frontend "FRONTEND_BASE_URL": "https://staging.yourdomain.com", - # Feature Flags "ENABLE_GAMIFICATION": "true", "ENABLE_ANALYTICS": "true", "ENABLE_INTEGRATIONS": "true", - # Monitoring "ENABLE_METRICS": "true", "ENABLE_LOGGING": "true", } - + env_content = "\n".join([f"{key}={value}" for key, value in staging_env.items()]) - + env_file = Path(".env.staging") env_file.write_text(env_content) - + print(f"[OK] Created {env_file}") print("\n[INFO] Next steps:") print("1. Update DATABASE_URL with your staging database") @@ -68,33 +61,32 @@ def create_docker_compose_staging(): "ports": ["8000:8000"], "environment": { "ENVIRONMENT": "staging", - "DATABASE_URL": "postgresql://pennygadget:password@db:5432/pennygadget_staging" + "DATABASE_URL": "postgresql://pennygadget:password@db:5432/pennygadget_staging", }, "env_file": [".env.staging"], "depends_on": ["db"], - "volumes": ["./src:/app/src"] + "volumes": ["./src:/app/src"], }, "db": { "image": "postgres:15-alpine", "environment": { "POSTGRES_USER": "pennygadget", "POSTGRES_PASSWORD": "password", - "POSTGRES_DB": "pennygadget_staging" + "POSTGRES_DB": "pennygadget_staging", }, "volumes": ["postgres_staging_data:/var/lib/postgresql/data"], - "ports": ["5433:5432"] - } + "ports": ["5433:5432"], + }, }, - "volumes": { - "postgres_staging_data": {} - } + "volumes": {"postgres_staging_data": {}}, } - + compose_file = Path("docker-compose.staging.yml") with open(compose_file, "w") as f: import yaml + yaml.dump(docker_compose, f, default_flow_style=False) - + print(f"[OK] Created {compose_file}") @@ -195,25 +187,25 @@ def create_staging_readme(): See `_docs/guides/AWS_DEPLOYMENT_CHECKLIST.md` for AWS deployment steps. """ - + readme_file = Path("STAGING_SETUP.md") readme_file.write_text(readme_content) - + print(f"[OK] Created {readme_file}") def main(): """Main setup function""" print("[SETUP] Creating staging environment configuration...\n") - + try: create_staging_env_file() create_staging_readme() - + print("\n[OK] Staging environment setup complete!") print("\n[INFO] Note: docker-compose.staging.yml requires PyYAML") print(" Install with: pip install pyyaml") - + except Exception as e: print(f"[ERROR] Error creating staging setup: {str(e)}") raise @@ -221,4 +213,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/scripts/demo_auth.py b/scripts/demo_auth.py index 24a7d5d..7ada20f 100644 --- a/scripts/demo_auth.py +++ b/scripts/demo_auth.py @@ -11,15 +11,15 @@ DEMO_PASSWORD = settings.demo_password -def login(email: str, password: str = None, base_url: str = "http://localhost:8000") -> dict: +def login( + email: str, password: str = None, base_url: str = "http://localhost:8000" +) -> dict: """Log in a demo account and return the login response (access_token, user_id, email, role).""" if password is None: password = settings.demo_password if not password: - raise SystemExit( - "DEMO_PASSWORD not set — add it to .env (see README)" - ) + raise SystemExit("DEMO_PASSWORD not set — add it to .env (see README)") response = requests.post( f"{base_url}/api/v1/auth/login", json={"email": email, "password": password}, diff --git a/scripts/deployment/update-task-def-image.py b/scripts/deployment/update-task-def-image.py index 546fd7f..40b8203 100644 --- a/scripts/deployment/update-task-def-image.py +++ b/scripts/deployment/update-task-def-image.py @@ -11,7 +11,7 @@ new_image = sys.argv[2] output_file = sys.argv[3] -with open(input_file, 'r', encoding='utf-8-sig') as f: +with open(input_file, "r", encoding="utf-8-sig") as f: content = f.read().strip() if not content: print(f"ERROR: {input_file} is empty!") @@ -19,21 +19,27 @@ task_def = json.loads(content) # Update image -task_def['containerDefinitions'][0]['image'] = new_image +task_def["containerDefinitions"][0]["image"] = new_image # Remove fields that can't be in new task definition -for field in ['revision', 'status', 'requiresAttributes', 'compatibilities', - 'registeredAt', 'registeredBy', 'taskDefinitionArn']: +for field in [ + "revision", + "status", + "requiresAttributes", + "compatibilities", + "registeredAt", + "registeredBy", + "taskDefinitionArn", +]: task_def.pop(field, None) # Remove hostPort from portMappings (not allowed in Fargate) -if 'portMappings' in task_def['containerDefinitions'][0]: - for pm in task_def['containerDefinitions'][0]['portMappings']: - pm.pop('hostPort', None) +if "portMappings" in task_def["containerDefinitions"][0]: + for pm in task_def["containerDefinitions"][0]["portMappings"]: + pm.pop("hostPort", None) # Write output -with open(output_file, 'w') as f: +with open(output_file, "w") as f: json.dump(task_def, f, indent=2) print(f"Task definition updated: {new_image}") - diff --git a/scripts/get_demo_uuids.py b/scripts/get_demo_uuids.py index 02b3396..aac2d11 100644 --- a/scripts/get_demo_uuids.py +++ b/scripts/get_demo_uuids.py @@ -1,19 +1,20 @@ #!/usr/bin/env python3 """Get demo user UUIDs from database""" -import sys import os +import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from sqlalchemy.orm import Session + from src.config.database import get_db_session from src.models.user import User with get_db_session() as db: - users = db.query(User).filter(User.email.like('demo_%@demo.com')).all() + users = db.query(User).filter(User.email.like("demo_%@demo.com")).all() print("Demo User UUIDs:") print("{") for u in sorted(users, key=lambda x: x.email): print(f" '{u.email}': '{str(u.id)}',") print("}") - diff --git a/scripts/run_migrations_aws.py b/scripts/run_migrations_aws.py index d17345b..793d507 100644 --- a/scripts/run_migrations_aws.py +++ b/scripts/run_migrations_aws.py @@ -10,26 +10,26 @@ - DB_PASSWORD: Database password """ -import sys -import os import argparse +import os +import sys from pathlib import Path # Add project root to path sys.path.insert(0, str(Path(__file__).parent.parent)) import psycopg2 -from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT from psycopg2 import sql +from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT def run_migration_file(conn, migration_file): """Run a single migration file""" print(f" Running migration: {migration_file.name}") - - with open(migration_file, 'r', encoding='utf-8') as f: + + with open(migration_file, "r", encoding="utf-8") as f: sql_content = f.read() - + try: cursor = conn.cursor() # Execute the entire SQL file @@ -48,7 +48,7 @@ def run_migration_file(conn, migration_file): return True except Exception as e: error_msg = str(e).lower() - if 'already exists' in error_msg or 'duplicate' in error_msg: + if "already exists" in error_msg or "duplicate" in error_msg: print(f" [SKIP] Some objects already exist in: {migration_file.name}") conn.rollback() return True @@ -59,43 +59,49 @@ def run_migration_file(conn, migration_file): def main(): - parser = argparse.ArgumentParser(description='Run database migrations on AWS RDS') - parser.add_argument('--host', help='Database host (or use DB_HOST env var)') - parser.add_argument('--port', type=int, default=5432, help='Database port (default: 5432)') - parser.add_argument('--database', help='Database name (or use DB_NAME env var)') - parser.add_argument('--user', help='Database user (or use DB_USER env var)') - parser.add_argument('--password', help='Database password (or use DB_PASSWORD env var)') - parser.add_argument('--migrations-dir', default='migrations', help='Path to migrations directory') - + parser = argparse.ArgumentParser(description="Run database migrations on AWS RDS") + parser.add_argument("--host", help="Database host (or use DB_HOST env var)") + parser.add_argument( + "--port", type=int, default=5432, help="Database port (default: 5432)" + ) + parser.add_argument("--database", help="Database name (or use DB_NAME env var)") + parser.add_argument("--user", help="Database user (or use DB_USER env var)") + parser.add_argument( + "--password", help="Database password (or use DB_PASSWORD env var)" + ) + parser.add_argument( + "--migrations-dir", default="migrations", help="Path to migrations directory" + ) + args = parser.parse_args() - + # Get connection parameters from args or environment - db_host = args.host or os.getenv('DB_HOST') - db_port = args.port or int(os.getenv('DB_PORT', '5432')) - db_name = args.database or os.getenv('DB_NAME') - db_user = args.user or os.getenv('DB_USER') - db_password = args.password or os.getenv('DB_PASSWORD') - + db_host = args.host or os.getenv("DB_HOST") + db_port = args.port or int(os.getenv("DB_PORT", "5432")) + db_name = args.database or os.getenv("DB_NAME") + db_user = args.user or os.getenv("DB_USER") + db_password = args.password or os.getenv("DB_PASSWORD") + if not all([db_host, db_name, db_user, db_password]): print("Error: Missing required database connection parameters") print("Provide via arguments or environment variables:") print(" DB_HOST, DB_NAME, DB_USER, DB_PASSWORD") sys.exit(1) - + # Get migrations directory project_root = Path(__file__).parent.parent migrations_dir = project_root / args.migrations_dir - + if not migrations_dir.exists(): print(f"Error: Migrations directory not found: {migrations_dir}") sys.exit(1) - + migration_files = sorted(migrations_dir.glob("*.sql")) - + if not migration_files: print(f"Warning: No migration files found in {migrations_dir}") sys.exit(0) - + print("=" * 60) print("AWS RDS Migration Runner") print("=" * 60) @@ -105,7 +111,7 @@ def main(): print(f"Migrations found: {len(migration_files)}") print("=" * 60) print() - + # Connect to database try: print("Connecting to database...") @@ -115,7 +121,7 @@ def main(): database=db_name, user=db_user, password=db_password, - connect_timeout=10 + connect_timeout=10, ) conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) print("[OK] Connected successfully") @@ -123,20 +129,20 @@ def main(): except Exception as e: print(f"[ERROR] Failed to connect to database: {e}") sys.exit(1) - + # Run migrations success_count = 0 failed_count = 0 - + for migration_file in migration_files: if run_migration_file(conn, migration_file): success_count += 1 else: failed_count += 1 print() - + conn.close() - + # Summary print("=" * 60) print("Migration Summary") @@ -146,7 +152,7 @@ def main(): if failed_count > 0: print(f"Failed: {failed_count}") print("=" * 60) - + if failed_count > 0: sys.exit(1) else: @@ -154,6 +160,5 @@ def main(): sys.exit(0) -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index e844629..7fb167b 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -22,7 +22,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) +from scripts.demo_auth import DEMO_PASSWORD # noqa: E402 from src.config.database import SessionLocal, engine # noqa: E402 +from src.config.settings import settings # noqa: E402 from src.models import ( # noqa: E402 Goal, Nudge, @@ -33,8 +35,6 @@ from src.models import Session as TutoringSession # noqa: E402 from src.models import Subject, Summary, User # noqa: E402 from src.services.auth import hash_password # noqa: E402 -from src.config.settings import settings # noqa: E402 -from scripts.demo_auth import DEMO_PASSWORD # noqa: E402 # Silence verbose SQL echo (enabled globally in development) for this script's output engine.echo = False @@ -313,9 +313,7 @@ def run_migrations() -> None: def seed_headline_accounts(db) -> Dict[str, User]: """Create the 3 known-credential demo accounts used for live demos.""" if not settings.demo_password: - raise SystemExit( - "DEMO_PASSWORD not set — add it to .env (see README)" - ) + raise SystemExit("DEMO_PASSWORD not set — add it to .env (see README)") accounts = {} for account in DEMO_ACCOUNTS: user, created = get_or_create_user( diff --git a/scripts/setup_beta_testing.py b/scripts/setup_beta_testing.py index 3d54727..eced6f2 100644 --- a/scripts/setup_beta_testing.py +++ b/scripts/setup_beta_testing.py @@ -4,34 +4,37 @@ Creates test users, data, and configurations for beta testing """ -import sys import os +import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import json +import uuid +from datetime import datetime, timedelta + from sqlalchemy.orm import Session + from src.config.database import get_db -from src.models.user import User -from src.models.subject import Subject from src.models.goal import Goal from src.models.session import Session as SessionModel -import uuid -from datetime import datetime, timedelta -import json +from src.models.subject import Subject +from src.models.user import User def create_beta_users(db: Session): """Create test users for beta testing""" users = [] - + # Check if beta users already exist - existing_beta = db.query(User).filter( - User.cognito_sub.like("beta-%") - ).all() - + existing_beta = db.query(User).filter(User.cognito_sub.like("beta-%")).all() + if existing_beta: - print(f"[INFO] Found {len(existing_beta)} existing beta users, skipping creation") + print( + f"[INFO] Found {len(existing_beta)} existing beta users, skipping creation" + ) return existing_beta - + # Create students for i in range(20): user = User( @@ -42,15 +45,13 @@ def create_beta_users(db: Session): profile={ "name": f"Student {i+1}", "grade": 9 + (i % 4), # Grades 9-12 - "preferences": { - "nudge_frequency_cap": 2 - } + "preferences": {"nudge_frequency_cap": 2}, }, - disclaimer_shown=True + disclaimer_shown=True, ) db.add(user) users.append(user) - + # Create tutors for i in range(5): user = User( @@ -60,12 +61,12 @@ def create_beta_users(db: Session): role="tutor", profile={ "name": f"Tutor {i+1}", - "subjects": ["Math", "Science", "English"][:i+1] - } + "subjects": ["Math", "Science", "English"][: i + 1], + }, ) db.add(user) users.append(user) - + # Create parents for i in range(10): user = User( @@ -73,13 +74,11 @@ def create_beta_users(db: Session): cognito_sub=f"beta-parent-{i}", email=f"parent{i}@betatest.com", role="parent", - profile={ - "name": f"Parent {i+1}" - } + profile={"name": f"Parent {i+1}"}, ) db.add(user) users.append(user) - + db.commit() print(f"[OK] Created {len(users)} beta test users") return users @@ -89,7 +88,7 @@ def create_beta_data(db: Session, users: list): """Create test data for beta testing""" students = [u for u in users if u.role == "student"] tutors = [u for u in users if u.role == "tutor"] - + # Get or create subjects subjects = db.query(Subject).all() if not subjects: @@ -101,7 +100,7 @@ def create_beta_data(db: Session, users: list): for s in subjects: db.add(s) db.commit() - + # Create goals for students goal_types = ["SAT", "AP", "Standard"] for student in students[:15]: # 15 students with goals @@ -112,10 +111,10 @@ def create_beta_data(db: Session, users: list): title=f"Improve {subjects[0].name}", goal_type=goal_types[student.id.int % len(goal_types)], target_completion_date=(datetime.utcnow() + timedelta(days=30)).date(), - status="active" + status="active", ) db.add(goal) - + # Create sessions for students for student in students[:10]: # 10 students with sessions session = SessionModel( @@ -125,10 +124,10 @@ def create_beta_data(db: Session, users: list): session_date=datetime.utcnow() - timedelta(days=7 - (student.id.int % 7)), duration_minutes=60, transcript_text="This is a test transcript for beta testing.", - topics_covered=["Algebra", "Geometry"] + topics_covered=["Algebra", "Geometry"], ) db.add(session) - + db.commit() print("[OK] Created beta test data (goals, sessions)") @@ -140,47 +139,47 @@ def generate_beta_report(db: Session): parent_count = db.query(User).filter(User.role == "parent").count() goal_count = db.query(Goal).count() session_count = db.query(SessionModel).count() - + report = { "beta_setup": { "students": student_count, "tutors": tutor_count, "parents": parent_count, "goals": goal_count, - "sessions": session_count + "sessions": session_count, }, - "ready_for_testing": True + "ready_for_testing": True, } - + print("\n[REPORT] Beta Testing Setup Report:") print(json.dumps(report, indent=2)) - + return report def main(): """Main setup function""" print("[SETUP] Setting up Beta Testing Environment...\n") - + db = next(get_db()) - + try: # Create users users = create_beta_users(db) - + # Create test data create_beta_data(db, users) - + # Generate report generate_beta_report(db) - + print("\n[OK] Beta testing environment ready!") print("\nNext steps:") print("1. Share credentials with beta testers") print("2. Set up feedback collection") print("3. Monitor analytics") print("4. Collect user feedback") - + except Exception as e: print(f"[ERROR] Error setting up beta testing: {str(e)}") db.rollback() @@ -191,4 +190,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/scripts/setup_db.py b/scripts/setup_db.py index 9e17176..fc265ac 100644 --- a/scripts/setup_db.py +++ b/scripts/setup_db.py @@ -7,43 +7,47 @@ python scripts/setup_db.py [--env-file .env] """ +import argparse import os import sys -import argparse from pathlib import Path -from sqlalchemy import create_engine, text -from sqlalchemy.exc import OperationalError + import psycopg2 from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from sqlalchemy import create_engine, text +from sqlalchemy.exc import OperationalError # Add parent directory to path to import config sys.path.insert(0, str(Path(__file__).parent.parent)) + def load_env_file(env_file): """Load environment variables from .env file""" env_vars = {} if os.path.exists(env_file): - with open(env_file, 'r') as f: + with open(env_file, "r") as f: for line in f: line = line.strip() - if line and not line.startswith('#') and '=' in line: - key, value = line.split('=', 1) + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) env_vars[key.strip()] = value.strip().strip('"').strip("'") return env_vars + def get_db_connection_string(env_vars=None): """Build database connection string from environment variables""" if env_vars is None: env_vars = {} - - db_host = env_vars.get('DB_HOST') or os.getenv('DB_HOST', 'localhost') - db_port = env_vars.get('DB_PORT') or os.getenv('DB_PORT', '5432') - db_name = env_vars.get('DB_NAME') or os.getenv('DB_NAME', 'elevareai') - db_user = env_vars.get('DB_USER') or os.getenv('DB_USER', 'postgres') - db_password = env_vars.get('DB_PASSWORD') or os.getenv('DB_PASSWORD', '') - + + db_host = env_vars.get("DB_HOST") or os.getenv("DB_HOST", "localhost") + db_port = env_vars.get("DB_PORT") or os.getenv("DB_PORT", "5432") + db_name = env_vars.get("DB_NAME") or os.getenv("DB_NAME", "elevareai") + db_user = env_vars.get("DB_USER") or os.getenv("DB_USER", "postgres") + db_password = env_vars.get("DB_PASSWORD") or os.getenv("DB_PASSWORD", "") + return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" + def create_database_if_not_exists(db_host, db_port, db_user, db_password, db_name): """Create database if it doesn't exist""" try: @@ -53,54 +57,52 @@ def create_database_if_not_exists(db_host, db_port, db_user, db_password, db_nam port=db_port, user=db_user, password=db_password, - database='postgres' + database="postgres", ) conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) cursor = conn.cursor() - + # Check if database exists - cursor.execute( - "SELECT 1 FROM pg_database WHERE datname = %s", - (db_name,) - ) + cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) exists = cursor.fetchone() - + if not exists: print(f" Creating database: {db_name}") cursor.execute(f'CREATE DATABASE "{db_name}"') print(f" [OK] Database created") else: print(f" [WARNING] Database already exists: {db_name}") - + cursor.close() conn.close() except Exception as e: print(f" [WARNING] Could not create database (may already exist): {e}") + def run_migration(engine, migration_file): """Run a SQL migration file using psycopg2 for better SQL handling""" import psycopg2 from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT - - with open(migration_file, 'r', encoding='utf-8') as f: + + with open(migration_file, "r", encoding="utf-8") as f: sql = f.read() - + # Get connection string from engine url = engine.url conn_params = { - 'host': url.host, - 'port': url.port or 5432, - 'database': url.database, - 'user': url.username, - 'password': url.password + "host": url.host, + "port": url.port or 5432, + "database": url.database, + "user": url.username, + "password": url.password, } - + try: # Use psycopg2 directly - it handles multi-statement SQL better conn = psycopg2.connect(**conn_params) conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) cursor = conn.cursor() - + # Execute the entire SQL file # psycopg2's execute can handle multiple statements try: @@ -112,23 +114,27 @@ def run_migration(engine, migration_file): except Exception as e: error_msg = str(e).lower() # Check if it's just "already exists" errors - if 'already exists' in error_msg: + if "already exists" in error_msg: print(f" [INFO] Some objects already exist: {str(e)[:100]}") else: # Try executing statement by statement for better error reporting - print(" [INFO] Full execution failed, trying statement by statement...") + print( + " [INFO] Full execution failed, trying statement by statement..." + ) conn.rollback() # Use execute with execute_values or split more carefully # For now, just execute and ignore "already exists" pass - + cursor.close() conn.close() - + except Exception as e: error_msg = str(e).lower() - if 'already exists' in error_msg or 'duplicate' in error_msg: - print(f" [INFO] Migration partially applied (some objects already exist)") + if "already exists" in error_msg or "duplicate" in error_msg: + print( + f" [INFO] Migration partially applied (some objects already exist)" + ) else: print(f" [ERROR] Migration failed: {e}") # Try using SQLAlchemy as fallback @@ -136,9 +142,12 @@ def run_migration(engine, migration_file): try: with engine.begin() as conn: # Remove comments and execute - lines = [line for line in sql.split('\n') - if line.strip() and not line.strip().startswith('--')] - clean_sql = '\n'.join(lines) + lines = [ + line + for line in sql.split("\n") + if line.strip() and not line.strip().startswith("--") + ] + clean_sql = "\n".join(lines) # Split on semicolons that are not inside dollar quotes # Simple approach: execute the whole thing conn.execute(text(clean_sql)) @@ -146,36 +155,39 @@ def run_migration(engine, migration_file): print(f" [ERROR] Both methods failed. Last error: {e2}") raise + def main(): - parser = argparse.ArgumentParser(description='Setup database schema') - parser.add_argument('--env-file', default='.env', help='Path to .env file') - parser.add_argument('--skip-create-db', action='store_true', help='Skip database creation') + parser = argparse.ArgumentParser(description="Setup database schema") + parser.add_argument("--env-file", default=".env", help="Path to .env file") + parser.add_argument( + "--skip-create-db", action="store_true", help="Skip database creation" + ) args = parser.parse_args() - + print("=" * 60) print("AI Study Companion - Database Setup") print("=" * 60) print() - + # Load environment variables env_vars = load_env_file(args.env_file) - + # Get database connection details - db_host = env_vars.get('DB_HOST') or os.getenv('DB_HOST', 'localhost') - db_port = env_vars.get('DB_PORT') or os.getenv('DB_PORT', '5432') - db_name = env_vars.get('DB_NAME') or os.getenv('DB_NAME', 'elevareai') - db_user = env_vars.get('DB_USER') or os.getenv('DB_USER', 'postgres') - db_password = env_vars.get('DB_PASSWORD') or os.getenv('DB_PASSWORD', '') - + db_host = env_vars.get("DB_HOST") or os.getenv("DB_HOST", "localhost") + db_port = env_vars.get("DB_PORT") or os.getenv("DB_PORT", "5432") + db_name = env_vars.get("DB_NAME") or os.getenv("DB_NAME", "elevareai") + db_user = env_vars.get("DB_USER") or os.getenv("DB_USER", "postgres") + db_password = env_vars.get("DB_PASSWORD") or os.getenv("DB_PASSWORD", "") + print(f"Database: {db_name} @ {db_host}:{db_port}") print() - + # Create database if it doesn't exist if not args.skip_create_db: print("Step 1: Checking database exists...") create_database_if_not_exists(db_host, db_port, db_user, db_password, db_name) print() - + # Create engine connection_string = get_db_connection_string(env_vars) print("Step 2: Connecting to database...") @@ -188,31 +200,31 @@ def main(): print(f" [ERROR] Connection failed: {e}") print("\nPlease check your database credentials in .env file") sys.exit(1) - + print() - + # Run migrations print("Step 3: Running migrations...") migrations_dir = Path(__file__).parent.parent / "migrations" - + if not migrations_dir.exists(): print(f" [WARNING] Migrations directory not found: {migrations_dir}") print(" Creating migrations directory...") migrations_dir.mkdir(parents=True, exist_ok=True) - + migration_files = sorted(migrations_dir.glob("*.sql")) - + if not migration_files: print(" [WARNING] No migration files found") print(" Creating initial migration from schema...") # We'll create the migration file create_initial_migration(migrations_dir) migration_files = sorted(migrations_dir.glob("*.sql")) - + for migration_file in migration_files: print(f" Running: {migration_file.name}") run_migration(engine, migration_file) - + print() print("=" * 60) print("[SUCCESS] Database setup complete!") @@ -223,18 +235,21 @@ def main(): print(" 2. Seed demo data: python scripts/seed_demo_data.py") print() + def create_initial_migration(migrations_dir): """Create initial migration file from schema""" migration_file = migrations_dir / "001_initial_schema.sql" - + # Read the schema from DATABASE_SCHEMA.md and extract SQL - schema_doc = Path(__file__).parent.parent / "_docs" / "active" / "DATABASE_SCHEMA.md" - + schema_doc = ( + Path(__file__).parent.parent / "_docs" / "active" / "DATABASE_SCHEMA.md" + ) + if schema_doc.exists(): print(f" Reading schema from {schema_doc}") # For now, we'll create a basic migration # In production, you'd parse the markdown and extract SQL - + # Write a basic migration that includes core tables migration_content = """-- Initial Schema Migration -- AI Study Companion MVP @@ -254,12 +269,12 @@ def create_initial_migration(migrations_dir): -- Note: Full schema will be loaded from DATABASE_SCHEMA.md -- Run: python scripts/generate_migration_from_schema.py """ - - with open(migration_file, 'w') as f: + + with open(migration_file, "w") as f: f.write(migration_content) - + print(f" ✅ Created: {migration_file.name}") + if __name__ == "__main__": main() - diff --git a/scripts/test_demo_scenarios.py b/scripts/test_demo_scenarios.py index fbe10ee..95aaa9d 100644 --- a/scripts/test_demo_scenarios.py +++ b/scripts/test_demo_scenarios.py @@ -4,16 +4,19 @@ Verifies all demo scenarios work correctly via API calls """ -import sys import os +import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import requests import json + +import requests from sqlalchemy.orm import Session + +from scripts.demo_auth import auth_headers, login from src.config.database import get_db_session from src.models.user import User -from scripts.demo_auth import login, auth_headers BASE_URL = "http://localhost:8000" @@ -51,9 +54,9 @@ def get_mock_token(): def test_health(): """Test health endpoint""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("Testing Health Endpoint") - print("="*60) + print("=" * 60) try: response = requests.get(f"{BASE_URL}/health", timeout=5) if response.status_code == 200: @@ -77,44 +80,44 @@ def test_progress_endpoint(user_id, email, scenario_name): print(f"\n--- Testing {scenario_name} ---") print(f"User: {email}") print(f"User ID: {user_id}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + try: # Test progress endpoint url = f"{BASE_URL}/api/v1/progress/{user_id}?include_suggestions=true" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: print(f"[FAIL] Progress endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Progress endpoint returned success=false") print(f" Response: {json.dumps(data, indent=2)[:500]}") return False - + progress_data = data.get("data", {}) goals = progress_data.get("goals", []) suggestions = progress_data.get("suggestions", []) - + print(f"[OK] Progress endpoint successful") print(f" Goals found: {len(goals)}") print(f" Suggestions found: {len(suggestions)}") - + # Show goal details for goal in goals[:3]: status = goal.get("status", "unknown") completion = goal.get("completion_percentage", 0) title = goal.get("title", "Unknown") print(f" - {title}: {status} ({completion}%)") - + # Show suggestions if suggestions: print(f" Suggestions:") @@ -122,12 +125,13 @@ def test_progress_endpoint(user_id, email, scenario_name): subjects = suggestion.get("subjects", []) if subjects: print(f" - {', '.join(subjects)}") - + return True - + except Exception as e: print(f"[FAIL] Error testing progress: {e}") import traceback + traceback.print_exc() return False @@ -136,33 +140,33 @@ def test_nudges_endpoint(user_id, email, scenario_name): """Test nudges endpoint for a user""" print(f"\n--- Testing Nudges for {scenario_name} ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + try: url = f"{BASE_URL}/api/v1/nudges/users/{user_id}" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: print(f"[FAIL] Nudges endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Nudges endpoint returned success=false") print(f" Response: {json.dumps(data, indent=2)[:500]}") return False - + nudges = data.get("data", {}).get("nudges", []) - + print(f"[OK] Nudges endpoint successful") print(f" Active nudges: {len(nudges)}") - + for nudge in nudges[:3]: nudge_type = nudge.get("nudge_type", "unknown") message = nudge.get("message", "")[:60] @@ -171,12 +175,13 @@ def test_nudges_endpoint(user_id, email, scenario_name): print(f" Message: {message}...") if suggestions: print(f" Suggestions: {', '.join(suggestions[:3])}") - + return True - + except Exception as e: print(f"[FAIL] Error testing nudges: {e}") import traceback + traceback.print_exc() return False @@ -185,22 +190,22 @@ def test_goals_endpoint(user_id, email): """Test goals endpoint""" print(f"\n--- Testing Goals Endpoint ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + try: url = f"{BASE_URL}/api/v1/goals?student_id={user_id}" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: print(f"[FAIL] Goals endpoint failed: {response.status_code}") return False - + data = response.json() - + # Handle response format: {"success": True, "data": [...]} if isinstance(data, dict) and "data" in data: goals = data.get("data", []) @@ -210,12 +215,12 @@ def test_goals_endpoint(user_id, email): goals = data else: goals = [] - + print(f"[OK] Goals endpoint successful") print(f" Goals found: {len(goals)}") - + return True - + except Exception as e: print(f"[FAIL] Error testing goals: {e}") return False @@ -225,43 +230,41 @@ def test_qa_endpoint(user_id, email): """Test Q&A endpoint""" print(f"\n--- Testing Q&A Endpoint ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + try: # Test Q&A query url = f"{BASE_URL}/api/v1/qa/query" - payload = { - "student_id": user_id, - "query": "What is photosynthesis?" - } + payload = {"student_id": user_id, "query": "What is photosynthesis?"} response = requests.post(url, headers=headers, json=payload, timeout=30) - + if response.status_code != 200: print(f"[FAIL] Q&A endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Q&A endpoint returned success=false") return False - + response_text = data.get("data", {}).get("response", "") - + print(f"[OK] Q&A endpoint successful") print(f" Response length: {len(response_text)} characters") print(f" Response preview: {response_text[:100]}...") - + return True - + except Exception as e: print(f"[FAIL] Error testing Q&A: {e}") import traceback + traceback.print_exc() return False @@ -270,61 +273,58 @@ def test_practice_endpoint(user_id, email): """Test practice assignment endpoint""" print(f"\n--- Testing Practice Endpoint ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + try: # Test practice assignment url = f"{BASE_URL}/api/v1/practice/assign" - params = { - "student_id": user_id, - "subject": "Math", - "num_items": 3 - } + params = {"student_id": user_id, "subject": "Math", "num_items": 3} response = requests.post(url, headers=headers, params=params, timeout=30) - + if response.status_code != 200: print(f"[FAIL] Practice endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Practice endpoint returned success=false") return False - + assignment = data.get("data", {}) items = assignment.get("items", []) - + print(f"[OK] Practice endpoint successful") print(f" Items assigned: {len(items)}") - + if items: first_item = items[0] has_choices = "choices" in first_item has_correct_answer = "correct_answer" in first_item print(f" First item has choices: {has_choices}") print(f" First item has correct_answer: {has_correct_answer}") - + return True - + except Exception as e: print(f"[FAIL] Error testing practice: {e}") import traceback + traceback.print_exc() return False def main(): """Run all demo scenario tests""" - print("="*60) + print("=" * 60) print("Testing All Demo Scenarios") - print("="*60) - + print("=" * 60) + # Test health first if not test_health(): print("\n❌ Server is not running. Please start it first.") @@ -340,24 +340,21 @@ def main(): scenarios = { "demo@elevare.ai": "Demo Student Account", } - + for email, scenario_name in scenarios.items(): user_id = DEMO_USERS.get(email) if not user_id: print(f"\n[FAIL] User ID not found for {email}") continue - + # Test progress progress_ok = test_progress_endpoint(user_id, email, scenario_name) - + # Test nudges (especially for low_sessions) nudges_ok = test_nudges_endpoint(user_id, email, scenario_name) - - results[email] = { - "progress": progress_ok, - "nudges": nudges_ok - } - + + results[email] = {"progress": progress_ok, "nudges": nudges_ok} + # Test Q&A with the demo student account qa_user_id = DEMO_USERS["demo@elevare.ai"] qa_email = "demo@elevare.ai" @@ -366,15 +363,15 @@ def main(): # Test common endpoints with the same user test_user_id = DEMO_USERS["demo@elevare.ai"] test_email = "demo@elevare.ai" - + goals_ok = test_goals_endpoint(test_user_id, test_email) practice_ok = test_practice_endpoint(test_user_id, test_email) - + # Summary - print("\n" + "="*60) + print("\n" + "=" * 60) print("Test Summary") - print("="*60) - + print("=" * 60) + all_passed = True for email, result in results.items(): status = "[OK]" if result["progress"] and result["nudges"] else "[FAIL]" @@ -385,21 +382,21 @@ def main(): if not result["nudges"]: print(f" - Nudges endpoint failed") all_passed = False - + print(f"\nCommon Endpoints:") print(f"{'[OK]' if goals_ok else '[FAIL]'} Goals endpoint") print(f"{'[OK]' if qa_ok else '[FAIL]'} Q&A endpoint") print(f"{'[OK]' if practice_ok else '[FAIL]'} Practice endpoint") - + if not goals_ok or not qa_ok or not practice_ok: all_passed = False - - print("\n" + "="*60) + + print("\n" + "=" * 60) if all_passed: print("[SUCCESS] All tests passed!") else: print("[FAILURE] Some tests failed. Check output above.") - print("="*60) + print("=" * 60) if __name__ == "__main__": diff --git a/scripts/verify_all_demo_accounts.py b/scripts/verify_all_demo_accounts.py index 8492a1f..f872c2d 100644 --- a/scripts/verify_all_demo_accounts.py +++ b/scripts/verify_all_demo_accounts.py @@ -4,16 +4,19 @@ Tests each demo account according to DEMO_USER_GUIDE.md specifications """ -import sys import os +import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import requests import json + +import requests from sqlalchemy.orm import Session + +from scripts.demo_auth import auth_headers, login from src.config.database import get_db_session from src.models.user import User -from scripts.demo_auth import login, auth_headers BASE_URL = "http://localhost:8000" @@ -106,23 +109,24 @@ def test_progress_api(email: str, user_id: str, token: str) -> dict: if response.status_code != 200: results["passed"] = False - results["issues"].append(f"API returned status {response.status_code}: {response.text[:200]}") + results["issues"].append( + f"API returned status {response.status_code}: {response.text[:200]}" + ) return results data = response.json() if not data.get("success"): results["passed"] = False - results["issues"].append(f"API returned success=false: {data.get('error', 'Unknown error')}") + results["issues"].append( + f"API returned success=false: {data.get('error', 'Unknown error')}" + ) return results progress_data = data.get("data", {}) goals = progress_data.get("goals", []) suggestions = progress_data.get("suggestions", []) - results["data"] = { - "goals": goals, - "suggestions": suggestions - } + results["data"] = {"goals": goals, "suggestions": suggestions} except requests.exceptions.ConnectionError: results["passed"] = False @@ -143,12 +147,14 @@ def test_nudges_api(email: str, user_id: str, token: str) -> dict: try: url = f"{BASE_URL}/api/v1/nudges/users/{user_id}" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: results["passed"] = False - results["issues"].append(f"Nudges API returned status {response.status_code}") + results["issues"].append( + f"Nudges API returned status {response.status_code}" + ) return results - + data = response.json() nudges = data.get("data", {}).get("nudges", []) @@ -173,7 +179,9 @@ def test_qa_api(email: str, user_id: str, token: str) -> dict: response = requests.get(url, headers=headers, timeout=10) if response.status_code != 200: - results["issues"].append(f"Q&A history API returned status {response.status_code} (may be OK if no history)") + results["issues"].append( + f"Q&A history API returned status {response.status_code} (may be OK if no history)" + ) return results data = response.json() @@ -195,7 +203,7 @@ def main(): print("Testing all accounts according to DEMO_USER_GUIDE.md") print("=" * 80) print() - + # Check backend print("Checking backend status...") if not test_backend(): @@ -205,17 +213,17 @@ def main(): return print("[OK] Backend is running") print() - + all_passed = True results_summary = [] - + # Test each demo account for email, config in DEMO_ACCOUNTS.items(): print("=" * 80) print(f"Testing: {email}") print(f"Scenario: {config['scenario']}") print("=" * 80) - + # Get user ID user_id = get_user_id_from_db(email) if not user_id: @@ -223,9 +231,11 @@ def main(): print(" Run: python scripts/create_demo_users.py") print() all_passed = False - results_summary.append({"email": email, "status": "FAIL", "reason": "User not found"}) + results_summary.append( + {"email": email, "status": "FAIL", "reason": "User not found"} + ) continue - + print(f"[OK] User found: {user_id}") # Verify database data @@ -270,7 +280,9 @@ def main(): if progress_results["data"].get("goals"): print(f" Goals: {len(progress_results['data']['goals'])}") if progress_results["data"].get("suggestions"): - print(f" Suggestions: {len(progress_results['data']['suggestions'])}") + print( + f" Suggestions: {len(progress_results['data']['suggestions'])}" + ) else: print(" [FAIL] Progress API issues:") for issue in progress_results["issues"]: @@ -296,22 +308,26 @@ def main(): if qa_results["passed"]: print(" [OK] Q&A API working") if qa_results["data"].get("history"): - print(f" Conversation history: {len(qa_results['data']['history'])} items") + print( + f" Conversation history: {len(qa_results['data']['history'])} items" + ) else: print(" [FAIL] Q&A API issues:") for issue in qa_results["issues"]: print(f" - {issue}") else: - print(f"\n3-5. Skipping progress/nudges/Q&A checks (role={role}, not a student)") + print( + f"\n3-5. Skipping progress/nudges/Q&A checks (role={role}, not a student)" + ) # Summary for this account account_passed = ( - db_results["passed"] and - login_results["passed"] and - progress_results["passed"] and - nudges_results["passed"] + db_results["passed"] + and login_results["passed"] + and progress_results["passed"] + and nudges_results["passed"] ) - + if account_passed: print(f"\n[PASS] {email} - All tests passed") results_summary.append({"email": email, "status": "PASS"}) @@ -319,15 +335,15 @@ def main(): print(f"\n[FAIL] {email} - Some tests failed") results_summary.append({"email": email, "status": "FAIL"}) all_passed = False - + print() - + # Final summary print("=" * 80) print("VERIFICATION SUMMARY") print("=" * 80) print() - + for result in results_summary: status = result["status"] email = result["email"] @@ -335,7 +351,7 @@ def main(): print(f"[PASS] {email}") else: print(f"[FAIL] {email}: {result.get('reason', 'See details above')}") - + print() print("=" * 80) if all_passed: @@ -348,7 +364,9 @@ def main(): print() print("Next steps:") print(" 1. If accounts are missing, run: python scripts/create_demo_users.py") - print(" 2. If backend is not running, start it: python -m uvicorn src.api.main:app --reload") + print( + " 2. If backend is not running, start it: python -m uvicorn src.api.main:app --reload" + ) print(" 3. Test frontend login with each account") print(" 4. See DEMO_USER_GUIDE.md for demo instructions") print() @@ -356,4 +374,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/scripts/verify_complete_system.py b/scripts/verify_complete_system.py index 153bf5f..596438f 100644 --- a/scripts/verify_complete_system.py +++ b/scripts/verify_complete_system.py @@ -4,51 +4,59 @@ Tests all components of the AI Study Companion platform """ -import sys -import os import json +import os +import sys from pathlib import Path from typing import Dict, List, Tuple try: import requests + HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False + # Colors for terminal output class Colors: - GREEN = '\033[92m' - RED = '\033[91m' - YELLOW = '\033[93m' - BLUE = '\033[94m' - RESET = '\033[0m' - BOLD = '\033[1m' + GREEN = "\033[92m" + RED = "\033[91m" + YELLOW = "\033[93m" + BLUE = "\033[94m" + RESET = "\033[0m" + BOLD = "\033[1m" + def print_header(text: str): print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.RESET}") print(f"{Colors.BOLD}{Colors.BLUE}{text}{Colors.RESET}") print(f"{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.RESET}\n") + def print_success(text: str): print(f"{Colors.GREEN}[OK] {text}{Colors.RESET}") + def print_error(text: str): print(f"{Colors.RED}[ERROR] {text}{Colors.RESET}") + def print_warning(text: str): print(f"{Colors.YELLOW}[WARN] {text}{Colors.RESET}") + def print_info(text: str): print(f"{Colors.BLUE}[INFO] {text}{Colors.RESET}") + def check_backend_health(base_url: str = "http://localhost:8000") -> bool: """Check if backend is running""" if not HAS_REQUESTS: print_warning("requests module not installed - skipping backend health check") print_info("Install with: pip install requests") return False - + try: response = requests.get(f"{base_url}/health", timeout=5) if response.status_code == 200: @@ -62,11 +70,12 @@ def check_backend_health(base_url: str = "http://localhost:8000") -> bool: print_info("Start backend with: python -m uvicorn src.api.main:app --reload") return False + def check_api_docs(base_url: str = "http://localhost:8000") -> bool: """Check if API docs are accessible""" if not HAS_REQUESTS: return False - + try: response = requests.get(f"{base_url}/docs", timeout=5) if response.status_code == 200: @@ -79,6 +88,7 @@ def check_api_docs(base_url: str = "http://localhost:8000") -> bool: print_warning(f"API docs not accessible: {e}") return False + def check_frontend_files() -> Tuple[bool, List[str]]: """Check if frontend files exist""" frontend_dir = Path("examples/frontend-starter") @@ -97,13 +107,13 @@ def check_frontend_files() -> Tuple[bool, List[str]]: "src/services/apiClient.js", "src/contexts/AuthContext.jsx", ] - + missing = [] for file in required_files: file_path = frontend_dir / file if not file_path.exists(): missing.append(str(file)) - + if missing: print_error(f"Missing frontend files: {len(missing)}") for file in missing: @@ -113,6 +123,7 @@ def check_frontend_files() -> Tuple[bool, List[str]]: print_success(f"All {len(required_files)} required frontend files exist") return True, [] + def check_backend_files() -> Tuple[bool, List[str]]: """Check if backend files exist""" required_files = [ @@ -132,13 +143,13 @@ def check_backend_files() -> Tuple[bool, List[str]]: "requirements.txt", "run_server.py", ] - + missing = [] for file in required_files: file_path = Path(file) if not file_path.exists(): missing.append(str(file)) - + if missing: print_error(f"Missing backend files: {len(missing)}") for file in missing: @@ -148,6 +159,7 @@ def check_backend_files() -> Tuple[bool, List[str]]: print_success(f"All {len(required_files)} required backend files exist") return True, [] + def check_documentation() -> Tuple[bool, List[str]]: """Check if documentation exists""" required_docs = [ @@ -160,13 +172,13 @@ def check_documentation() -> Tuple[bool, List[str]]: "_docs/active/POST_MVP_PRD.md", "examples/frontend-starter/README.md", ] - + missing = [] for doc in required_docs: doc_path = Path(doc) if not doc_path.exists(): missing.append(str(doc)) - + if missing: print_warning(f"Missing documentation: {len(missing)}") for doc in missing: @@ -176,12 +188,14 @@ def check_documentation() -> Tuple[bool, List[str]]: print_success(f"All {len(required_docs)} required documentation files exist") return True, [] + def check_tests() -> bool: """Check if tests can run""" try: import pytest + print_success("pytest is installed") - + # Check if test files exist test_dir = Path("tests") if test_dir.exists(): @@ -199,11 +213,12 @@ def check_tests() -> bool: print_warning("pytest is not installed") return False + def check_docker() -> bool: """Check if Docker files exist""" docker_files = ["Dockerfile", "docker-compose.yml", ".dockerignore"] all_exist = all(Path(f).exists() for f in docker_files) - + if all_exist: print_success("All Docker files exist") return True @@ -212,9 +227,10 @@ def check_docker() -> bool: print_warning(f"Missing Docker files: {', '.join(missing)}") return False + def main(): print_header("AI Study Companion - Complete System Verification") - + results = { "backend_running": False, "api_docs": False, @@ -224,52 +240,56 @@ def main(): "tests": False, "docker": False, } - + # Check backend print_header("Backend Status") results["backend_running"] = check_backend_health() results["api_docs"] = check_api_docs() - + # Check files print_header("File Structure") backend_ok, _ = check_backend_files() results["backend_files"] = backend_ok - + frontend_ok, _ = check_frontend_files() results["frontend_files"] = frontend_ok - + docs_ok, _ = check_documentation() results["documentation"] = docs_ok - + # Check tests print_header("Testing") results["tests"] = check_tests() - + # Check Docker print_header("Docker") results["docker"] = check_docker() - + # Summary print_header("Verification Summary") - + total = len(results) passed = sum(1 for v in results.values() if v) - + print(f"\n{Colors.BOLD}Results: {passed}/{total} checks passed{Colors.RESET}\n") - + for check, status in results.items(): if status: print_success(f"{check.replace('_', ' ').title()}") else: print_error(f"{check.replace('_', ' ').title()}") - + if passed == total: - print(f"\n{Colors.GREEN}{Colors.BOLD}[SUCCESS] All checks passed! System is ready.{Colors.RESET}\n") + print( + f"\n{Colors.GREEN}{Colors.BOLD}[SUCCESS] All checks passed! System is ready.{Colors.RESET}\n" + ) return 0 else: - print(f"\n{Colors.YELLOW}{Colors.BOLD}[WARN] Some checks failed. Review the output above.{Colors.RESET}\n") + print( + f"\n{Colors.YELLOW}{Colors.BOLD}[WARN] Some checks failed. Review the output above.{Colors.RESET}\n" + ) return 1 + if __name__ == "__main__": sys.exit(main()) - diff --git a/scripts/verify_demo_data.py b/scripts/verify_demo_data.py index fc28c41..e3fdda0 100644 --- a/scripts/verify_demo_data.py +++ b/scripts/verify_demo_data.py @@ -9,50 +9,56 @@ # Add current directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) + def verify_script(): """Verify the demo data script is ready""" print("Verifying demo data script...") - + try: from scripts.seed_demo_data import ( - SUBJECTS, STUDENT_NAMES, TUTOR_NAMES, TRANSCRIPTS, PRACTICE_QUESTIONS + PRACTICE_QUESTIONS, + STUDENT_NAMES, + SUBJECTS, + TRANSCRIPTS, + TUTOR_NAMES, ) - + print(f"[OK] Subjects: {len(SUBJECTS)}") print(f"[OK] Student names: {len(STUDENT_NAMES)}") print(f"[OK] Tutor names: {len(TUTOR_NAMES)}") print(f"[OK] Transcript templates: {len(TRANSCRIPTS)}") print(f"[OK] Practice questions: {len(PRACTICE_QUESTIONS)}") - + # Check for required functions import scripts.seed_demo_data as seed_script - + required_functions = [ - 'generate_all_demo_data', - 'save_to_json', - 'generate_sql_inserts' + "generate_all_demo_data", + "save_to_json", + "generate_sql_inserts", ] - + missing = [] for func in required_functions: if not hasattr(seed_script, func): missing.append(func) - + if missing: print(f"[WARNING] Missing functions: {', '.join(missing)}") else: print("[OK] All required functions present") - + print("\n[OK] Demo data script is ready!") return True - + except Exception as e: print(f"[ERROR] Error verifying script: {e}") import traceback + traceback.print_exc() return False + if __name__ == "__main__": success = verify_script() sys.exit(0 if success else 1) - diff --git a/scripts/verify_demo_users.py b/scripts/verify_demo_users.py index e286a06..9ade8b0 100644 --- a/scripts/verify_demo_users.py +++ b/scripts/verify_demo_users.py @@ -4,20 +4,25 @@ Verifies that all demo accounts are set up correctly with expected data """ -import sys import os +import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from datetime import datetime, timedelta, timezone +from typing import Dict, Optional, Tuple + from sqlalchemy.orm import Session + from src.config.database import get_db_session -from src.models.user import User from src.models.goal import Goal from src.models.session import Session as SessionModel from src.models.subject import Subject -from datetime import datetime, timedelta, timezone -from typing import Tuple, Optional, Dict +from src.models.user import User + try: import requests + HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False @@ -29,30 +34,34 @@ def verify_user_exists(db: Session, email: str) -> Tuple[bool, Optional[User]]: return (user is not None, user) -def verify_goals(db: Session, user_id, expected_completed: int = 0, expected_active: int = 0) -> bool: +def verify_goals( + db: Session, user_id, expected_completed: int = 0, expected_active: int = 0 +) -> bool: """Verify user has expected goals""" - completed = db.query(Goal).filter( - Goal.student_id == user_id, - Goal.status == "completed" - ).count() - - active = db.query(Goal).filter( - Goal.student_id == user_id, - Goal.status == "active" - ).count() - + completed = ( + db.query(Goal) + .filter(Goal.student_id == user_id, Goal.status == "completed") + .count() + ) + + active = ( + db.query(Goal) + .filter(Goal.student_id == user_id, Goal.status == "active") + .count() + ) + return completed >= expected_completed and active >= expected_active def verify_sessions(db: Session, user_id, min_count: int) -> bool: """Verify user has minimum session count""" - count = db.query(SessionModel).filter( - SessionModel.student_id == user_id - ).count() + count = db.query(SessionModel).filter(SessionModel.student_id == user_id).count() return count >= min_count -def verify_user_age(db: Session, user: User, expected_days: int, tolerance: int = 1) -> bool: +def verify_user_age( + db: Session, user: User, expected_days: int, tolerance: int = 1 +) -> bool: """Verify user was created expected_days ago (with tolerance)""" # Handle both timezone-aware and naive datetimes now = datetime.now(timezone.utc) @@ -63,14 +72,22 @@ def verify_user_age(db: Session, user: User, expected_days: int, tolerance: int else: # If timezone-aware, convert to UTC created_at = created_at.astimezone(timezone.utc) - + days_ago = (now - created_at).days return abs(days_ago - expected_days) <= tolerance def verify_subjects_exist(db: Session) -> bool: """Verify required subjects exist""" - required = ["College Essays", "Study Skills", "AP Prep", "Physics", "Biology", "AP Chemistry", "STEM Prep"] + required = [ + "College Essays", + "Study Skills", + "AP Prep", + "Physics", + "Biology", + "AP Chemistry", + "STEM Prep", + ] for name in required: subject = db.query(Subject).filter(Subject.name == name).first() if not subject: @@ -83,17 +100,17 @@ def test_progress_endpoint(base_url: str, user_id: str) -> Dict: """Test progress endpoint returns suggestions""" if not HAS_REQUESTS: return {"success": False, "error": "requests module not installed"} - + try: # Note: This would normally require auth, but for demo we'll just check if endpoint exists # In real scenario, you'd need to authenticate first - response = requests.get( - f"{base_url}/api/v1/progress/{user_id}", - timeout=5 - ) + response = requests.get(f"{base_url}/api/v1/progress/{user_id}", timeout=5) if response.status_code == 200: data = response.json() - return {"success": True, "has_suggestions": "suggestions" in data.get("data", {})} + return { + "success": True, + "has_suggestions": "suggestions" in data.get("data", {}), + } else: return {"success": False, "status": response.status_code} except Exception as e: @@ -106,43 +123,43 @@ def main(): print("Verifying Demo User Accounts") print("=" * 60) print() - + demo_accounts = { "demo_goal_complete@demo.com": { "expected_completed_goals": 1, "expected_active_goals": 2, "min_sessions": 5, - "expected_age_days": 30 + "expected_age_days": 30, }, "demo_sat_complete@demo.com": { "expected_completed_goals": 1, "expected_active_goals": 0, "min_sessions": 5, - "expected_age_days": 30 + "expected_age_days": 30, }, "demo_chemistry@demo.com": { "expected_completed_goals": 1, "expected_active_goals": 0, "min_sessions": 5, - "expected_age_days": 30 + "expected_age_days": 30, }, "demo_low_sessions@demo.com": { "expected_completed_goals": 0, "expected_active_goals": 1, "min_sessions": 2, "max_sessions": 2, # Must be exactly 2 - "expected_age_days": 7 + "expected_age_days": 7, }, "demo_multi_goal@demo.com": { "expected_completed_goals": 0, "expected_active_goals": 3, "min_sessions": 5, - "expected_age_days": 30 - } + "expected_age_days": 30, + }, } - + all_passed = True - + with get_db_session() as db: # Verify subjects exist print("Verifying subjects...") @@ -151,60 +168,76 @@ def main(): else: print("[ERROR] Some required subjects are missing") all_passed = False - + print() - + # Verify each demo account for email, expectations in demo_accounts.items(): print(f"Verifying {email}...") exists, user = verify_user_exists(db, email) - + if not exists: print(f" [ERROR] User does not exist") all_passed = False continue - + # Verify user age if not verify_user_age(db, user, expectations["expected_age_days"]): - print(f" [ERROR] User age incorrect (expected ~{expectations['expected_age_days']} days ago)") + print( + f" [ERROR] User age incorrect (expected ~{expectations['expected_age_days']} days ago)" + ) all_passed = False else: print(f" [OK] User age correct") - + # Verify goals if not verify_goals( - db, - user.id, + db, + user.id, expectations["expected_completed_goals"], - expectations["expected_active_goals"] + expectations["expected_active_goals"], ): - print(f" [ERROR] Goals incorrect (expected {expectations['expected_completed_goals']} completed, {expectations['expected_active_goals']} active)") + print( + f" [ERROR] Goals incorrect (expected {expectations['expected_completed_goals']} completed, {expectations['expected_active_goals']} active)" + ) all_passed = False else: print(f" [OK] Goals correct") - + # Verify sessions min_sessions = expectations["min_sessions"] max_sessions = expectations.get("max_sessions") - + if max_sessions: # Must be exactly this number - session_count = db.query(SessionModel).filter(SessionModel.student_id == user.id).count() + session_count = ( + db.query(SessionModel) + .filter(SessionModel.student_id == user.id) + .count() + ) if session_count != max_sessions: - print(f" [ERROR] Session count incorrect (expected exactly {max_sessions}, got {session_count})") + print( + f" [ERROR] Session count incorrect (expected exactly {max_sessions}, got {session_count})" + ) all_passed = False else: print(f" [OK] Session count correct ({session_count})") else: if not verify_sessions(db, user.id, min_sessions): - print(f" [ERROR] Session count too low (expected at least {min_sessions})") + print( + f" [ERROR] Session count too low (expected at least {min_sessions})" + ) all_passed = False else: - session_count = db.query(SessionModel).filter(SessionModel.student_id == user.id).count() + session_count = ( + db.query(SessionModel) + .filter(SessionModel.student_id == user.id) + .count() + ) print(f" [OK] Session count correct ({session_count})") - + print() - + # Summary print("=" * 60) if all_passed: @@ -221,4 +254,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/tests/test_build_demo_media.py b/tests/test_build_demo_media.py index 6ee453e..bcaaa0e 100644 --- a/tests/test_build_demo_media.py +++ b/tests/test_build_demo_media.py @@ -9,6 +9,10 @@ from pathlib import Path import pytest + +pytest.importorskip("PIL", reason="demo tooling dep (Pillow) not installed") +pytest.importorskip("imageio_ffmpeg", reason="demo tooling dep not installed") + from PIL import Image sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) @@ -37,6 +41,18 @@ def test_build_demo_media_produces_mp4_and_gif(tmp_path): assert mp4_path.exists() assert mp4_path.stat().st_size > 0 + # Verify mp4 is decodable by reading metadata + import imageio_ffmpeg + + reader = imageio_ffmpeg.read_frames(str(mp4_path)) + try: + metadata = next(iter(reader)) + assert isinstance(metadata, dict) + assert metadata.get("source_size") is not None + assert metadata.get("fps") is not None + finally: + reader.close() + assert gif_path.exists() assert gif_path.stat().st_size > 0 @@ -55,10 +71,48 @@ def test_build_demo_media_handles_odd_dimensions(tmp_path): assert mp4_path.exists() assert mp4_path.stat().st_size > 0 + + # Verify mp4 is decodable by reading metadata + import imageio_ffmpeg + + reader = imageio_ffmpeg.read_frames(str(mp4_path)) + try: + metadata = next(iter(reader)) + assert isinstance(metadata, dict) + assert metadata.get("source_size") is not None + assert metadata.get("fps") is not None + finally: + reader.close() + assert gif_path.exists() assert gif_path.stat().st_size > 0 +def test_build_demo_media_handles_mismatched_frame_sizes(tmp_path): + """Test that gif normalizes frames to common canvas size before downscaling.""" + frames_dir = tmp_path / "frames" + frames_dir.mkdir() + # Create two frames with different sizes + img1 = Image.new("RGB", (1280, 720), (200, 0, 0)) + img1.save(frames_dir / "00-frame.png") + img2 = Image.new("RGB", (1000, 700), (0, 200, 0)) + img2.save(frames_dir / "01-frame.png") + + out_dir = tmp_path / "out" + mp4_path, gif_path = build_demo_media( + frames_dir, out_dir, seconds_per_frame=0.2, fps=10, gif_width=400 + ) + + # Verify gif builds successfully with normalized frames + with Image.open(gif_path) as gif: + assert gif.is_animated + assert gif.n_frames == 2 + # All frames should have the same canvas size (first frame's dims, downscaled) + for frame_idx in range(gif.n_frames): + gif.seek(frame_idx) + assert gif.width == 400 # downscaled to gif_width + + def test_load_frame_paths_missing_dir_raises(tmp_path): with pytest.raises(FileNotFoundError): load_frame_paths(tmp_path / "does-not-exist") From cf5d386ebe6e1dcb3a69bb21bd648bab9a82883b Mon Sep 17 00:00:00 2001 From: Francisco de Guzman <17106076+franciszver@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:26:11 -0700 Subject: [PATCH 4/4] revert: restore 14 scripts reformatted out-of-scope by the #48 format gate The format gate ran black/isort on the whole scripts/ dir, reformatting pre-existing files unrelated to #48 (CI only lints src+tests, so scripts/ was never black-clean). Restores them to main; #48 keeps only its own files. Assisted-by: Claude Code (fable orchestrator) Co-Authored-By: Claude Fable 5 --- scripts/collect_feedback.py | 104 +++++------- scripts/create_staging_env.py | 45 +++-- scripts/demo_auth.py | 8 +- scripts/deployment/update-task-def-image.py | 24 +-- scripts/get_demo_uuids.py | 7 +- scripts/run_migrations_aws.py | 77 ++++----- scripts/seed_demo_data.py | 8 +- scripts/setup_beta_testing.py | 84 +++++----- scripts/setup_db.py | 153 ++++++++--------- scripts/test_demo_scenarios.py | 175 ++++++++++---------- scripts/verify_all_demo_accounts.py | 83 ++++------ scripts/verify_complete_system.py | 86 ++++------ scripts/verify_demo_data.py | 32 ++-- scripts/verify_demo_users.py | 142 ++++++---------- 14 files changed, 462 insertions(+), 566 deletions(-) diff --git a/scripts/collect_feedback.py b/scripts/collect_feedback.py index 14e437b..af77d80 100644 --- a/scripts/collect_feedback.py +++ b/scripts/collect_feedback.py @@ -4,120 +4,101 @@ Collects and analyzes user feedback from beta testing """ -import os import sys - +import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import json -from datetime import datetime, timedelta - from sqlalchemy.orm import Session - from src.config.database import get_db +from src.models.user import User from src.models.practice import PracticeAssignment from src.models.qa import QAInteraction from src.models.session import Session as SessionModel -from src.models.user import User +from datetime import datetime, timedelta +import json def analyze_user_engagement(db: Session, days: int = 7): """Analyze user engagement metrics""" cutoff_date = datetime.utcnow() - timedelta(days=days) - + # Get active users - active_students = db.query(User).filter(User.role == "student").all() - + active_students = db.query(User).filter( + User.role == "student" + ).all() + engagement_data = { "total_students": len(active_students), "active_students": 0, "sessions_completed": 0, "practice_completed": 0, "qa_queries": 0, - "engagement_rate": 0.0, + "engagement_rate": 0.0 } - + for student in active_students: # Check recent activity - recent_sessions = ( - db.query(SessionModel) - .filter( - SessionModel.student_id == student.id, - SessionModel.session_date >= cutoff_date, - ) - .count() - ) - - recent_practice = ( - db.query(PracticeAssignment) - .filter( - PracticeAssignment.student_id == student.id, - PracticeAssignment.completed_at >= cutoff_date, - ) - .count() - ) - - recent_qa = ( - db.query(QAInteraction) - .filter( - QAInteraction.student_id == student.id, - QAInteraction.created_at >= cutoff_date, - ) - .count() - ) - + recent_sessions = db.query(SessionModel).filter( + SessionModel.student_id == student.id, + SessionModel.session_date >= cutoff_date + ).count() + + recent_practice = db.query(PracticeAssignment).filter( + PracticeAssignment.student_id == student.id, + PracticeAssignment.completed_at >= cutoff_date + ).count() + + recent_qa = db.query(QAInteraction).filter( + QAInteraction.student_id == student.id, + QAInteraction.created_at >= cutoff_date + ).count() + if recent_sessions > 0 or recent_practice > 0 or recent_qa > 0: engagement_data["active_students"] += 1 - + engagement_data["sessions_completed"] += recent_sessions engagement_data["practice_completed"] += recent_practice engagement_data["qa_queries"] += recent_qa - + if engagement_data["total_students"] > 0: engagement_data["engagement_rate"] = ( engagement_data["active_students"] / engagement_data["total_students"] ) * 100 - + return engagement_data def generate_feedback_report(db: Session): """Generate comprehensive feedback report""" print("[REPORT] Generating Feedback Report...\n") - + # Engagement metrics engagement_7d = analyze_user_engagement(db, days=7) engagement_30d = analyze_user_engagement(db, days=30) - + report = { "engagement_metrics": { "last_7_days": engagement_7d, - "last_30_days": engagement_30d, + "last_30_days": engagement_30d }, "feature_usage": { "sessions": engagement_7d["sessions_completed"], "practice": engagement_7d["practice_completed"], - "qa": engagement_7d["qa_queries"], + "qa": engagement_7d["qa_queries"] }, - "recommendations": [], + "recommendations": [] } - + # Generate recommendations if engagement_7d["engagement_rate"] < 50: - report["recommendations"].append( - "Low engagement - consider improving onboarding or feature discoverability" - ) - + report["recommendations"].append("Low engagement - consider improving onboarding or feature discoverability") + if engagement_7d["practice_completed"] == 0: - report["recommendations"].append( - "No practice completion - check practice assignment flow" - ) - + report["recommendations"].append("No practice completion - check practice assignment flow") + if engagement_7d["qa_queries"] == 0: - report["recommendations"].append( - "No Q&A usage - check Q&A interface accessibility" - ) - + report["recommendations"].append("No Q&A usage - check Q&A interface accessibility") + print(json.dumps(report, indent=2)) return report @@ -125,7 +106,7 @@ def generate_feedback_report(db: Session): def main(): """Main function""" db = next(get_db()) - + try: generate_feedback_report(db) except Exception as e: @@ -137,3 +118,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/scripts/create_staging_env.py b/scripts/create_staging_env.py index 2665a6d..46254f4 100644 --- a/scripts/create_staging_env.py +++ b/scripts/create_staging_env.py @@ -4,8 +4,8 @@ Creates a staging environment configuration for testing before production """ -import json import os +import json from pathlib import Path @@ -15,34 +15,41 @@ def create_staging_env_file(): "ENVIRONMENT": "staging", "LOG_LEVEL": "INFO", "API_VERSION": "v1", + # Database (use staging database) "DATABASE_URL": "postgresql://user:password@staging-db:5432/pennygadget_staging", "DATABASE_POOL_SIZE": "10", + # AWS (staging credentials) "AWS_REGION": "us-east-1", "COGNITO_USER_POOL_ID": "us-east-1_STAGING_POOL_ID", "COGNITO_CLIENT_ID": "staging_client_id", + # OpenAI (staging key) "OPENAI_API_KEY": "sk-staging-key-here", + # Email (staging SES) "AWS_SES_REGION": "us-east-1", "AWS_SES_FROM_EMAIL": "staging@yourdomain.com", + # Frontend "FRONTEND_BASE_URL": "https://staging.yourdomain.com", + # Feature Flags "ENABLE_GAMIFICATION": "true", "ENABLE_ANALYTICS": "true", "ENABLE_INTEGRATIONS": "true", + # Monitoring "ENABLE_METRICS": "true", "ENABLE_LOGGING": "true", } - + env_content = "\n".join([f"{key}={value}" for key, value in staging_env.items()]) - + env_file = Path(".env.staging") env_file.write_text(env_content) - + print(f"[OK] Created {env_file}") print("\n[INFO] Next steps:") print("1. Update DATABASE_URL with your staging database") @@ -61,32 +68,33 @@ def create_docker_compose_staging(): "ports": ["8000:8000"], "environment": { "ENVIRONMENT": "staging", - "DATABASE_URL": "postgresql://pennygadget:password@db:5432/pennygadget_staging", + "DATABASE_URL": "postgresql://pennygadget:password@db:5432/pennygadget_staging" }, "env_file": [".env.staging"], "depends_on": ["db"], - "volumes": ["./src:/app/src"], + "volumes": ["./src:/app/src"] }, "db": { "image": "postgres:15-alpine", "environment": { "POSTGRES_USER": "pennygadget", "POSTGRES_PASSWORD": "password", - "POSTGRES_DB": "pennygadget_staging", + "POSTGRES_DB": "pennygadget_staging" }, "volumes": ["postgres_staging_data:/var/lib/postgresql/data"], - "ports": ["5433:5432"], - }, + "ports": ["5433:5432"] + } }, - "volumes": {"postgres_staging_data": {}}, + "volumes": { + "postgres_staging_data": {} + } } - + compose_file = Path("docker-compose.staging.yml") with open(compose_file, "w") as f: import yaml - yaml.dump(docker_compose, f, default_flow_style=False) - + print(f"[OK] Created {compose_file}") @@ -187,25 +195,25 @@ def create_staging_readme(): See `_docs/guides/AWS_DEPLOYMENT_CHECKLIST.md` for AWS deployment steps. """ - + readme_file = Path("STAGING_SETUP.md") readme_file.write_text(readme_content) - + print(f"[OK] Created {readme_file}") def main(): """Main setup function""" print("[SETUP] Creating staging environment configuration...\n") - + try: create_staging_env_file() create_staging_readme() - + print("\n[OK] Staging environment setup complete!") print("\n[INFO] Note: docker-compose.staging.yml requires PyYAML") print(" Install with: pip install pyyaml") - + except Exception as e: print(f"[ERROR] Error creating staging setup: {str(e)}") raise @@ -213,3 +221,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/scripts/demo_auth.py b/scripts/demo_auth.py index 7ada20f..24a7d5d 100644 --- a/scripts/demo_auth.py +++ b/scripts/demo_auth.py @@ -11,15 +11,15 @@ DEMO_PASSWORD = settings.demo_password -def login( - email: str, password: str = None, base_url: str = "http://localhost:8000" -) -> dict: +def login(email: str, password: str = None, base_url: str = "http://localhost:8000") -> dict: """Log in a demo account and return the login response (access_token, user_id, email, role).""" if password is None: password = settings.demo_password if not password: - raise SystemExit("DEMO_PASSWORD not set — add it to .env (see README)") + raise SystemExit( + "DEMO_PASSWORD not set — add it to .env (see README)" + ) response = requests.post( f"{base_url}/api/v1/auth/login", json={"email": email, "password": password}, diff --git a/scripts/deployment/update-task-def-image.py b/scripts/deployment/update-task-def-image.py index 40b8203..546fd7f 100644 --- a/scripts/deployment/update-task-def-image.py +++ b/scripts/deployment/update-task-def-image.py @@ -11,7 +11,7 @@ new_image = sys.argv[2] output_file = sys.argv[3] -with open(input_file, "r", encoding="utf-8-sig") as f: +with open(input_file, 'r', encoding='utf-8-sig') as f: content = f.read().strip() if not content: print(f"ERROR: {input_file} is empty!") @@ -19,27 +19,21 @@ task_def = json.loads(content) # Update image -task_def["containerDefinitions"][0]["image"] = new_image +task_def['containerDefinitions'][0]['image'] = new_image # Remove fields that can't be in new task definition -for field in [ - "revision", - "status", - "requiresAttributes", - "compatibilities", - "registeredAt", - "registeredBy", - "taskDefinitionArn", -]: +for field in ['revision', 'status', 'requiresAttributes', 'compatibilities', + 'registeredAt', 'registeredBy', 'taskDefinitionArn']: task_def.pop(field, None) # Remove hostPort from portMappings (not allowed in Fargate) -if "portMappings" in task_def["containerDefinitions"][0]: - for pm in task_def["containerDefinitions"][0]["portMappings"]: - pm.pop("hostPort", None) +if 'portMappings' in task_def['containerDefinitions'][0]: + for pm in task_def['containerDefinitions'][0]['portMappings']: + pm.pop('hostPort', None) # Write output -with open(output_file, "w") as f: +with open(output_file, 'w') as f: json.dump(task_def, f, indent=2) print(f"Task definition updated: {new_image}") + diff --git a/scripts/get_demo_uuids.py b/scripts/get_demo_uuids.py index aac2d11..02b3396 100644 --- a/scripts/get_demo_uuids.py +++ b/scripts/get_demo_uuids.py @@ -1,20 +1,19 @@ #!/usr/bin/env python3 """Get demo user UUIDs from database""" -import os import sys - +import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from sqlalchemy.orm import Session - from src.config.database import get_db_session from src.models.user import User with get_db_session() as db: - users = db.query(User).filter(User.email.like("demo_%@demo.com")).all() + users = db.query(User).filter(User.email.like('demo_%@demo.com')).all() print("Demo User UUIDs:") print("{") for u in sorted(users, key=lambda x: x.email): print(f" '{u.email}': '{str(u.id)}',") print("}") + diff --git a/scripts/run_migrations_aws.py b/scripts/run_migrations_aws.py index 793d507..d17345b 100644 --- a/scripts/run_migrations_aws.py +++ b/scripts/run_migrations_aws.py @@ -10,26 +10,26 @@ - DB_PASSWORD: Database password """ -import argparse -import os import sys +import os +import argparse from pathlib import Path # Add project root to path sys.path.insert(0, str(Path(__file__).parent.parent)) import psycopg2 -from psycopg2 import sql from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from psycopg2 import sql def run_migration_file(conn, migration_file): """Run a single migration file""" print(f" Running migration: {migration_file.name}") - - with open(migration_file, "r", encoding="utf-8") as f: + + with open(migration_file, 'r', encoding='utf-8') as f: sql_content = f.read() - + try: cursor = conn.cursor() # Execute the entire SQL file @@ -48,7 +48,7 @@ def run_migration_file(conn, migration_file): return True except Exception as e: error_msg = str(e).lower() - if "already exists" in error_msg or "duplicate" in error_msg: + if 'already exists' in error_msg or 'duplicate' in error_msg: print(f" [SKIP] Some objects already exist in: {migration_file.name}") conn.rollback() return True @@ -59,49 +59,43 @@ def run_migration_file(conn, migration_file): def main(): - parser = argparse.ArgumentParser(description="Run database migrations on AWS RDS") - parser.add_argument("--host", help="Database host (or use DB_HOST env var)") - parser.add_argument( - "--port", type=int, default=5432, help="Database port (default: 5432)" - ) - parser.add_argument("--database", help="Database name (or use DB_NAME env var)") - parser.add_argument("--user", help="Database user (or use DB_USER env var)") - parser.add_argument( - "--password", help="Database password (or use DB_PASSWORD env var)" - ) - parser.add_argument( - "--migrations-dir", default="migrations", help="Path to migrations directory" - ) - + parser = argparse.ArgumentParser(description='Run database migrations on AWS RDS') + parser.add_argument('--host', help='Database host (or use DB_HOST env var)') + parser.add_argument('--port', type=int, default=5432, help='Database port (default: 5432)') + parser.add_argument('--database', help='Database name (or use DB_NAME env var)') + parser.add_argument('--user', help='Database user (or use DB_USER env var)') + parser.add_argument('--password', help='Database password (or use DB_PASSWORD env var)') + parser.add_argument('--migrations-dir', default='migrations', help='Path to migrations directory') + args = parser.parse_args() - + # Get connection parameters from args or environment - db_host = args.host or os.getenv("DB_HOST") - db_port = args.port or int(os.getenv("DB_PORT", "5432")) - db_name = args.database or os.getenv("DB_NAME") - db_user = args.user or os.getenv("DB_USER") - db_password = args.password or os.getenv("DB_PASSWORD") - + db_host = args.host or os.getenv('DB_HOST') + db_port = args.port or int(os.getenv('DB_PORT', '5432')) + db_name = args.database or os.getenv('DB_NAME') + db_user = args.user or os.getenv('DB_USER') + db_password = args.password or os.getenv('DB_PASSWORD') + if not all([db_host, db_name, db_user, db_password]): print("Error: Missing required database connection parameters") print("Provide via arguments or environment variables:") print(" DB_HOST, DB_NAME, DB_USER, DB_PASSWORD") sys.exit(1) - + # Get migrations directory project_root = Path(__file__).parent.parent migrations_dir = project_root / args.migrations_dir - + if not migrations_dir.exists(): print(f"Error: Migrations directory not found: {migrations_dir}") sys.exit(1) - + migration_files = sorted(migrations_dir.glob("*.sql")) - + if not migration_files: print(f"Warning: No migration files found in {migrations_dir}") sys.exit(0) - + print("=" * 60) print("AWS RDS Migration Runner") print("=" * 60) @@ -111,7 +105,7 @@ def main(): print(f"Migrations found: {len(migration_files)}") print("=" * 60) print() - + # Connect to database try: print("Connecting to database...") @@ -121,7 +115,7 @@ def main(): database=db_name, user=db_user, password=db_password, - connect_timeout=10, + connect_timeout=10 ) conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) print("[OK] Connected successfully") @@ -129,20 +123,20 @@ def main(): except Exception as e: print(f"[ERROR] Failed to connect to database: {e}") sys.exit(1) - + # Run migrations success_count = 0 failed_count = 0 - + for migration_file in migration_files: if run_migration_file(conn, migration_file): success_count += 1 else: failed_count += 1 print() - + conn.close() - + # Summary print("=" * 60) print("Migration Summary") @@ -152,7 +146,7 @@ def main(): if failed_count > 0: print(f"Failed: {failed_count}") print("=" * 60) - + if failed_count > 0: sys.exit(1) else: @@ -160,5 +154,6 @@ def main(): sys.exit(0) -if __name__ == "__main__": +if __name__ == '__main__': main() + diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 7fb167b..e844629 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -22,9 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) -from scripts.demo_auth import DEMO_PASSWORD # noqa: E402 from src.config.database import SessionLocal, engine # noqa: E402 -from src.config.settings import settings # noqa: E402 from src.models import ( # noqa: E402 Goal, Nudge, @@ -35,6 +33,8 @@ from src.models import Session as TutoringSession # noqa: E402 from src.models import Subject, Summary, User # noqa: E402 from src.services.auth import hash_password # noqa: E402 +from src.config.settings import settings # noqa: E402 +from scripts.demo_auth import DEMO_PASSWORD # noqa: E402 # Silence verbose SQL echo (enabled globally in development) for this script's output engine.echo = False @@ -313,7 +313,9 @@ def run_migrations() -> None: def seed_headline_accounts(db) -> Dict[str, User]: """Create the 3 known-credential demo accounts used for live demos.""" if not settings.demo_password: - raise SystemExit("DEMO_PASSWORD not set — add it to .env (see README)") + raise SystemExit( + "DEMO_PASSWORD not set — add it to .env (see README)" + ) accounts = {} for account in DEMO_ACCOUNTS: user, created = get_or_create_user( diff --git a/scripts/setup_beta_testing.py b/scripts/setup_beta_testing.py index eced6f2..3d54727 100644 --- a/scripts/setup_beta_testing.py +++ b/scripts/setup_beta_testing.py @@ -4,37 +4,34 @@ Creates test users, data, and configurations for beta testing """ -import os import sys - +import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import json -import uuid -from datetime import datetime, timedelta - from sqlalchemy.orm import Session - from src.config.database import get_db +from src.models.user import User +from src.models.subject import Subject from src.models.goal import Goal from src.models.session import Session as SessionModel -from src.models.subject import Subject -from src.models.user import User +import uuid +from datetime import datetime, timedelta +import json def create_beta_users(db: Session): """Create test users for beta testing""" users = [] - + # Check if beta users already exist - existing_beta = db.query(User).filter(User.cognito_sub.like("beta-%")).all() - + existing_beta = db.query(User).filter( + User.cognito_sub.like("beta-%") + ).all() + if existing_beta: - print( - f"[INFO] Found {len(existing_beta)} existing beta users, skipping creation" - ) + print(f"[INFO] Found {len(existing_beta)} existing beta users, skipping creation") return existing_beta - + # Create students for i in range(20): user = User( @@ -45,13 +42,15 @@ def create_beta_users(db: Session): profile={ "name": f"Student {i+1}", "grade": 9 + (i % 4), # Grades 9-12 - "preferences": {"nudge_frequency_cap": 2}, + "preferences": { + "nudge_frequency_cap": 2 + } }, - disclaimer_shown=True, + disclaimer_shown=True ) db.add(user) users.append(user) - + # Create tutors for i in range(5): user = User( @@ -61,12 +60,12 @@ def create_beta_users(db: Session): role="tutor", profile={ "name": f"Tutor {i+1}", - "subjects": ["Math", "Science", "English"][: i + 1], - }, + "subjects": ["Math", "Science", "English"][:i+1] + } ) db.add(user) users.append(user) - + # Create parents for i in range(10): user = User( @@ -74,11 +73,13 @@ def create_beta_users(db: Session): cognito_sub=f"beta-parent-{i}", email=f"parent{i}@betatest.com", role="parent", - profile={"name": f"Parent {i+1}"}, + profile={ + "name": f"Parent {i+1}" + } ) db.add(user) users.append(user) - + db.commit() print(f"[OK] Created {len(users)} beta test users") return users @@ -88,7 +89,7 @@ def create_beta_data(db: Session, users: list): """Create test data for beta testing""" students = [u for u in users if u.role == "student"] tutors = [u for u in users if u.role == "tutor"] - + # Get or create subjects subjects = db.query(Subject).all() if not subjects: @@ -100,7 +101,7 @@ def create_beta_data(db: Session, users: list): for s in subjects: db.add(s) db.commit() - + # Create goals for students goal_types = ["SAT", "AP", "Standard"] for student in students[:15]: # 15 students with goals @@ -111,10 +112,10 @@ def create_beta_data(db: Session, users: list): title=f"Improve {subjects[0].name}", goal_type=goal_types[student.id.int % len(goal_types)], target_completion_date=(datetime.utcnow() + timedelta(days=30)).date(), - status="active", + status="active" ) db.add(goal) - + # Create sessions for students for student in students[:10]: # 10 students with sessions session = SessionModel( @@ -124,10 +125,10 @@ def create_beta_data(db: Session, users: list): session_date=datetime.utcnow() - timedelta(days=7 - (student.id.int % 7)), duration_minutes=60, transcript_text="This is a test transcript for beta testing.", - topics_covered=["Algebra", "Geometry"], + topics_covered=["Algebra", "Geometry"] ) db.add(session) - + db.commit() print("[OK] Created beta test data (goals, sessions)") @@ -139,47 +140,47 @@ def generate_beta_report(db: Session): parent_count = db.query(User).filter(User.role == "parent").count() goal_count = db.query(Goal).count() session_count = db.query(SessionModel).count() - + report = { "beta_setup": { "students": student_count, "tutors": tutor_count, "parents": parent_count, "goals": goal_count, - "sessions": session_count, + "sessions": session_count }, - "ready_for_testing": True, + "ready_for_testing": True } - + print("\n[REPORT] Beta Testing Setup Report:") print(json.dumps(report, indent=2)) - + return report def main(): """Main setup function""" print("[SETUP] Setting up Beta Testing Environment...\n") - + db = next(get_db()) - + try: # Create users users = create_beta_users(db) - + # Create test data create_beta_data(db, users) - + # Generate report generate_beta_report(db) - + print("\n[OK] Beta testing environment ready!") print("\nNext steps:") print("1. Share credentials with beta testers") print("2. Set up feedback collection") print("3. Monitor analytics") print("4. Collect user feedback") - + except Exception as e: print(f"[ERROR] Error setting up beta testing: {str(e)}") db.rollback() @@ -190,3 +191,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/scripts/setup_db.py b/scripts/setup_db.py index fc265ac..9e17176 100644 --- a/scripts/setup_db.py +++ b/scripts/setup_db.py @@ -7,47 +7,43 @@ python scripts/setup_db.py [--env-file .env] """ -import argparse import os import sys +import argparse from pathlib import Path - -import psycopg2 -from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT from sqlalchemy import create_engine, text from sqlalchemy.exc import OperationalError +import psycopg2 +from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT # Add parent directory to path to import config sys.path.insert(0, str(Path(__file__).parent.parent)) - def load_env_file(env_file): """Load environment variables from .env file""" env_vars = {} if os.path.exists(env_file): - with open(env_file, "r") as f: + with open(env_file, 'r') as f: for line in f: line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, value = line.split("=", 1) + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) env_vars[key.strip()] = value.strip().strip('"').strip("'") return env_vars - def get_db_connection_string(env_vars=None): """Build database connection string from environment variables""" if env_vars is None: env_vars = {} - - db_host = env_vars.get("DB_HOST") or os.getenv("DB_HOST", "localhost") - db_port = env_vars.get("DB_PORT") or os.getenv("DB_PORT", "5432") - db_name = env_vars.get("DB_NAME") or os.getenv("DB_NAME", "elevareai") - db_user = env_vars.get("DB_USER") or os.getenv("DB_USER", "postgres") - db_password = env_vars.get("DB_PASSWORD") or os.getenv("DB_PASSWORD", "") - + + db_host = env_vars.get('DB_HOST') or os.getenv('DB_HOST', 'localhost') + db_port = env_vars.get('DB_PORT') or os.getenv('DB_PORT', '5432') + db_name = env_vars.get('DB_NAME') or os.getenv('DB_NAME', 'elevareai') + db_user = env_vars.get('DB_USER') or os.getenv('DB_USER', 'postgres') + db_password = env_vars.get('DB_PASSWORD') or os.getenv('DB_PASSWORD', '') + return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" - def create_database_if_not_exists(db_host, db_port, db_user, db_password, db_name): """Create database if it doesn't exist""" try: @@ -57,52 +53,54 @@ def create_database_if_not_exists(db_host, db_port, db_user, db_password, db_nam port=db_port, user=db_user, password=db_password, - database="postgres", + database='postgres' ) conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) cursor = conn.cursor() - + # Check if database exists - cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + cursor.execute( + "SELECT 1 FROM pg_database WHERE datname = %s", + (db_name,) + ) exists = cursor.fetchone() - + if not exists: print(f" Creating database: {db_name}") cursor.execute(f'CREATE DATABASE "{db_name}"') print(f" [OK] Database created") else: print(f" [WARNING] Database already exists: {db_name}") - + cursor.close() conn.close() except Exception as e: print(f" [WARNING] Could not create database (may already exist): {e}") - def run_migration(engine, migration_file): """Run a SQL migration file using psycopg2 for better SQL handling""" import psycopg2 from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT - - with open(migration_file, "r", encoding="utf-8") as f: + + with open(migration_file, 'r', encoding='utf-8') as f: sql = f.read() - + # Get connection string from engine url = engine.url conn_params = { - "host": url.host, - "port": url.port or 5432, - "database": url.database, - "user": url.username, - "password": url.password, + 'host': url.host, + 'port': url.port or 5432, + 'database': url.database, + 'user': url.username, + 'password': url.password } - + try: # Use psycopg2 directly - it handles multi-statement SQL better conn = psycopg2.connect(**conn_params) conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) cursor = conn.cursor() - + # Execute the entire SQL file # psycopg2's execute can handle multiple statements try: @@ -114,27 +112,23 @@ def run_migration(engine, migration_file): except Exception as e: error_msg = str(e).lower() # Check if it's just "already exists" errors - if "already exists" in error_msg: + if 'already exists' in error_msg: print(f" [INFO] Some objects already exist: {str(e)[:100]}") else: # Try executing statement by statement for better error reporting - print( - " [INFO] Full execution failed, trying statement by statement..." - ) + print(" [INFO] Full execution failed, trying statement by statement...") conn.rollback() # Use execute with execute_values or split more carefully # For now, just execute and ignore "already exists" pass - + cursor.close() conn.close() - + except Exception as e: error_msg = str(e).lower() - if "already exists" in error_msg or "duplicate" in error_msg: - print( - f" [INFO] Migration partially applied (some objects already exist)" - ) + if 'already exists' in error_msg or 'duplicate' in error_msg: + print(f" [INFO] Migration partially applied (some objects already exist)") else: print(f" [ERROR] Migration failed: {e}") # Try using SQLAlchemy as fallback @@ -142,12 +136,9 @@ def run_migration(engine, migration_file): try: with engine.begin() as conn: # Remove comments and execute - lines = [ - line - for line in sql.split("\n") - if line.strip() and not line.strip().startswith("--") - ] - clean_sql = "\n".join(lines) + lines = [line for line in sql.split('\n') + if line.strip() and not line.strip().startswith('--')] + clean_sql = '\n'.join(lines) # Split on semicolons that are not inside dollar quotes # Simple approach: execute the whole thing conn.execute(text(clean_sql)) @@ -155,39 +146,36 @@ def run_migration(engine, migration_file): print(f" [ERROR] Both methods failed. Last error: {e2}") raise - def main(): - parser = argparse.ArgumentParser(description="Setup database schema") - parser.add_argument("--env-file", default=".env", help="Path to .env file") - parser.add_argument( - "--skip-create-db", action="store_true", help="Skip database creation" - ) + parser = argparse.ArgumentParser(description='Setup database schema') + parser.add_argument('--env-file', default='.env', help='Path to .env file') + parser.add_argument('--skip-create-db', action='store_true', help='Skip database creation') args = parser.parse_args() - + print("=" * 60) print("AI Study Companion - Database Setup") print("=" * 60) print() - + # Load environment variables env_vars = load_env_file(args.env_file) - + # Get database connection details - db_host = env_vars.get("DB_HOST") or os.getenv("DB_HOST", "localhost") - db_port = env_vars.get("DB_PORT") or os.getenv("DB_PORT", "5432") - db_name = env_vars.get("DB_NAME") or os.getenv("DB_NAME", "elevareai") - db_user = env_vars.get("DB_USER") or os.getenv("DB_USER", "postgres") - db_password = env_vars.get("DB_PASSWORD") or os.getenv("DB_PASSWORD", "") - + db_host = env_vars.get('DB_HOST') or os.getenv('DB_HOST', 'localhost') + db_port = env_vars.get('DB_PORT') or os.getenv('DB_PORT', '5432') + db_name = env_vars.get('DB_NAME') or os.getenv('DB_NAME', 'elevareai') + db_user = env_vars.get('DB_USER') or os.getenv('DB_USER', 'postgres') + db_password = env_vars.get('DB_PASSWORD') or os.getenv('DB_PASSWORD', '') + print(f"Database: {db_name} @ {db_host}:{db_port}") print() - + # Create database if it doesn't exist if not args.skip_create_db: print("Step 1: Checking database exists...") create_database_if_not_exists(db_host, db_port, db_user, db_password, db_name) print() - + # Create engine connection_string = get_db_connection_string(env_vars) print("Step 2: Connecting to database...") @@ -200,31 +188,31 @@ def main(): print(f" [ERROR] Connection failed: {e}") print("\nPlease check your database credentials in .env file") sys.exit(1) - + print() - + # Run migrations print("Step 3: Running migrations...") migrations_dir = Path(__file__).parent.parent / "migrations" - + if not migrations_dir.exists(): print(f" [WARNING] Migrations directory not found: {migrations_dir}") print(" Creating migrations directory...") migrations_dir.mkdir(parents=True, exist_ok=True) - + migration_files = sorted(migrations_dir.glob("*.sql")) - + if not migration_files: print(" [WARNING] No migration files found") print(" Creating initial migration from schema...") # We'll create the migration file create_initial_migration(migrations_dir) migration_files = sorted(migrations_dir.glob("*.sql")) - + for migration_file in migration_files: print(f" Running: {migration_file.name}") run_migration(engine, migration_file) - + print() print("=" * 60) print("[SUCCESS] Database setup complete!") @@ -235,21 +223,18 @@ def main(): print(" 2. Seed demo data: python scripts/seed_demo_data.py") print() - def create_initial_migration(migrations_dir): """Create initial migration file from schema""" migration_file = migrations_dir / "001_initial_schema.sql" - + # Read the schema from DATABASE_SCHEMA.md and extract SQL - schema_doc = ( - Path(__file__).parent.parent / "_docs" / "active" / "DATABASE_SCHEMA.md" - ) - + schema_doc = Path(__file__).parent.parent / "_docs" / "active" / "DATABASE_SCHEMA.md" + if schema_doc.exists(): print(f" Reading schema from {schema_doc}") # For now, we'll create a basic migration # In production, you'd parse the markdown and extract SQL - + # Write a basic migration that includes core tables migration_content = """-- Initial Schema Migration -- AI Study Companion MVP @@ -269,12 +254,12 @@ def create_initial_migration(migrations_dir): -- Note: Full schema will be loaded from DATABASE_SCHEMA.md -- Run: python scripts/generate_migration_from_schema.py """ - - with open(migration_file, "w") as f: + + with open(migration_file, 'w') as f: f.write(migration_content) - + print(f" ✅ Created: {migration_file.name}") - if __name__ == "__main__": main() + diff --git a/scripts/test_demo_scenarios.py b/scripts/test_demo_scenarios.py index 95aaa9d..fbe10ee 100644 --- a/scripts/test_demo_scenarios.py +++ b/scripts/test_demo_scenarios.py @@ -4,19 +4,16 @@ Verifies all demo scenarios work correctly via API calls """ -import os import sys - +import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import json - import requests +import json from sqlalchemy.orm import Session - -from scripts.demo_auth import auth_headers, login from src.config.database import get_db_session from src.models.user import User +from scripts.demo_auth import login, auth_headers BASE_URL = "http://localhost:8000" @@ -54,9 +51,9 @@ def get_mock_token(): def test_health(): """Test health endpoint""" - print("\n" + "=" * 60) + print("\n" + "="*60) print("Testing Health Endpoint") - print("=" * 60) + print("="*60) try: response = requests.get(f"{BASE_URL}/health", timeout=5) if response.status_code == 200: @@ -80,44 +77,44 @@ def test_progress_endpoint(user_id, email, scenario_name): print(f"\n--- Testing {scenario_name} ---") print(f"User: {email}") print(f"User ID: {user_id}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json", + "Content-Type": "application/json" } - + try: # Test progress endpoint url = f"{BASE_URL}/api/v1/progress/{user_id}?include_suggestions=true" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: print(f"[FAIL] Progress endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Progress endpoint returned success=false") print(f" Response: {json.dumps(data, indent=2)[:500]}") return False - + progress_data = data.get("data", {}) goals = progress_data.get("goals", []) suggestions = progress_data.get("suggestions", []) - + print(f"[OK] Progress endpoint successful") print(f" Goals found: {len(goals)}") print(f" Suggestions found: {len(suggestions)}") - + # Show goal details for goal in goals[:3]: status = goal.get("status", "unknown") completion = goal.get("completion_percentage", 0) title = goal.get("title", "Unknown") print(f" - {title}: {status} ({completion}%)") - + # Show suggestions if suggestions: print(f" Suggestions:") @@ -125,13 +122,12 @@ def test_progress_endpoint(user_id, email, scenario_name): subjects = suggestion.get("subjects", []) if subjects: print(f" - {', '.join(subjects)}") - + return True - + except Exception as e: print(f"[FAIL] Error testing progress: {e}") import traceback - traceback.print_exc() return False @@ -140,33 +136,33 @@ def test_nudges_endpoint(user_id, email, scenario_name): """Test nudges endpoint for a user""" print(f"\n--- Testing Nudges for {scenario_name} ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json", + "Content-Type": "application/json" } - + try: url = f"{BASE_URL}/api/v1/nudges/users/{user_id}" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: print(f"[FAIL] Nudges endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Nudges endpoint returned success=false") print(f" Response: {json.dumps(data, indent=2)[:500]}") return False - + nudges = data.get("data", {}).get("nudges", []) - + print(f"[OK] Nudges endpoint successful") print(f" Active nudges: {len(nudges)}") - + for nudge in nudges[:3]: nudge_type = nudge.get("nudge_type", "unknown") message = nudge.get("message", "")[:60] @@ -175,13 +171,12 @@ def test_nudges_endpoint(user_id, email, scenario_name): print(f" Message: {message}...") if suggestions: print(f" Suggestions: {', '.join(suggestions[:3])}") - + return True - + except Exception as e: print(f"[FAIL] Error testing nudges: {e}") import traceback - traceback.print_exc() return False @@ -190,22 +185,22 @@ def test_goals_endpoint(user_id, email): """Test goals endpoint""" print(f"\n--- Testing Goals Endpoint ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json", + "Content-Type": "application/json" } - + try: url = f"{BASE_URL}/api/v1/goals?student_id={user_id}" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: print(f"[FAIL] Goals endpoint failed: {response.status_code}") return False - + data = response.json() - + # Handle response format: {"success": True, "data": [...]} if isinstance(data, dict) and "data" in data: goals = data.get("data", []) @@ -215,12 +210,12 @@ def test_goals_endpoint(user_id, email): goals = data else: goals = [] - + print(f"[OK] Goals endpoint successful") print(f" Goals found: {len(goals)}") - + return True - + except Exception as e: print(f"[FAIL] Error testing goals: {e}") return False @@ -230,41 +225,43 @@ def test_qa_endpoint(user_id, email): """Test Q&A endpoint""" print(f"\n--- Testing Q&A Endpoint ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json", + "Content-Type": "application/json" } - + try: # Test Q&A query url = f"{BASE_URL}/api/v1/qa/query" - payload = {"student_id": user_id, "query": "What is photosynthesis?"} + payload = { + "student_id": user_id, + "query": "What is photosynthesis?" + } response = requests.post(url, headers=headers, json=payload, timeout=30) - + if response.status_code != 200: print(f"[FAIL] Q&A endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Q&A endpoint returned success=false") return False - + response_text = data.get("data", {}).get("response", "") - + print(f"[OK] Q&A endpoint successful") print(f" Response length: {len(response_text)} characters") print(f" Response preview: {response_text[:100]}...") - + return True - + except Exception as e: print(f"[FAIL] Error testing Q&A: {e}") import traceback - traceback.print_exc() return False @@ -273,58 +270,61 @@ def test_practice_endpoint(user_id, email): """Test practice assignment endpoint""" print(f"\n--- Testing Practice Endpoint ---") print(f"User: {email}") - + headers = { "Authorization": f"Bearer {get_mock_token()}", - "Content-Type": "application/json", + "Content-Type": "application/json" } - + try: # Test practice assignment url = f"{BASE_URL}/api/v1/practice/assign" - params = {"student_id": user_id, "subject": "Math", "num_items": 3} + params = { + "student_id": user_id, + "subject": "Math", + "num_items": 3 + } response = requests.post(url, headers=headers, params=params, timeout=30) - + if response.status_code != 200: print(f"[FAIL] Practice endpoint failed: {response.status_code}") print(f" Response: {response.text[:200]}") return False - + data = response.json() - + if not data.get("success"): print(f"[FAIL] Practice endpoint returned success=false") return False - + assignment = data.get("data", {}) items = assignment.get("items", []) - + print(f"[OK] Practice endpoint successful") print(f" Items assigned: {len(items)}") - + if items: first_item = items[0] has_choices = "choices" in first_item has_correct_answer = "correct_answer" in first_item print(f" First item has choices: {has_choices}") print(f" First item has correct_answer: {has_correct_answer}") - + return True - + except Exception as e: print(f"[FAIL] Error testing practice: {e}") import traceback - traceback.print_exc() return False def main(): """Run all demo scenario tests""" - print("=" * 60) + print("="*60) print("Testing All Demo Scenarios") - print("=" * 60) - + print("="*60) + # Test health first if not test_health(): print("\n❌ Server is not running. Please start it first.") @@ -340,21 +340,24 @@ def main(): scenarios = { "demo@elevare.ai": "Demo Student Account", } - + for email, scenario_name in scenarios.items(): user_id = DEMO_USERS.get(email) if not user_id: print(f"\n[FAIL] User ID not found for {email}") continue - + # Test progress progress_ok = test_progress_endpoint(user_id, email, scenario_name) - + # Test nudges (especially for low_sessions) nudges_ok = test_nudges_endpoint(user_id, email, scenario_name) - - results[email] = {"progress": progress_ok, "nudges": nudges_ok} - + + results[email] = { + "progress": progress_ok, + "nudges": nudges_ok + } + # Test Q&A with the demo student account qa_user_id = DEMO_USERS["demo@elevare.ai"] qa_email = "demo@elevare.ai" @@ -363,15 +366,15 @@ def main(): # Test common endpoints with the same user test_user_id = DEMO_USERS["demo@elevare.ai"] test_email = "demo@elevare.ai" - + goals_ok = test_goals_endpoint(test_user_id, test_email) practice_ok = test_practice_endpoint(test_user_id, test_email) - + # Summary - print("\n" + "=" * 60) + print("\n" + "="*60) print("Test Summary") - print("=" * 60) - + print("="*60) + all_passed = True for email, result in results.items(): status = "[OK]" if result["progress"] and result["nudges"] else "[FAIL]" @@ -382,21 +385,21 @@ def main(): if not result["nudges"]: print(f" - Nudges endpoint failed") all_passed = False - + print(f"\nCommon Endpoints:") print(f"{'[OK]' if goals_ok else '[FAIL]'} Goals endpoint") print(f"{'[OK]' if qa_ok else '[FAIL]'} Q&A endpoint") print(f"{'[OK]' if practice_ok else '[FAIL]'} Practice endpoint") - + if not goals_ok or not qa_ok or not practice_ok: all_passed = False - - print("\n" + "=" * 60) + + print("\n" + "="*60) if all_passed: print("[SUCCESS] All tests passed!") else: print("[FAILURE] Some tests failed. Check output above.") - print("=" * 60) + print("="*60) if __name__ == "__main__": diff --git a/scripts/verify_all_demo_accounts.py b/scripts/verify_all_demo_accounts.py index f872c2d..8492a1f 100644 --- a/scripts/verify_all_demo_accounts.py +++ b/scripts/verify_all_demo_accounts.py @@ -4,19 +4,16 @@ Tests each demo account according to DEMO_USER_GUIDE.md specifications """ -import os import sys - +import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import json - import requests +import json from sqlalchemy.orm import Session - -from scripts.demo_auth import auth_headers, login from src.config.database import get_db_session from src.models.user import User +from scripts.demo_auth import login, auth_headers BASE_URL = "http://localhost:8000" @@ -109,24 +106,23 @@ def test_progress_api(email: str, user_id: str, token: str) -> dict: if response.status_code != 200: results["passed"] = False - results["issues"].append( - f"API returned status {response.status_code}: {response.text[:200]}" - ) + results["issues"].append(f"API returned status {response.status_code}: {response.text[:200]}") return results data = response.json() if not data.get("success"): results["passed"] = False - results["issues"].append( - f"API returned success=false: {data.get('error', 'Unknown error')}" - ) + results["issues"].append(f"API returned success=false: {data.get('error', 'Unknown error')}") return results progress_data = data.get("data", {}) goals = progress_data.get("goals", []) suggestions = progress_data.get("suggestions", []) - results["data"] = {"goals": goals, "suggestions": suggestions} + results["data"] = { + "goals": goals, + "suggestions": suggestions + } except requests.exceptions.ConnectionError: results["passed"] = False @@ -147,14 +143,12 @@ def test_nudges_api(email: str, user_id: str, token: str) -> dict: try: url = f"{BASE_URL}/api/v1/nudges/users/{user_id}" response = requests.get(url, headers=headers, timeout=10) - + if response.status_code != 200: results["passed"] = False - results["issues"].append( - f"Nudges API returned status {response.status_code}" - ) + results["issues"].append(f"Nudges API returned status {response.status_code}") return results - + data = response.json() nudges = data.get("data", {}).get("nudges", []) @@ -179,9 +173,7 @@ def test_qa_api(email: str, user_id: str, token: str) -> dict: response = requests.get(url, headers=headers, timeout=10) if response.status_code != 200: - results["issues"].append( - f"Q&A history API returned status {response.status_code} (may be OK if no history)" - ) + results["issues"].append(f"Q&A history API returned status {response.status_code} (may be OK if no history)") return results data = response.json() @@ -203,7 +195,7 @@ def main(): print("Testing all accounts according to DEMO_USER_GUIDE.md") print("=" * 80) print() - + # Check backend print("Checking backend status...") if not test_backend(): @@ -213,17 +205,17 @@ def main(): return print("[OK] Backend is running") print() - + all_passed = True results_summary = [] - + # Test each demo account for email, config in DEMO_ACCOUNTS.items(): print("=" * 80) print(f"Testing: {email}") print(f"Scenario: {config['scenario']}") print("=" * 80) - + # Get user ID user_id = get_user_id_from_db(email) if not user_id: @@ -231,11 +223,9 @@ def main(): print(" Run: python scripts/create_demo_users.py") print() all_passed = False - results_summary.append( - {"email": email, "status": "FAIL", "reason": "User not found"} - ) + results_summary.append({"email": email, "status": "FAIL", "reason": "User not found"}) continue - + print(f"[OK] User found: {user_id}") # Verify database data @@ -280,9 +270,7 @@ def main(): if progress_results["data"].get("goals"): print(f" Goals: {len(progress_results['data']['goals'])}") if progress_results["data"].get("suggestions"): - print( - f" Suggestions: {len(progress_results['data']['suggestions'])}" - ) + print(f" Suggestions: {len(progress_results['data']['suggestions'])}") else: print(" [FAIL] Progress API issues:") for issue in progress_results["issues"]: @@ -308,26 +296,22 @@ def main(): if qa_results["passed"]: print(" [OK] Q&A API working") if qa_results["data"].get("history"): - print( - f" Conversation history: {len(qa_results['data']['history'])} items" - ) + print(f" Conversation history: {len(qa_results['data']['history'])} items") else: print(" [FAIL] Q&A API issues:") for issue in qa_results["issues"]: print(f" - {issue}") else: - print( - f"\n3-5. Skipping progress/nudges/Q&A checks (role={role}, not a student)" - ) + print(f"\n3-5. Skipping progress/nudges/Q&A checks (role={role}, not a student)") # Summary for this account account_passed = ( - db_results["passed"] - and login_results["passed"] - and progress_results["passed"] - and nudges_results["passed"] + db_results["passed"] and + login_results["passed"] and + progress_results["passed"] and + nudges_results["passed"] ) - + if account_passed: print(f"\n[PASS] {email} - All tests passed") results_summary.append({"email": email, "status": "PASS"}) @@ -335,15 +319,15 @@ def main(): print(f"\n[FAIL] {email} - Some tests failed") results_summary.append({"email": email, "status": "FAIL"}) all_passed = False - + print() - + # Final summary print("=" * 80) print("VERIFICATION SUMMARY") print("=" * 80) print() - + for result in results_summary: status = result["status"] email = result["email"] @@ -351,7 +335,7 @@ def main(): print(f"[PASS] {email}") else: print(f"[FAIL] {email}: {result.get('reason', 'See details above')}") - + print() print("=" * 80) if all_passed: @@ -364,9 +348,7 @@ def main(): print() print("Next steps:") print(" 1. If accounts are missing, run: python scripts/create_demo_users.py") - print( - " 2. If backend is not running, start it: python -m uvicorn src.api.main:app --reload" - ) + print(" 2. If backend is not running, start it: python -m uvicorn src.api.main:app --reload") print(" 3. Test frontend login with each account") print(" 4. See DEMO_USER_GUIDE.md for demo instructions") print() @@ -374,3 +356,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/scripts/verify_complete_system.py b/scripts/verify_complete_system.py index 596438f..153bf5f 100644 --- a/scripts/verify_complete_system.py +++ b/scripts/verify_complete_system.py @@ -4,59 +4,51 @@ Tests all components of the AI Study Companion platform """ -import json -import os import sys +import os +import json from pathlib import Path from typing import Dict, List, Tuple try: import requests - HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False - # Colors for terminal output class Colors: - GREEN = "\033[92m" - RED = "\033[91m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - RESET = "\033[0m" - BOLD = "\033[1m" - + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + RESET = '\033[0m' + BOLD = '\033[1m' def print_header(text: str): print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.RESET}") print(f"{Colors.BOLD}{Colors.BLUE}{text}{Colors.RESET}") print(f"{Colors.BOLD}{Colors.BLUE}{'='*60}{Colors.RESET}\n") - def print_success(text: str): print(f"{Colors.GREEN}[OK] {text}{Colors.RESET}") - def print_error(text: str): print(f"{Colors.RED}[ERROR] {text}{Colors.RESET}") - def print_warning(text: str): print(f"{Colors.YELLOW}[WARN] {text}{Colors.RESET}") - def print_info(text: str): print(f"{Colors.BLUE}[INFO] {text}{Colors.RESET}") - def check_backend_health(base_url: str = "http://localhost:8000") -> bool: """Check if backend is running""" if not HAS_REQUESTS: print_warning("requests module not installed - skipping backend health check") print_info("Install with: pip install requests") return False - + try: response = requests.get(f"{base_url}/health", timeout=5) if response.status_code == 200: @@ -70,12 +62,11 @@ def check_backend_health(base_url: str = "http://localhost:8000") -> bool: print_info("Start backend with: python -m uvicorn src.api.main:app --reload") return False - def check_api_docs(base_url: str = "http://localhost:8000") -> bool: """Check if API docs are accessible""" if not HAS_REQUESTS: return False - + try: response = requests.get(f"{base_url}/docs", timeout=5) if response.status_code == 200: @@ -88,7 +79,6 @@ def check_api_docs(base_url: str = "http://localhost:8000") -> bool: print_warning(f"API docs not accessible: {e}") return False - def check_frontend_files() -> Tuple[bool, List[str]]: """Check if frontend files exist""" frontend_dir = Path("examples/frontend-starter") @@ -107,13 +97,13 @@ def check_frontend_files() -> Tuple[bool, List[str]]: "src/services/apiClient.js", "src/contexts/AuthContext.jsx", ] - + missing = [] for file in required_files: file_path = frontend_dir / file if not file_path.exists(): missing.append(str(file)) - + if missing: print_error(f"Missing frontend files: {len(missing)}") for file in missing: @@ -123,7 +113,6 @@ def check_frontend_files() -> Tuple[bool, List[str]]: print_success(f"All {len(required_files)} required frontend files exist") return True, [] - def check_backend_files() -> Tuple[bool, List[str]]: """Check if backend files exist""" required_files = [ @@ -143,13 +132,13 @@ def check_backend_files() -> Tuple[bool, List[str]]: "requirements.txt", "run_server.py", ] - + missing = [] for file in required_files: file_path = Path(file) if not file_path.exists(): missing.append(str(file)) - + if missing: print_error(f"Missing backend files: {len(missing)}") for file in missing: @@ -159,7 +148,6 @@ def check_backend_files() -> Tuple[bool, List[str]]: print_success(f"All {len(required_files)} required backend files exist") return True, [] - def check_documentation() -> Tuple[bool, List[str]]: """Check if documentation exists""" required_docs = [ @@ -172,13 +160,13 @@ def check_documentation() -> Tuple[bool, List[str]]: "_docs/active/POST_MVP_PRD.md", "examples/frontend-starter/README.md", ] - + missing = [] for doc in required_docs: doc_path = Path(doc) if not doc_path.exists(): missing.append(str(doc)) - + if missing: print_warning(f"Missing documentation: {len(missing)}") for doc in missing: @@ -188,14 +176,12 @@ def check_documentation() -> Tuple[bool, List[str]]: print_success(f"All {len(required_docs)} required documentation files exist") return True, [] - def check_tests() -> bool: """Check if tests can run""" try: import pytest - print_success("pytest is installed") - + # Check if test files exist test_dir = Path("tests") if test_dir.exists(): @@ -213,12 +199,11 @@ def check_tests() -> bool: print_warning("pytest is not installed") return False - def check_docker() -> bool: """Check if Docker files exist""" docker_files = ["Dockerfile", "docker-compose.yml", ".dockerignore"] all_exist = all(Path(f).exists() for f in docker_files) - + if all_exist: print_success("All Docker files exist") return True @@ -227,10 +212,9 @@ def check_docker() -> bool: print_warning(f"Missing Docker files: {', '.join(missing)}") return False - def main(): print_header("AI Study Companion - Complete System Verification") - + results = { "backend_running": False, "api_docs": False, @@ -240,56 +224,52 @@ def main(): "tests": False, "docker": False, } - + # Check backend print_header("Backend Status") results["backend_running"] = check_backend_health() results["api_docs"] = check_api_docs() - + # Check files print_header("File Structure") backend_ok, _ = check_backend_files() results["backend_files"] = backend_ok - + frontend_ok, _ = check_frontend_files() results["frontend_files"] = frontend_ok - + docs_ok, _ = check_documentation() results["documentation"] = docs_ok - + # Check tests print_header("Testing") results["tests"] = check_tests() - + # Check Docker print_header("Docker") results["docker"] = check_docker() - + # Summary print_header("Verification Summary") - + total = len(results) passed = sum(1 for v in results.values() if v) - + print(f"\n{Colors.BOLD}Results: {passed}/{total} checks passed{Colors.RESET}\n") - + for check, status in results.items(): if status: print_success(f"{check.replace('_', ' ').title()}") else: print_error(f"{check.replace('_', ' ').title()}") - + if passed == total: - print( - f"\n{Colors.GREEN}{Colors.BOLD}[SUCCESS] All checks passed! System is ready.{Colors.RESET}\n" - ) + print(f"\n{Colors.GREEN}{Colors.BOLD}[SUCCESS] All checks passed! System is ready.{Colors.RESET}\n") return 0 else: - print( - f"\n{Colors.YELLOW}{Colors.BOLD}[WARN] Some checks failed. Review the output above.{Colors.RESET}\n" - ) + print(f"\n{Colors.YELLOW}{Colors.BOLD}[WARN] Some checks failed. Review the output above.{Colors.RESET}\n") return 1 - if __name__ == "__main__": sys.exit(main()) + diff --git a/scripts/verify_demo_data.py b/scripts/verify_demo_data.py index e3fdda0..fc28c41 100644 --- a/scripts/verify_demo_data.py +++ b/scripts/verify_demo_data.py @@ -9,56 +9,50 @@ # Add current directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) - def verify_script(): """Verify the demo data script is ready""" print("Verifying demo data script...") - + try: from scripts.seed_demo_data import ( - PRACTICE_QUESTIONS, - STUDENT_NAMES, - SUBJECTS, - TRANSCRIPTS, - TUTOR_NAMES, + SUBJECTS, STUDENT_NAMES, TUTOR_NAMES, TRANSCRIPTS, PRACTICE_QUESTIONS ) - + print(f"[OK] Subjects: {len(SUBJECTS)}") print(f"[OK] Student names: {len(STUDENT_NAMES)}") print(f"[OK] Tutor names: {len(TUTOR_NAMES)}") print(f"[OK] Transcript templates: {len(TRANSCRIPTS)}") print(f"[OK] Practice questions: {len(PRACTICE_QUESTIONS)}") - + # Check for required functions import scripts.seed_demo_data as seed_script - + required_functions = [ - "generate_all_demo_data", - "save_to_json", - "generate_sql_inserts", + 'generate_all_demo_data', + 'save_to_json', + 'generate_sql_inserts' ] - + missing = [] for func in required_functions: if not hasattr(seed_script, func): missing.append(func) - + if missing: print(f"[WARNING] Missing functions: {', '.join(missing)}") else: print("[OK] All required functions present") - + print("\n[OK] Demo data script is ready!") return True - + except Exception as e: print(f"[ERROR] Error verifying script: {e}") import traceback - traceback.print_exc() return False - if __name__ == "__main__": success = verify_script() sys.exit(0 if success else 1) + diff --git a/scripts/verify_demo_users.py b/scripts/verify_demo_users.py index 9ade8b0..e286a06 100644 --- a/scripts/verify_demo_users.py +++ b/scripts/verify_demo_users.py @@ -4,25 +4,20 @@ Verifies that all demo accounts are set up correctly with expected data """ -import os import sys - +import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from datetime import datetime, timedelta, timezone -from typing import Dict, Optional, Tuple - from sqlalchemy.orm import Session - from src.config.database import get_db_session +from src.models.user import User from src.models.goal import Goal from src.models.session import Session as SessionModel from src.models.subject import Subject -from src.models.user import User - +from datetime import datetime, timedelta, timezone +from typing import Tuple, Optional, Dict try: import requests - HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False @@ -34,34 +29,30 @@ def verify_user_exists(db: Session, email: str) -> Tuple[bool, Optional[User]]: return (user is not None, user) -def verify_goals( - db: Session, user_id, expected_completed: int = 0, expected_active: int = 0 -) -> bool: +def verify_goals(db: Session, user_id, expected_completed: int = 0, expected_active: int = 0) -> bool: """Verify user has expected goals""" - completed = ( - db.query(Goal) - .filter(Goal.student_id == user_id, Goal.status == "completed") - .count() - ) - - active = ( - db.query(Goal) - .filter(Goal.student_id == user_id, Goal.status == "active") - .count() - ) - + completed = db.query(Goal).filter( + Goal.student_id == user_id, + Goal.status == "completed" + ).count() + + active = db.query(Goal).filter( + Goal.student_id == user_id, + Goal.status == "active" + ).count() + return completed >= expected_completed and active >= expected_active def verify_sessions(db: Session, user_id, min_count: int) -> bool: """Verify user has minimum session count""" - count = db.query(SessionModel).filter(SessionModel.student_id == user_id).count() + count = db.query(SessionModel).filter( + SessionModel.student_id == user_id + ).count() return count >= min_count -def verify_user_age( - db: Session, user: User, expected_days: int, tolerance: int = 1 -) -> bool: +def verify_user_age(db: Session, user: User, expected_days: int, tolerance: int = 1) -> bool: """Verify user was created expected_days ago (with tolerance)""" # Handle both timezone-aware and naive datetimes now = datetime.now(timezone.utc) @@ -72,22 +63,14 @@ def verify_user_age( else: # If timezone-aware, convert to UTC created_at = created_at.astimezone(timezone.utc) - + days_ago = (now - created_at).days return abs(days_ago - expected_days) <= tolerance def verify_subjects_exist(db: Session) -> bool: """Verify required subjects exist""" - required = [ - "College Essays", - "Study Skills", - "AP Prep", - "Physics", - "Biology", - "AP Chemistry", - "STEM Prep", - ] + required = ["College Essays", "Study Skills", "AP Prep", "Physics", "Biology", "AP Chemistry", "STEM Prep"] for name in required: subject = db.query(Subject).filter(Subject.name == name).first() if not subject: @@ -100,17 +83,17 @@ def test_progress_endpoint(base_url: str, user_id: str) -> Dict: """Test progress endpoint returns suggestions""" if not HAS_REQUESTS: return {"success": False, "error": "requests module not installed"} - + try: # Note: This would normally require auth, but for demo we'll just check if endpoint exists # In real scenario, you'd need to authenticate first - response = requests.get(f"{base_url}/api/v1/progress/{user_id}", timeout=5) + response = requests.get( + f"{base_url}/api/v1/progress/{user_id}", + timeout=5 + ) if response.status_code == 200: data = response.json() - return { - "success": True, - "has_suggestions": "suggestions" in data.get("data", {}), - } + return {"success": True, "has_suggestions": "suggestions" in data.get("data", {})} else: return {"success": False, "status": response.status_code} except Exception as e: @@ -123,43 +106,43 @@ def main(): print("Verifying Demo User Accounts") print("=" * 60) print() - + demo_accounts = { "demo_goal_complete@demo.com": { "expected_completed_goals": 1, "expected_active_goals": 2, "min_sessions": 5, - "expected_age_days": 30, + "expected_age_days": 30 }, "demo_sat_complete@demo.com": { "expected_completed_goals": 1, "expected_active_goals": 0, "min_sessions": 5, - "expected_age_days": 30, + "expected_age_days": 30 }, "demo_chemistry@demo.com": { "expected_completed_goals": 1, "expected_active_goals": 0, "min_sessions": 5, - "expected_age_days": 30, + "expected_age_days": 30 }, "demo_low_sessions@demo.com": { "expected_completed_goals": 0, "expected_active_goals": 1, "min_sessions": 2, "max_sessions": 2, # Must be exactly 2 - "expected_age_days": 7, + "expected_age_days": 7 }, "demo_multi_goal@demo.com": { "expected_completed_goals": 0, "expected_active_goals": 3, "min_sessions": 5, - "expected_age_days": 30, - }, + "expected_age_days": 30 + } } - + all_passed = True - + with get_db_session() as db: # Verify subjects exist print("Verifying subjects...") @@ -168,76 +151,60 @@ def main(): else: print("[ERROR] Some required subjects are missing") all_passed = False - + print() - + # Verify each demo account for email, expectations in demo_accounts.items(): print(f"Verifying {email}...") exists, user = verify_user_exists(db, email) - + if not exists: print(f" [ERROR] User does not exist") all_passed = False continue - + # Verify user age if not verify_user_age(db, user, expectations["expected_age_days"]): - print( - f" [ERROR] User age incorrect (expected ~{expectations['expected_age_days']} days ago)" - ) + print(f" [ERROR] User age incorrect (expected ~{expectations['expected_age_days']} days ago)") all_passed = False else: print(f" [OK] User age correct") - + # Verify goals if not verify_goals( - db, - user.id, + db, + user.id, expectations["expected_completed_goals"], - expectations["expected_active_goals"], + expectations["expected_active_goals"] ): - print( - f" [ERROR] Goals incorrect (expected {expectations['expected_completed_goals']} completed, {expectations['expected_active_goals']} active)" - ) + print(f" [ERROR] Goals incorrect (expected {expectations['expected_completed_goals']} completed, {expectations['expected_active_goals']} active)") all_passed = False else: print(f" [OK] Goals correct") - + # Verify sessions min_sessions = expectations["min_sessions"] max_sessions = expectations.get("max_sessions") - + if max_sessions: # Must be exactly this number - session_count = ( - db.query(SessionModel) - .filter(SessionModel.student_id == user.id) - .count() - ) + session_count = db.query(SessionModel).filter(SessionModel.student_id == user.id).count() if session_count != max_sessions: - print( - f" [ERROR] Session count incorrect (expected exactly {max_sessions}, got {session_count})" - ) + print(f" [ERROR] Session count incorrect (expected exactly {max_sessions}, got {session_count})") all_passed = False else: print(f" [OK] Session count correct ({session_count})") else: if not verify_sessions(db, user.id, min_sessions): - print( - f" [ERROR] Session count too low (expected at least {min_sessions})" - ) + print(f" [ERROR] Session count too low (expected at least {min_sessions})") all_passed = False else: - session_count = ( - db.query(SessionModel) - .filter(SessionModel.student_id == user.id) - .count() - ) + session_count = db.query(SessionModel).filter(SessionModel.student_id == user.id).count() print(f" [OK] Session count correct ({session_count})") - + print() - + # Summary print("=" * 60) if all_passed: @@ -254,3 +221,4 @@ def main(): if __name__ == "__main__": main() +