Spaces:
Running
Running
Upload 40 files
Browse files- .gitattributes +1 -0
- .gitignore +11 -0
- Dockerfile +14 -0
- README.md +119 -10
- alembic.ini +40 -0
- alembic/README +1 -0
- alembic/env.py +97 -0
- alembic/script.py.mako +26 -0
- alembic/versions/add_pgvector_001.py +58 -0
- app/__init__.py +0 -0
- app/config.py +48 -0
- app/database.py +39 -0
- app/dependencies.py +128 -0
- app/main.py +108 -0
- app/models/__init__.py +34 -0
- app/models/course.py +107 -0
- app/models/document.py +29 -0
- app/models/forum.py +56 -0
- app/models/progress.py +73 -0
- app/models/quiz.py +83 -0
- app/models/user.py +43 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +244 -0
- app/routes/auth.py +292 -0
- app/routes/courses.py +459 -0
- app/routes/documents.py +330 -0
- app/routes/forum.py +446 -0
- app/routes/progress.py +508 -0
- app/routes/quiz.py +582 -0
- app/routes/tutor.py +193 -0
- app/schemas/__init__.py +0 -0
- app/schemas/course.py +154 -0
- app/schemas/progress.py +83 -0
- app/schemas/quiz.py +114 -0
- app/schemas/tutor.py +63 -0
- app/schemas/user.py +68 -0
- app/services/__init__.py +0 -0
- app/services/auth.py +79 -0
- app/services/tutor.py +359 -0
- requirements.txt +38 -0
- uploads/20260307_154626_42dcb39b_resume.pdf +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
uploads/20260307_154626_42dcb39b_resume.pdf filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*$py.class
|
| 4 |
+
.env
|
| 5 |
+
venv/
|
| 6 |
+
uploads/
|
| 7 |
+
*.egg-info/
|
| 8 |
+
dist/
|
| 9 |
+
build/
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
alembic/versions/
|
Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
RUN mkdir -p uploads
|
| 11 |
+
|
| 12 |
+
EXPOSE 7860
|
| 13 |
+
|
| 14 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,119 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LearnHub AI Platform
|
| 3 |
+
emoji: 🎓
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# LearnHub - Phase 2 AI Learning Platform
|
| 12 |
+
|
| 13 |
+
**Advanced AI-powered learning platform with multi-user support, course management, and RAG-based tutoring.**
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
✅ **User Authentication** - JWT-based auth with student/instructor/admin roles
|
| 18 |
+
✅ **Course Management** - Modules, lessons, quizzes, and progress tracking
|
| 19 |
+
✅ **AI Tutor** - RAG-powered Q&A using PostgreSQL pgvector + Google Gemini
|
| 20 |
+
✅ **Smart Features** - Auto-generate summaries, MCQs, and progressive hints
|
| 21 |
+
✅ **Forum** - Discussion boards for students and instructors
|
| 22 |
+
✅ **Vector Search** - pgvector for semantic search over course materials
|
| 23 |
+
|
| 24 |
+
## Tech Stack
|
| 25 |
+
|
| 26 |
+
- **Backend**: FastAPI + SQLAlchemy + PostgreSQL
|
| 27 |
+
- **Vector DB**: pgvector extension for PostgreSQL
|
| 28 |
+
- **AI**: Google Gemini 2.5 Flash + LangChain
|
| 29 |
+
- **Auth**: JWT tokens with bcrypt password hashing
|
| 30 |
+
|
| 31 |
+
## Deployment on HuggingFace Spaces
|
| 32 |
+
|
| 33 |
+
This Space requires a **PostgreSQL database with pgvector extension**.
|
| 34 |
+
|
| 35 |
+
### Option 1: Use Supabase (Recommended)
|
| 36 |
+
|
| 37 |
+
1. Create a Supabase project at https://supabase.com
|
| 38 |
+
2. Go to Project Settings → Database → Connection string (URI)
|
| 39 |
+
3. Enable pgvector: Go to SQL Editor and run:
|
| 40 |
+
```sql
|
| 41 |
+
CREATE EXTENSION vector;
|
| 42 |
+
```
|
| 43 |
+
4. Add your Supabase connection string as `DATABASE_URL` secret in HF Spaces
|
| 44 |
+
|
| 45 |
+
### Option 2: Use Render PostgreSQL
|
| 46 |
+
|
| 47 |
+
1. Create a PostgreSQL database on Render.com
|
| 48 |
+
2. Connect and enable pgvector:
|
| 49 |
+
```sql
|
| 50 |
+
CREATE EXTENSION vector;
|
| 51 |
+
```
|
| 52 |
+
3. Use the external connection string as `DATABASE_URL`
|
| 53 |
+
|
| 54 |
+
### Required Secrets
|
| 55 |
+
|
| 56 |
+
Configure in **Settings → Variables and secrets**:
|
| 57 |
+
|
| 58 |
+
```
|
| 59 |
+
DATABASE_URL=postgresql+asyncpg://user:password@host:5432/database
|
| 60 |
+
GOOGLE_API_KEY=your_google_api_key
|
| 61 |
+
JWT_SECRET_KEY=your-super-secret-jwt-key
|
| 62 |
+
CORS_ORIGINS=https://your-frontend.vercel.app
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### Run Database Migrations
|
| 66 |
+
|
| 67 |
+
After deployment, you need to run Alembic migrations:
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
# Connect to your database and enable pgvector
|
| 71 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 72 |
+
|
| 73 |
+
# Or use the migration script in alembic/versions/
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## API Endpoints
|
| 77 |
+
|
| 78 |
+
| Endpoint | Description |
|
| 79 |
+
|----------|-------------|
|
| 80 |
+
| `POST /auth/register` | Register new user |
|
| 81 |
+
| `POST /auth/login` | Login and get JWT token |
|
| 82 |
+
| `GET /courses` | List all courses |
|
| 83 |
+
| `POST /tutor/ask` | Ask AI tutor a question (RAG) |
|
| 84 |
+
| `POST /tutor/generate-mcq` | Generate MCQ questions |
|
| 85 |
+
| `POST /tutor/hint` | Get progressive hints |
|
| 86 |
+
| `GET /docs` | Interactive API documentation |
|
| 87 |
+
|
| 88 |
+
## Local Development
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
# Setup
|
| 92 |
+
cd backend
|
| 93 |
+
pip install -r requirements.txt
|
| 94 |
+
|
| 95 |
+
# Configure .env
|
| 96 |
+
cp .env.example .env
|
| 97 |
+
# Edit .env with your credentials
|
| 98 |
+
|
| 99 |
+
# Run migrations
|
| 100 |
+
alembic upgrade head
|
| 101 |
+
|
| 102 |
+
# Start server
|
| 103 |
+
uvicorn app.main:app --reload --port 8001
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
## Differences from Phase 1
|
| 107 |
+
|
| 108 |
+
| Feature | Phase 1 | Phase 2 |
|
| 109 |
+
|---------|---------|---------|
|
| 110 |
+
| Database | SQLite | PostgreSQL |
|
| 111 |
+
| Vectors | Pinecone | pgvector (PostgreSQL) |
|
| 112 |
+
| Auth | None | JWT multi-user |
|
| 113 |
+
| Courses | No | Full course management |
|
| 114 |
+
| AI Features | Basic Q&A | Q&A + MCQ + Summaries + Hints |
|
| 115 |
+
| Users | Single | Multi-user with roles |
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
**Note**: This is the backend API only. For a full app experience, deploy the Next.js frontend separately and connect via CORS.
|
alembic.ini
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[alembic]
|
| 2 |
+
script_location = alembic
|
| 3 |
+
prepend_sys_path = .
|
| 4 |
+
sqlalchemy.url = sqlite:///./placeholder.db
|
| 5 |
+
|
| 6 |
+
[post_write_hooks]
|
| 7 |
+
|
| 8 |
+
[loggers]
|
| 9 |
+
keys = root,sqlalchemy,alembic
|
| 10 |
+
|
| 11 |
+
[handlers]
|
| 12 |
+
keys = console
|
| 13 |
+
|
| 14 |
+
[formatters]
|
| 15 |
+
keys = generic
|
| 16 |
+
|
| 17 |
+
[logger_root]
|
| 18 |
+
level = WARN
|
| 19 |
+
handlers = console
|
| 20 |
+
qualname =
|
| 21 |
+
|
| 22 |
+
[logger_sqlalchemy]
|
| 23 |
+
level = WARN
|
| 24 |
+
handlers =
|
| 25 |
+
qualname = sqlalchemy.engine
|
| 26 |
+
|
| 27 |
+
[logger_alembic]
|
| 28 |
+
level = INFO
|
| 29 |
+
handlers =
|
| 30 |
+
qualname = alembic
|
| 31 |
+
|
| 32 |
+
[handler_console]
|
| 33 |
+
class = StreamHandler
|
| 34 |
+
args = (sys.stderr,)
|
| 35 |
+
level = NOTSET
|
| 36 |
+
formatter = generic
|
| 37 |
+
|
| 38 |
+
[formatter_generic]
|
| 39 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 40 |
+
datefmt = %H:%M:%S
|
alembic/README
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Generic single-database configuration.
|
alembic/env.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
import asyncio
|
| 3 |
+
from sqlalchemy import pool
|
| 4 |
+
from sqlalchemy.engine import Connection
|
| 5 |
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
| 6 |
+
|
| 7 |
+
from alembic import context
|
| 8 |
+
|
| 9 |
+
# Import all models here
|
| 10 |
+
import sys
|
| 11 |
+
import os
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 13 |
+
|
| 14 |
+
from app.database import Base
|
| 15 |
+
from app.config import settings
|
| 16 |
+
from app.models.user import User
|
| 17 |
+
from app.models.course import Course, Module, Lesson
|
| 18 |
+
from app.models.progress import Enrollment, Progress, StudyStreak
|
| 19 |
+
from app.models.quiz import Quiz, QuizQuestion, QuizAttempt
|
| 20 |
+
from app.models.forum import ForumPost, ForumComment
|
| 21 |
+
|
| 22 |
+
# this is the Alembic Config object, which provides
|
| 23 |
+
# access to the values within the .ini file in use.
|
| 24 |
+
config = context.config
|
| 25 |
+
|
| 26 |
+
# Override sqlalchemy.url with our settings
|
| 27 |
+
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
| 28 |
+
|
| 29 |
+
# Interpret the config file for Python logging.
|
| 30 |
+
# This line sets up loggers basically.
|
| 31 |
+
if config.config_file_name is not None:
|
| 32 |
+
fileConfig(config.config_file_name)
|
| 33 |
+
|
| 34 |
+
# add your model's MetaData object here
|
| 35 |
+
# for 'autogenerate' support
|
| 36 |
+
target_metadata = Base.metadata
|
| 37 |
+
|
| 38 |
+
# other values from the config, defined by the needs of env.py,
|
| 39 |
+
# can be acquired:
|
| 40 |
+
# my_important_option = config.get_main_option("my_important_option")
|
| 41 |
+
# ... etc.
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def run_migrations_offline() -> None:
|
| 45 |
+
"""Run migrations in 'offline' mode.
|
| 46 |
+
|
| 47 |
+
This configures the context with just a URL
|
| 48 |
+
and not an Engine, though an Engine is acceptable
|
| 49 |
+
here as well. By skipping the Engine creation
|
| 50 |
+
we don't even need a DBAPI to be available.
|
| 51 |
+
|
| 52 |
+
Calls to context.execute() here emit the given string to the
|
| 53 |
+
script output.
|
| 54 |
+
|
| 55 |
+
"""
|
| 56 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 57 |
+
context.configure(
|
| 58 |
+
url=url,
|
| 59 |
+
target_metadata=target_metadata,
|
| 60 |
+
literal_binds=True,
|
| 61 |
+
dialect_opts={"paramstyle": "named"},
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
with context.begin_transaction():
|
| 65 |
+
context.run_migrations()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def do_run_migrations(connection: Connection) -> None:
|
| 69 |
+
context.configure(connection=connection, target_metadata=target_metadata)
|
| 70 |
+
|
| 71 |
+
with context.begin_transaction():
|
| 72 |
+
context.run_migrations()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def run_async_migrations() -> None:
|
| 76 |
+
"""Run migrations in 'online' mode with async support."""
|
| 77 |
+
connectable = async_engine_from_config(
|
| 78 |
+
config.get_section(config.config_ini_section, {}),
|
| 79 |
+
prefix="sqlalchemy.",
|
| 80 |
+
poolclass=pool.NullPool,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
async with connectable.connect() as connection:
|
| 84 |
+
await connection.run_sync(do_run_migrations)
|
| 85 |
+
|
| 86 |
+
await connectable.dispose()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def run_migrations_online() -> None:
|
| 90 |
+
"""Run migrations in 'online' mode."""
|
| 91 |
+
asyncio.run(run_async_migrations())
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if context.is_offline_mode():
|
| 95 |
+
run_migrations_offline()
|
| 96 |
+
else:
|
| 97 |
+
run_migrations_online()
|
alembic/script.py.mako
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
${imports if imports else ""}
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = ${repr(up_revision)}
|
| 16 |
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
${upgrades if upgrades else "pass"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def downgrade() -> None:
|
| 26 |
+
${downgrades if downgrades else "pass"}
|
alembic/versions/add_pgvector_001.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enable pgvector extension and create documents table
|
| 2 |
+
|
| 3 |
+
Revision ID: add_pgvector_001
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2026-03-07 01:00:00.000000
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from sqlalchemy.dialects import postgresql
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = 'add_pgvector_001'
|
| 14 |
+
down_revision = None
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
# Enable pgvector extension
|
| 21 |
+
op.execute('CREATE EXTENSION IF NOT EXISTS vector')
|
| 22 |
+
|
| 23 |
+
# Create documents table with vector column
|
| 24 |
+
op.create_table(
|
| 25 |
+
'documents',
|
| 26 |
+
sa.Column('id', sa.String(36), primary_key=True),
|
| 27 |
+
sa.Column('content', sa.Text(), nullable=False),
|
| 28 |
+
sa.Column('embedding', postgresql.ARRAY(sa.Float), nullable=False),
|
| 29 |
+
sa.Column('meta', sa.JSON(), nullable=True),
|
| 30 |
+
sa.Column('filename', sa.String(500), nullable=True),
|
| 31 |
+
sa.Column('file_path', sa.String(1000), nullable=True),
|
| 32 |
+
sa.Column('chunk_index', sa.Integer(), default=0),
|
| 33 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 34 |
+
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Create index for vector similarity search
|
| 38 |
+
op.execute('''
|
| 39 |
+
CREATE INDEX documents_embedding_idx
|
| 40 |
+
ON documents
|
| 41 |
+
USING ivfflat (embedding vector_cosine_ops)
|
| 42 |
+
WITH (lists = 100)
|
| 43 |
+
''')
|
| 44 |
+
|
| 45 |
+
# Create index on meta for filtering
|
| 46 |
+
op.create_index(
|
| 47 |
+
'ix_documents_meta',
|
| 48 |
+
'documents',
|
| 49 |
+
['meta'],
|
| 50 |
+
postgresql_using='gin'
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def downgrade() -> None:
|
| 55 |
+
op.drop_index('ix_documents_meta', table_name='documents')
|
| 56 |
+
op.execute('DROP INDEX IF EXISTS documents_embedding_idx')
|
| 57 |
+
op.drop_table('documents')
|
| 58 |
+
op.execute('DROP EXTENSION IF EXISTS vector')
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
from typing import List
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
# Get the backend directory path
|
| 7 |
+
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
+
ENV_FILE = BACKEND_DIR / ".env"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
model_config = SettingsConfigDict(
|
| 13 |
+
env_file=str(ENV_FILE),
|
| 14 |
+
env_file_encoding="utf-8",
|
| 15 |
+
case_sensitive=False
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Database
|
| 19 |
+
DATABASE_URL: str = "postgresql+asyncpg://learnhub:learnhub_secret@localhost:5432/learnhub"
|
| 20 |
+
|
| 21 |
+
# Google Gemini
|
| 22 |
+
GOOGLE_API_KEY: str = ""
|
| 23 |
+
|
| 24 |
+
# JWT Auth
|
| 25 |
+
JWT_SECRET_KEY: str = "change-this-secret-key"
|
| 26 |
+
JWT_ALGORITHM: str = "HS256"
|
| 27 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
| 28 |
+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 29 |
+
|
| 30 |
+
# App settings
|
| 31 |
+
APP_ENV: str = "development"
|
| 32 |
+
CORS_ORIGINS: str = "http://localhost:3001"
|
| 33 |
+
UPLOAD_DIR: str = "./uploads"
|
| 34 |
+
MAX_UPLOAD_SIZE_MB: int = 50
|
| 35 |
+
|
| 36 |
+
# AI Settings
|
| 37 |
+
EMBEDDING_MODEL: str = "sentence-transformers/all-MiniLM-L6-v2"
|
| 38 |
+
LLM_MODEL: str = "gemini-2.5-flash"
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def cors_origins_list(self) -> List[str]:
|
| 42 |
+
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
settings = Settings()
|
| 46 |
+
|
| 47 |
+
# Ensure upload directory exists
|
| 48 |
+
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
app/database.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
| 2 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 3 |
+
from sqlalchemy.pool import AsyncAdaptedQueuePool
|
| 4 |
+
from app.config import settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Base(DeclarativeBase):
|
| 8 |
+
pass
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# Use connection pooling for faster responses
|
| 12 |
+
# Direct connection with AsyncAdaptedQueuePool keeps connections open and reuses them
|
| 13 |
+
engine = create_async_engine(
|
| 14 |
+
settings.DATABASE_URL.replace("+asyncpg://", "+psycopg://"),
|
| 15 |
+
echo=False,
|
| 16 |
+
future=True,
|
| 17 |
+
poolclass=AsyncAdaptedQueuePool,
|
| 18 |
+
pool_size=5, # Keep 5 connections ready
|
| 19 |
+
max_overflow=10, # Allow up to 15 total (5 + 10)
|
| 20 |
+
pool_timeout=30, # Wait up to 30s for a connection
|
| 21 |
+
pool_recycle=1800, # Recycle connections every 30 minutes
|
| 22 |
+
pool_pre_ping=True, # Verify connections are alive before using
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
async_session_maker = async_sessionmaker(
|
| 26 |
+
engine,
|
| 27 |
+
class_=AsyncSession,
|
| 28 |
+
expire_on_commit=False,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def get_db():
|
| 33 |
+
async with async_session_maker() as session:
|
| 34 |
+
yield session
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def create_tables():
|
| 38 |
+
async with engine.begin() as conn:
|
| 39 |
+
await conn.run_sync(Base.metadata.create_all)
|
app/dependencies.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API dependencies for authentication and database."""
|
| 2 |
+
|
| 3 |
+
from fastapi import Depends, HTTPException, status
|
| 4 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
from types import SimpleNamespace
|
| 8 |
+
from app.database import get_db
|
| 9 |
+
from app.services.auth import decode_token
|
| 10 |
+
from app.models.user import User, UserRole
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
security = HTTPBearer()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def get_current_user(
|
| 18 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 19 |
+
db: AsyncSession = Depends(get_db),
|
| 20 |
+
) -> User:
|
| 21 |
+
"""Get current authenticated user from JWT token."""
|
| 22 |
+
token = credentials.credentials
|
| 23 |
+
payload = decode_token(token)
|
| 24 |
+
|
| 25 |
+
if not payload:
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 28 |
+
detail="Invalid or expired token",
|
| 29 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
if payload.get("type") != "access":
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 35 |
+
detail="Invalid token type",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
user_id = payload.get("sub")
|
| 39 |
+
email = payload.get("email", "demo@example.com")
|
| 40 |
+
role = payload.get("role", "student")
|
| 41 |
+
|
| 42 |
+
# Check if this is a demo user (IDs contain "demo")
|
| 43 |
+
if user_id and "demo" in str(user_id):
|
| 44 |
+
# Return a plain object (no ORM) to avoid SQLAlchemy mapper issues
|
| 45 |
+
full_name_map = {
|
| 46 |
+
"admin-demo-001": "Admin Demo",
|
| 47 |
+
"instructor-demo-001": "Instructor Demo",
|
| 48 |
+
"student-demo-001": "Student Demo",
|
| 49 |
+
}
|
| 50 |
+
return SimpleNamespace(
|
| 51 |
+
id=user_id,
|
| 52 |
+
email=email,
|
| 53 |
+
hashed_password="",
|
| 54 |
+
full_name=full_name_map.get(user_id, "Demo User"),
|
| 55 |
+
role=UserRole(role) if role else UserRole.STUDENT,
|
| 56 |
+
is_active=True,
|
| 57 |
+
is_verified=True,
|
| 58 |
+
bio=None,
|
| 59 |
+
avatar_url=None,
|
| 60 |
+
created_at=datetime.utcnow(),
|
| 61 |
+
updated_at=None,
|
| 62 |
+
last_login=None,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Try to fetch from database
|
| 66 |
+
try:
|
| 67 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 68 |
+
user = result.scalar_one_or_none()
|
| 69 |
+
|
| 70 |
+
if not user:
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 73 |
+
detail="User not found",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
if not user.is_active:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 79 |
+
detail="User account is deactivated",
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
return user
|
| 83 |
+
except HTTPException:
|
| 84 |
+
raise
|
| 85 |
+
except Exception as e:
|
| 86 |
+
# Database connection failed, return demo user
|
| 87 |
+
print(f"⚠️ Could not fetch user from database: {e}, using demo mode")
|
| 88 |
+
return SimpleNamespace(
|
| 89 |
+
id=user_id,
|
| 90 |
+
email=email,
|
| 91 |
+
hashed_password="",
|
| 92 |
+
full_name="Demo User",
|
| 93 |
+
role=UserRole(role) if role else UserRole.STUDENT,
|
| 94 |
+
is_active=True,
|
| 95 |
+
is_verified=True,
|
| 96 |
+
bio=None,
|
| 97 |
+
avatar_url=None,
|
| 98 |
+
created_at=datetime.utcnow(),
|
| 99 |
+
updated_at=None,
|
| 100 |
+
last_login=None,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def get_current_active_user(
|
| 105 |
+
current_user: User = Depends(get_current_user),
|
| 106 |
+
) -> User:
|
| 107 |
+
"""Get current active user."""
|
| 108 |
+
return current_user
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def require_role(*roles: UserRole):
|
| 112 |
+
"""Dependency factory for role-based access control."""
|
| 113 |
+
async def role_checker(
|
| 114 |
+
current_user: User = Depends(get_current_user),
|
| 115 |
+
) -> User:
|
| 116 |
+
if current_user.role not in roles:
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 119 |
+
detail=f"Requires one of these roles: {', '.join(r.value for r in roles)}",
|
| 120 |
+
)
|
| 121 |
+
return current_user
|
| 122 |
+
return role_checker
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# Convenience dependencies
|
| 126 |
+
require_admin = require_role(UserRole.ADMIN)
|
| 127 |
+
require_instructor = require_role(UserRole.INSTRUCTOR, UserRole.ADMIN)
|
| 128 |
+
require_student = require_role(UserRole.STUDENT, UserRole.INSTRUCTOR, UserRole.ADMIN)
|
app/main.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 2 - Student Learning Platform API."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import asyncio
|
| 5 |
+
|
| 6 |
+
# Fix for Windows + psycopg async
|
| 7 |
+
if sys.platform == 'win32':
|
| 8 |
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from app.config import settings
|
| 13 |
+
from app.database import create_tables
|
| 14 |
+
from app.routes import auth, courses, progress, tutor, admin, quiz, forum, documents
|
| 15 |
+
|
| 16 |
+
# Import all models so SQLAlchemy mapper can resolve all relationships
|
| 17 |
+
import app.models.user # noqa: F401
|
| 18 |
+
import app.models.course # noqa: F401
|
| 19 |
+
import app.models.progress # noqa: F401
|
| 20 |
+
import app.models.quiz # noqa: F401
|
| 21 |
+
import app.models.forum # noqa: F401
|
| 22 |
+
import app.models.document # noqa: F401
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
app = FastAPI(
|
| 26 |
+
title="Student Learning Platform API",
|
| 27 |
+
description="AI-powered learning platform with courses, progress tracking, and AI tutor",
|
| 28 |
+
version="2.0.0",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Register CORS middleware FIRST - it should be the outermost middleware
|
| 32 |
+
app.add_middleware(
|
| 33 |
+
CORSMiddleware,
|
| 34 |
+
allow_origins=["*"] if settings.APP_ENV == "development" else settings.cors_origins_list,
|
| 35 |
+
allow_credentials=True,
|
| 36 |
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
| 37 |
+
allow_headers=["*"],
|
| 38 |
+
max_age=3600,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Register routes
|
| 42 |
+
app.include_router(auth.router, prefix="/api")
|
| 43 |
+
app.include_router(courses.router, prefix="/api")
|
| 44 |
+
app.include_router(progress.router, prefix="/api")
|
| 45 |
+
app.include_router(tutor.router, prefix="/api")
|
| 46 |
+
app.include_router(admin.router, prefix="/api")
|
| 47 |
+
app.include_router(quiz.router, prefix="/api")
|
| 48 |
+
app.include_router(forum.router, prefix="/api")
|
| 49 |
+
app.include_router(documents.router, prefix="/api")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@app.get("/")
|
| 53 |
+
async def root():
|
| 54 |
+
"""Root endpoint."""
|
| 55 |
+
return {
|
| 56 |
+
"message": "LearnHub Student Learning Platform API",
|
| 57 |
+
"version": "2.0.0",
|
| 58 |
+
"environment": settings.APP_ENV,
|
| 59 |
+
"docs": "/docs",
|
| 60 |
+
"health": "/health",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.get("/health")
|
| 65 |
+
async def health_check():
|
| 66 |
+
"""Health check endpoint."""
|
| 67 |
+
db_status = "unknown"
|
| 68 |
+
try:
|
| 69 |
+
from app.database import engine
|
| 70 |
+
async with engine.connect() as conn:
|
| 71 |
+
await conn.execute("SELECT 1")
|
| 72 |
+
db_status = "connected"
|
| 73 |
+
except Exception:
|
| 74 |
+
db_status = "disconnected"
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"status": "healthy",
|
| 78 |
+
"version": "2.0.0",
|
| 79 |
+
"environment": settings.APP_ENV,
|
| 80 |
+
"database": db_status,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@app.on_event("startup")
|
| 85 |
+
async def startup_event():
|
| 86 |
+
"""Initialize services on startup."""
|
| 87 |
+
# In development, create tables automatically
|
| 88 |
+
if settings.APP_ENV == "development":
|
| 89 |
+
try:
|
| 90 |
+
await create_tables()
|
| 91 |
+
print("✅ Database tables created (development mode)")
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"⚠️ Database connection failed: {e}")
|
| 94 |
+
print("⚠️ App will start without database. Some features may not work.")
|
| 95 |
+
|
| 96 |
+
# Initialize AI tutor
|
| 97 |
+
try:
|
| 98 |
+
from app.services.tutor import tutor_service
|
| 99 |
+
await tutor_service.initialize()
|
| 100 |
+
print("✅ AI Tutor service initialized")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
print(f"⚠️ AI Tutor initialization failed: {e}")
|
| 103 |
+
print("⚠️ AI Tutor features may not work properly.")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
import uvicorn
|
| 108 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=8001, reload=True)
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Models package."""
|
| 2 |
+
|
| 3 |
+
from app.models.user import User, UserRole
|
| 4 |
+
from app.models.course import (
|
| 5 |
+
Course,
|
| 6 |
+
Module,
|
| 7 |
+
Lesson,
|
| 8 |
+
ContentType,
|
| 9 |
+
DifficultyLevel,
|
| 10 |
+
)
|
| 11 |
+
from app.models.progress import Progress, Enrollment, StudyStreak
|
| 12 |
+
from app.models.quiz import Quiz, QuizQuestion, QuizAttempt, QuestionType
|
| 13 |
+
from app.models.forum import ForumPost, ForumComment
|
| 14 |
+
from app.models.document import Document
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"User",
|
| 18 |
+
"UserRole",
|
| 19 |
+
"Course",
|
| 20 |
+
"Module",
|
| 21 |
+
"Lesson",
|
| 22 |
+
"ContentType",
|
| 23 |
+
"DifficultyLevel",
|
| 24 |
+
"Enrollment",
|
| 25 |
+
"Progress",
|
| 26 |
+
"StudyStreak",
|
| 27 |
+
"Quiz",
|
| 28 |
+
"QuizQuestion",
|
| 29 |
+
"QuizAttempt",
|
| 30 |
+
"QuestionType",
|
| 31 |
+
"ForumPost",
|
| 32 |
+
"ForumComment",
|
| 33 |
+
"Document",
|
| 34 |
+
]
|
app/models/course.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Course and Module models for content organization."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from enum import Enum as PyEnum
|
| 5 |
+
from sqlalchemy import Column, String, Boolean, DateTime, Enum, Text, Integer, ForeignKey
|
| 6 |
+
from sqlalchemy.orm import relationship
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
from app.database import Base
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ContentType(str, PyEnum):
|
| 12 |
+
PDF = "pdf"
|
| 13 |
+
VIDEO = "video"
|
| 14 |
+
MARKDOWN = "markdown"
|
| 15 |
+
QUIZ = "quiz"
|
| 16 |
+
EXTERNAL_LINK = "external_link"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class DifficultyLevel(str, PyEnum):
|
| 20 |
+
BEGINNER = "beginner"
|
| 21 |
+
INTERMEDIATE = "intermediate"
|
| 22 |
+
ADVANCED = "advanced"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Course(Base):
|
| 26 |
+
__tablename__ = "courses"
|
| 27 |
+
|
| 28 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 29 |
+
title = Column(String(255), nullable=False)
|
| 30 |
+
slug = Column(String(255), unique=True, nullable=False, index=True)
|
| 31 |
+
description = Column(Text, nullable=True)
|
| 32 |
+
thumbnail_url = Column(String(500), nullable=True)
|
| 33 |
+
difficulty = Column(Enum(DifficultyLevel), default=DifficultyLevel.BEGINNER)
|
| 34 |
+
is_published = Column(Boolean, default=False)
|
| 35 |
+
is_featured = Column(Boolean, default=False)
|
| 36 |
+
estimated_hours = Column(Integer, default=0)
|
| 37 |
+
order_index = Column(Integer, default=0)
|
| 38 |
+
|
| 39 |
+
# Creator
|
| 40 |
+
created_by = Column(String(36), ForeignKey("users.id"), nullable=True)
|
| 41 |
+
|
| 42 |
+
# Timestamps
|
| 43 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 44 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 45 |
+
|
| 46 |
+
# Relationships
|
| 47 |
+
modules = relationship("Module", back_populates="course", cascade="all, delete-orphan", order_by="Module.order_index")
|
| 48 |
+
enrollments = relationship("Enrollment", back_populates="course", cascade="all, delete-orphan")
|
| 49 |
+
|
| 50 |
+
def __repr__(self):
|
| 51 |
+
return f"<Course {self.title}>"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class Module(Base):
|
| 55 |
+
__tablename__ = "modules"
|
| 56 |
+
|
| 57 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 58 |
+
course_id = Column(String(36), ForeignKey("courses.id"), nullable=False)
|
| 59 |
+
title = Column(String(255), nullable=False)
|
| 60 |
+
description = Column(Text, nullable=True)
|
| 61 |
+
order_index = Column(Integer, default=0)
|
| 62 |
+
is_published = Column(Boolean, default=False)
|
| 63 |
+
|
| 64 |
+
# Unlock requirements
|
| 65 |
+
prerequisite_module_id = Column(String(36), ForeignKey("modules.id"), nullable=True)
|
| 66 |
+
|
| 67 |
+
# Timestamps
|
| 68 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 69 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 70 |
+
|
| 71 |
+
# Relationships
|
| 72 |
+
course = relationship("Course", back_populates="modules")
|
| 73 |
+
lessons = relationship("Lesson", back_populates="module", cascade="all, delete-orphan", order_by="Lesson.order_index")
|
| 74 |
+
prerequisite = relationship("Module", remote_side="Module.id")
|
| 75 |
+
|
| 76 |
+
def __repr__(self):
|
| 77 |
+
return f"<Module {self.title}>"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class Lesson(Base):
|
| 81 |
+
__tablename__ = "lessons"
|
| 82 |
+
|
| 83 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 84 |
+
module_id = Column(String(36), ForeignKey("modules.id"), nullable=False)
|
| 85 |
+
title = Column(String(255), nullable=False)
|
| 86 |
+
description = Column(Text, nullable=True)
|
| 87 |
+
content_type = Column(Enum(ContentType), nullable=False)
|
| 88 |
+
content_url = Column(String(500), nullable=True) # File URL or external link
|
| 89 |
+
content_text = Column(Text, nullable=True) # For markdown content
|
| 90 |
+
duration_minutes = Column(Integer, default=0)
|
| 91 |
+
order_index = Column(Integer, default=0)
|
| 92 |
+
is_published = Column(Boolean, default=False)
|
| 93 |
+
|
| 94 |
+
# Vector store reference
|
| 95 |
+
vector_namespace = Column(String(255), nullable=True)
|
| 96 |
+
|
| 97 |
+
# Timestamps
|
| 98 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 99 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 100 |
+
|
| 101 |
+
# Relationships
|
| 102 |
+
module = relationship("Module", back_populates="lessons")
|
| 103 |
+
progress_records = relationship("Progress", back_populates="lesson", cascade="all, delete-orphan")
|
| 104 |
+
quizzes = relationship("Quiz", back_populates="lesson", cascade="all, delete-orphan")
|
| 105 |
+
|
| 106 |
+
def __repr__(self):
|
| 107 |
+
return f"<Lesson {self.title}>"
|
app/models/document.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document model for pgvector-based knowledge base."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from sqlalchemy import Column, String, Text, DateTime, Integer, JSON
|
| 5 |
+
from pgvector.sqlalchemy import Vector
|
| 6 |
+
from uuid import uuid4
|
| 7 |
+
from app.database import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Document(Base):
|
| 11 |
+
"""Vector store for course materials and knowledge base."""
|
| 12 |
+
__tablename__ = "documents"
|
| 13 |
+
|
| 14 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 15 |
+
content = Column(Text, nullable=False)
|
| 16 |
+
embedding = Column(Vector(768), nullable=False) # Google text-embedding-004 dimension
|
| 17 |
+
meta = Column(JSON, nullable=True) # Store lesson_id, module_id, source, etc.
|
| 18 |
+
|
| 19 |
+
# File metadata
|
| 20 |
+
filename = Column(String(500), nullable=True)
|
| 21 |
+
file_path = Column(String(1000), nullable=True)
|
| 22 |
+
chunk_index = Column(Integer, default=0)
|
| 23 |
+
|
| 24 |
+
# Timestamps
|
| 25 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 26 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 27 |
+
|
| 28 |
+
def __repr__(self):
|
| 29 |
+
return f"<Document {self.id[:8]}... from {self.filename}>"
|
app/models/forum.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forum and discussion models for peer interaction."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer, ForeignKey
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
from uuid import uuid4
|
| 7 |
+
from app.database import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ForumPost(Base):
|
| 11 |
+
__tablename__ = "forum_posts"
|
| 12 |
+
|
| 13 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 14 |
+
module_id = Column(String(36), ForeignKey("modules.id"), nullable=True) # Optional: tie to module
|
| 15 |
+
author_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
| 16 |
+
title = Column(String(255), nullable=False)
|
| 17 |
+
content = Column(Text, nullable=False)
|
| 18 |
+
is_pinned = Column(Boolean, default=False)
|
| 19 |
+
is_resolved = Column(Boolean, default=False) # For Q&A threads
|
| 20 |
+
upvotes = Column(Integer, default=0)
|
| 21 |
+
view_count = Column(Integer, default=0)
|
| 22 |
+
|
| 23 |
+
# Timestamps
|
| 24 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 25 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 26 |
+
|
| 27 |
+
# Relationships
|
| 28 |
+
author = relationship("User", back_populates="forum_posts")
|
| 29 |
+
comments = relationship("ForumComment", back_populates="post", cascade="all, delete-orphan")
|
| 30 |
+
|
| 31 |
+
def __repr__(self):
|
| 32 |
+
return f"<ForumPost {self.title}>"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ForumComment(Base):
|
| 36 |
+
__tablename__ = "forum_comments"
|
| 37 |
+
|
| 38 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 39 |
+
post_id = Column(String(36), ForeignKey("forum_posts.id"), nullable=False)
|
| 40 |
+
author_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
| 41 |
+
parent_id = Column(String(36), ForeignKey("forum_comments.id"), nullable=True) # For nested replies
|
| 42 |
+
content = Column(Text, nullable=False)
|
| 43 |
+
is_accepted_answer = Column(Boolean, default=False)
|
| 44 |
+
upvotes = Column(Integer, default=0)
|
| 45 |
+
|
| 46 |
+
# Timestamps
|
| 47 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 48 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 49 |
+
|
| 50 |
+
# Relationships
|
| 51 |
+
post = relationship("ForumPost", back_populates="comments")
|
| 52 |
+
author = relationship("User", back_populates="forum_comments")
|
| 53 |
+
replies = relationship("ForumComment", backref="parent", remote_side="ForumComment.id")
|
| 54 |
+
|
| 55 |
+
def __repr__(self):
|
| 56 |
+
return f"<ForumComment {self.id}>"
|
app/models/progress.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Progress tracking models."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey, Float, UniqueConstraint
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
from uuid import uuid4
|
| 7 |
+
from app.database import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Enrollment(Base):
|
| 11 |
+
__tablename__ = "enrollments"
|
| 12 |
+
__table_args__ = (
|
| 13 |
+
UniqueConstraint('student_id', 'course_id', name='unique_enrollment'),
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 17 |
+
student_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
| 18 |
+
course_id = Column(String(36), ForeignKey("courses.id"), nullable=False)
|
| 19 |
+
enrolled_at = Column(DateTime, default=datetime.utcnow)
|
| 20 |
+
completed_at = Column(DateTime, nullable=True)
|
| 21 |
+
is_completed = Column(Boolean, default=False)
|
| 22 |
+
progress_percentage = Column(Float, default=0.0)
|
| 23 |
+
|
| 24 |
+
# Relationships
|
| 25 |
+
student = relationship("User", back_populates="enrollments")
|
| 26 |
+
course = relationship("Course", back_populates="enrollments")
|
| 27 |
+
|
| 28 |
+
def __repr__(self):
|
| 29 |
+
return f"<Enrollment {self.student_id} -> {self.course_id}>"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Progress(Base):
|
| 33 |
+
__tablename__ = "progress"
|
| 34 |
+
__table_args__ = (
|
| 35 |
+
UniqueConstraint('student_id', 'lesson_id', name='unique_progress'),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 39 |
+
student_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
| 40 |
+
lesson_id = Column(String(36), ForeignKey("lessons.id"), nullable=False)
|
| 41 |
+
|
| 42 |
+
is_completed = Column(Boolean, default=False)
|
| 43 |
+
completed_at = Column(DateTime, nullable=True)
|
| 44 |
+
time_spent_seconds = Column(Integer, default=0)
|
| 45 |
+
last_position = Column(Integer, default=0) # For video progress
|
| 46 |
+
|
| 47 |
+
# Timestamps
|
| 48 |
+
started_at = Column(DateTime, default=datetime.utcnow)
|
| 49 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 50 |
+
|
| 51 |
+
# Relationships
|
| 52 |
+
student = relationship("User", back_populates="progress_records")
|
| 53 |
+
lesson = relationship("Lesson", back_populates="progress_records")
|
| 54 |
+
|
| 55 |
+
def __repr__(self):
|
| 56 |
+
return f"<Progress {self.student_id} -> {self.lesson_id}>"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class StudyStreak(Base):
|
| 60 |
+
__tablename__ = "study_streaks"
|
| 61 |
+
|
| 62 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 63 |
+
student_id = Column(String(36), ForeignKey("users.id"), nullable=False, unique=True)
|
| 64 |
+
current_streak = Column(Integer, default=0)
|
| 65 |
+
longest_streak = Column(Integer, default=0)
|
| 66 |
+
last_study_date = Column(DateTime, nullable=True)
|
| 67 |
+
total_study_days = Column(Integer, default=0)
|
| 68 |
+
|
| 69 |
+
# Relationships
|
| 70 |
+
student = relationship("User")
|
| 71 |
+
|
| 72 |
+
def __repr__(self):
|
| 73 |
+
return f"<StudyStreak {self.student_id}: {self.current_streak} days>"
|
app/models/quiz.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quiz and assessment models."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from enum import Enum as PyEnum
|
| 5 |
+
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer, ForeignKey, Float, JSON
|
| 6 |
+
from sqlalchemy.orm import relationship
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
from app.database import Base
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class QuestionType(str, PyEnum):
|
| 12 |
+
MULTIPLE_CHOICE = "multiple_choice"
|
| 13 |
+
TRUE_FALSE = "true_false"
|
| 14 |
+
SHORT_ANSWER = "short_answer"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Quiz(Base):
|
| 18 |
+
__tablename__ = "quizzes"
|
| 19 |
+
|
| 20 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 21 |
+
lesson_id = Column(String(36), ForeignKey("lessons.id"), nullable=False)
|
| 22 |
+
title = Column(String(255), nullable=False)
|
| 23 |
+
description = Column(Text, nullable=True)
|
| 24 |
+
passing_score = Column(Float, default=70.0)
|
| 25 |
+
time_limit_minutes = Column(Integer, nullable=True)
|
| 26 |
+
max_attempts = Column(Integer, default=3)
|
| 27 |
+
is_ai_generated = Column(Boolean, default=False)
|
| 28 |
+
is_published = Column(Boolean, default=False)
|
| 29 |
+
|
| 30 |
+
# Timestamps
|
| 31 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 32 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 33 |
+
|
| 34 |
+
# Relationships
|
| 35 |
+
lesson = relationship("Lesson", back_populates="quizzes")
|
| 36 |
+
questions = relationship("QuizQuestion", back_populates="quiz", cascade="all, delete-orphan")
|
| 37 |
+
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
|
| 38 |
+
|
| 39 |
+
def __repr__(self):
|
| 40 |
+
return f"<Quiz {self.title}>"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class QuizQuestion(Base):
|
| 44 |
+
__tablename__ = "quiz_questions"
|
| 45 |
+
|
| 46 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 47 |
+
quiz_id = Column(String(36), ForeignKey("quizzes.id"), nullable=False)
|
| 48 |
+
question_type = Column(String(50), default=QuestionType.MULTIPLE_CHOICE)
|
| 49 |
+
question_text = Column(Text, nullable=False)
|
| 50 |
+
options = Column(JSON, nullable=True) # List of options for MCQ
|
| 51 |
+
correct_answer = Column(Text, nullable=False)
|
| 52 |
+
explanation = Column(Text, nullable=True)
|
| 53 |
+
points = Column(Integer, default=1)
|
| 54 |
+
order_index = Column(Integer, default=0)
|
| 55 |
+
|
| 56 |
+
# Relationships
|
| 57 |
+
quiz = relationship("Quiz", back_populates="questions")
|
| 58 |
+
|
| 59 |
+
def __repr__(self):
|
| 60 |
+
return f"<QuizQuestion {self.id}>"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class QuizAttempt(Base):
|
| 64 |
+
__tablename__ = "quiz_attempts"
|
| 65 |
+
|
| 66 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 67 |
+
quiz_id = Column(String(36), ForeignKey("quizzes.id"), nullable=False)
|
| 68 |
+
student_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
| 69 |
+
score = Column(Float, default=0.0)
|
| 70 |
+
passed = Column(Boolean, default=False)
|
| 71 |
+
answers = Column(JSON, nullable=True) # Student's answers
|
| 72 |
+
time_taken_seconds = Column(Integer, nullable=True)
|
| 73 |
+
|
| 74 |
+
# Timestamps
|
| 75 |
+
started_at = Column(DateTime, default=datetime.utcnow)
|
| 76 |
+
completed_at = Column(DateTime, nullable=True)
|
| 77 |
+
|
| 78 |
+
# Relationships
|
| 79 |
+
quiz = relationship("Quiz", back_populates="attempts")
|
| 80 |
+
student = relationship("User", back_populates="quiz_attempts")
|
| 81 |
+
|
| 82 |
+
def __repr__(self):
|
| 83 |
+
return f"<QuizAttempt {self.student_id} -> {self.quiz_id}>"
|
app/models/user.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User model for authentication and profiles."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from enum import Enum as PyEnum
|
| 5 |
+
from sqlalchemy import Column, String, Boolean, DateTime, Enum, Text
|
| 6 |
+
from sqlalchemy.orm import relationship
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
from app.database import Base
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class UserRole(str, PyEnum):
|
| 12 |
+
STUDENT = "student"
|
| 13 |
+
INSTRUCTOR = "instructor"
|
| 14 |
+
ADMIN = "admin"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class User(Base):
|
| 18 |
+
__tablename__ = "users"
|
| 19 |
+
|
| 20 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 21 |
+
email = Column(String(255), unique=True, nullable=False, index=True)
|
| 22 |
+
hashed_password = Column(String(255), nullable=False)
|
| 23 |
+
full_name = Column(String(255), nullable=False)
|
| 24 |
+
avatar_url = Column(String(500), nullable=True)
|
| 25 |
+
role = Column(Enum(UserRole), default=UserRole.STUDENT, nullable=False)
|
| 26 |
+
is_active = Column(Boolean, default=True)
|
| 27 |
+
is_verified = Column(Boolean, default=False)
|
| 28 |
+
bio = Column(Text, nullable=True)
|
| 29 |
+
|
| 30 |
+
# Timestamps
|
| 31 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 32 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 33 |
+
last_login = Column(DateTime, nullable=True)
|
| 34 |
+
|
| 35 |
+
# Relationships
|
| 36 |
+
enrollments = relationship("Enrollment", back_populates="student", cascade="all, delete-orphan")
|
| 37 |
+
progress_records = relationship("Progress", back_populates="student", cascade="all, delete-orphan")
|
| 38 |
+
quiz_attempts = relationship("QuizAttempt", back_populates="student", cascade="all, delete-orphan")
|
| 39 |
+
forum_posts = relationship("ForumPost", back_populates="author", cascade="all, delete-orphan")
|
| 40 |
+
forum_comments = relationship("ForumComment", back_populates="author", cascade="all, delete-orphan")
|
| 41 |
+
|
| 42 |
+
def __repr__(self):
|
| 43 |
+
return f"<User {self.email}>"
|
app/routes/__init__.py
ADDED
|
File without changes
|
app/routes/admin.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin routes for platform management."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
from sqlalchemy import select, func
|
| 7 |
+
from app.database import get_db
|
| 8 |
+
from app.models.user import User, UserRole
|
| 9 |
+
from app.models.course import Course, Module, Lesson
|
| 10 |
+
from app.models.progress import Enrollment, Progress
|
| 11 |
+
from app.models.quiz import QuizAttempt
|
| 12 |
+
from app.schemas.user import UserResponse, UserBrief
|
| 13 |
+
from app.dependencies import require_admin
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
from datetime import datetime, timedelta
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/admin", tags=["Admin"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PlatformStats(BaseModel):
|
| 22 |
+
total_users: int
|
| 23 |
+
total_students: int
|
| 24 |
+
total_instructors: int
|
| 25 |
+
total_admins: int
|
| 26 |
+
total_courses: int
|
| 27 |
+
published_courses: int
|
| 28 |
+
total_enrollments: int
|
| 29 |
+
total_lessons_completed: int
|
| 30 |
+
active_users_7d: int
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UserListItem(BaseModel):
|
| 34 |
+
id: str
|
| 35 |
+
email: str
|
| 36 |
+
full_name: str
|
| 37 |
+
role: UserRole
|
| 38 |
+
is_active: bool
|
| 39 |
+
created_at: datetime
|
| 40 |
+
last_login: Optional[datetime]
|
| 41 |
+
enrollments_count: int = 0
|
| 42 |
+
|
| 43 |
+
class Config:
|
| 44 |
+
from_attributes = True
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class UpdateUserRole(BaseModel):
|
| 48 |
+
role: UserRole
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ─── Platform Analytics ─────────────────────────────────
|
| 52 |
+
|
| 53 |
+
@router.get("/stats", response_model=PlatformStats)
|
| 54 |
+
async def get_platform_stats(
|
| 55 |
+
current_user: User = Depends(require_admin),
|
| 56 |
+
db: AsyncSession = Depends(get_db),
|
| 57 |
+
):
|
| 58 |
+
"""Get platform-wide statistics."""
|
| 59 |
+
try:
|
| 60 |
+
total_users = await db.execute(select(func.count(User.id)))
|
| 61 |
+
students = await db.execute(
|
| 62 |
+
select(func.count(User.id)).where(User.role == UserRole.STUDENT)
|
| 63 |
+
)
|
| 64 |
+
instructors = await db.execute(
|
| 65 |
+
select(func.count(User.id)).where(User.role == UserRole.INSTRUCTOR)
|
| 66 |
+
)
|
| 67 |
+
admins = await db.execute(
|
| 68 |
+
select(func.count(User.id)).where(User.role == UserRole.ADMIN)
|
| 69 |
+
)
|
| 70 |
+
total_courses = await db.execute(select(func.count(Course.id)))
|
| 71 |
+
published_courses = await db.execute(
|
| 72 |
+
select(func.count(Course.id)).where(Course.is_published == True)
|
| 73 |
+
)
|
| 74 |
+
total_enrollments = await db.execute(select(func.count(Enrollment.id)))
|
| 75 |
+
total_completed = await db.execute(
|
| 76 |
+
select(func.count(Progress.id)).where(Progress.is_completed == True)
|
| 77 |
+
)
|
| 78 |
+
week_ago = datetime.utcnow() - timedelta(days=7)
|
| 79 |
+
active_users = await db.execute(
|
| 80 |
+
select(func.count(User.id)).where(User.last_login >= week_ago)
|
| 81 |
+
)
|
| 82 |
+
return PlatformStats(
|
| 83 |
+
total_users=total_users.scalar() or 0,
|
| 84 |
+
total_students=students.scalar() or 0,
|
| 85 |
+
total_instructors=instructors.scalar() or 0,
|
| 86 |
+
total_admins=admins.scalar() or 0,
|
| 87 |
+
total_courses=total_courses.scalar() or 0,
|
| 88 |
+
published_courses=published_courses.scalar() or 0,
|
| 89 |
+
total_enrollments=total_enrollments.scalar() or 0,
|
| 90 |
+
total_lessons_completed=total_completed.scalar() or 0,
|
| 91 |
+
active_users_7d=active_users.scalar() or 0,
|
| 92 |
+
)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
print(f"⚠️ Could not fetch admin stats: {e}")
|
| 95 |
+
return PlatformStats(
|
| 96 |
+
total_users=0, total_students=0, total_instructors=0, total_admins=0,
|
| 97 |
+
total_courses=0, published_courses=0, total_enrollments=0,
|
| 98 |
+
total_lessons_completed=0, active_users_7d=0,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ─── User Management ────────────────────────────────────
|
| 103 |
+
|
| 104 |
+
@router.get("/users", response_model=List[UserListItem])
|
| 105 |
+
async def list_users(
|
| 106 |
+
role: Optional[UserRole] = None,
|
| 107 |
+
search: Optional[str] = None,
|
| 108 |
+
limit: int = Query(default=50, le=100),
|
| 109 |
+
offset: int = 0,
|
| 110 |
+
current_user: User = Depends(require_admin),
|
| 111 |
+
db: AsyncSession = Depends(get_db),
|
| 112 |
+
):
|
| 113 |
+
"""List all users with optional filtering."""
|
| 114 |
+
try:
|
| 115 |
+
query = select(User)
|
| 116 |
+
|
| 117 |
+
if role:
|
| 118 |
+
query = query.where(User.role == role)
|
| 119 |
+
|
| 120 |
+
if search:
|
| 121 |
+
query = query.where(
|
| 122 |
+
(User.email.ilike(f"%{search}%")) | (User.full_name.ilike(f"%{search}%"))
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
query = query.order_by(User.created_at.desc()).limit(limit).offset(offset)
|
| 126 |
+
result = await db.execute(query)
|
| 127 |
+
users = result.scalars().all()
|
| 128 |
+
|
| 129 |
+
# Get enrollment counts
|
| 130 |
+
user_list = []
|
| 131 |
+
for user in users:
|
| 132 |
+
enrollment_count = await db.execute(
|
| 133 |
+
select(func.count(Enrollment.id)).where(Enrollment.student_id == user.id)
|
| 134 |
+
)
|
| 135 |
+
user_list.append(
|
| 136 |
+
UserListItem(
|
| 137 |
+
**UserResponse.model_validate(user).model_dump(),
|
| 138 |
+
enrollments_count=enrollment_count.scalar() or 0,
|
| 139 |
+
)
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
return user_list
|
| 143 |
+
except Exception as e:
|
| 144 |
+
print(f"⚠️ Could not fetch users: {e}")
|
| 145 |
+
return []
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.patch("/users/{user_id}/role", response_model=UserResponse)
|
| 149 |
+
async def update_user_role(
|
| 150 |
+
user_id: str,
|
| 151 |
+
data: UpdateUserRole,
|
| 152 |
+
current_user: User = Depends(require_admin),
|
| 153 |
+
db: AsyncSession = Depends(get_db),
|
| 154 |
+
):
|
| 155 |
+
"""Update a user's role."""
|
| 156 |
+
if user_id == current_user.id:
|
| 157 |
+
raise HTTPException(status_code=400, detail="Cannot change your own role")
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 161 |
+
user = result.scalar_one_or_none()
|
| 162 |
+
|
| 163 |
+
if not user:
|
| 164 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 165 |
+
|
| 166 |
+
user.role = data.role
|
| 167 |
+
await db.commit()
|
| 168 |
+
await db.refresh(user)
|
| 169 |
+
|
| 170 |
+
return UserResponse.model_validate(user)
|
| 171 |
+
except HTTPException:
|
| 172 |
+
raise
|
| 173 |
+
except Exception as e:
|
| 174 |
+
print(f"⚠️ Could not update user role: {e}")
|
| 175 |
+
raise HTTPException(status_code=503, detail="Database unavailable in demo mode")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.patch("/users/{user_id}/status")
|
| 179 |
+
async def toggle_user_status(
|
| 180 |
+
user_id: str,
|
| 181 |
+
current_user: User = Depends(require_admin),
|
| 182 |
+
db: AsyncSession = Depends(get_db),
|
| 183 |
+
):
|
| 184 |
+
"""Activate/deactivate a user."""
|
| 185 |
+
if user_id == current_user.id:
|
| 186 |
+
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
| 187 |
+
|
| 188 |
+
try:
|
| 189 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 190 |
+
user = result.scalar_one_or_none()
|
| 191 |
+
|
| 192 |
+
if not user:
|
| 193 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 194 |
+
|
| 195 |
+
user.is_active = not user.is_active
|
| 196 |
+
await db.commit()
|
| 197 |
+
|
| 198 |
+
return {"id": user.id, "is_active": user.is_active}
|
| 199 |
+
except HTTPException:
|
| 200 |
+
raise
|
| 201 |
+
except Exception as e:
|
| 202 |
+
print(f"⚠️ Could not toggle user status: {e}")
|
| 203 |
+
raise HTTPException(status_code=503, detail="Database unavailable in demo mode")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ─── Course Management ──────────────────────────────────
|
| 207 |
+
|
| 208 |
+
@router.get("/courses/analytics")
|
| 209 |
+
async def get_course_analytics(
|
| 210 |
+
current_user: User = Depends(require_admin),
|
| 211 |
+
db: AsyncSession = Depends(get_db),
|
| 212 |
+
):
|
| 213 |
+
"""Get analytics for all courses."""
|
| 214 |
+
try:
|
| 215 |
+
courses_result = await db.execute(select(Course))
|
| 216 |
+
courses = courses_result.scalars().all()
|
| 217 |
+
|
| 218 |
+
analytics = []
|
| 219 |
+
for course in courses:
|
| 220 |
+
enrollment_count = await db.execute(
|
| 221 |
+
select(func.count(Enrollment.id)).where(Enrollment.course_id == course.id)
|
| 222 |
+
)
|
| 223 |
+
completion_count = await db.execute(
|
| 224 |
+
select(func.count(Enrollment.id)).where(
|
| 225 |
+
Enrollment.course_id == course.id,
|
| 226 |
+
Enrollment.is_completed == True,
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
analytics.append({
|
| 231 |
+
"course_id": course.id,
|
| 232 |
+
"title": course.title,
|
| 233 |
+
"is_published": course.is_published,
|
| 234 |
+
"enrollments": enrollment_count.scalar() or 0,
|
| 235 |
+
"completions": completion_count.scalar() or 0,
|
| 236 |
+
"completion_rate": (
|
| 237 |
+
round((completion_count.scalar() or 0) / (enrollment_count.scalar() or 1) * 100, 1)
|
| 238 |
+
),
|
| 239 |
+
})
|
| 240 |
+
|
| 241 |
+
return analytics
|
| 242 |
+
except Exception as e:
|
| 243 |
+
print(f"⚠️ Could not fetch course analytics: {e}")
|
| 244 |
+
return []
|
app/routes/auth.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication routes."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
from app.database import get_db
|
| 8 |
+
from app.models.user import User
|
| 9 |
+
from app.schemas.user import (
|
| 10 |
+
UserCreate,
|
| 11 |
+
UserLogin,
|
| 12 |
+
UserResponse,
|
| 13 |
+
TokenResponse,
|
| 14 |
+
RefreshTokenRequest,
|
| 15 |
+
)
|
| 16 |
+
from app.services.auth import (
|
| 17 |
+
hash_password,
|
| 18 |
+
verify_password,
|
| 19 |
+
create_tokens,
|
| 20 |
+
decode_token,
|
| 21 |
+
)
|
| 22 |
+
from app.dependencies import get_current_user
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 26 |
+
|
| 27 |
+
# Demo users for testing without database
|
| 28 |
+
DEMO_USERS = {
|
| 29 |
+
"admin@demo.com": {
|
| 30 |
+
"password": "admin123",
|
| 31 |
+
"full_name": "Admin Demo",
|
| 32 |
+
"role": "admin",
|
| 33 |
+
"id": "admin-demo-001",
|
| 34 |
+
},
|
| 35 |
+
"instructor@demo.com": {
|
| 36 |
+
"password": "instructor123",
|
| 37 |
+
"full_name": "Instructor Demo",
|
| 38 |
+
"role": "instructor",
|
| 39 |
+
"id": "instructor-demo-001",
|
| 40 |
+
},
|
| 41 |
+
"student@demo.com": {
|
| 42 |
+
"password": "student123",
|
| 43 |
+
"full_name": "Student Demo",
|
| 44 |
+
"role": "student",
|
| 45 |
+
"id": "student-demo-001",
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
| 51 |
+
async def register(
|
| 52 |
+
data: UserCreate,
|
| 53 |
+
db: AsyncSession = Depends(get_db),
|
| 54 |
+
):
|
| 55 |
+
"""Register a new user."""
|
| 56 |
+
try:
|
| 57 |
+
# Check if email exists
|
| 58 |
+
result = await db.execute(select(User).where(User.email == data.email))
|
| 59 |
+
if result.scalar_one_or_none():
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 62 |
+
detail="Email already registered",
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Create user
|
| 66 |
+
user = User(
|
| 67 |
+
email=data.email,
|
| 68 |
+
hashed_password=hash_password(data.password),
|
| 69 |
+
full_name=data.full_name,
|
| 70 |
+
role=data.role, # Use role from request (student or instructor)
|
| 71 |
+
)
|
| 72 |
+
db.add(user)
|
| 73 |
+
await db.commit()
|
| 74 |
+
await db.refresh(user)
|
| 75 |
+
|
| 76 |
+
# Create tokens
|
| 77 |
+
tokens = create_tokens(user.id, user.email, user.role.value)
|
| 78 |
+
|
| 79 |
+
return TokenResponse(
|
| 80 |
+
**tokens,
|
| 81 |
+
user=UserResponse.model_validate(user),
|
| 82 |
+
)
|
| 83 |
+
except Exception as e:
|
| 84 |
+
# Fallback to demo mode if database is not available
|
| 85 |
+
if "password authentication failed" in str(e) or "connect" in str(e).lower():
|
| 86 |
+
# Demo mode: Create demo user
|
| 87 |
+
demo_id = f"demo-{hash(data.email)}"
|
| 88 |
+
tokens = create_tokens(demo_id, data.email, "student")
|
| 89 |
+
|
| 90 |
+
demo_user = UserResponse(
|
| 91 |
+
id=demo_id,
|
| 92 |
+
email=data.email,
|
| 93 |
+
full_name=data.full_name,
|
| 94 |
+
avatar_url=None,
|
| 95 |
+
role="student",
|
| 96 |
+
is_active=True,
|
| 97 |
+
is_verified=True,
|
| 98 |
+
bio=None,
|
| 99 |
+
created_at=datetime.utcnow(),
|
| 100 |
+
last_login=None,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return TokenResponse(
|
| 104 |
+
**tokens,
|
| 105 |
+
user=demo_user,
|
| 106 |
+
)
|
| 107 |
+
raise
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.post("/login", response_model=TokenResponse)
|
| 111 |
+
async def login(
|
| 112 |
+
data: UserLogin,
|
| 113 |
+
db: AsyncSession = Depends(get_db),
|
| 114 |
+
):
|
| 115 |
+
"""Login and get tokens."""
|
| 116 |
+
try:
|
| 117 |
+
# Try database first
|
| 118 |
+
try:
|
| 119 |
+
# Find user in database
|
| 120 |
+
result = await db.execute(select(User).where(User.email == data.email))
|
| 121 |
+
user = result.scalar_one_or_none()
|
| 122 |
+
|
| 123 |
+
if not user or not verify_password(data.password, user.hashed_password):
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 126 |
+
detail="Invalid email or password",
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if not user.is_active:
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 132 |
+
detail="Account is deactivated",
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Update last login
|
| 136 |
+
user.last_login = datetime.utcnow()
|
| 137 |
+
await db.commit()
|
| 138 |
+
await db.refresh(user)
|
| 139 |
+
|
| 140 |
+
# Create tokens
|
| 141 |
+
tokens = create_tokens(user.id, user.email, user.role.value)
|
| 142 |
+
|
| 143 |
+
return TokenResponse(
|
| 144 |
+
**tokens,
|
| 145 |
+
user=UserResponse.model_validate(user),
|
| 146 |
+
)
|
| 147 |
+
except HTTPException:
|
| 148 |
+
raise
|
| 149 |
+
except Exception as db_error:
|
| 150 |
+
# Database failed, fall back to demo mode
|
| 151 |
+
print(f"⚠️ Database error during login: {db_error}")
|
| 152 |
+
raise db_error
|
| 153 |
+
|
| 154 |
+
except HTTPException:
|
| 155 |
+
# Let HTTP exceptions pass through
|
| 156 |
+
raise
|
| 157 |
+
except Exception as e:
|
| 158 |
+
# Fallback to demo mode if database is not available
|
| 159 |
+
error_str = str(e).lower()
|
| 160 |
+
if "password authentication failed" in error_str or "connect" in error_str or "cannot connect" in error_str:
|
| 161 |
+
# Check demo users
|
| 162 |
+
if data.email in DEMO_USERS:
|
| 163 |
+
demo_user = DEMO_USERS[data.email]
|
| 164 |
+
if demo_user["password"] != data.password:
|
| 165 |
+
raise HTTPException(
|
| 166 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 167 |
+
detail="Invalid email or password (demo mode)",
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
tokens = create_tokens(demo_user["id"], data.email, demo_user["role"])
|
| 171 |
+
return TokenResponse(
|
| 172 |
+
**tokens,
|
| 173 |
+
user=UserResponse(
|
| 174 |
+
id=demo_user["id"],
|
| 175 |
+
email=data.email,
|
| 176 |
+
full_name=demo_user["full_name"],
|
| 177 |
+
avatar_url=None,
|
| 178 |
+
role=demo_user["role"],
|
| 179 |
+
is_active=True,
|
| 180 |
+
is_verified=True,
|
| 181 |
+
bio=None,
|
| 182 |
+
created_at=datetime.utcnow(),
|
| 183 |
+
last_login=None,
|
| 184 |
+
),
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Any other credentials work in demo mode
|
| 188 |
+
user_id = f"demo-user-{hash(data.email)}"
|
| 189 |
+
tokens = create_tokens(user_id, data.email, "student")
|
| 190 |
+
return TokenResponse(
|
| 191 |
+
**tokens,
|
| 192 |
+
user=UserResponse(
|
| 193 |
+
id=user_id,
|
| 194 |
+
email=data.email,
|
| 195 |
+
full_name="Demo User",
|
| 196 |
+
avatar_url=None,
|
| 197 |
+
role="student",
|
| 198 |
+
is_active=True,
|
| 199 |
+
is_verified=True,
|
| 200 |
+
bio=None,
|
| 201 |
+
created_at=datetime.utcnow(),
|
| 202 |
+
last_login=None,
|
| 203 |
+
),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
# For other errors, still try demo mode
|
| 207 |
+
print(f"⚠️ Login error: {e}")
|
| 208 |
+
|
| 209 |
+
# Check demo users
|
| 210 |
+
if data.email in DEMO_USERS:
|
| 211 |
+
demo_user = DEMO_USERS[data.email]
|
| 212 |
+
if demo_user["password"] != data.password:
|
| 213 |
+
raise HTTPException(
|
| 214 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 215 |
+
detail="Invalid email or password",
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
tokens = create_tokens(demo_user["id"], data.email, demo_user["role"])
|
| 219 |
+
return TokenResponse(
|
| 220 |
+
**tokens,
|
| 221 |
+
user=UserResponse(
|
| 222 |
+
id=demo_user["id"],
|
| 223 |
+
email=data.email,
|
| 224 |
+
full_name=demo_user["full_name"],
|
| 225 |
+
avatar_url=None,
|
| 226 |
+
role=demo_user["role"],
|
| 227 |
+
is_active=True,
|
| 228 |
+
is_verified=True,
|
| 229 |
+
bio=None,
|
| 230 |
+
created_at=datetime.utcnow(),
|
| 231 |
+
last_login=None,
|
| 232 |
+
),
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Generic fallback user
|
| 236 |
+
user_id = f"demo-user-{hash(data.email)}"
|
| 237 |
+
tokens = create_tokens(user_id, data.email, "student")
|
| 238 |
+
return TokenResponse(
|
| 239 |
+
**tokens,
|
| 240 |
+
user=UserResponse(
|
| 241 |
+
id=user_id,
|
| 242 |
+
email=data.email,
|
| 243 |
+
full_name="Demo User",
|
| 244 |
+
avatar_url=None,
|
| 245 |
+
role="student",
|
| 246 |
+
is_active=True,
|
| 247 |
+
is_verified=True,
|
| 248 |
+
bio=None,
|
| 249 |
+
created_at=datetime.utcnow(),
|
| 250 |
+
last_login=None,
|
| 251 |
+
),
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.post("/refresh", response_model=TokenResponse)
|
| 256 |
+
async def refresh_token(
|
| 257 |
+
data: RefreshTokenRequest,
|
| 258 |
+
db: AsyncSession = Depends(get_db),
|
| 259 |
+
):
|
| 260 |
+
"""Refresh access token using refresh token."""
|
| 261 |
+
payload = decode_token(data.refresh_token)
|
| 262 |
+
|
| 263 |
+
if not payload or payload.get("type") != "refresh":
|
| 264 |
+
raise HTTPException(
|
| 265 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 266 |
+
detail="Invalid refresh token",
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
user_id = payload.get("sub")
|
| 270 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 271 |
+
user = result.scalar_one_or_none()
|
| 272 |
+
|
| 273 |
+
if not user or not user.is_active:
|
| 274 |
+
raise HTTPException(
|
| 275 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 276 |
+
detail="User not found or inactive",
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
tokens = create_tokens(user.id, user.email, user.role.value)
|
| 280 |
+
|
| 281 |
+
return TokenResponse(
|
| 282 |
+
**tokens,
|
| 283 |
+
user=UserResponse.model_validate(user),
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@router.get("/me", response_model=UserResponse)
|
| 288 |
+
async def get_me(
|
| 289 |
+
current_user: User = Depends(get_current_user),
|
| 290 |
+
):
|
| 291 |
+
"""Get current user profile."""
|
| 292 |
+
return UserResponse.model_validate(current_user)
|
app/routes/courses.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Course management routes."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import aiofiles
|
| 5 |
+
from typing import List
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlalchemy import select, func
|
| 9 |
+
from sqlalchemy.orm import selectinload
|
| 10 |
+
from app.database import get_db
|
| 11 |
+
from app.models.user import User, UserRole
|
| 12 |
+
from app.models.course import Course, Module, Lesson
|
| 13 |
+
from app.models.progress import Enrollment
|
| 14 |
+
from app.schemas.course import (
|
| 15 |
+
CourseCreate,
|
| 16 |
+
CourseUpdate,
|
| 17 |
+
CourseResponse,
|
| 18 |
+
CourseBrief,
|
| 19 |
+
CourseWithModules,
|
| 20 |
+
ModuleCreate,
|
| 21 |
+
ModuleUpdate,
|
| 22 |
+
ModuleResponse,
|
| 23 |
+
ModuleWithLessons,
|
| 24 |
+
LessonCreate,
|
| 25 |
+
LessonUpdate,
|
| 26 |
+
LessonResponse,
|
| 27 |
+
)
|
| 28 |
+
from app.schemas.progress import EnrollmentWithStudent
|
| 29 |
+
from app.dependencies import get_current_user, require_instructor
|
| 30 |
+
from app.config import settings
|
| 31 |
+
from app.services.tutor import tutor_service
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
router = APIRouter(prefix="/courses", tags=["Courses"])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ─── Courses ─────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
@router.get("", response_model=List[CourseBrief])
|
| 40 |
+
async def list_courses(
|
| 41 |
+
published_only: bool = True,
|
| 42 |
+
db: AsyncSession = Depends(get_db),
|
| 43 |
+
):
|
| 44 |
+
"""List all courses (public endpoint)."""
|
| 45 |
+
try:
|
| 46 |
+
query = select(Course).order_by(Course.order_index)
|
| 47 |
+
if published_only:
|
| 48 |
+
query = query.where(Course.is_published == True)
|
| 49 |
+
|
| 50 |
+
result = await db.execute(query)
|
| 51 |
+
return [CourseBrief.model_validate(c) for c in result.scalars().all()]
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"⚠️ Could not fetch courses: {e}")
|
| 54 |
+
return []
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/{course_id}", response_model=CourseWithModules)
|
| 58 |
+
async def get_course(
|
| 59 |
+
course_id: str,
|
| 60 |
+
db: AsyncSession = Depends(get_db),
|
| 61 |
+
):
|
| 62 |
+
"""Get course with modules and lessons."""
|
| 63 |
+
try:
|
| 64 |
+
result = await db.execute(
|
| 65 |
+
select(Course)
|
| 66 |
+
.options(
|
| 67 |
+
selectinload(Course.modules).selectinload(Module.lessons),
|
| 68 |
+
selectinload(Course.enrollments),
|
| 69 |
+
)
|
| 70 |
+
.where(Course.id == course_id)
|
| 71 |
+
)
|
| 72 |
+
course = result.scalar_one_or_none()
|
| 73 |
+
|
| 74 |
+
if not course:
|
| 75 |
+
raise HTTPException(status_code=404, detail="Course not found")
|
| 76 |
+
|
| 77 |
+
# Build response with counts
|
| 78 |
+
response = CourseWithModules.model_validate(course)
|
| 79 |
+
response.module_count = len(course.modules)
|
| 80 |
+
response.enrolled_count = len(course.enrollments)
|
| 81 |
+
response.modules = [
|
| 82 |
+
ModuleWithLessons(
|
| 83 |
+
id=m.id,
|
| 84 |
+
course_id=m.course_id,
|
| 85 |
+
title=m.title,
|
| 86 |
+
description=m.description,
|
| 87 |
+
order_index=m.order_index,
|
| 88 |
+
is_published=m.is_published,
|
| 89 |
+
prerequisite_module_id=m.prerequisite_module_id,
|
| 90 |
+
created_at=m.created_at,
|
| 91 |
+
lesson_count=len(m.lessons),
|
| 92 |
+
lessons=[l for l in m.lessons],
|
| 93 |
+
)
|
| 94 |
+
for m in course.modules
|
| 95 |
+
]
|
| 96 |
+
return response
|
| 97 |
+
except HTTPException:
|
| 98 |
+
raise
|
| 99 |
+
except Exception as e:
|
| 100 |
+
print(f"⚠️ Could not fetch course {course_id}: {e}")
|
| 101 |
+
raise HTTPException(status_code=404, detail="Course not found")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/{course_id}/enrollments", response_model=List[EnrollmentWithStudent])
|
| 105 |
+
async def get_course_enrollments(
|
| 106 |
+
course_id: str,
|
| 107 |
+
current_user: User = Depends(require_instructor),
|
| 108 |
+
db: AsyncSession = Depends(get_db),
|
| 109 |
+
):
|
| 110 |
+
"""Get all enrollments for a course (instructor/admin only)."""
|
| 111 |
+
# Verify course exists and user has permission
|
| 112 |
+
course_result = await db.execute(select(Course).where(Course.id == course_id))
|
| 113 |
+
course = course_result.scalar_one_or_none()
|
| 114 |
+
|
| 115 |
+
if not course:
|
| 116 |
+
raise HTTPException(status_code=404, detail="Course not found")
|
| 117 |
+
|
| 118 |
+
# Only course creator or admin can view enrollments
|
| 119 |
+
if current_user.role != UserRole.ADMIN and course.created_by != current_user.id:
|
| 120 |
+
raise HTTPException(status_code=403, detail="Not authorized to view this course's enrollments")
|
| 121 |
+
|
| 122 |
+
# Get enrollments with student data
|
| 123 |
+
result = await db.execute(
|
| 124 |
+
select(Enrollment)
|
| 125 |
+
.options(selectinload(Enrollment.student))
|
| 126 |
+
.where(Enrollment.course_id == course_id)
|
| 127 |
+
.order_by(Enrollment.enrolled_at.desc())
|
| 128 |
+
)
|
| 129 |
+
enrollments = result.scalars().all()
|
| 130 |
+
|
| 131 |
+
# Build response with student info
|
| 132 |
+
return [
|
| 133 |
+
EnrollmentWithStudent(
|
| 134 |
+
id=e.id,
|
| 135 |
+
student_id=e.student_id,
|
| 136 |
+
student_name=e.student.full_name,
|
| 137 |
+
student_email=e.student.email,
|
| 138 |
+
course_id=e.course_id,
|
| 139 |
+
enrolled_at=e.enrolled_at,
|
| 140 |
+
completed_at=e.completed_at,
|
| 141 |
+
is_completed=e.is_completed,
|
| 142 |
+
progress_percentage=e.progress_percentage,
|
| 143 |
+
)
|
| 144 |
+
for e in enrollments
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.post("", response_model=CourseResponse, status_code=status.HTTP_201_CREATED)
|
| 149 |
+
async def create_course(
|
| 150 |
+
data: CourseCreate,
|
| 151 |
+
current_user: User = Depends(require_instructor),
|
| 152 |
+
db: AsyncSession = Depends(get_db),
|
| 153 |
+
):
|
| 154 |
+
"""Create a new course (instructors/admins only)."""
|
| 155 |
+
# Check slug uniqueness
|
| 156 |
+
result = await db.execute(select(Course).where(Course.slug == data.slug))
|
| 157 |
+
if result.scalar_one_or_none():
|
| 158 |
+
raise HTTPException(status_code=400, detail="Slug already exists")
|
| 159 |
+
|
| 160 |
+
course = Course(**data.model_dump(), created_by=current_user.id)
|
| 161 |
+
db.add(course)
|
| 162 |
+
await db.commit()
|
| 163 |
+
await db.refresh(course)
|
| 164 |
+
|
| 165 |
+
return CourseResponse.model_validate(course)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@router.patch("/{course_id}", response_model=CourseResponse)
|
| 169 |
+
async def update_course(
|
| 170 |
+
course_id: str,
|
| 171 |
+
data: CourseUpdate,
|
| 172 |
+
current_user: User = Depends(require_instructor),
|
| 173 |
+
db: AsyncSession = Depends(get_db),
|
| 174 |
+
):
|
| 175 |
+
"""Update a course."""
|
| 176 |
+
result = await db.execute(select(Course).where(Course.id == course_id))
|
| 177 |
+
course = result.scalar_one_or_none()
|
| 178 |
+
|
| 179 |
+
if not course:
|
| 180 |
+
raise HTTPException(status_code=404, detail="Course not found")
|
| 181 |
+
|
| 182 |
+
# Only course creator or admin can update
|
| 183 |
+
if current_user.role != UserRole.ADMIN and course.created_by != current_user.id:
|
| 184 |
+
raise HTTPException(status_code=403, detail="You can only update your own courses")
|
| 185 |
+
|
| 186 |
+
for field, value in data.model_dump(exclude_unset=True).items():
|
| 187 |
+
setattr(course, field, value)
|
| 188 |
+
|
| 189 |
+
await db.commit()
|
| 190 |
+
await db.refresh(course)
|
| 191 |
+
|
| 192 |
+
return CourseResponse.model_validate(course)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@router.delete("/{course_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 196 |
+
async def delete_course(
|
| 197 |
+
course_id: str,
|
| 198 |
+
current_user: User = Depends(require_instructor),
|
| 199 |
+
db: AsyncSession = Depends(get_db),
|
| 200 |
+
):
|
| 201 |
+
"""Delete a course."""
|
| 202 |
+
result = await db.execute(select(Course).where(Course.id == course_id))
|
| 203 |
+
course = result.scalar_one_or_none()
|
| 204 |
+
|
| 205 |
+
if not course:
|
| 206 |
+
raise HTTPException(status_code=404, detail="Course not found")
|
| 207 |
+
|
| 208 |
+
# Only course creator or admin can delete
|
| 209 |
+
if current_user.role != UserRole.ADMIN and course.created_by != current_user.id:
|
| 210 |
+
raise HTTPException(status_code=403, detail="You can only delete your own courses")
|
| 211 |
+
|
| 212 |
+
await db.delete(course)
|
| 213 |
+
await db.commit()
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ─── Modules ─────────────────────────────────────────────
|
| 217 |
+
|
| 218 |
+
@router.post("/{course_id}/modules", response_model=ModuleResponse, status_code=status.HTTP_201_CREATED)
|
| 219 |
+
async def create_module(
|
| 220 |
+
course_id: str,
|
| 221 |
+
data: ModuleCreate,
|
| 222 |
+
current_user: User = Depends(require_instructor),
|
| 223 |
+
db: AsyncSession = Depends(get_db),
|
| 224 |
+
):
|
| 225 |
+
"""Create a new module in a course."""
|
| 226 |
+
result = await db.execute(select(Course).where(Course.id == course_id))
|
| 227 |
+
course = result.scalar_one_or_none()
|
| 228 |
+
if not course:
|
| 229 |
+
raise HTTPException(status_code=404, detail="Course not found")
|
| 230 |
+
|
| 231 |
+
# Only course creator or admin can add modules
|
| 232 |
+
if current_user.role != UserRole.ADMIN and course.created_by != current_user.id:
|
| 233 |
+
raise HTTPException(status_code=403, detail="You can only add modules to your own courses")
|
| 234 |
+
|
| 235 |
+
module = Module(**data.model_dump(), course_id=course_id)
|
| 236 |
+
db.add(module)
|
| 237 |
+
await db.commit()
|
| 238 |
+
await db.refresh(module)
|
| 239 |
+
|
| 240 |
+
return ModuleResponse.model_validate(module)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@router.patch("/modules/{module_id}", response_model=ModuleResponse)
|
| 244 |
+
async def update_module(
|
| 245 |
+
module_id: str,
|
| 246 |
+
data: ModuleUpdate,
|
| 247 |
+
current_user: User = Depends(require_instructor),
|
| 248 |
+
db: AsyncSession = Depends(get_db),
|
| 249 |
+
):
|
| 250 |
+
"""Update a module."""
|
| 251 |
+
result = await db.execute(
|
| 252 |
+
select(Module).options(selectinload(Module.course)).where(Module.id == module_id)
|
| 253 |
+
)
|
| 254 |
+
module = result.scalar_one_or_none()
|
| 255 |
+
|
| 256 |
+
if not module:
|
| 257 |
+
raise HTTPException(status_code=404, detail="Module not found")
|
| 258 |
+
|
| 259 |
+
# Only course creator or admin can update modules
|
| 260 |
+
if current_user.role != UserRole.ADMIN and module.course.created_by != current_user.id:
|
| 261 |
+
raise HTTPException(status_code=403, detail="You can only update modules in your own courses")
|
| 262 |
+
|
| 263 |
+
for field, value in data.model_dump(exclude_unset=True).items():
|
| 264 |
+
setattr(module, field, value)
|
| 265 |
+
|
| 266 |
+
await db.commit()
|
| 267 |
+
await db.refresh(module)
|
| 268 |
+
|
| 269 |
+
return ModuleResponse.model_validate(module)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@router.delete("/modules/{module_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 273 |
+
async def delete_module(
|
| 274 |
+
module_id: str,
|
| 275 |
+
current_user: User = Depends(require_instructor),
|
| 276 |
+
db: AsyncSession = Depends(get_db),
|
| 277 |
+
):
|
| 278 |
+
"""Delete a module and all its lessons."""
|
| 279 |
+
result = await db.execute(
|
| 280 |
+
select(Module).options(selectinload(Module.course)).where(Module.id == module_id)
|
| 281 |
+
)
|
| 282 |
+
module = result.scalar_one_or_none()
|
| 283 |
+
|
| 284 |
+
if not module:
|
| 285 |
+
raise HTTPException(status_code=404, detail="Module not found")
|
| 286 |
+
|
| 287 |
+
# Only course creator or admin can delete modules
|
| 288 |
+
if current_user.role != UserRole.ADMIN and module.course.created_by != current_user.id:
|
| 289 |
+
raise HTTPException(status_code=403, detail="You can only delete modules from your own courses")
|
| 290 |
+
|
| 291 |
+
await db.delete(module)
|
| 292 |
+
await db.commit()
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# ─── Lessons ─────────────────────────────────────────────
|
| 296 |
+
|
| 297 |
+
@router.post("/modules/{module_id}/lessons", response_model=LessonResponse, status_code=status.HTTP_201_CREATED)
|
| 298 |
+
async def create_lesson(
|
| 299 |
+
module_id: str,
|
| 300 |
+
data: LessonCreate,
|
| 301 |
+
current_user: User = Depends(require_instructor),
|
| 302 |
+
db: AsyncSession = Depends(get_db),
|
| 303 |
+
):
|
| 304 |
+
"""Create a new lesson in a module."""
|
| 305 |
+
result = await db.execute(
|
| 306 |
+
select(Module).options(selectinload(Module.course)).where(Module.id == module_id)
|
| 307 |
+
)
|
| 308 |
+
module = result.scalar_one_or_none()
|
| 309 |
+
if not module:
|
| 310 |
+
raise HTTPException(status_code=404, detail="Module not found")
|
| 311 |
+
|
| 312 |
+
# Only course creator or admin can add lessons
|
| 313 |
+
if current_user.role != UserRole.ADMIN and module.course.created_by != current_user.id:
|
| 314 |
+
raise HTTPException(status_code=403, detail="You can only add lessons to your own courses")
|
| 315 |
+
|
| 316 |
+
lesson = Lesson(**data.model_dump(), module_id=module_id)
|
| 317 |
+
db.add(lesson)
|
| 318 |
+
await db.commit()
|
| 319 |
+
await db.refresh(lesson)
|
| 320 |
+
|
| 321 |
+
# Index lesson content for AI tutor (background task)
|
| 322 |
+
try:
|
| 323 |
+
await tutor_service.index_lesson_content(
|
| 324 |
+
lesson_id=str(lesson.id),
|
| 325 |
+
module_id=str(module_id),
|
| 326 |
+
title=lesson.title,
|
| 327 |
+
content_text=lesson.content_text,
|
| 328 |
+
content_url=lesson.content_url,
|
| 329 |
+
content_type=lesson.content_type.value if lesson.content_type else "markdown",
|
| 330 |
+
)
|
| 331 |
+
except Exception as e:
|
| 332 |
+
print(f"⚠️ Failed to index lesson content: {e}")
|
| 333 |
+
# Don't fail the request if indexing fails
|
| 334 |
+
|
| 335 |
+
return LessonResponse.model_validate(lesson)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@router.get("/lessons/{lesson_id}", response_model=LessonResponse)
|
| 339 |
+
async def get_lesson(
|
| 340 |
+
lesson_id: str,
|
| 341 |
+
db: AsyncSession = Depends(get_db),
|
| 342 |
+
):
|
| 343 |
+
"""Get a specific lesson."""
|
| 344 |
+
try:
|
| 345 |
+
result = await db.execute(select(Lesson).where(Lesson.id == lesson_id))
|
| 346 |
+
lesson = result.scalar_one_or_none()
|
| 347 |
+
|
| 348 |
+
if not lesson:
|
| 349 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 350 |
+
|
| 351 |
+
return LessonResponse.model_validate(lesson)
|
| 352 |
+
except HTTPException:
|
| 353 |
+
raise
|
| 354 |
+
except Exception as e:
|
| 355 |
+
print(f"⚠️ Could not fetch lesson {lesson_id}: {e}")
|
| 356 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@router.patch("/lessons/{lesson_id}", response_model=LessonResponse)
|
| 360 |
+
async def update_lesson(
|
| 361 |
+
lesson_id: str,
|
| 362 |
+
data: LessonUpdate,
|
| 363 |
+
current_user: User = Depends(require_instructor),
|
| 364 |
+
db: AsyncSession = Depends(get_db),
|
| 365 |
+
):
|
| 366 |
+
"""Update a lesson."""
|
| 367 |
+
result = await db.execute(
|
| 368 |
+
select(Lesson).options(
|
| 369 |
+
selectinload(Lesson.module).selectinload(Module.course)
|
| 370 |
+
).where(Lesson.id == lesson_id)
|
| 371 |
+
)
|
| 372 |
+
lesson = result.scalar_one_or_none()
|
| 373 |
+
|
| 374 |
+
if not lesson:
|
| 375 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 376 |
+
|
| 377 |
+
# Only course creator or admin can update lessons
|
| 378 |
+
if current_user.role != UserRole.ADMIN and lesson.module.course.created_by != current_user.id:
|
| 379 |
+
raise HTTPException(status_code=403, detail="You can only update lessons in your own courses")
|
| 380 |
+
|
| 381 |
+
for field, value in data.model_dump(exclude_unset=True).items():
|
| 382 |
+
setattr(lesson, field, value)
|
| 383 |
+
|
| 384 |
+
await db.commit()
|
| 385 |
+
await db.refresh(lesson)
|
| 386 |
+
|
| 387 |
+
return LessonResponse.model_validate(lesson)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ─── File Upload ─────────────────────────────────────────
|
| 391 |
+
|
| 392 |
+
ALLOWED_EXTENSIONS = {".pdf", ".mp4", ".mov", ".avi", ".mkv", ".md", ".txt", ".docx", ".pptx"}
|
| 393 |
+
MAX_FILE_SIZE = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 # Convert MB to bytes
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
@router.post("/lessons/upload", status_code=status.HTTP_201_CREATED)
|
| 397 |
+
async def upload_lesson_file(
|
| 398 |
+
file: UploadFile = File(...),
|
| 399 |
+
current_user: User = Depends(require_instructor),
|
| 400 |
+
):
|
| 401 |
+
"""Upload a file for a lesson (PDF, video, etc.)."""
|
| 402 |
+
|
| 403 |
+
# Validate file extension
|
| 404 |
+
ext = os.path.splitext(file.filename)[1].lower()
|
| 405 |
+
if ext not in ALLOWED_EXTENSIONS:
|
| 406 |
+
raise HTTPException(
|
| 407 |
+
status_code=400,
|
| 408 |
+
detail=f"Unsupported file type: {ext}. Allowed: {', '.join(ALLOWED_EXTENSIONS)}",
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
# Check file size
|
| 412 |
+
content = await file.read()
|
| 413 |
+
if len(content) > MAX_FILE_SIZE:
|
| 414 |
+
raise HTTPException(
|
| 415 |
+
status_code=400,
|
| 416 |
+
detail=f"File too large. Maximum size: {settings.MAX_UPLOAD_SIZE_MB}MB",
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
# Generate unique filename
|
| 420 |
+
import uuid
|
| 421 |
+
from datetime import datetime
|
| 422 |
+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
| 423 |
+
unique_filename = f"{timestamp}_{uuid.uuid4().hex[:8]}_{file.filename}"
|
| 424 |
+
file_path = os.path.join(settings.UPLOAD_DIR, unique_filename)
|
| 425 |
+
|
| 426 |
+
# Save file
|
| 427 |
+
try:
|
| 428 |
+
async with aiofiles.open(file_path, "wb") as f:
|
| 429 |
+
await f.write(content)
|
| 430 |
+
except Exception as e:
|
| 431 |
+
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
|
| 432 |
+
|
| 433 |
+
# Return file URL
|
| 434 |
+
file_url = f"/uploads/{unique_filename}"
|
| 435 |
+
|
| 436 |
+
return {
|
| 437 |
+
"filename": file.filename,
|
| 438 |
+
"file_url": file_url,
|
| 439 |
+
"file_size": len(content),
|
| 440 |
+
"content_type": file.content_type,
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
@router.get("/uploads/{filename}")
|
| 445 |
+
async def download_file(filename: str):
|
| 446 |
+
"""Download/view uploaded file."""
|
| 447 |
+
from fastapi.responses import FileResponse
|
| 448 |
+
|
| 449 |
+
file_path = os.path.join(settings.UPLOAD_DIR, filename)
|
| 450 |
+
|
| 451 |
+
if not os.path.exists(file_path):
|
| 452 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 453 |
+
|
| 454 |
+
return FileResponse(
|
| 455 |
+
file_path,
|
| 456 |
+
filename=filename,
|
| 457 |
+
media_type="application/octet-stream",
|
| 458 |
+
)
|
| 459 |
+
|
app/routes/documents.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document management routes for resource library."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlalchemy import select, and_, or_
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app.models.user import User, UserRole
|
| 11 |
+
from app.models.document import Document
|
| 12 |
+
from app.dependencies import get_current_user, require_instructor
|
| 13 |
+
from app.config import settings
|
| 14 |
+
from app.services.tutor import tutor_service
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
import aiofiles
|
| 17 |
+
from uuid import uuid4
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/documents", tags=["Documents"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ─── Schemas ─────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
class DocumentMeta(BaseModel):
|
| 26 |
+
"""Metadata stored in document."""
|
| 27 |
+
course_id: Optional[str] = None
|
| 28 |
+
module_id: Optional[str] = None
|
| 29 |
+
lesson_id: Optional[str] = None
|
| 30 |
+
category: Optional[str] = None
|
| 31 |
+
tags: Optional[List[str]] = []
|
| 32 |
+
uploaded_by: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class DocumentResponse(BaseModel):
|
| 36 |
+
id: str
|
| 37 |
+
filename: str
|
| 38 |
+
file_path: Optional[str] = None
|
| 39 |
+
chunk_index: int
|
| 40 |
+
meta: Optional[dict] = None
|
| 41 |
+
created_at: datetime
|
| 42 |
+
updated_at: datetime
|
| 43 |
+
|
| 44 |
+
class Config:
|
| 45 |
+
from_attributes = True
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class DocumentBrief(BaseModel):
|
| 49 |
+
"""Grouped document info (one per file, not per chunk)."""
|
| 50 |
+
id: str
|
| 51 |
+
filename: str
|
| 52 |
+
file_path: str
|
| 53 |
+
category: Optional[str] = None
|
| 54 |
+
tags: Optional[List[str]] = []
|
| 55 |
+
chunk_count: int
|
| 56 |
+
created_at: datetime
|
| 57 |
+
uploaded_by: Optional[str] = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class DocumentUpload(BaseModel):
|
| 61 |
+
course_id: Optional[str] = None
|
| 62 |
+
module_id: Optional[str] = None
|
| 63 |
+
lesson_id: Optional[str] = None
|
| 64 |
+
category: Optional[str] = "resource"
|
| 65 |
+
tags: Optional[List[str]] = []
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ─── Document Upload & Management ────────────────────────
|
| 69 |
+
|
| 70 |
+
@router.post("/upload", response_model=dict)
|
| 71 |
+
async def upload_document(
|
| 72 |
+
file: UploadFile = File(...),
|
| 73 |
+
course_id: Optional[str] = None,
|
| 74 |
+
module_id: Optional[str] = None,
|
| 75 |
+
lesson_id: Optional[str] = None,
|
| 76 |
+
category: str = "resource",
|
| 77 |
+
current_user: User = Depends(require_instructor),
|
| 78 |
+
db: AsyncSession = Depends(get_db),
|
| 79 |
+
):
|
| 80 |
+
"""Upload a document for indexing into vector store (instructor only)."""
|
| 81 |
+
|
| 82 |
+
# Validate file type
|
| 83 |
+
allowed_extensions = [".pdf", ".txt", ".md", ".docx"]
|
| 84 |
+
file_ext = os.path.splitext(file.filename)[1].lower()
|
| 85 |
+
if file_ext not in allowed_extensions:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 88 |
+
detail=f"File type not supported. Allowed: {', '.join(allowed_extensions)}"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Save file to uploads directory
|
| 92 |
+
file_id = str(uuid4())
|
| 93 |
+
filename = f"{file_id}_{file.filename}"
|
| 94 |
+
file_path = os.path.join(settings.UPLOAD_DIR, filename)
|
| 95 |
+
|
| 96 |
+
try:
|
| 97 |
+
async with aiofiles.open(file_path, "wb") as f:
|
| 98 |
+
content = await file.read()
|
| 99 |
+
await f.write(content)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
raise HTTPException(
|
| 102 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 103 |
+
detail=f"Failed to save file: {str(e)}"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Extract text and create embeddings
|
| 107 |
+
try:
|
| 108 |
+
# Read file content
|
| 109 |
+
if file_ext == ".pdf":
|
| 110 |
+
from PyPDF2 import PdfReader
|
| 111 |
+
reader = PdfReader(file_path)
|
| 112 |
+
text_parts = []
|
| 113 |
+
for page in reader.pages[:50]: # Max 50 pages
|
| 114 |
+
text = page.extract_text()
|
| 115 |
+
if text:
|
| 116 |
+
text_parts.append(text)
|
| 117 |
+
file_content = "\n\n".join(text_parts)
|
| 118 |
+
elif file_ext in [".txt", ".md"]:
|
| 119 |
+
async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
|
| 120 |
+
file_content = await f.read()
|
| 121 |
+
else:
|
| 122 |
+
# For DOCX, would need python-docx library
|
| 123 |
+
file_content = ""
|
| 124 |
+
|
| 125 |
+
if not file_content or len(file_content.strip()) < 50:
|
| 126 |
+
os.remove(file_path)
|
| 127 |
+
raise HTTPException(
|
| 128 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 129 |
+
detail="Document content is too short or empty"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Index using tutor service
|
| 133 |
+
await tutor_service.initialize()
|
| 134 |
+
|
| 135 |
+
# Split into chunks
|
| 136 |
+
chunks = tutor_service.text_splitter.split_text(file_content)
|
| 137 |
+
|
| 138 |
+
# Create metadata
|
| 139 |
+
metadata = {
|
| 140 |
+
"filename": file.filename,
|
| 141 |
+
"file_path": filename,
|
| 142 |
+
"uploaded_by": current_user.id,
|
| 143 |
+
"category": category,
|
| 144 |
+
"course_id": course_id,
|
| 145 |
+
"module_id": module_id,
|
| 146 |
+
"lesson_id": lesson_id,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# Create embeddings for each chunk
|
| 150 |
+
chunk_ids = []
|
| 151 |
+
for i, chunk in enumerate(chunks):
|
| 152 |
+
# Get embedding
|
| 153 |
+
embedding_vector = tutor_service.embeddings.embed_query(chunk)
|
| 154 |
+
|
| 155 |
+
# Save to database
|
| 156 |
+
doc = Document(
|
| 157 |
+
content=chunk,
|
| 158 |
+
embedding=embedding_vector,
|
| 159 |
+
meta=metadata,
|
| 160 |
+
filename=file.filename,
|
| 161 |
+
file_path=filename,
|
| 162 |
+
chunk_index=i,
|
| 163 |
+
)
|
| 164 |
+
db.add(doc)
|
| 165 |
+
chunk_ids.append(doc.id)
|
| 166 |
+
|
| 167 |
+
await db.commit()
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
"message": "Document uploaded and indexed successfully",
|
| 171 |
+
"filename": file.filename,
|
| 172 |
+
"chunks": len(chunks),
|
| 173 |
+
"file_path": filename,
|
| 174 |
+
"chunk_ids": chunk_ids,
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
except Exception as e:
|
| 178 |
+
# Clean up file on error
|
| 179 |
+
if os.path.exists(file_path):
|
| 180 |
+
os.remove(file_path)
|
| 181 |
+
raise HTTPException(
|
| 182 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 183 |
+
detail=f"Failed to process document: {str(e)}"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@router.get("", response_model=List[DocumentBrief])
|
| 188 |
+
async def list_documents(
|
| 189 |
+
course_id: Optional[str] = None,
|
| 190 |
+
module_id: Optional[str] = None,
|
| 191 |
+
lesson_id: Optional[str] = None,
|
| 192 |
+
category: Optional[str] = None,
|
| 193 |
+
current_user: User = Depends(get_current_user),
|
| 194 |
+
db: AsyncSession = Depends(get_db),
|
| 195 |
+
):
|
| 196 |
+
"""List all documents (grouped by filename)."""
|
| 197 |
+
query = select(Document)
|
| 198 |
+
|
| 199 |
+
# Apply filters using JSONB operators
|
| 200 |
+
filters = []
|
| 201 |
+
if course_id:
|
| 202 |
+
filters.append(Document.meta["course_id"].astext == course_id)
|
| 203 |
+
if module_id:
|
| 204 |
+
filters.append(Document.meta["module_id"].astext == module_id)
|
| 205 |
+
if lesson_id:
|
| 206 |
+
filters.append(Document.meta["lesson_id"].astext == lesson_id)
|
| 207 |
+
if category:
|
| 208 |
+
filters.append(Document.meta["category"].astext == category)
|
| 209 |
+
|
| 210 |
+
if filters:
|
| 211 |
+
query = query.where(and_(*filters))
|
| 212 |
+
|
| 213 |
+
result = await db.execute(query.order_by(Document.created_at.desc()))
|
| 214 |
+
documents = result.scalars().all()
|
| 215 |
+
|
| 216 |
+
# Group by filename
|
| 217 |
+
grouped = {}
|
| 218 |
+
for doc in documents:
|
| 219 |
+
if doc.filename not in grouped:
|
| 220 |
+
grouped[doc.filename] = {
|
| 221 |
+
"id": doc.id,
|
| 222 |
+
"filename": doc.filename,
|
| 223 |
+
"file_path": doc.file_path or "",
|
| 224 |
+
"category": doc.meta.get("category") if doc.meta else None,
|
| 225 |
+
"tags": doc.meta.get("tags", []) if doc.meta else [],
|
| 226 |
+
"chunk_count": 0,
|
| 227 |
+
"created_at": doc.created_at,
|
| 228 |
+
"uploaded_by": doc.meta.get("uploaded_by") if doc.meta else None,
|
| 229 |
+
}
|
| 230 |
+
grouped[doc.filename]["chunk_count"] += 1
|
| 231 |
+
|
| 232 |
+
return list(grouped.values())
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@router.get("/{document_id}", response_model=DocumentResponse)
|
| 236 |
+
async def get_document(
|
| 237 |
+
document_id: str,
|
| 238 |
+
current_user: User = Depends(get_current_user),
|
| 239 |
+
db: AsyncSession = Depends(get_db),
|
| 240 |
+
):
|
| 241 |
+
"""Get document details."""
|
| 242 |
+
result = await db.execute(
|
| 243 |
+
select(Document).where(Document.id == document_id)
|
| 244 |
+
)
|
| 245 |
+
document = result.scalar_one_or_none()
|
| 246 |
+
if not document:
|
| 247 |
+
raise HTTPException(
|
| 248 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 249 |
+
detail="Document not found"
|
| 250 |
+
)
|
| 251 |
+
return document
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@router.delete("/file/{filename}", status_code=status.HTTP_204_NO_CONTENT)
|
| 255 |
+
async def delete_document_file(
|
| 256 |
+
filename: str,
|
| 257 |
+
current_user: User = Depends(require_instructor),
|
| 258 |
+
db: AsyncSession = Depends(get_db),
|
| 259 |
+
):
|
| 260 |
+
"""Delete all chunks of a document file (instructor only)."""
|
| 261 |
+
# Find all chunks with this filename
|
| 262 |
+
result = await db.execute(
|
| 263 |
+
select(Document).where(Document.filename == filename)
|
| 264 |
+
)
|
| 265 |
+
documents = result.scalars().all()
|
| 266 |
+
|
| 267 |
+
if not documents:
|
| 268 |
+
raise HTTPException(
|
| 269 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 270 |
+
detail="Document not found"
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Delete file from disk
|
| 274 |
+
file_path = os.path.join(settings.UPLOAD_DIR, documents[0].file_path)
|
| 275 |
+
if os.path.exists(file_path):
|
| 276 |
+
try:
|
| 277 |
+
os.remove(file_path)
|
| 278 |
+
except Exception as e:
|
| 279 |
+
print(f"Warning: Could not delete file {file_path}: {e}")
|
| 280 |
+
|
| 281 |
+
# Delete all chunks from database
|
| 282 |
+
for doc in documents:
|
| 283 |
+
await db.delete(doc)
|
| 284 |
+
|
| 285 |
+
await db.commit()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
@router.post("/search", response_model=List[DocumentResponse])
|
| 289 |
+
async def search_documents(
|
| 290 |
+
query: str,
|
| 291 |
+
top_k: int = 5,
|
| 292 |
+
course_id: Optional[str] = None,
|
| 293 |
+
module_id: Optional[str] = None,
|
| 294 |
+
current_user: User = Depends(get_current_user),
|
| 295 |
+
db: AsyncSession = Depends(get_db),
|
| 296 |
+
):
|
| 297 |
+
"""Semantic search across documents using vector similarity."""
|
| 298 |
+
await tutor_service.initialize()
|
| 299 |
+
|
| 300 |
+
try:
|
| 301 |
+
# Get query embedding
|
| 302 |
+
query_embedding = tutor_service.embeddings.embed_query(query)
|
| 303 |
+
|
| 304 |
+
# Build filter
|
| 305 |
+
filter_parts = []
|
| 306 |
+
if course_id:
|
| 307 |
+
filter_parts.append(Document.meta["course_id"].astext == course_id)
|
| 308 |
+
if module_id:
|
| 309 |
+
filter_parts.append(Document.meta["module_id"].astext == module_id)
|
| 310 |
+
|
| 311 |
+
# Perform vector similarity search
|
| 312 |
+
query_obj = select(Document)
|
| 313 |
+
if filter_parts:
|
| 314 |
+
query_obj = query_obj.where(and_(*filter_parts))
|
| 315 |
+
|
| 316 |
+
# Order by cosine distance
|
| 317 |
+
query_obj = query_obj.order_by(
|
| 318 |
+
Document.embedding.cosine_distance(query_embedding)
|
| 319 |
+
).limit(top_k)
|
| 320 |
+
|
| 321 |
+
result = await db.execute(query_obj)
|
| 322 |
+
documents = result.scalars().all()
|
| 323 |
+
|
| 324 |
+
return documents
|
| 325 |
+
|
| 326 |
+
except Exception as e:
|
| 327 |
+
raise HTTPException(
|
| 328 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 329 |
+
detail=f"Search failed: {str(e)}"
|
| 330 |
+
)
|
app/routes/forum.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forum and discussion routes."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
from sqlalchemy import select, and_, or_, func, desc
|
| 8 |
+
from sqlalchemy.orm import selectinload
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app.models.user import User
|
| 11 |
+
from app.models.forum import ForumPost, ForumComment
|
| 12 |
+
from app.dependencies import get_current_user
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/forum", tags=["Forum"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ─── Schemas ─────────────────────────────────────────────
|
| 20 |
+
|
| 21 |
+
class PostCreate(BaseModel):
|
| 22 |
+
module_id: Optional[str] = None
|
| 23 |
+
title: str
|
| 24 |
+
content: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class PostUpdate(BaseModel):
|
| 28 |
+
title: Optional[str] = None
|
| 29 |
+
content: Optional[str] = None
|
| 30 |
+
is_pinned: Optional[bool] = None
|
| 31 |
+
is_resolved: Optional[bool] = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class CommentCreate(BaseModel):
|
| 35 |
+
content: str
|
| 36 |
+
parent_id: Optional[str] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class CommentUpdate(BaseModel):
|
| 40 |
+
content: Optional[str] = None
|
| 41 |
+
is_accepted_answer: Optional[bool] = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class UserBrief(BaseModel):
|
| 45 |
+
id: str
|
| 46 |
+
full_name: str
|
| 47 |
+
avatar_url: Optional[str] = None
|
| 48 |
+
role: str
|
| 49 |
+
|
| 50 |
+
class Config:
|
| 51 |
+
from_attributes = True
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class CommentResponse(BaseModel):
|
| 55 |
+
id: str
|
| 56 |
+
post_id: str
|
| 57 |
+
author_id: str
|
| 58 |
+
parent_id: Optional[str] = None
|
| 59 |
+
content: str
|
| 60 |
+
is_accepted_answer: bool
|
| 61 |
+
upvotes: int
|
| 62 |
+
created_at: datetime
|
| 63 |
+
updated_at: datetime
|
| 64 |
+
author: UserBrief
|
| 65 |
+
|
| 66 |
+
class Config:
|
| 67 |
+
from_attributes = True
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class PostResponse(BaseModel):
|
| 71 |
+
id: str
|
| 72 |
+
module_id: Optional[str] = None
|
| 73 |
+
author_id: str
|
| 74 |
+
title: str
|
| 75 |
+
content: str
|
| 76 |
+
is_pinned: bool
|
| 77 |
+
is_resolved: bool
|
| 78 |
+
upvotes: int
|
| 79 |
+
view_count: int
|
| 80 |
+
created_at: datetime
|
| 81 |
+
updated_at: datetime
|
| 82 |
+
author: UserBrief
|
| 83 |
+
|
| 84 |
+
class Config:
|
| 85 |
+
from_attributes = True
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class PostWithComments(PostResponse):
|
| 89 |
+
comments: List[CommentResponse] = []
|
| 90 |
+
comment_count: int = 0
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class PostBrief(BaseModel):
|
| 94 |
+
id: str
|
| 95 |
+
module_id: Optional[str] = None
|
| 96 |
+
title: str
|
| 97 |
+
is_pinned: bool
|
| 98 |
+
is_resolved: bool
|
| 99 |
+
upvotes: int
|
| 100 |
+
view_count: int
|
| 101 |
+
created_at: datetime
|
| 102 |
+
author: UserBrief
|
| 103 |
+
comment_count: int = 0
|
| 104 |
+
|
| 105 |
+
class Config:
|
| 106 |
+
from_attributes = True
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ─── Forum Posts ─────────────────────────────────────────
|
| 110 |
+
|
| 111 |
+
@router.post("/posts", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
|
| 112 |
+
async def create_post(
|
| 113 |
+
data: PostCreate,
|
| 114 |
+
current_user: User = Depends(get_current_user),
|
| 115 |
+
db: AsyncSession = Depends(get_db),
|
| 116 |
+
):
|
| 117 |
+
"""Create a new forum post."""
|
| 118 |
+
post = ForumPost(
|
| 119 |
+
module_id=data.module_id,
|
| 120 |
+
author_id=current_user.id,
|
| 121 |
+
title=data.title,
|
| 122 |
+
content=data.content,
|
| 123 |
+
)
|
| 124 |
+
db.add(post)
|
| 125 |
+
await db.commit()
|
| 126 |
+
await db.refresh(post, ["author"])
|
| 127 |
+
return post
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@router.get("/posts", response_model=List[PostBrief])
|
| 131 |
+
async def list_posts(
|
| 132 |
+
module_id: Optional[str] = None,
|
| 133 |
+
search: Optional[str] = None,
|
| 134 |
+
resolved: Optional[bool] = None,
|
| 135 |
+
skip: int = 0,
|
| 136 |
+
limit: int = 50,
|
| 137 |
+
current_user: User = Depends(get_current_user),
|
| 138 |
+
db: AsyncSession = Depends(get_db),
|
| 139 |
+
):
|
| 140 |
+
"""List forum posts with filters."""
|
| 141 |
+
query = select(ForumPost).options(selectinload(ForumPost.author))
|
| 142 |
+
|
| 143 |
+
# Filters
|
| 144 |
+
if module_id:
|
| 145 |
+
query = query.where(ForumPost.module_id == module_id)
|
| 146 |
+
|
| 147 |
+
if search:
|
| 148 |
+
query = query.where(
|
| 149 |
+
or_(
|
| 150 |
+
ForumPost.title.ilike(f"%{search}%"),
|
| 151 |
+
ForumPost.content.ilike(f"%{search}%")
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
if resolved is not None:
|
| 156 |
+
query = query.where(ForumPost.is_resolved == resolved)
|
| 157 |
+
|
| 158 |
+
# Order: pinned first, then by creation date
|
| 159 |
+
query = query.order_by(
|
| 160 |
+
desc(ForumPost.is_pinned),
|
| 161 |
+
desc(ForumPost.created_at)
|
| 162 |
+
).offset(skip).limit(limit)
|
| 163 |
+
|
| 164 |
+
result = await db.execute(query)
|
| 165 |
+
posts = result.scalars().all()
|
| 166 |
+
|
| 167 |
+
# Get comment counts
|
| 168 |
+
posts_with_counts = []
|
| 169 |
+
for post in posts:
|
| 170 |
+
comment_count_result = await db.execute(
|
| 171 |
+
select(func.count(ForumComment.id)).where(ForumComment.post_id == post.id)
|
| 172 |
+
)
|
| 173 |
+
comment_count = comment_count_result.scalar() or 0
|
| 174 |
+
|
| 175 |
+
post_dict = {
|
| 176 |
+
"id": post.id,
|
| 177 |
+
"module_id": post.module_id,
|
| 178 |
+
"title": post.title,
|
| 179 |
+
"is_pinned": post.is_pinned,
|
| 180 |
+
"is_resolved": post.is_resolved,
|
| 181 |
+
"upvotes": post.upvotes,
|
| 182 |
+
"view_count": post.view_count,
|
| 183 |
+
"created_at": post.created_at,
|
| 184 |
+
"author": post.author,
|
| 185 |
+
"comment_count": comment_count,
|
| 186 |
+
}
|
| 187 |
+
posts_with_counts.append(post_dict)
|
| 188 |
+
|
| 189 |
+
return posts_with_counts
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@router.get("/posts/{post_id}", response_model=PostWithComments)
|
| 193 |
+
async def get_post(
|
| 194 |
+
post_id: str,
|
| 195 |
+
current_user: User = Depends(get_current_user),
|
| 196 |
+
db: AsyncSession = Depends(get_db),
|
| 197 |
+
):
|
| 198 |
+
"""Get post details with comments."""
|
| 199 |
+
result = await db.execute(
|
| 200 |
+
select(ForumPost)
|
| 201 |
+
.options(
|
| 202 |
+
selectinload(ForumPost.author),
|
| 203 |
+
selectinload(ForumPost.comments).selectinload(ForumComment.author)
|
| 204 |
+
)
|
| 205 |
+
.where(ForumPost.id == post_id)
|
| 206 |
+
)
|
| 207 |
+
post = result.scalar_one_or_none()
|
| 208 |
+
if not post:
|
| 209 |
+
raise HTTPException(
|
| 210 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 211 |
+
detail="Post not found"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# Increment view count
|
| 215 |
+
post.view_count += 1
|
| 216 |
+
await db.commit()
|
| 217 |
+
await db.refresh(post)
|
| 218 |
+
|
| 219 |
+
# Return post with comment count
|
| 220 |
+
return {
|
| 221 |
+
**{k: getattr(post, k) for k in [
|
| 222 |
+
"id", "module_id", "author_id", "title", "content",
|
| 223 |
+
"is_pinned", "is_resolved", "upvotes", "view_count",
|
| 224 |
+
"created_at", "updated_at"
|
| 225 |
+
]},
|
| 226 |
+
"author": post.author,
|
| 227 |
+
"comments": sorted(post.comments, key=lambda c: c.created_at),
|
| 228 |
+
"comment_count": len(post.comments),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@router.put("/posts/{post_id}", response_model=PostResponse)
|
| 233 |
+
async def update_post(
|
| 234 |
+
post_id: str,
|
| 235 |
+
data: PostUpdate,
|
| 236 |
+
current_user: User = Depends(get_current_user),
|
| 237 |
+
db: AsyncSession = Depends(get_db),
|
| 238 |
+
):
|
| 239 |
+
"""Update a forum post."""
|
| 240 |
+
result = await db.execute(
|
| 241 |
+
select(ForumPost).where(ForumPost.id == post_id)
|
| 242 |
+
)
|
| 243 |
+
post = result.scalar_one_or_none()
|
| 244 |
+
if not post:
|
| 245 |
+
raise HTTPException(
|
| 246 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 247 |
+
detail="Post not found"
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Check permissions (author or instructor)
|
| 251 |
+
if post.author_id != current_user.id and current_user.role.value != "instructor":
|
| 252 |
+
raise HTTPException(
|
| 253 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 254 |
+
detail="Not authorized to edit this post"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Update fields
|
| 258 |
+
for field, value in data.model_dump(exclude_unset=True).items():
|
| 259 |
+
setattr(post, field, value)
|
| 260 |
+
|
| 261 |
+
post.updated_at = datetime.utcnow()
|
| 262 |
+
await db.commit()
|
| 263 |
+
await db.refresh(post, ["author"])
|
| 264 |
+
return post
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@router.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 268 |
+
async def delete_post(
|
| 269 |
+
post_id: str,
|
| 270 |
+
current_user: User = Depends(get_current_user),
|
| 271 |
+
db: AsyncSession = Depends(get_db),
|
| 272 |
+
):
|
| 273 |
+
"""Delete a forum post."""
|
| 274 |
+
result = await db.execute(
|
| 275 |
+
select(ForumPost).where(ForumPost.id == post_id)
|
| 276 |
+
)
|
| 277 |
+
post = result.scalar_one_or_none()
|
| 278 |
+
if not post:
|
| 279 |
+
raise HTTPException(
|
| 280 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 281 |
+
detail="Post not found"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
# Check permissions (author or instructor)
|
| 285 |
+
if post.author_id != current_user.id and current_user.role.value != "instructor":
|
| 286 |
+
raise HTTPException(
|
| 287 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 288 |
+
detail="Not authorized to delete this post"
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
await db.delete(post)
|
| 292 |
+
await db.commit()
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@router.post("/posts/{post_id}/upvote", response_model=PostResponse)
|
| 296 |
+
async def upvote_post(
|
| 297 |
+
post_id: str,
|
| 298 |
+
current_user: User = Depends(get_current_user),
|
| 299 |
+
db: AsyncSession = Depends(get_db),
|
| 300 |
+
):
|
| 301 |
+
"""Upvote a post."""
|
| 302 |
+
result = await db.execute(
|
| 303 |
+
select(ForumPost).where(ForumPost.id == post_id)
|
| 304 |
+
)
|
| 305 |
+
post = result.scalar_one_or_none()
|
| 306 |
+
if not post:
|
| 307 |
+
raise HTTPException(
|
| 308 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 309 |
+
detail="Post not found"
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
post.upvotes += 1
|
| 313 |
+
await db.commit()
|
| 314 |
+
await db.refresh(post, ["author"])
|
| 315 |
+
return post
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ─── Forum Comments ──────────────────────────────────────
|
| 319 |
+
|
| 320 |
+
@router.post("/posts/{post_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
| 321 |
+
async def create_comment(
|
| 322 |
+
post_id: str,
|
| 323 |
+
data: CommentCreate,
|
| 324 |
+
current_user: User = Depends(get_current_user),
|
| 325 |
+
db: AsyncSession = Depends(get_db),
|
| 326 |
+
):
|
| 327 |
+
"""Create a comment on a post."""
|
| 328 |
+
# Verify post exists
|
| 329 |
+
post_result = await db.execute(
|
| 330 |
+
select(ForumPost).where(ForumPost.id == post_id)
|
| 331 |
+
)
|
| 332 |
+
post = post_result.scalar_one_or_none()
|
| 333 |
+
if not post:
|
| 334 |
+
raise HTTPException(
|
| 335 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 336 |
+
detail="Post not found"
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# If parent_id provided, verify it exists
|
| 340 |
+
if data.parent_id:
|
| 341 |
+
parent_result = await db.execute(
|
| 342 |
+
select(ForumComment).where(ForumComment.id == data.parent_id)
|
| 343 |
+
)
|
| 344 |
+
parent = parent_result.scalar_one_or_none()
|
| 345 |
+
if not parent or parent.post_id != post_id:
|
| 346 |
+
raise HTTPException(
|
| 347 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 348 |
+
detail="Parent comment not found"
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
comment = ForumComment(
|
| 352 |
+
post_id=post_id,
|
| 353 |
+
author_id=current_user.id,
|
| 354 |
+
parent_id=data.parent_id,
|
| 355 |
+
content=data.content,
|
| 356 |
+
)
|
| 357 |
+
db.add(comment)
|
| 358 |
+
await db.commit()
|
| 359 |
+
await db.refresh(comment, ["author"])
|
| 360 |
+
return comment
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
@router.put("/comments/{comment_id}", response_model=CommentResponse)
|
| 364 |
+
async def update_comment(
|
| 365 |
+
comment_id: str,
|
| 366 |
+
data: CommentUpdate,
|
| 367 |
+
current_user: User = Depends(get_current_user),
|
| 368 |
+
db: AsyncSession = Depends(get_db),
|
| 369 |
+
):
|
| 370 |
+
"""Update a comment."""
|
| 371 |
+
result = await db.execute(
|
| 372 |
+
select(ForumComment).where(ForumComment.id == comment_id)
|
| 373 |
+
)
|
| 374 |
+
comment = result.scalar_one_or_none()
|
| 375 |
+
if not comment:
|
| 376 |
+
raise HTTPException(
|
| 377 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 378 |
+
detail="Comment not found"
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
# Check permissions
|
| 382 |
+
if comment.author_id != current_user.id and current_user.role.value != "instructor":
|
| 383 |
+
raise HTTPException(
|
| 384 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 385 |
+
detail="Not authorized to edit this comment"
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
# Update fields
|
| 389 |
+
for field, value in data.model_dump(exclude_unset=True).items():
|
| 390 |
+
setattr(comment, field, value)
|
| 391 |
+
|
| 392 |
+
comment.updated_at = datetime.utcnow()
|
| 393 |
+
await db.commit()
|
| 394 |
+
await db.refresh(comment, ["author"])
|
| 395 |
+
return comment
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 399 |
+
async def delete_comment(
|
| 400 |
+
comment_id: str,
|
| 401 |
+
current_user: User = Depends(get_current_user),
|
| 402 |
+
db: AsyncSession = Depends(get_db),
|
| 403 |
+
):
|
| 404 |
+
"""Delete a comment."""
|
| 405 |
+
result = await db.execute(
|
| 406 |
+
select(ForumComment).where(ForumComment.id == comment_id)
|
| 407 |
+
)
|
| 408 |
+
comment = result.scalar_one_or_none()
|
| 409 |
+
if not comment:
|
| 410 |
+
raise HTTPException(
|
| 411 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 412 |
+
detail="Comment not found"
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
# Check permissions
|
| 416 |
+
if comment.author_id != current_user.id and current_user.role.value != "instructor":
|
| 417 |
+
raise HTTPException(
|
| 418 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 419 |
+
detail="Not authorized to delete this comment"
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
await db.delete(comment)
|
| 423 |
+
await db.commit()
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
@router.post("/comments/{comment_id}/upvote", response_model=CommentResponse)
|
| 427 |
+
async def upvote_comment(
|
| 428 |
+
comment_id: str,
|
| 429 |
+
current_user: User = Depends(get_current_user),
|
| 430 |
+
db: AsyncSession = Depends(get_db),
|
| 431 |
+
):
|
| 432 |
+
"""Upvote a comment."""
|
| 433 |
+
result = await db.execute(
|
| 434 |
+
select(ForumComment).where(ForumComment.id == comment_id)
|
| 435 |
+
)
|
| 436 |
+
comment = result.scalar_one_or_none()
|
| 437 |
+
if not comment:
|
| 438 |
+
raise HTTPException(
|
| 439 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 440 |
+
detail="Comment not found"
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
comment.upvotes += 1
|
| 444 |
+
await db.commit()
|
| 445 |
+
await db.refresh(comment, ["author"])
|
| 446 |
+
return comment
|
app/routes/progress.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Student progress and enrollment routes."""
|
| 2 |
+
|
| 3 |
+
from typing import List
|
| 4 |
+
from datetime import datetime, date, timedelta
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
from sqlalchemy import select, func
|
| 8 |
+
from sqlalchemy.orm import selectinload
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app.models.user import User
|
| 11 |
+
from app.models.course import Course, Module, Lesson
|
| 12 |
+
from app.models.progress import Enrollment, Progress, StudyStreak
|
| 13 |
+
from app.models.quiz import QuizAttempt
|
| 14 |
+
from app.schemas.progress import (
|
| 15 |
+
EnrollmentCreate,
|
| 16 |
+
EnrollmentResponse,
|
| 17 |
+
ProgressUpdate,
|
| 18 |
+
ProgressResponse,
|
| 19 |
+
StudyStreakResponse,
|
| 20 |
+
DashboardStats,
|
| 21 |
+
CourseProgress,
|
| 22 |
+
)
|
| 23 |
+
from app.dependencies import get_current_user
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
router = APIRouter(prefix="/progress", tags=["Progress"])
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("/dashboard", response_model=DashboardStats)
|
| 30 |
+
async def get_dashboard_stats(
|
| 31 |
+
current_user: User = Depends(get_current_user),
|
| 32 |
+
db: AsyncSession = Depends(get_db),
|
| 33 |
+
):
|
| 34 |
+
"""Get student dashboard statistics."""
|
| 35 |
+
try:
|
| 36 |
+
# Get enrollments
|
| 37 |
+
enrollment_result = await db.execute(
|
| 38 |
+
select(Enrollment).where(Enrollment.student_id == current_user.id)
|
| 39 |
+
)
|
| 40 |
+
enrollments = enrollment_result.scalars().all()
|
| 41 |
+
|
| 42 |
+
enrolled_count = len(enrollments)
|
| 43 |
+
completed_count = sum(1 for e in enrollments if e.is_completed)
|
| 44 |
+
|
| 45 |
+
# Get lessons completed
|
| 46 |
+
progress_result = await db.execute(
|
| 47 |
+
select(Progress).where(
|
| 48 |
+
Progress.student_id == current_user.id,
|
| 49 |
+
Progress.is_completed == True,
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
lessons_completed = len(progress_result.scalars().all())
|
| 53 |
+
|
| 54 |
+
# Get total study time
|
| 55 |
+
time_result = await db.execute(
|
| 56 |
+
select(func.sum(Progress.time_spent_seconds)).where(
|
| 57 |
+
Progress.student_id == current_user.id
|
| 58 |
+
)
|
| 59 |
+
)
|
| 60 |
+
total_seconds = time_result.scalar() or 0
|
| 61 |
+
total_hours = round(total_seconds / 3600, 1)
|
| 62 |
+
|
| 63 |
+
# Get streak
|
| 64 |
+
streak_result = await db.execute(
|
| 65 |
+
select(StudyStreak).where(StudyStreak.student_id == current_user.id)
|
| 66 |
+
)
|
| 67 |
+
streak = streak_result.scalar_one_or_none()
|
| 68 |
+
|
| 69 |
+
# Get average quiz score
|
| 70 |
+
quiz_result = await db.execute(
|
| 71 |
+
select(func.avg(QuizAttempt.score)).where(
|
| 72 |
+
QuizAttempt.student_id == current_user.id,
|
| 73 |
+
QuizAttempt.completed_at.isnot(None),
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
avg_score = quiz_result.scalar() or 0.0
|
| 77 |
+
|
| 78 |
+
return DashboardStats(
|
| 79 |
+
enrolled_courses=enrolled_count,
|
| 80 |
+
completed_courses=completed_count,
|
| 81 |
+
lessons_completed=lessons_completed,
|
| 82 |
+
total_study_time_hours=total_hours,
|
| 83 |
+
current_streak=streak.current_streak if streak else 0,
|
| 84 |
+
longest_streak=streak.longest_streak if streak else 0,
|
| 85 |
+
average_quiz_score=round(avg_score, 1),
|
| 86 |
+
)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"⚠️ Could not fetch dashboard stats: {e}")
|
| 89 |
+
return DashboardStats(
|
| 90 |
+
enrolled_courses=0,
|
| 91 |
+
completed_courses=0,
|
| 92 |
+
lessons_completed=0,
|
| 93 |
+
total_study_time_hours=0.0,
|
| 94 |
+
current_streak=0,
|
| 95 |
+
longest_streak=0,
|
| 96 |
+
average_quiz_score=0.0,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.get("/enrollments", response_model=List[CourseProgress])
|
| 101 |
+
async def get_enrollments(
|
| 102 |
+
current_user: User = Depends(get_current_user),
|
| 103 |
+
db: AsyncSession = Depends(get_db),
|
| 104 |
+
):
|
| 105 |
+
"""Get all enrollments for current user - alias for get_course_progress."""
|
| 106 |
+
try:
|
| 107 |
+
result = await db.execute(
|
| 108 |
+
select(Enrollment)
|
| 109 |
+
.options(selectinload(Enrollment.course).selectinload(Course.modules).selectinload(Module.lessons))
|
| 110 |
+
.where(Enrollment.student_id == current_user.id)
|
| 111 |
+
)
|
| 112 |
+
enrollments = result.scalars().all()
|
| 113 |
+
|
| 114 |
+
progress_list = []
|
| 115 |
+
for enrollment in enrollments:
|
| 116 |
+
course = enrollment.course
|
| 117 |
+
total_lessons = sum(len(m.lessons) for m in course.modules)
|
| 118 |
+
|
| 119 |
+
# Get completed lessons count
|
| 120 |
+
lesson_ids = [l.id for m in course.modules for l in m.lessons]
|
| 121 |
+
if lesson_ids:
|
| 122 |
+
completed_result = await db.execute(
|
| 123 |
+
select(func.count(Progress.id)).where(
|
| 124 |
+
Progress.student_id == current_user.id,
|
| 125 |
+
Progress.lesson_id.in_(lesson_ids),
|
| 126 |
+
Progress.is_completed == True,
|
| 127 |
+
)
|
| 128 |
+
)
|
| 129 |
+
completed_count = completed_result.scalar() or 0
|
| 130 |
+
else:
|
| 131 |
+
completed_count = 0
|
| 132 |
+
|
| 133 |
+
# Calculate progress percentage from actual completed count
|
| 134 |
+
calculated_percentage = (completed_count / total_lessons * 100) if total_lessons > 0 else 0
|
| 135 |
+
|
| 136 |
+
progress_list.append(
|
| 137 |
+
CourseProgress(
|
| 138 |
+
course_id=course.id,
|
| 139 |
+
course_title=course.title,
|
| 140 |
+
progress_percentage=calculated_percentage,
|
| 141 |
+
lessons_completed=completed_count,
|
| 142 |
+
total_lessons=total_lessons,
|
| 143 |
+
last_accessed=enrollment.enrolled_at,
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
return progress_list
|
| 148 |
+
except Exception as e:
|
| 149 |
+
# In demo mode, return empty list
|
| 150 |
+
print(f"⚠️ Could not fetch enrollments: {e}")
|
| 151 |
+
return []
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@router.get("/courses", response_model=List[CourseProgress])
|
| 155 |
+
async def get_course_progress(
|
| 156 |
+
current_user: User = Depends(get_current_user),
|
| 157 |
+
db: AsyncSession = Depends(get_db),
|
| 158 |
+
):
|
| 159 |
+
"""Get progress for all enrolled courses."""
|
| 160 |
+
try:
|
| 161 |
+
result = await db.execute(
|
| 162 |
+
select(Enrollment)
|
| 163 |
+
.options(selectinload(Enrollment.course).selectinload(Course.modules).selectinload(Module.lessons))
|
| 164 |
+
.where(Enrollment.student_id == current_user.id)
|
| 165 |
+
)
|
| 166 |
+
enrollments = result.scalars().all()
|
| 167 |
+
|
| 168 |
+
progress_list = []
|
| 169 |
+
for enrollment in enrollments:
|
| 170 |
+
course = enrollment.course
|
| 171 |
+
total_lessons = sum(len(m.lessons) for m in course.modules)
|
| 172 |
+
|
| 173 |
+
# Get completed lessons count
|
| 174 |
+
lesson_ids = [l.id for m in course.modules for l in m.lessons]
|
| 175 |
+
completed_result = await db.execute(
|
| 176 |
+
select(func.count(Progress.id)).where(
|
| 177 |
+
Progress.student_id == current_user.id,
|
| 178 |
+
Progress.lesson_id.in_(lesson_ids),
|
| 179 |
+
Progress.is_completed == True,
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
completed_count = completed_result.scalar() or 0
|
| 183 |
+
|
| 184 |
+
# Calculate progress percentage from actual completed count
|
| 185 |
+
calculated_percentage = (completed_count / total_lessons * 100) if total_lessons > 0 else 0
|
| 186 |
+
|
| 187 |
+
progress_list.append(
|
| 188 |
+
CourseProgress(
|
| 189 |
+
course_id=course.id,
|
| 190 |
+
course_title=course.title,
|
| 191 |
+
progress_percentage=calculated_percentage,
|
| 192 |
+
lessons_completed=completed_count,
|
| 193 |
+
total_lessons=total_lessons,
|
| 194 |
+
last_accessed=enrollment.enrolled_at,
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
return progress_list
|
| 199 |
+
except Exception as e:
|
| 200 |
+
print(f"⚠️ Could not fetch course progress: {e}")
|
| 201 |
+
return []
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@router.post("/enroll", response_model=EnrollmentResponse, status_code=status.HTTP_201_CREATED)
|
| 205 |
+
async def enroll_in_course(
|
| 206 |
+
data: EnrollmentCreate,
|
| 207 |
+
current_user: User = Depends(get_current_user),
|
| 208 |
+
db: AsyncSession = Depends(get_db),
|
| 209 |
+
):
|
| 210 |
+
"""Enroll in a course."""
|
| 211 |
+
# Check if course exists
|
| 212 |
+
course_result = await db.execute(select(Course).where(Course.id == data.course_id))
|
| 213 |
+
course = course_result.scalar_one_or_none()
|
| 214 |
+
if not course:
|
| 215 |
+
raise HTTPException(status_code=404, detail="Course not found")
|
| 216 |
+
|
| 217 |
+
# Prevent instructors from enrolling in their own courses
|
| 218 |
+
if course.created_by == current_user.id:
|
| 219 |
+
raise HTTPException(
|
| 220 |
+
status_code=400,
|
| 221 |
+
detail="You cannot enroll in your own course. As the instructor, you have full access to manage it."
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Check if already enrolled
|
| 225 |
+
existing = await db.execute(
|
| 226 |
+
select(Enrollment).where(
|
| 227 |
+
Enrollment.student_id == current_user.id,
|
| 228 |
+
Enrollment.course_id == data.course_id,
|
| 229 |
+
)
|
| 230 |
+
)
|
| 231 |
+
if existing.scalar_one_or_none():
|
| 232 |
+
raise HTTPException(status_code=400, detail="Already enrolled")
|
| 233 |
+
|
| 234 |
+
enrollment = Enrollment(
|
| 235 |
+
student_id=current_user.id,
|
| 236 |
+
course_id=data.course_id,
|
| 237 |
+
)
|
| 238 |
+
db.add(enrollment)
|
| 239 |
+
await db.commit()
|
| 240 |
+
await db.refresh(enrollment)
|
| 241 |
+
|
| 242 |
+
return EnrollmentResponse.model_validate(enrollment)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@router.get("/lessons/{lesson_id}", response_model=ProgressResponse)
|
| 246 |
+
async def get_lesson_progress(
|
| 247 |
+
lesson_id: str,
|
| 248 |
+
current_user: User = Depends(get_current_user),
|
| 249 |
+
db: AsyncSession = Depends(get_db),
|
| 250 |
+
):
|
| 251 |
+
"""Get progress for a specific lesson."""
|
| 252 |
+
lesson_result = await db.execute(select(Lesson).where(Lesson.id == lesson_id))
|
| 253 |
+
if not lesson_result.scalar_one_or_none():
|
| 254 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 255 |
+
|
| 256 |
+
result = await db.execute(
|
| 257 |
+
select(Progress).where(
|
| 258 |
+
Progress.student_id == current_user.id,
|
| 259 |
+
Progress.lesson_id == lesson_id,
|
| 260 |
+
)
|
| 261 |
+
)
|
| 262 |
+
progress = result.scalar_one_or_none()
|
| 263 |
+
|
| 264 |
+
if not progress:
|
| 265 |
+
raise HTTPException(status_code=404, detail="Progress not found")
|
| 266 |
+
|
| 267 |
+
return ProgressResponse.model_validate(progress)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
@router.post("/lessons/{lesson_id}", response_model=ProgressResponse)
|
| 271 |
+
async def update_lesson_progress(
|
| 272 |
+
lesson_id: str,
|
| 273 |
+
data: ProgressUpdate,
|
| 274 |
+
current_user: User = Depends(get_current_user),
|
| 275 |
+
db: AsyncSession = Depends(get_db),
|
| 276 |
+
):
|
| 277 |
+
"""Update progress for a lesson."""
|
| 278 |
+
# Check if lesson exists
|
| 279 |
+
lesson_result = await db.execute(select(Lesson).where(Lesson.id == lesson_id))
|
| 280 |
+
if not lesson_result.scalar_one_or_none():
|
| 281 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 282 |
+
|
| 283 |
+
# Get or create progress record
|
| 284 |
+
result = await db.execute(
|
| 285 |
+
select(Progress).where(
|
| 286 |
+
Progress.student_id == current_user.id,
|
| 287 |
+
Progress.lesson_id == lesson_id,
|
| 288 |
+
)
|
| 289 |
+
)
|
| 290 |
+
progress = result.scalar_one_or_none()
|
| 291 |
+
|
| 292 |
+
if not progress:
|
| 293 |
+
progress = Progress(student_id=current_user.id, lesson_id=lesson_id)
|
| 294 |
+
db.add(progress)
|
| 295 |
+
|
| 296 |
+
# Update fields
|
| 297 |
+
for field, value in data.model_dump(exclude_unset=True).items():
|
| 298 |
+
setattr(progress, field, value)
|
| 299 |
+
|
| 300 |
+
if data.is_completed and not progress.completed_at:
|
| 301 |
+
progress.completed_at = datetime.utcnow()
|
| 302 |
+
|
| 303 |
+
await db.commit()
|
| 304 |
+
await db.refresh(progress)
|
| 305 |
+
|
| 306 |
+
# Update study streak
|
| 307 |
+
await _update_study_streak(current_user.id, db)
|
| 308 |
+
|
| 309 |
+
return ProgressResponse.model_validate(progress)
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@router.post("/lesson/{lesson_id}/complete")
|
| 313 |
+
async def mark_lesson_complete(
|
| 314 |
+
lesson_id: str,
|
| 315 |
+
data: ProgressUpdate,
|
| 316 |
+
current_user: User = Depends(get_current_user),
|
| 317 |
+
db: AsyncSession = Depends(get_db),
|
| 318 |
+
):
|
| 319 |
+
"""Mark a lesson as complete."""
|
| 320 |
+
# Check if lesson exists
|
| 321 |
+
lesson_result = await db.execute(select(Lesson).where(Lesson.id == lesson_id))
|
| 322 |
+
lesson = lesson_result.scalar_one_or_none()
|
| 323 |
+
if not lesson:
|
| 324 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 325 |
+
|
| 326 |
+
# Get or create progress record
|
| 327 |
+
result = await db.execute(
|
| 328 |
+
select(Progress).where(
|
| 329 |
+
Progress.student_id == current_user.id,
|
| 330 |
+
Progress.lesson_id == lesson_id,
|
| 331 |
+
)
|
| 332 |
+
)
|
| 333 |
+
progress = result.scalar_one_or_none()
|
| 334 |
+
|
| 335 |
+
if not progress:
|
| 336 |
+
progress = Progress(student_id=current_user.id, lesson_id=lesson_id)
|
| 337 |
+
db.add(progress)
|
| 338 |
+
|
| 339 |
+
# Mark as completed
|
| 340 |
+
progress.is_completed = True
|
| 341 |
+
if not progress.completed_at:
|
| 342 |
+
progress.completed_at = datetime.utcnow()
|
| 343 |
+
|
| 344 |
+
# Update time spent if provided
|
| 345 |
+
if data.time_spent_seconds:
|
| 346 |
+
progress.time_spent_seconds = data.time_spent_seconds
|
| 347 |
+
|
| 348 |
+
await db.commit()
|
| 349 |
+
await db.refresh(progress)
|
| 350 |
+
|
| 351 |
+
# Update enrollment progress percentage
|
| 352 |
+
# Get the course for this lesson
|
| 353 |
+
module_result = await db.execute(
|
| 354 |
+
select(Module).options(selectinload(Module.course)).where(Module.id == lesson.module_id)
|
| 355 |
+
)
|
| 356 |
+
module = module_result.scalar_one_or_none()
|
| 357 |
+
|
| 358 |
+
if module and module.course:
|
| 359 |
+
course = module.course
|
| 360 |
+
# Get total lessons in course
|
| 361 |
+
total_lessons_result = await db.execute(
|
| 362 |
+
select(func.count(Lesson.id)).where(
|
| 363 |
+
Lesson.module_id.in_(
|
| 364 |
+
select(Module.id).where(Module.course_id == course.id)
|
| 365 |
+
)
|
| 366 |
+
)
|
| 367 |
+
)
|
| 368 |
+
total_lessons = total_lessons_result.scalar() or 0
|
| 369 |
+
|
| 370 |
+
if total_lessons > 0:
|
| 371 |
+
# Get completed lessons count
|
| 372 |
+
completed_result = await db.execute(
|
| 373 |
+
select(func.count(Progress.id)).where(
|
| 374 |
+
Progress.student_id == current_user.id,
|
| 375 |
+
Progress.lesson_id.in_(
|
| 376 |
+
select(Lesson.id).where(
|
| 377 |
+
Lesson.module_id.in_(
|
| 378 |
+
select(Module.id).where(Module.course_id == course.id)
|
| 379 |
+
)
|
| 380 |
+
)
|
| 381 |
+
),
|
| 382 |
+
Progress.is_completed == True,
|
| 383 |
+
)
|
| 384 |
+
)
|
| 385 |
+
completed_count = completed_result.scalar() or 0
|
| 386 |
+
progress_percentage = (completed_count / total_lessons) * 100 if total_lessons > 0 else 0
|
| 387 |
+
|
| 388 |
+
# Update enrollment progress
|
| 389 |
+
enrollment_result = await db.execute(
|
| 390 |
+
select(Enrollment).where(
|
| 391 |
+
Enrollment.student_id == current_user.id,
|
| 392 |
+
Enrollment.course_id == course.id,
|
| 393 |
+
)
|
| 394 |
+
)
|
| 395 |
+
enrollment = enrollment_result.scalar_one_or_none()
|
| 396 |
+
if enrollment:
|
| 397 |
+
enrollment.progress_percentage = progress_percentage
|
| 398 |
+
await db.commit()
|
| 399 |
+
await db.refresh(enrollment)
|
| 400 |
+
|
| 401 |
+
# Update study streak
|
| 402 |
+
await _update_study_streak(current_user.id, db)
|
| 403 |
+
|
| 404 |
+
return ProgressResponse.model_validate(progress)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
@router.get("/streak", response_model=StudyStreakResponse)
|
| 408 |
+
async def get_study_streak(
|
| 409 |
+
current_user: User = Depends(get_current_user),
|
| 410 |
+
db: AsyncSession = Depends(get_db),
|
| 411 |
+
):
|
| 412 |
+
"""Get current study streak."""
|
| 413 |
+
try:
|
| 414 |
+
result = await db.execute(
|
| 415 |
+
select(StudyStreak).where(StudyStreak.student_id == current_user.id)
|
| 416 |
+
)
|
| 417 |
+
streak = result.scalar_one_or_none()
|
| 418 |
+
|
| 419 |
+
if not streak:
|
| 420 |
+
return StudyStreakResponse(
|
| 421 |
+
current_streak=0,
|
| 422 |
+
longest_streak=0,
|
| 423 |
+
last_study_date=None,
|
| 424 |
+
total_study_days=0,
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
return StudyStreakResponse.model_validate(streak)
|
| 428 |
+
except Exception as e:
|
| 429 |
+
# In demo mode, return default values
|
| 430 |
+
print(f"⚠️ Could not fetch study streak: {e}")
|
| 431 |
+
return StudyStreakResponse(
|
| 432 |
+
current_streak=0,
|
| 433 |
+
longest_streak=0,
|
| 434 |
+
last_study_date=None,
|
| 435 |
+
total_study_days=0,
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
@router.post("/study", response_model=StudyStreakResponse)
|
| 440 |
+
async def record_study_session(
|
| 441 |
+
current_user: User = Depends(get_current_user),
|
| 442 |
+
db: AsyncSession = Depends(get_db),
|
| 443 |
+
):
|
| 444 |
+
"""Record a study session and update study streak."""
|
| 445 |
+
try:
|
| 446 |
+
await _update_study_streak(current_user.id, db)
|
| 447 |
+
|
| 448 |
+
result = await db.execute(
|
| 449 |
+
select(StudyStreak).where(StudyStreak.student_id == current_user.id)
|
| 450 |
+
)
|
| 451 |
+
streak = result.scalar_one_or_none()
|
| 452 |
+
|
| 453 |
+
if not streak:
|
| 454 |
+
return StudyStreakResponse(
|
| 455 |
+
current_streak=0,
|
| 456 |
+
longest_streak=0,
|
| 457 |
+
last_study_date=None,
|
| 458 |
+
total_study_days=0,
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
return StudyStreakResponse.model_validate(streak)
|
| 462 |
+
except Exception as e:
|
| 463 |
+
print(f"⚠️ Could not record study session: {e}")
|
| 464 |
+
return StudyStreakResponse(
|
| 465 |
+
current_streak=0,
|
| 466 |
+
longest_streak=0,
|
| 467 |
+
last_study_date=None,
|
| 468 |
+
total_study_days=0,
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
async def _update_study_streak(user_id: str, db: AsyncSession):
|
| 473 |
+
"""Update study streak for a user."""
|
| 474 |
+
result = await db.execute(
|
| 475 |
+
select(StudyStreak).where(StudyStreak.student_id == user_id)
|
| 476 |
+
)
|
| 477 |
+
streak = result.scalar_one_or_none()
|
| 478 |
+
|
| 479 |
+
today = date.today()
|
| 480 |
+
|
| 481 |
+
if not streak:
|
| 482 |
+
streak = StudyStreak(
|
| 483 |
+
student_id=user_id,
|
| 484 |
+
current_streak=1,
|
| 485 |
+
longest_streak=1,
|
| 486 |
+
last_study_date=datetime.utcnow(),
|
| 487 |
+
total_study_days=1,
|
| 488 |
+
)
|
| 489 |
+
db.add(streak)
|
| 490 |
+
else:
|
| 491 |
+
last_date = streak.last_study_date.date() if streak.last_study_date else None
|
| 492 |
+
|
| 493 |
+
if last_date == today:
|
| 494 |
+
# Already studied today
|
| 495 |
+
pass
|
| 496 |
+
elif last_date == today - timedelta(days=1):
|
| 497 |
+
# Consecutive day
|
| 498 |
+
streak.current_streak += 1
|
| 499 |
+
streak.longest_streak = max(streak.longest_streak, streak.current_streak)
|
| 500 |
+
streak.total_study_days += 1
|
| 501 |
+
streak.last_study_date = datetime.utcnow()
|
| 502 |
+
else:
|
| 503 |
+
# Streak broken
|
| 504 |
+
streak.current_streak = 1
|
| 505 |
+
streak.total_study_days += 1
|
| 506 |
+
streak.last_study_date = datetime.utcnow()
|
| 507 |
+
|
| 508 |
+
await db.commit()
|
app/routes/quiz.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quiz management and assessment routes."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlalchemy import select, and_, func
|
| 9 |
+
from sqlalchemy.orm import selectinload
|
| 10 |
+
from app.database import get_db
|
| 11 |
+
from app.models.user import User, UserRole
|
| 12 |
+
from app.models.quiz import Quiz, QuizQuestion, QuizAttempt, QuestionType
|
| 13 |
+
from app.models.course import Lesson
|
| 14 |
+
from app.dependencies import get_current_user, require_instructor
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/quizzes", tags=["Quizzes"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ─── Schemas ─────────────────────────────────────────────
|
| 22 |
+
|
| 23 |
+
class QuestionCreate(BaseModel):
|
| 24 |
+
question_type: QuestionType = QuestionType.MULTIPLE_CHOICE
|
| 25 |
+
question_text: str
|
| 26 |
+
options: Optional[List[str]] = None
|
| 27 |
+
correct_answer: str
|
| 28 |
+
explanation: Optional[str] = None
|
| 29 |
+
points: int = 1
|
| 30 |
+
order_index: int = 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class QuestionResponse(BaseModel):
|
| 34 |
+
id: str
|
| 35 |
+
question_type: str
|
| 36 |
+
question_text: str
|
| 37 |
+
options: Optional[List[str]] = None
|
| 38 |
+
correct_answer: str
|
| 39 |
+
explanation: Optional[str] = None
|
| 40 |
+
points: int
|
| 41 |
+
order_index: int
|
| 42 |
+
|
| 43 |
+
class Config:
|
| 44 |
+
from_attributes = True
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class QuizCreate(BaseModel):
|
| 48 |
+
lesson_id: str
|
| 49 |
+
title: str
|
| 50 |
+
description: Optional[str] = None
|
| 51 |
+
passing_score: float = 70.0
|
| 52 |
+
time_limit_minutes: Optional[int] = None
|
| 53 |
+
max_attempts: int = 3
|
| 54 |
+
is_ai_generated: bool = False
|
| 55 |
+
is_published: bool = False
|
| 56 |
+
questions: List[QuestionCreate] = []
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class QuizUpdate(BaseModel):
|
| 60 |
+
title: Optional[str] = None
|
| 61 |
+
description: Optional[str] = None
|
| 62 |
+
passing_score: Optional[float] = None
|
| 63 |
+
time_limit_minutes: Optional[int] = None
|
| 64 |
+
max_attempts: Optional[int] = None
|
| 65 |
+
is_published: Optional[bool] = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class QuizResponse(BaseModel):
|
| 69 |
+
id: str
|
| 70 |
+
lesson_id: str
|
| 71 |
+
title: str
|
| 72 |
+
description: Optional[str] = None
|
| 73 |
+
passing_score: float
|
| 74 |
+
time_limit_minutes: Optional[int] = None
|
| 75 |
+
max_attempts: int
|
| 76 |
+
is_ai_generated: bool
|
| 77 |
+
is_published: bool
|
| 78 |
+
created_at: datetime
|
| 79 |
+
updated_at: datetime
|
| 80 |
+
|
| 81 |
+
class Config:
|
| 82 |
+
from_attributes = True
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class QuizWithQuestions(QuizResponse):
|
| 86 |
+
questions: List[QuestionResponse] = []
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class QuizQuestionPublic(BaseModel):
|
| 90 |
+
"""Quiz question without correct answer (for students taking quiz)."""
|
| 91 |
+
id: str
|
| 92 |
+
question_type: str
|
| 93 |
+
question_text: str
|
| 94 |
+
options: Optional[List[str]] = None
|
| 95 |
+
points: int
|
| 96 |
+
order_index: int
|
| 97 |
+
|
| 98 |
+
class Config:
|
| 99 |
+
from_attributes = True
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class QuizForStudent(QuizResponse):
|
| 103 |
+
"""Quiz with questions but without correct answers."""
|
| 104 |
+
questions: List[QuizQuestionPublic] = []
|
| 105 |
+
attempts_left: int = 0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class AnswerSubmission(BaseModel):
|
| 109 |
+
question_id: str
|
| 110 |
+
answer: str
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class QuizSubmission(BaseModel):
|
| 114 |
+
quiz_id: str
|
| 115 |
+
answers: List[AnswerSubmission]
|
| 116 |
+
time_taken_seconds: Optional[int] = None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class AttemptResponse(BaseModel):
|
| 120 |
+
id: str
|
| 121 |
+
quiz_id: str
|
| 122 |
+
student_id: str
|
| 123 |
+
score: float
|
| 124 |
+
passed: bool
|
| 125 |
+
answers: Optional[dict] = None
|
| 126 |
+
time_taken_seconds: Optional[int] = None
|
| 127 |
+
started_at: datetime
|
| 128 |
+
completed_at: Optional[datetime] = None
|
| 129 |
+
|
| 130 |
+
class Config:
|
| 131 |
+
from_attributes = True
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class AttemptWithQuiz(AttemptResponse):
|
| 135 |
+
quiz: QuizResponse
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class QuestionResult(BaseModel):
|
| 139 |
+
question_id: str
|
| 140 |
+
question_text: str
|
| 141 |
+
student_answer: str
|
| 142 |
+
correct_answer: str
|
| 143 |
+
is_correct: bool
|
| 144 |
+
points_earned: int
|
| 145 |
+
points_possible: int
|
| 146 |
+
explanation: Optional[str] = None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class DetailedAttemptResponse(AttemptResponse):
|
| 150 |
+
quiz: QuizResponse
|
| 151 |
+
question_results: List[QuestionResult] = []
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ─── Quiz CRUD (Instructor) ──────────────────────────────
|
| 155 |
+
|
| 156 |
+
@router.post("", response_model=QuizResponse, status_code=status.HTTP_201_CREATED)
|
| 157 |
+
async def create_quiz(
|
| 158 |
+
data: QuizCreate,
|
| 159 |
+
current_user: User = Depends(require_instructor),
|
| 160 |
+
db: AsyncSession = Depends(get_db),
|
| 161 |
+
):
|
| 162 |
+
"""Create a new quiz (instructor only)."""
|
| 163 |
+
# Verify lesson exists
|
| 164 |
+
lesson_result = await db.execute(
|
| 165 |
+
select(Lesson).where(Lesson.id == data.lesson_id)
|
| 166 |
+
)
|
| 167 |
+
lesson = lesson_result.scalar_one_or_none()
|
| 168 |
+
if not lesson:
|
| 169 |
+
raise HTTPException(
|
| 170 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 171 |
+
detail="Lesson not found"
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# Create quiz
|
| 175 |
+
quiz = Quiz(
|
| 176 |
+
lesson_id=data.lesson_id,
|
| 177 |
+
title=data.title,
|
| 178 |
+
description=data.description,
|
| 179 |
+
passing_score=data.passing_score,
|
| 180 |
+
time_limit_minutes=data.time_limit_minutes,
|
| 181 |
+
max_attempts=data.max_attempts,
|
| 182 |
+
is_ai_generated=data.is_ai_generated,
|
| 183 |
+
is_published=data.is_published,
|
| 184 |
+
)
|
| 185 |
+
db.add(quiz)
|
| 186 |
+
await db.flush()
|
| 187 |
+
|
| 188 |
+
# Create questions
|
| 189 |
+
for q_data in data.questions:
|
| 190 |
+
question = QuizQuestion(
|
| 191 |
+
quiz_id=quiz.id,
|
| 192 |
+
question_type=q_data.question_type,
|
| 193 |
+
question_text=q_data.question_text,
|
| 194 |
+
options=q_data.options,
|
| 195 |
+
correct_answer=q_data.correct_answer,
|
| 196 |
+
explanation=q_data.explanation,
|
| 197 |
+
points=q_data.points,
|
| 198 |
+
order_index=q_data.order_index,
|
| 199 |
+
)
|
| 200 |
+
db.add(question)
|
| 201 |
+
|
| 202 |
+
await db.commit()
|
| 203 |
+
await db.refresh(quiz)
|
| 204 |
+
return quiz
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@router.get("/lesson/{lesson_id}", response_model=List[QuizResponse])
|
| 208 |
+
async def get_lesson_quizzes(
|
| 209 |
+
lesson_id: str,
|
| 210 |
+
current_user: User = Depends(get_current_user),
|
| 211 |
+
db: AsyncSession = Depends(get_db),
|
| 212 |
+
):
|
| 213 |
+
"""Get all quizzes for a lesson."""
|
| 214 |
+
query = select(Quiz).where(Quiz.lesson_id == lesson_id)
|
| 215 |
+
|
| 216 |
+
# Students only see published quizzes
|
| 217 |
+
if current_user.role == UserRole.STUDENT:
|
| 218 |
+
query = query.where(Quiz.is_published == True)
|
| 219 |
+
|
| 220 |
+
result = await db.execute(query.order_by(Quiz.created_at))
|
| 221 |
+
quizzes = result.scalars().all()
|
| 222 |
+
return quizzes
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.get("/{quiz_id}", response_model=QuizWithQuestions)
|
| 226 |
+
async def get_quiz(
|
| 227 |
+
quiz_id: str,
|
| 228 |
+
current_user: User = Depends(require_instructor),
|
| 229 |
+
db: AsyncSession = Depends(get_db),
|
| 230 |
+
):
|
| 231 |
+
"""Get quiz details with questions (instructor only)."""
|
| 232 |
+
result = await db.execute(
|
| 233 |
+
select(Quiz)
|
| 234 |
+
.options(selectinload(Quiz.questions))
|
| 235 |
+
.where(Quiz.id == quiz_id)
|
| 236 |
+
)
|
| 237 |
+
quiz = result.scalar_one_or_none()
|
| 238 |
+
if not quiz:
|
| 239 |
+
raise HTTPException(
|
| 240 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 241 |
+
detail="Quiz not found"
|
| 242 |
+
)
|
| 243 |
+
return quiz
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@router.get("/{quiz_id}/take", response_model=QuizForStudent)
|
| 247 |
+
async def get_quiz_for_taking(
|
| 248 |
+
quiz_id: str,
|
| 249 |
+
current_user: User = Depends(get_current_user),
|
| 250 |
+
db: AsyncSession = Depends(get_db),
|
| 251 |
+
):
|
| 252 |
+
"""Get quiz for student to take (without correct answers)."""
|
| 253 |
+
result = await db.execute(
|
| 254 |
+
select(Quiz)
|
| 255 |
+
.options(selectinload(Quiz.questions))
|
| 256 |
+
.where(Quiz.id == quiz_id)
|
| 257 |
+
)
|
| 258 |
+
quiz = result.scalar_one_or_none()
|
| 259 |
+
if not quiz:
|
| 260 |
+
raise HTTPException(
|
| 261 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 262 |
+
detail="Quiz not found"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
if not quiz.is_published and current_user.role == UserRole.STUDENT:
|
| 266 |
+
raise HTTPException(
|
| 267 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 268 |
+
detail="Quiz is not published"
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
# Check attempts left (only for students)
|
| 272 |
+
attempts_left = quiz.max_attempts # Default for instructors
|
| 273 |
+
if current_user.role == UserRole.STUDENT:
|
| 274 |
+
attempts_result = await db.execute(
|
| 275 |
+
select(func.count(QuizAttempt.id))
|
| 276 |
+
.where(
|
| 277 |
+
and_(
|
| 278 |
+
QuizAttempt.quiz_id == quiz_id,
|
| 279 |
+
QuizAttempt.student_id == current_user.id,
|
| 280 |
+
QuizAttempt.completed_at.isnot(None)
|
| 281 |
+
)
|
| 282 |
+
)
|
| 283 |
+
)
|
| 284 |
+
attempts_count = attempts_result.scalar() or 0
|
| 285 |
+
attempts_left = max(0, quiz.max_attempts - attempts_count)
|
| 286 |
+
|
| 287 |
+
# Return quiz without correct answers
|
| 288 |
+
quiz_dict = {
|
| 289 |
+
"id": quiz.id,
|
| 290 |
+
"lesson_id": quiz.lesson_id,
|
| 291 |
+
"title": quiz.title,
|
| 292 |
+
"description": quiz.description,
|
| 293 |
+
"passing_score": quiz.passing_score,
|
| 294 |
+
"time_limit_minutes": quiz.time_limit_minutes,
|
| 295 |
+
"max_attempts": quiz.max_attempts,
|
| 296 |
+
"is_ai_generated": quiz.is_ai_generated,
|
| 297 |
+
"is_published": quiz.is_published,
|
| 298 |
+
"created_at": quiz.created_at,
|
| 299 |
+
"updated_at": quiz.updated_at,
|
| 300 |
+
"questions": [
|
| 301 |
+
{
|
| 302 |
+
"id": q.id,
|
| 303 |
+
"question_type": q.question_type,
|
| 304 |
+
"question_text": q.question_text,
|
| 305 |
+
"options": q.options,
|
| 306 |
+
"points": q.points,
|
| 307 |
+
"order_index": q.order_index,
|
| 308 |
+
}
|
| 309 |
+
for q in sorted(quiz.questions, key=lambda x: x.order_index)
|
| 310 |
+
],
|
| 311 |
+
"attempts_left": attempts_left,
|
| 312 |
+
}
|
| 313 |
+
return quiz_dict
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@router.put("/{quiz_id}", response_model=QuizResponse)
|
| 317 |
+
async def update_quiz(
|
| 318 |
+
quiz_id: str,
|
| 319 |
+
data: QuizUpdate,
|
| 320 |
+
current_user: User = Depends(require_instructor),
|
| 321 |
+
db: AsyncSession = Depends(get_db),
|
| 322 |
+
):
|
| 323 |
+
"""Update quiz (instructor only)."""
|
| 324 |
+
result = await db.execute(select(Quiz).where(Quiz.id == quiz_id))
|
| 325 |
+
quiz = result.scalar_one_or_none()
|
| 326 |
+
if not quiz:
|
| 327 |
+
raise HTTPException(
|
| 328 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 329 |
+
detail="Quiz not found"
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
# Update fields
|
| 333 |
+
for field, value in data.model_dump(exclude_unset=True).items():
|
| 334 |
+
setattr(quiz, field, value)
|
| 335 |
+
|
| 336 |
+
await db.commit()
|
| 337 |
+
await db.refresh(quiz)
|
| 338 |
+
return quiz
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@router.delete("/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 342 |
+
async def delete_quiz(
|
| 343 |
+
quiz_id: str,
|
| 344 |
+
current_user: User = Depends(require_instructor),
|
| 345 |
+
db: AsyncSession = Depends(get_db),
|
| 346 |
+
):
|
| 347 |
+
"""Delete quiz (instructor only)."""
|
| 348 |
+
result = await db.execute(select(Quiz).where(Quiz.id == quiz_id))
|
| 349 |
+
quiz = result.scalar_one_or_none()
|
| 350 |
+
if not quiz:
|
| 351 |
+
raise HTTPException(
|
| 352 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 353 |
+
detail="Quiz not found"
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
await db.delete(quiz)
|
| 357 |
+
await db.commit()
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# ─── Quiz Attempts (Student) ─────────────────────────────
|
| 361 |
+
|
| 362 |
+
@router.post("/submit", response_model=DetailedAttemptResponse)
|
| 363 |
+
async def submit_quiz(
|
| 364 |
+
submission: QuizSubmission,
|
| 365 |
+
current_user: User = Depends(get_current_user),
|
| 366 |
+
db: AsyncSession = Depends(get_db),
|
| 367 |
+
):
|
| 368 |
+
"""Submit quiz answers and get graded results."""
|
| 369 |
+
# Get quiz with questions
|
| 370 |
+
result = await db.execute(
|
| 371 |
+
select(Quiz)
|
| 372 |
+
.options(selectinload(Quiz.questions))
|
| 373 |
+
.where(Quiz.id == submission.quiz_id)
|
| 374 |
+
)
|
| 375 |
+
quiz = result.scalar_one_or_none()
|
| 376 |
+
if not quiz:
|
| 377 |
+
raise HTTPException(
|
| 378 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 379 |
+
detail="Quiz not found"
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# Check if quiz is published
|
| 383 |
+
if not quiz.is_published and current_user.role == UserRole.STUDENT:
|
| 384 |
+
raise HTTPException(
|
| 385 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 386 |
+
detail="Quiz is not published"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# Check max attempts (only for students)
|
| 390 |
+
if current_user.role == UserRole.STUDENT:
|
| 391 |
+
attempts_result = await db.execute(
|
| 392 |
+
select(func.count(QuizAttempt.id))
|
| 393 |
+
.where(
|
| 394 |
+
and_(
|
| 395 |
+
QuizAttempt.quiz_id == submission.quiz_id,
|
| 396 |
+
QuizAttempt.student_id == current_user.id,
|
| 397 |
+
QuizAttempt.completed_at.isnot(None)
|
| 398 |
+
)
|
| 399 |
+
)
|
| 400 |
+
)
|
| 401 |
+
attempts_count = attempts_result.scalar() or 0
|
| 402 |
+
if attempts_count >= quiz.max_attempts:
|
| 403 |
+
raise HTTPException(
|
| 404 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 405 |
+
detail="Maximum attempts reached"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
# Grade the quiz
|
| 409 |
+
total_points = 0
|
| 410 |
+
earned_points = 0
|
| 411 |
+
question_results = []
|
| 412 |
+
answers_dict = {}
|
| 413 |
+
|
| 414 |
+
for q in quiz.questions:
|
| 415 |
+
total_points += q.points
|
| 416 |
+
|
| 417 |
+
# Find student's answer
|
| 418 |
+
student_answer = next(
|
| 419 |
+
(a.answer for a in submission.answers if a.question_id == q.id),
|
| 420 |
+
None
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
if student_answer is None:
|
| 424 |
+
student_answer = ""
|
| 425 |
+
|
| 426 |
+
# Strip "A. " prefixes from both answers for comparison
|
| 427 |
+
clean_student_answer = re.sub(r'^[A-D]\.\s*', '', student_answer.strip())
|
| 428 |
+
clean_correct_answer = re.sub(r'^[A-D]\.\s*', '', q.correct_answer.strip())
|
| 429 |
+
|
| 430 |
+
# Check if correct (case-insensitive, prefix-stripped comparison)
|
| 431 |
+
is_correct = clean_student_answer.lower() == clean_correct_answer.lower()
|
| 432 |
+
points_earned = q.points if is_correct else 0
|
| 433 |
+
earned_points += points_earned
|
| 434 |
+
|
| 435 |
+
question_results.append({
|
| 436 |
+
"question_id": q.id,
|
| 437 |
+
"question_text": q.question_text,
|
| 438 |
+
"student_answer": student_answer,
|
| 439 |
+
"correct_answer": q.correct_answer,
|
| 440 |
+
"is_correct": is_correct,
|
| 441 |
+
"points_earned": points_earned,
|
| 442 |
+
"points_possible": q.points,
|
| 443 |
+
"explanation": q.explanation,
|
| 444 |
+
})
|
| 445 |
+
|
| 446 |
+
answers_dict[q.id] = {
|
| 447 |
+
"answer": student_answer,
|
| 448 |
+
"correct": is_correct,
|
| 449 |
+
"points": points_earned,
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
# Calculate score percentage
|
| 453 |
+
score = (earned_points / total_points * 100) if total_points > 0 else 0
|
| 454 |
+
passed = score >= quiz.passing_score
|
| 455 |
+
|
| 456 |
+
# Save attempt
|
| 457 |
+
attempt = QuizAttempt(
|
| 458 |
+
quiz_id=submission.quiz_id,
|
| 459 |
+
student_id=current_user.id,
|
| 460 |
+
score=round(score, 2),
|
| 461 |
+
passed=passed,
|
| 462 |
+
answers=answers_dict,
|
| 463 |
+
time_taken_seconds=submission.time_taken_seconds,
|
| 464 |
+
completed_at=datetime.utcnow(),
|
| 465 |
+
)
|
| 466 |
+
db.add(attempt)
|
| 467 |
+
await db.commit()
|
| 468 |
+
await db.refresh(attempt)
|
| 469 |
+
|
| 470 |
+
# Return detailed results
|
| 471 |
+
return {
|
| 472 |
+
"id": attempt.id,
|
| 473 |
+
"quiz_id": attempt.quiz_id,
|
| 474 |
+
"student_id": attempt.student_id,
|
| 475 |
+
"score": attempt.score,
|
| 476 |
+
"passed": attempt.passed,
|
| 477 |
+
"answers": attempt.answers,
|
| 478 |
+
"time_taken_seconds": attempt.time_taken_seconds,
|
| 479 |
+
"started_at": attempt.started_at,
|
| 480 |
+
"completed_at": attempt.completed_at,
|
| 481 |
+
"quiz": quiz,
|
| 482 |
+
"question_results": question_results,
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
@router.get("/attempts/my", response_model=List[AttemptWithQuiz])
|
| 487 |
+
async def get_my_attempts(
|
| 488 |
+
current_user: User = Depends(get_current_user),
|
| 489 |
+
db: AsyncSession = Depends(get_db),
|
| 490 |
+
):
|
| 491 |
+
"""Get all quiz attempts for current user."""
|
| 492 |
+
result = await db.execute(
|
| 493 |
+
select(QuizAttempt)
|
| 494 |
+
.options(selectinload(QuizAttempt.quiz))
|
| 495 |
+
.where(QuizAttempt.student_id == current_user.id)
|
| 496 |
+
.where(QuizAttempt.completed_at.isnot(None))
|
| 497 |
+
.order_by(QuizAttempt.completed_at.desc())
|
| 498 |
+
)
|
| 499 |
+
attempts = result.scalars().all()
|
| 500 |
+
return attempts
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
@router.get("/attempts/{attempt_id}", response_model=DetailedAttemptResponse)
|
| 504 |
+
async def get_attempt_details(
|
| 505 |
+
attempt_id: str,
|
| 506 |
+
current_user: User = Depends(get_current_user),
|
| 507 |
+
db: AsyncSession = Depends(get_db),
|
| 508 |
+
):
|
| 509 |
+
"""Get detailed results for a specific attempt."""
|
| 510 |
+
result = await db.execute(
|
| 511 |
+
select(QuizAttempt)
|
| 512 |
+
.options(
|
| 513 |
+
selectinload(QuizAttempt.quiz).selectinload(Quiz.questions)
|
| 514 |
+
)
|
| 515 |
+
.where(QuizAttempt.id == attempt_id)
|
| 516 |
+
)
|
| 517 |
+
attempt = result.scalar_one_or_none()
|
| 518 |
+
if not attempt:
|
| 519 |
+
raise HTTPException(
|
| 520 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 521 |
+
detail="Attempt not found"
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
# Check permissions
|
| 525 |
+
if attempt.student_id != current_user.id and current_user.role != UserRole.INSTRUCTOR:
|
| 526 |
+
raise HTTPException(
|
| 527 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 528 |
+
detail="Not authorized to view this attempt"
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
# Reconstruct question results
|
| 532 |
+
question_results = []
|
| 533 |
+
for q in attempt.quiz.questions:
|
| 534 |
+
answer_data = attempt.answers.get(q.id, {})
|
| 535 |
+
question_results.append({
|
| 536 |
+
"question_id": q.id,
|
| 537 |
+
"question_text": q.question_text,
|
| 538 |
+
"student_answer": answer_data.get("answer", ""),
|
| 539 |
+
"correct_answer": q.correct_answer,
|
| 540 |
+
"is_correct": answer_data.get("correct", False),
|
| 541 |
+
"points_earned": answer_data.get("points", 0),
|
| 542 |
+
"points_possible": q.points,
|
| 543 |
+
"explanation": q.explanation,
|
| 544 |
+
})
|
| 545 |
+
|
| 546 |
+
return {
|
| 547 |
+
"id": attempt.id,
|
| 548 |
+
"quiz_id": attempt.quiz_id,
|
| 549 |
+
"student_id": attempt.student_id,
|
| 550 |
+
"score": attempt.score,
|
| 551 |
+
"passed": attempt.passed,
|
| 552 |
+
"answers": attempt.answers,
|
| 553 |
+
"time_taken_seconds": attempt.time_taken_seconds,
|
| 554 |
+
"started_at": attempt.started_at,
|
| 555 |
+
"completed_at": attempt.completed_at,
|
| 556 |
+
"quiz": attempt.quiz,
|
| 557 |
+
"question_results": question_results,
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
@router.get("/lesson/{lesson_id}/attempts", response_model=List[AttemptWithQuiz])
|
| 562 |
+
async def get_lesson_attempts(
|
| 563 |
+
lesson_id: str,
|
| 564 |
+
current_user: User = Depends(get_current_user),
|
| 565 |
+
db: AsyncSession = Depends(get_db),
|
| 566 |
+
):
|
| 567 |
+
"""Get all attempts for quizzes in a lesson (for current user)."""
|
| 568 |
+
result = await db.execute(
|
| 569 |
+
select(QuizAttempt)
|
| 570 |
+
.join(Quiz)
|
| 571 |
+
.options(selectinload(QuizAttempt.quiz))
|
| 572 |
+
.where(
|
| 573 |
+
and_(
|
| 574 |
+
Quiz.lesson_id == lesson_id,
|
| 575 |
+
QuizAttempt.student_id == current_user.id,
|
| 576 |
+
QuizAttempt.completed_at.isnot(None)
|
| 577 |
+
)
|
| 578 |
+
)
|
| 579 |
+
.order_by(QuizAttempt.completed_at.desc())
|
| 580 |
+
)
|
| 581 |
+
attempts = result.scalars().all()
|
| 582 |
+
return attempts
|
app/routes/tutor.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Tutor routes for enhanced learning features."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pypdf import PdfReader
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
from sqlalchemy import select
|
| 8 |
+
from app.database import get_db
|
| 9 |
+
from app.models.user import User
|
| 10 |
+
from app.models.course import Lesson, ContentType
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.schemas.tutor import (
|
| 13 |
+
AskTutorRequest,
|
| 14 |
+
AskTutorResponse,
|
| 15 |
+
GenerateSummaryRequest,
|
| 16 |
+
SummaryResponse,
|
| 17 |
+
GenerateMCQRequest,
|
| 18 |
+
GenerateMCQResponse,
|
| 19 |
+
HintRequest,
|
| 20 |
+
HintResponse,
|
| 21 |
+
SourceDocument,
|
| 22 |
+
)
|
| 23 |
+
from app.services.tutor import tutor_service
|
| 24 |
+
from app.dependencies import get_current_user
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
router = APIRouter(prefix="/tutor", tags=["AI Tutor"])
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def extract_pdf_text(file_path: str, max_pages: int = 10) -> str:
|
| 31 |
+
"""Extract text from PDF file (limit to first N pages for performance)."""
|
| 32 |
+
try:
|
| 33 |
+
reader = PdfReader(file_path)
|
| 34 |
+
text_parts = []
|
| 35 |
+
|
| 36 |
+
# Limit pages to prevent huge content
|
| 37 |
+
num_pages = min(len(reader.pages), max_pages)
|
| 38 |
+
|
| 39 |
+
for i in range(num_pages):
|
| 40 |
+
page = reader.pages[i]
|
| 41 |
+
text = page.extract_text()
|
| 42 |
+
if text:
|
| 43 |
+
text_parts.append(text)
|
| 44 |
+
|
| 45 |
+
extracted_text = "\n\n".join(text_parts)
|
| 46 |
+
|
| 47 |
+
# Limit total length (gemini has token limits)
|
| 48 |
+
if len(extracted_text) > 15000:
|
| 49 |
+
extracted_text = extracted_text[:15000] + "\n\n[Content truncated for length...]"
|
| 50 |
+
|
| 51 |
+
return extracted_text
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"⚠️ PDF extraction error: {e}")
|
| 54 |
+
return ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_lesson_content(lesson: Lesson) -> str:
|
| 58 |
+
"""Get content from lesson based on content type."""
|
| 59 |
+
# For markdown, use content_text
|
| 60 |
+
if lesson.content_type == ContentType.MARKDOWN:
|
| 61 |
+
return lesson.content_text or f"Lesson: {lesson.title}\n{lesson.description or ''}"
|
| 62 |
+
|
| 63 |
+
# For PDF, extract text from file
|
| 64 |
+
if lesson.content_type == ContentType.PDF and lesson.content_url:
|
| 65 |
+
file_path = os.path.join(settings.UPLOAD_DIR, os.path.basename(lesson.content_url))
|
| 66 |
+
if os.path.exists(file_path):
|
| 67 |
+
pdf_text = extract_pdf_text(file_path, max_pages=10)
|
| 68 |
+
if pdf_text:
|
| 69 |
+
return f"Lesson: {lesson.title}\n\n{pdf_text}"
|
| 70 |
+
|
| 71 |
+
# Fallback to title + description
|
| 72 |
+
return f"Lesson: {lesson.title}\n{lesson.description or 'No content available.'}"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.post("/ask", response_model=AskTutorResponse)
|
| 76 |
+
async def ask_tutor(
|
| 77 |
+
data: AskTutorRequest,
|
| 78 |
+
current_user: User = Depends(get_current_user),
|
| 79 |
+
db: AsyncSession = Depends(get_db),
|
| 80 |
+
):
|
| 81 |
+
"""Ask the AI tutor a question with optional lesson/module context."""
|
| 82 |
+
try:
|
| 83 |
+
answer, sources = await tutor_service.ask_question(
|
| 84 |
+
question=data.question,
|
| 85 |
+
lesson_context=data.lesson_id,
|
| 86 |
+
module_context=data.module_id,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
return AskTutorResponse(
|
| 90 |
+
answer=answer,
|
| 91 |
+
conversation_id=data.conversation_id or "new",
|
| 92 |
+
sources=sources,
|
| 93 |
+
)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.post("/summary", response_model=SummaryResponse)
|
| 99 |
+
async def generate_summary(
|
| 100 |
+
data: GenerateSummaryRequest,
|
| 101 |
+
current_user: User = Depends(get_current_user),
|
| 102 |
+
db: AsyncSession = Depends(get_db),
|
| 103 |
+
):
|
| 104 |
+
"""Generate a summary for a lesson."""
|
| 105 |
+
try:
|
| 106 |
+
result = await db.execute(select(Lesson).where(Lesson.id == data.lesson_id))
|
| 107 |
+
lesson = result.scalar_one_or_none()
|
| 108 |
+
except Exception as e:
|
| 109 |
+
print(f"⚠️ Could not fetch lesson for summary: {e}")
|
| 110 |
+
lesson = None
|
| 111 |
+
|
| 112 |
+
if not lesson:
|
| 113 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 114 |
+
|
| 115 |
+
# Get content based on lesson type (handles PDF extraction)
|
| 116 |
+
content = get_lesson_content(lesson)
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
summary, key_points = await tutor_service.generate_summary(
|
| 120 |
+
content=content,
|
| 121 |
+
title=lesson.title,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
return SummaryResponse(
|
| 125 |
+
lesson_id=data.lesson_id,
|
| 126 |
+
summary=summary,
|
| 127 |
+
key_points=key_points,
|
| 128 |
+
)
|
| 129 |
+
except Exception as e:
|
| 130 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.post("/mcq", response_model=GenerateMCQResponse)
|
| 134 |
+
async def generate_mcq(
|
| 135 |
+
data: GenerateMCQRequest,
|
| 136 |
+
current_user: User = Depends(get_current_user),
|
| 137 |
+
db: AsyncSession = Depends(get_db),
|
| 138 |
+
):
|
| 139 |
+
"""Generate MCQ questions for a lesson."""
|
| 140 |
+
try:
|
| 141 |
+
result = await db.execute(select(Lesson).where(Lesson.id == data.lesson_id))
|
| 142 |
+
lesson = result.scalar_one_or_none()
|
| 143 |
+
except Exception as e:
|
| 144 |
+
print(f"⚠️ Could not fetch lesson for MCQ: {e}")
|
| 145 |
+
lesson = None
|
| 146 |
+
|
| 147 |
+
if not lesson:
|
| 148 |
+
raise HTTPException(status_code=404, detail="Lesson not found")
|
| 149 |
+
|
| 150 |
+
# Get content based on lesson type (handles PDF extraction)
|
| 151 |
+
content = get_lesson_content(lesson)
|
| 152 |
+
|
| 153 |
+
if not content or len(content) < 100:
|
| 154 |
+
raise HTTPException(
|
| 155 |
+
status_code=400,
|
| 156 |
+
detail="Insufficient content to generate quiz. Please add more content to the lesson."
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
questions = await tutor_service.generate_mcqs(
|
| 161 |
+
content=content,
|
| 162 |
+
num_questions=data.num_questions,
|
| 163 |
+
difficulty=data.difficulty,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
return GenerateMCQResponse(
|
| 167 |
+
lesson_id=data.lesson_id,
|
| 168 |
+
questions=questions,
|
| 169 |
+
)
|
| 170 |
+
except Exception as e:
|
| 171 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.post("/hint", response_model=HintResponse)
|
| 175 |
+
async def get_hint(
|
| 176 |
+
data: HintRequest,
|
| 177 |
+
current_user: User = Depends(get_current_user),
|
| 178 |
+
):
|
| 179 |
+
"""Get a progressive hint for a question."""
|
| 180 |
+
try:
|
| 181 |
+
hint, has_more = await tutor_service.generate_hint(
|
| 182 |
+
question=data.question,
|
| 183 |
+
context=data.context,
|
| 184 |
+
hint_level=data.hint_level,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
return HintResponse(
|
| 188 |
+
hint=hint,
|
| 189 |
+
hint_level=data.hint_level,
|
| 190 |
+
has_more_hints=has_more,
|
| 191 |
+
)
|
| 192 |
+
except Exception as e:
|
| 193 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/course.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Course and module schemas."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from app.models.course import ContentType, DifficultyLevel
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# ─── Course ──────────────────────────────────────────────
|
| 10 |
+
|
| 11 |
+
class CourseCreate(BaseModel):
|
| 12 |
+
title: str = Field(..., min_length=1, max_length=255)
|
| 13 |
+
slug: str = Field(..., min_length=1, max_length=255)
|
| 14 |
+
description: Optional[str] = None
|
| 15 |
+
thumbnail_url: Optional[str] = None
|
| 16 |
+
difficulty: DifficultyLevel = DifficultyLevel.BEGINNER
|
| 17 |
+
estimated_hours: int = 0
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class CourseUpdate(BaseModel):
|
| 21 |
+
title: Optional[str] = None
|
| 22 |
+
description: Optional[str] = None
|
| 23 |
+
thumbnail_url: Optional[str] = None
|
| 24 |
+
difficulty: Optional[DifficultyLevel] = None
|
| 25 |
+
estimated_hours: Optional[int] = None
|
| 26 |
+
is_published: Optional[bool] = None
|
| 27 |
+
is_featured: Optional[bool] = None
|
| 28 |
+
order_index: Optional[int] = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CourseResponse(BaseModel):
|
| 32 |
+
id: str
|
| 33 |
+
title: str
|
| 34 |
+
slug: str
|
| 35 |
+
description: Optional[str]
|
| 36 |
+
thumbnail_url: Optional[str]
|
| 37 |
+
difficulty: DifficultyLevel
|
| 38 |
+
is_published: bool
|
| 39 |
+
is_featured: bool
|
| 40 |
+
estimated_hours: int
|
| 41 |
+
order_index: int
|
| 42 |
+
created_by: Optional[str] = None
|
| 43 |
+
created_at: datetime
|
| 44 |
+
updated_at: datetime
|
| 45 |
+
module_count: int = 0
|
| 46 |
+
enrolled_count: int = 0
|
| 47 |
+
|
| 48 |
+
class Config:
|
| 49 |
+
from_attributes = True
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class CourseBrief(BaseModel):
|
| 53 |
+
id: str
|
| 54 |
+
title: str
|
| 55 |
+
slug: str
|
| 56 |
+
thumbnail_url: Optional[str]
|
| 57 |
+
difficulty: DifficultyLevel
|
| 58 |
+
estimated_hours: int
|
| 59 |
+
|
| 60 |
+
class Config:
|
| 61 |
+
from_attributes = True
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ─── Module ──────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
class ModuleCreate(BaseModel):
|
| 67 |
+
title: str = Field(..., min_length=1, max_length=255)
|
| 68 |
+
description: Optional[str] = None
|
| 69 |
+
order_index: int = 0
|
| 70 |
+
prerequisite_module_id: Optional[str] = None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class ModuleUpdate(BaseModel):
|
| 74 |
+
title: Optional[str] = None
|
| 75 |
+
description: Optional[str] = None
|
| 76 |
+
order_index: Optional[int] = None
|
| 77 |
+
is_published: Optional[bool] = None
|
| 78 |
+
prerequisite_module_id: Optional[str] = None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ModuleResponse(BaseModel):
|
| 82 |
+
id: str
|
| 83 |
+
course_id: str
|
| 84 |
+
title: str
|
| 85 |
+
description: Optional[str]
|
| 86 |
+
order_index: int
|
| 87 |
+
is_published: bool
|
| 88 |
+
prerequisite_module_id: Optional[str]
|
| 89 |
+
created_at: datetime
|
| 90 |
+
lesson_count: int = 0
|
| 91 |
+
|
| 92 |
+
class Config:
|
| 93 |
+
from_attributes = True
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ─── Lesson ──────────────────────────────────────────────
|
| 97 |
+
|
| 98 |
+
class LessonCreate(BaseModel):
|
| 99 |
+
title: str = Field(..., min_length=1, max_length=255)
|
| 100 |
+
description: Optional[str] = None
|
| 101 |
+
content_type: ContentType
|
| 102 |
+
content_url: Optional[str] = None
|
| 103 |
+
content_text: Optional[str] = None
|
| 104 |
+
duration_minutes: int = 0
|
| 105 |
+
order_index: int = 0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class LessonUpdate(BaseModel):
|
| 109 |
+
title: Optional[str] = None
|
| 110 |
+
description: Optional[str] = None
|
| 111 |
+
content_type: Optional[ContentType] = None
|
| 112 |
+
content_url: Optional[str] = None
|
| 113 |
+
content_text: Optional[str] = None
|
| 114 |
+
duration_minutes: Optional[int] = None
|
| 115 |
+
order_index: Optional[int] = None
|
| 116 |
+
is_published: Optional[bool] = None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class LessonResponse(BaseModel):
|
| 120 |
+
id: str
|
| 121 |
+
module_id: str
|
| 122 |
+
title: str
|
| 123 |
+
description: Optional[str]
|
| 124 |
+
content_type: ContentType
|
| 125 |
+
content_url: Optional[str]
|
| 126 |
+
content_text: Optional[str]
|
| 127 |
+
duration_minutes: int
|
| 128 |
+
order_index: int
|
| 129 |
+
is_published: bool
|
| 130 |
+
created_at: datetime
|
| 131 |
+
|
| 132 |
+
class Config:
|
| 133 |
+
from_attributes = True
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class LessonBrief(BaseModel):
|
| 137 |
+
id: str
|
| 138 |
+
title: str
|
| 139 |
+
content_type: ContentType
|
| 140 |
+
duration_minutes: int
|
| 141 |
+
order_index: int
|
| 142 |
+
|
| 143 |
+
class Config:
|
| 144 |
+
from_attributes = True
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ─── Full Course with Modules ─────────────────────────────
|
| 148 |
+
|
| 149 |
+
class ModuleWithLessons(ModuleResponse):
|
| 150 |
+
lessons: List[LessonBrief] = []
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class CourseWithModules(CourseResponse):
|
| 154 |
+
modules: List[ModuleWithLessons] = []
|
app/schemas/progress.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Progress and enrollment schemas."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class EnrollmentCreate(BaseModel):
|
| 9 |
+
course_id: str
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EnrollmentResponse(BaseModel):
|
| 13 |
+
id: str
|
| 14 |
+
student_id: str
|
| 15 |
+
course_id: str
|
| 16 |
+
enrolled_at: datetime
|
| 17 |
+
completed_at: Optional[datetime]
|
| 18 |
+
is_completed: bool
|
| 19 |
+
progress_percentage: float
|
| 20 |
+
|
| 21 |
+
class Config:
|
| 22 |
+
from_attributes = True
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class EnrollmentWithStudent(BaseModel):
|
| 26 |
+
id: str
|
| 27 |
+
student_id: str
|
| 28 |
+
student_name: str
|
| 29 |
+
student_email: str
|
| 30 |
+
course_id: str
|
| 31 |
+
enrolled_at: datetime
|
| 32 |
+
completed_at: Optional[datetime]
|
| 33 |
+
is_completed: bool
|
| 34 |
+
progress_percentage: float
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ProgressUpdate(BaseModel):
|
| 38 |
+
is_completed: Optional[bool] = None
|
| 39 |
+
time_spent_seconds: Optional[int] = None
|
| 40 |
+
last_position: Optional[int] = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ProgressResponse(BaseModel):
|
| 44 |
+
id: str
|
| 45 |
+
student_id: str
|
| 46 |
+
lesson_id: str
|
| 47 |
+
is_completed: bool
|
| 48 |
+
completed_at: Optional[datetime]
|
| 49 |
+
time_spent_seconds: int
|
| 50 |
+
last_position: int
|
| 51 |
+
started_at: datetime
|
| 52 |
+
|
| 53 |
+
class Config:
|
| 54 |
+
from_attributes = True
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class StudyStreakResponse(BaseModel):
|
| 58 |
+
current_streak: int
|
| 59 |
+
longest_streak: int
|
| 60 |
+
last_study_date: Optional[datetime]
|
| 61 |
+
total_study_days: int
|
| 62 |
+
|
| 63 |
+
class Config:
|
| 64 |
+
from_attributes = True
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class DashboardStats(BaseModel):
|
| 68 |
+
enrolled_courses: int
|
| 69 |
+
completed_courses: int
|
| 70 |
+
lessons_completed: int
|
| 71 |
+
total_study_time_hours: float
|
| 72 |
+
current_streak: int
|
| 73 |
+
longest_streak: int
|
| 74 |
+
average_quiz_score: float
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class CourseProgress(BaseModel):
|
| 78 |
+
course_id: str
|
| 79 |
+
course_title: str
|
| 80 |
+
progress_percentage: float
|
| 81 |
+
lessons_completed: int
|
| 82 |
+
total_lessons: int
|
| 83 |
+
last_accessed: Optional[datetime]
|
app/schemas/quiz.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quiz schemas."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from typing import Optional, List, Any
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from app.models.quiz import QuestionType
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class QuizCreate(BaseModel):
|
| 10 |
+
title: str = Field(..., min_length=1, max_length=255)
|
| 11 |
+
description: Optional[str] = None
|
| 12 |
+
passing_score: float = 70.0
|
| 13 |
+
time_limit_minutes: Optional[int] = None
|
| 14 |
+
max_attempts: int = 3
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class QuizUpdate(BaseModel):
|
| 18 |
+
title: Optional[str] = None
|
| 19 |
+
description: Optional[str] = None
|
| 20 |
+
passing_score: Optional[float] = None
|
| 21 |
+
time_limit_minutes: Optional[int] = None
|
| 22 |
+
max_attempts: Optional[int] = None
|
| 23 |
+
is_published: Optional[bool] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class QuizResponse(BaseModel):
|
| 27 |
+
id: str
|
| 28 |
+
lesson_id: str
|
| 29 |
+
title: str
|
| 30 |
+
description: Optional[str]
|
| 31 |
+
passing_score: float
|
| 32 |
+
time_limit_minutes: Optional[int]
|
| 33 |
+
max_attempts: int
|
| 34 |
+
is_ai_generated: bool
|
| 35 |
+
is_published: bool
|
| 36 |
+
question_count: int = 0
|
| 37 |
+
created_at: datetime
|
| 38 |
+
|
| 39 |
+
class Config:
|
| 40 |
+
from_attributes = True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class QuestionCreate(BaseModel):
|
| 44 |
+
question_type: QuestionType = QuestionType.MULTIPLE_CHOICE
|
| 45 |
+
question_text: str
|
| 46 |
+
options: Optional[List[str]] = None
|
| 47 |
+
correct_answer: str
|
| 48 |
+
explanation: Optional[str] = None
|
| 49 |
+
points: int = 1
|
| 50 |
+
order_index: int = 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class QuestionResponse(BaseModel):
|
| 54 |
+
id: str
|
| 55 |
+
question_type: str
|
| 56 |
+
question_text: str
|
| 57 |
+
options: Optional[List[str]]
|
| 58 |
+
correct_answer: str
|
| 59 |
+
explanation: Optional[str]
|
| 60 |
+
points: int
|
| 61 |
+
order_index: int
|
| 62 |
+
|
| 63 |
+
class Config:
|
| 64 |
+
from_attributes = True
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class QuestionForStudent(BaseModel):
|
| 68 |
+
"""Question without correct answer (for taking quiz)."""
|
| 69 |
+
id: str
|
| 70 |
+
question_type: str
|
| 71 |
+
question_text: str
|
| 72 |
+
options: Optional[List[str]]
|
| 73 |
+
points: int
|
| 74 |
+
|
| 75 |
+
class Config:
|
| 76 |
+
from_attributes = True
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class QuizWithQuestions(QuizResponse):
|
| 80 |
+
questions: List[QuestionForStudent] = []
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class AnswerSubmit(BaseModel):
|
| 84 |
+
question_id: str
|
| 85 |
+
answer: str
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class QuizSubmit(BaseModel):
|
| 89 |
+
answers: List[AnswerSubmit]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class QuizAttemptResponse(BaseModel):
|
| 93 |
+
id: str
|
| 94 |
+
quiz_id: str
|
| 95 |
+
score: float
|
| 96 |
+
passed: bool
|
| 97 |
+
answers: Optional[dict]
|
| 98 |
+
time_taken_seconds: Optional[int]
|
| 99 |
+
started_at: datetime
|
| 100 |
+
completed_at: Optional[datetime]
|
| 101 |
+
|
| 102 |
+
class Config:
|
| 103 |
+
from_attributes = True
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class QuizResultDetail(BaseModel):
|
| 107 |
+
question_id: str
|
| 108 |
+
question_text: str
|
| 109 |
+
your_answer: str
|
| 110 |
+
correct_answer: str
|
| 111 |
+
is_correct: bool
|
| 112 |
+
explanation: Optional[str]
|
| 113 |
+
points_earned: int
|
| 114 |
+
points_possible: int
|
app/schemas/tutor.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Tutor schemas."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class AskTutorRequest(BaseModel):
|
| 8 |
+
question: str = Field(..., min_length=1, max_length=2000)
|
| 9 |
+
lesson_id: Optional[str] = None # Context from specific lesson
|
| 10 |
+
module_id: Optional[str] = None # Context from specific module
|
| 11 |
+
conversation_id: Optional[str] = None
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SourceDocument(BaseModel):
|
| 15 |
+
content: str
|
| 16 |
+
source: str
|
| 17 |
+
page: Optional[int] = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AskTutorResponse(BaseModel):
|
| 21 |
+
answer: str
|
| 22 |
+
conversation_id: str
|
| 23 |
+
sources: List[SourceDocument] = []
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GenerateSummaryRequest(BaseModel):
|
| 27 |
+
lesson_id: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SummaryResponse(BaseModel):
|
| 31 |
+
lesson_id: str
|
| 32 |
+
summary: str
|
| 33 |
+
key_points: List[str]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class GenerateMCQRequest(BaseModel):
|
| 37 |
+
lesson_id: str
|
| 38 |
+
num_questions: int = Field(default=5, ge=1, le=20)
|
| 39 |
+
difficulty: str = "medium" # easy, medium, hard
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class GeneratedMCQ(BaseModel):
|
| 43 |
+
question: str
|
| 44 |
+
options: List[str]
|
| 45 |
+
correct_answer: str
|
| 46 |
+
explanation: str
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class GenerateMCQResponse(BaseModel):
|
| 50 |
+
lesson_id: str
|
| 51 |
+
questions: List[GeneratedMCQ]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class HintRequest(BaseModel):
|
| 55 |
+
question: str
|
| 56 |
+
context: Optional[str] = None
|
| 57 |
+
hint_level: int = Field(default=1, ge=1, le=3) # 1=subtle, 2=moderate, 3=strong
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class HintResponse(BaseModel):
|
| 61 |
+
hint: str
|
| 62 |
+
hint_level: int
|
| 63 |
+
has_more_hints: bool
|
app/schemas/user.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User schemas for API requests/responses."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, EmailStr, Field, field_validator
|
| 4 |
+
from typing import Optional, Literal
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from app.models.user import UserRole
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class UserCreate(BaseModel):
|
| 10 |
+
email: EmailStr
|
| 11 |
+
password: str = Field(..., min_length=8)
|
| 12 |
+
full_name: str = Field(..., min_length=2, max_length=255)
|
| 13 |
+
role: Optional[Literal["student", "instructor"]] = "student" # Only student or instructor allowed
|
| 14 |
+
|
| 15 |
+
@field_validator("role")
|
| 16 |
+
@classmethod
|
| 17 |
+
def validate_role(cls, v):
|
| 18 |
+
if v not in ["student", "instructor"]:
|
| 19 |
+
raise ValueError("Role must be either 'student' or 'instructor'")
|
| 20 |
+
return v
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class UserLogin(BaseModel):
|
| 24 |
+
email: EmailStr
|
| 25 |
+
password: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class UserUpdate(BaseModel):
|
| 29 |
+
full_name: Optional[str] = None
|
| 30 |
+
avatar_url: Optional[str] = None
|
| 31 |
+
bio: Optional[str] = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class UserResponse(BaseModel):
|
| 35 |
+
id: str
|
| 36 |
+
email: str
|
| 37 |
+
full_name: str
|
| 38 |
+
avatar_url: Optional[str]
|
| 39 |
+
role: UserRole
|
| 40 |
+
is_active: bool
|
| 41 |
+
is_verified: bool
|
| 42 |
+
bio: Optional[str]
|
| 43 |
+
created_at: datetime
|
| 44 |
+
last_login: Optional[datetime]
|
| 45 |
+
|
| 46 |
+
class Config:
|
| 47 |
+
from_attributes = True
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class UserBrief(BaseModel):
|
| 51 |
+
id: str
|
| 52 |
+
full_name: str
|
| 53 |
+
avatar_url: Optional[str]
|
| 54 |
+
role: UserRole
|
| 55 |
+
|
| 56 |
+
class Config:
|
| 57 |
+
from_attributes = True
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TokenResponse(BaseModel):
|
| 61 |
+
access_token: str
|
| 62 |
+
refresh_token: str
|
| 63 |
+
token_type: str = "bearer"
|
| 64 |
+
user: UserResponse
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class RefreshTokenRequest(BaseModel):
|
| 68 |
+
refresh_token: str
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/auth.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication service with JWT tokens and password hashing."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from passlib.context import CryptContext
|
| 6 |
+
import jwt
|
| 7 |
+
from app.config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def hash_password(password: str) -> str:
|
| 14 |
+
"""Hash a password using bcrypt."""
|
| 15 |
+
return pwd_context.hash(password)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 19 |
+
"""Verify a password against its hash."""
|
| 20 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def create_access_token(
|
| 24 |
+
data: dict,
|
| 25 |
+
expires_delta: Optional[timedelta] = None
|
| 26 |
+
) -> str:
|
| 27 |
+
"""Create a JWT access token."""
|
| 28 |
+
to_encode = data.copy()
|
| 29 |
+
expire = datetime.utcnow() + (
|
| 30 |
+
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 31 |
+
)
|
| 32 |
+
to_encode.update({"exp": expire, "type": "access"})
|
| 33 |
+
return jwt.encode(
|
| 34 |
+
to_encode,
|
| 35 |
+
settings.JWT_SECRET_KEY,
|
| 36 |
+
algorithm=settings.JWT_ALGORITHM
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def create_refresh_token(
|
| 41 |
+
data: dict,
|
| 42 |
+
expires_delta: Optional[timedelta] = None
|
| 43 |
+
) -> str:
|
| 44 |
+
"""Create a JWT refresh token."""
|
| 45 |
+
to_encode = data.copy()
|
| 46 |
+
expire = datetime.utcnow() + (
|
| 47 |
+
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
| 48 |
+
)
|
| 49 |
+
to_encode.update({"exp": expire, "type": "refresh"})
|
| 50 |
+
return jwt.encode(
|
| 51 |
+
to_encode,
|
| 52 |
+
settings.JWT_SECRET_KEY,
|
| 53 |
+
algorithm=settings.JWT_ALGORITHM
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def decode_token(token: str) -> Optional[dict]:
|
| 58 |
+
"""Decode and verify a JWT token."""
|
| 59 |
+
try:
|
| 60 |
+
payload = jwt.decode(
|
| 61 |
+
token,
|
| 62 |
+
settings.JWT_SECRET_KEY,
|
| 63 |
+
algorithms=[settings.JWT_ALGORITHM]
|
| 64 |
+
)
|
| 65 |
+
return payload
|
| 66 |
+
except jwt.ExpiredSignatureError:
|
| 67 |
+
return None
|
| 68 |
+
except jwt.InvalidTokenError:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def create_tokens(user_id: str, email: str, role: str) -> dict:
|
| 73 |
+
"""Create both access and refresh tokens."""
|
| 74 |
+
token_data = {"sub": user_id, "email": email, "role": role}
|
| 75 |
+
return {
|
| 76 |
+
"access_token": create_access_token(token_data),
|
| 77 |
+
"refresh_token": create_refresh_token(token_data),
|
| 78 |
+
"token_type": "bearer",
|
| 79 |
+
}
|
app/services/tutor.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Tutor service using Gemini 2.5 Flash with PostgreSQL pgvector for RAG."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 7 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 8 |
+
from langchain_postgres import PGVector
|
| 9 |
+
from langchain.prompts import ChatPromptTemplate
|
| 10 |
+
from langchain.schema import Document
|
| 11 |
+
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
| 12 |
+
from pypdf import PdfReader
|
| 13 |
+
from app.config import settings
|
| 14 |
+
from app.schemas.tutor import SourceDocument, GeneratedMCQ
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AITutorService:
|
| 18 |
+
"""Enhanced AI Tutor with Gemini 2.5 Flash and PostgreSQL pgvector for RAG."""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self.embeddings: Optional[HuggingFaceEmbeddings] = None
|
| 22 |
+
self.vector_store: Optional[PGVector] = None
|
| 23 |
+
self.text_splitter = RecursiveCharacterTextSplitter(
|
| 24 |
+
chunk_size=1000,
|
| 25 |
+
chunk_overlap=200,
|
| 26 |
+
length_function=len,
|
| 27 |
+
)
|
| 28 |
+
self._initialized = False
|
| 29 |
+
self._llm = None # Cache LLM instance
|
| 30 |
+
|
| 31 |
+
async def initialize(self):
|
| 32 |
+
"""Initialize HuggingFace embeddings and pgvector store."""
|
| 33 |
+
if self._initialized:
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
print("🔄 Loading embedding model (this may take a moment on first run)...")
|
| 37 |
+
|
| 38 |
+
# Use HuggingFace sentence-transformers for embeddings
|
| 39 |
+
self.embeddings = HuggingFaceEmbeddings(
|
| 40 |
+
model_name=settings.EMBEDDING_MODEL,
|
| 41 |
+
model_kwargs={'device': 'cpu'},
|
| 42 |
+
encode_kwargs={'normalize_embeddings': True}
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
print("✅ Embedding model loaded!")
|
| 46 |
+
|
| 47 |
+
# Initialize PGVector with PostgreSQL connection
|
| 48 |
+
# Remove query parameters from URL for PGVector compatibility
|
| 49 |
+
clean_db_url = settings.DATABASE_URL.replace("+asyncpg", "").split("?")[0]
|
| 50 |
+
self.vector_store = PGVector(
|
| 51 |
+
embeddings=self.embeddings,
|
| 52 |
+
collection_name="documents",
|
| 53 |
+
connection=clean_db_url, # psycopg2 format without query params
|
| 54 |
+
use_jsonb=True,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Pre-initialize LLM
|
| 58 |
+
self._llm = self._get_llm(temperature=0.3)
|
| 59 |
+
|
| 60 |
+
self._initialized = True
|
| 61 |
+
|
| 62 |
+
def _get_llm(self, temperature: float = 0.3) -> ChatGoogleGenerativeAI:
|
| 63 |
+
"""Get Gemini LLM instance."""
|
| 64 |
+
return ChatGoogleGenerativeAI(
|
| 65 |
+
model=settings.LLM_MODEL,
|
| 66 |
+
google_api_key=settings.GOOGLE_API_KEY,
|
| 67 |
+
temperature=temperature,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
async def ask_question(
|
| 71 |
+
self,
|
| 72 |
+
question: str,
|
| 73 |
+
lesson_context: Optional[str] = None,
|
| 74 |
+
module_context: Optional[str] = None,
|
| 75 |
+
) -> tuple[str, List[SourceDocument]]:
|
| 76 |
+
"""Answer a question using RAG with lesson/module context."""
|
| 77 |
+
await self.initialize()
|
| 78 |
+
|
| 79 |
+
# Build filter for specific lesson/module if provided
|
| 80 |
+
filter_dict = {}
|
| 81 |
+
if lesson_context:
|
| 82 |
+
filter_dict["lesson_id"] = lesson_context
|
| 83 |
+
elif module_context:
|
| 84 |
+
filter_dict["module_id"] = module_context
|
| 85 |
+
|
| 86 |
+
# Retrieve relevant documents
|
| 87 |
+
try:
|
| 88 |
+
if filter_dict:
|
| 89 |
+
docs = self.vector_store.similarity_search(
|
| 90 |
+
question, k=4, filter=filter_dict
|
| 91 |
+
)
|
| 92 |
+
else:
|
| 93 |
+
docs = self.vector_store.similarity_search(question, k=4)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"⚠️ Vector search error: {e}")
|
| 96 |
+
# If vector search fails (e.g., no documents yet), provide general answer
|
| 97 |
+
docs = []
|
| 98 |
+
|
| 99 |
+
# Format context
|
| 100 |
+
context = self._format_context(docs)
|
| 101 |
+
sources = self._format_sources(docs)
|
| 102 |
+
|
| 103 |
+
# Generate answer
|
| 104 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 105 |
+
("system", """You are an AI tutor helping students learn. Answer questions based on the provided context.
|
| 106 |
+
|
| 107 |
+
INSTRUCTIONS:
|
| 108 |
+
- Be clear, educational, and helpful
|
| 109 |
+
- Use examples when appropriate
|
| 110 |
+
- If the context doesn't have the answer, say so honestly
|
| 111 |
+
- Format with markdown for readability
|
| 112 |
+
|
| 113 |
+
CONTEXT:
|
| 114 |
+
{context}"""),
|
| 115 |
+
("human", "{question}"),
|
| 116 |
+
])
|
| 117 |
+
|
| 118 |
+
llm = self._llm or self._get_llm(temperature=0.3)
|
| 119 |
+
chain = prompt | llm
|
| 120 |
+
response = await chain.ainvoke({"context": context, "question": question})
|
| 121 |
+
|
| 122 |
+
return response.content, sources
|
| 123 |
+
|
| 124 |
+
async def generate_summary(self, content: str, title: str) -> tuple[str, List[str]]:
|
| 125 |
+
"""Generate a summary with key points from lesson content."""
|
| 126 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 127 |
+
("system", """You are an expert educator. Create a concise summary and extract key learning points.
|
| 128 |
+
|
| 129 |
+
Return ONLY a valid JSON object (no markdown formatting, no code blocks) with:
|
| 130 |
+
- "summary": A 2-3 paragraph summary of the content
|
| 131 |
+
- "key_points": A list of 5-7 bullet points highlighting the most important concepts
|
| 132 |
+
|
| 133 |
+
Be educational and clear. Focus on what students need to remember.
|
| 134 |
+
|
| 135 |
+
IMPORTANT: Return ONLY the JSON object, do NOT wrap it in ```json code blocks."""),
|
| 136 |
+
("human", "Summarize this lesson titled '{title}':\n\n{content}"),
|
| 137 |
+
])
|
| 138 |
+
|
| 139 |
+
llm = self._get_llm(temperature=0.2)
|
| 140 |
+
chain = prompt | llm
|
| 141 |
+
response = await chain.ainvoke({"title": title, "content": content})
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
# Clean response - remove markdown code blocks if present
|
| 145 |
+
content = response.content.strip()
|
| 146 |
+
|
| 147 |
+
# Remove ```json and ``` wrappers if present
|
| 148 |
+
if content.startswith("```json"):
|
| 149 |
+
content = content[7:] # Remove ```json
|
| 150 |
+
elif content.startswith("```"):
|
| 151 |
+
content = content[3:] # Remove ```
|
| 152 |
+
|
| 153 |
+
if content.endswith("```"):
|
| 154 |
+
content = content[:-3] # Remove trailing ```
|
| 155 |
+
|
| 156 |
+
content = content.strip()
|
| 157 |
+
|
| 158 |
+
# Try to parse JSON response
|
| 159 |
+
result = json.loads(content)
|
| 160 |
+
return result.get("summary", ""), result.get("key_points", [])
|
| 161 |
+
except json.JSONDecodeError as e:
|
| 162 |
+
print(f"⚠️ JSON decode error: {e}")
|
| 163 |
+
print(f"⚠️ Raw response: {response.content[:500]}")
|
| 164 |
+
# Fallback: return raw response as summary
|
| 165 |
+
return response.content, []
|
| 166 |
+
|
| 167 |
+
async def generate_mcqs(
|
| 168 |
+
self,
|
| 169 |
+
content: str,
|
| 170 |
+
num_questions: int = 5,
|
| 171 |
+
difficulty: str = "medium",
|
| 172 |
+
) -> List[GeneratedMCQ]:
|
| 173 |
+
"""Generate MCQ questions from content using Gemini."""
|
| 174 |
+
difficulty_instruction = {
|
| 175 |
+
"easy": "Focus on basic recall and understanding",
|
| 176 |
+
"medium": "Include application and analysis questions",
|
| 177 |
+
"hard": "Focus on synthesis, evaluation, and edge cases",
|
| 178 |
+
}.get(difficulty, "Include a mix of question difficulties")
|
| 179 |
+
|
| 180 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 181 |
+
("system", f"""You are an expert test creator. Generate {num_questions} multiple-choice questions based on the provided content.
|
| 182 |
+
|
| 183 |
+
DIFFICULTY LEVEL: {difficulty}
|
| 184 |
+
INSTRUCTION: {difficulty_instruction}
|
| 185 |
+
|
| 186 |
+
Return ONLY a valid JSON array (no markdown formatting, no code blocks) where each question has:
|
| 187 |
+
- "question": The question text
|
| 188 |
+
- "options": Array of 4 options (A, B, C, D)
|
| 189 |
+
- "correct_answer": The correct option letter and text (e.g., "A. Photosynthesis")
|
| 190 |
+
- "explanation": Brief explanation of why this is correct
|
| 191 |
+
|
| 192 |
+
Make questions educational and test real understanding, not just memorization.
|
| 193 |
+
|
| 194 |
+
IMPORTANT: Return ONLY the JSON array, do NOT wrap it in ```json code blocks."""),
|
| 195 |
+
("human", "{content}"),
|
| 196 |
+
])
|
| 197 |
+
|
| 198 |
+
llm = self._get_llm(temperature=0.4)
|
| 199 |
+
chain = prompt | llm
|
| 200 |
+
response = await chain.ainvoke({"content": content})
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
# Clean response - remove markdown code blocks if present
|
| 204 |
+
content_str = response.content.strip()
|
| 205 |
+
|
| 206 |
+
# Remove ```json and ``` wrappers if present
|
| 207 |
+
if content_str.startswith("```json"):
|
| 208 |
+
content_str = content_str[7:]
|
| 209 |
+
elif content_str.startswith("```"):
|
| 210 |
+
content_str = content_str[3:]
|
| 211 |
+
|
| 212 |
+
if content_str.endswith("```"):
|
| 213 |
+
content_str = content_str[:-3]
|
| 214 |
+
|
| 215 |
+
content_str = content_str.strip()
|
| 216 |
+
|
| 217 |
+
questions_data = json.loads(content_str)
|
| 218 |
+
return [
|
| 219 |
+
GeneratedMCQ(
|
| 220 |
+
question=q["question"],
|
| 221 |
+
options=q["options"],
|
| 222 |
+
correct_answer=q["correct_answer"],
|
| 223 |
+
explanation=q["explanation"],
|
| 224 |
+
)
|
| 225 |
+
for q in questions_data
|
| 226 |
+
]
|
| 227 |
+
except (json.JSONDecodeError, KeyError) as e:
|
| 228 |
+
print(f"⚠️ Quiz generation error: {e}")
|
| 229 |
+
print(f"⚠️ Raw response: {response.content[:500]}")
|
| 230 |
+
return []
|
| 231 |
+
|
| 232 |
+
async def generate_hint(
|
| 233 |
+
self,
|
| 234 |
+
question: str,
|
| 235 |
+
context: Optional[str] = None,
|
| 236 |
+
hint_level: int = 1,
|
| 237 |
+
) -> tuple[str, bool]:
|
| 238 |
+
"""Generate progressive hints for a question."""
|
| 239 |
+
hint_styles = {
|
| 240 |
+
1: "Give a subtle hint that points the student in the right direction without revealing the answer. Be Socratic — ask guiding questions.",
|
| 241 |
+
2: "Give a moderate hint that narrows down the possibilities. Mention relevant concepts without stating the answer directly.",
|
| 242 |
+
3: "Give a strong hint that almost reveals the answer. Provide key information but still require the student to make the final connection.",
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
style = hint_styles.get(hint_level, hint_styles[1])
|
| 246 |
+
|
| 247 |
+
prompt = ChatPromptTemplate.from_messages([
|
| 248 |
+
("system", f"""You are a patient tutor helping a student who is stuck.
|
| 249 |
+
|
| 250 |
+
HINT STYLE: {style}
|
| 251 |
+
|
| 252 |
+
Your goal is to help the student learn, not just give them the answer.
|
| 253 |
+
{f"Use this context if helpful: {context}" if context else ""}"""),
|
| 254 |
+
("human", "I need help with this: {question}"),
|
| 255 |
+
])
|
| 256 |
+
|
| 257 |
+
llm = self._get_llm(temperature=0.5)
|
| 258 |
+
chain = prompt | llm
|
| 259 |
+
response = await chain.ainvoke({"question": question})
|
| 260 |
+
|
| 261 |
+
has_more_hints = hint_level < 3
|
| 262 |
+
return response.content, has_more_hints
|
| 263 |
+
|
| 264 |
+
def _format_context(self, documents: List[Document]) -> str:
|
| 265 |
+
"""Format retrieved documents into context string."""
|
| 266 |
+
if not documents:
|
| 267 |
+
return "No relevant content found."
|
| 268 |
+
|
| 269 |
+
parts = []
|
| 270 |
+
for i, doc in enumerate(documents, 1):
|
| 271 |
+
source = doc.metadata.get("source", "Unknown")
|
| 272 |
+
parts.append(f"[Source {i}: {source}]\n{doc.page_content}")
|
| 273 |
+
|
| 274 |
+
return "\n\n---\n\n".join(parts)
|
| 275 |
+
|
| 276 |
+
def _format_sources(self, documents: List[Document]) -> List[SourceDocument]:
|
| 277 |
+
"""Convert documents to SourceDocument list."""
|
| 278 |
+
sources = []
|
| 279 |
+
seen = set()
|
| 280 |
+
for doc in documents:
|
| 281 |
+
key = doc.metadata.get("source", "")
|
| 282 |
+
if key not in seen:
|
| 283 |
+
seen.add(key)
|
| 284 |
+
sources.append(
|
| 285 |
+
SourceDocument(
|
| 286 |
+
content=doc.page_content[:300],
|
| 287 |
+
source=doc.metadata.get("source", "Unknown"),
|
| 288 |
+
page=doc.metadata.get("page"),
|
| 289 |
+
)
|
| 290 |
+
)
|
| 291 |
+
return sources
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
async def index_lesson_content(
|
| 295 |
+
self,
|
| 296 |
+
lesson_id: str,
|
| 297 |
+
module_id: str,
|
| 298 |
+
title: str,
|
| 299 |
+
content_text: Optional[str] = None,
|
| 300 |
+
content_url: Optional[str] = None,
|
| 301 |
+
content_type: str = "markdown",
|
| 302 |
+
) -> bool:
|
| 303 |
+
"""Index lesson content into the vector store."""
|
| 304 |
+
await self.initialize()
|
| 305 |
+
|
| 306 |
+
# Extract content based on type
|
| 307 |
+
text_to_index = ""
|
| 308 |
+
|
| 309 |
+
if content_type == "markdown" and content_text:
|
| 310 |
+
text_to_index = content_text
|
| 311 |
+
elif content_type == "pdf" and content_url:
|
| 312 |
+
# Extract text from PDF file
|
| 313 |
+
file_path = os.path.join(settings.UPLOAD_DIR, os.path.basename(content_url))
|
| 314 |
+
if os.path.exists(file_path):
|
| 315 |
+
try:
|
| 316 |
+
reader = PdfReader(file_path)
|
| 317 |
+
pdf_parts = []
|
| 318 |
+
# Extract up to 50 pages for full indexing
|
| 319 |
+
for i, page in enumerate(reader.pages[:50]):
|
| 320 |
+
text = page.extract_text()
|
| 321 |
+
if text:
|
| 322 |
+
pdf_parts.append(text)
|
| 323 |
+
text_to_index = "\n\n".join(pdf_parts)
|
| 324 |
+
except Exception as e:
|
| 325 |
+
print(f"⚠️ PDF extraction error for {file_path}: {e}")
|
| 326 |
+
return False
|
| 327 |
+
|
| 328 |
+
if not text_to_index or len(text_to_index.strip()) < 50:
|
| 329 |
+
print(f"⚠️ No content to index for lesson {lesson_id}")
|
| 330 |
+
return False
|
| 331 |
+
|
| 332 |
+
# Split text into chunks
|
| 333 |
+
documents = []
|
| 334 |
+
chunks = self.text_splitter.split_text(text_to_index)
|
| 335 |
+
|
| 336 |
+
for i, chunk in enumerate(chunks):
|
| 337 |
+
doc = Document(
|
| 338 |
+
page_content=chunk,
|
| 339 |
+
metadata={
|
| 340 |
+
"source": title,
|
| 341 |
+
"lesson_id": lesson_id,
|
| 342 |
+
"module_id": module_id,
|
| 343 |
+
"chunk_index": i,
|
| 344 |
+
"content_type": content_type,
|
| 345 |
+
}
|
| 346 |
+
)
|
| 347 |
+
documents.append(doc)
|
| 348 |
+
|
| 349 |
+
# Add documents to vector store
|
| 350 |
+
try:
|
| 351 |
+
self.vector_store.add_documents(documents)
|
| 352 |
+
print(f"✅ Indexed {len(documents)} chunks for lesson '{title}'")
|
| 353 |
+
return True
|
| 354 |
+
except Exception as e:
|
| 355 |
+
print(f"⚠️ Failed to index lesson {lesson_id}: {e}")
|
| 356 |
+
return False
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
tutor_service = AITutorService()
|
requirements.txt
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.34.0
|
| 3 |
+
python-dotenv==1.0.1
|
| 4 |
+
pydantic==2.10.4
|
| 5 |
+
pydantic-settings==2.7.1
|
| 6 |
+
|
| 7 |
+
# Database
|
| 8 |
+
sqlalchemy==2.0.36
|
| 9 |
+
psycopg[binary]==3.2.3
|
| 10 |
+
psycopg-pool==3.2.4
|
| 11 |
+
alembic==1.14.0
|
| 12 |
+
psycopg2-binary==2.9.10
|
| 13 |
+
|
| 14 |
+
# Auth
|
| 15 |
+
pyjwt==2.10.1
|
| 16 |
+
passlib[bcrypt]==1.7.4
|
| 17 |
+
python-multipart==0.0.20
|
| 18 |
+
email-validator==2.1.1
|
| 19 |
+
|
| 20 |
+
# AI & RAG
|
| 21 |
+
langchain==0.3.14
|
| 22 |
+
langchain-core>=0.3.29,<0.4.0
|
| 23 |
+
langchain-google-genai==2.0.8
|
| 24 |
+
langchain-community==0.3.14
|
| 25 |
+
langchain-postgres==0.0.12
|
| 26 |
+
pgvector>=0.2.5,<0.3.0
|
| 27 |
+
google-generativeai==0.8.3
|
| 28 |
+
sentence-transformers==3.3.1
|
| 29 |
+
langchain-huggingface==0.1.2
|
| 30 |
+
|
| 31 |
+
# File processing
|
| 32 |
+
pypdf==5.1.0
|
| 33 |
+
aiofiles==24.1.0
|
| 34 |
+
python-magic-bin==0.4.14
|
| 35 |
+
|
| 36 |
+
# Utilities
|
| 37 |
+
uuid6==2024.7.10
|
| 38 |
+
httpx==0.28.1
|
uploads/20260307_154626_42dcb39b_resume.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eb5f461061c8a5042013249c70614e52917f22a88126fee196ff5f1659fe1e97
|
| 3 |
+
size 191823
|