Hamza4100 commited on
Commit
2732fa3
·
verified ·
1 Parent(s): 1cec19a

Upload 40 files

Browse files
.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: Healthtech Api
3
- emoji:
4
- colorFrom: yellow
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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