Hamdy005 commited on
Commit
8c7a11b
Β·
1 Parent(s): 2c18d2c

feat: implement authentication system, add quiz history support, and update API endpoints for material and tutor interactions

Browse files
auth/__init__.py ADDED
File without changes
auth/routes.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel
4
+ from typing import Optional
5
+
6
+ from src.store import create_user, get_user_by_email
7
+
8
+ router = APIRouter(prefix="/api/auth", tags=["Auth"])
9
+
10
+
11
+ class LoginRequest(BaseModel):
12
+ email: str
13
+ password: str
14
+
15
+
16
+ class SignupRequest(BaseModel):
17
+ name: str
18
+ email: str
19
+ password: str
20
+
21
+
22
+ class GoogleAuthRequest(BaseModel):
23
+ token: str
24
+ name: Optional[str] = None
25
+ email: Optional[str] = None
26
+
27
+
28
+ @router.post("/login")
29
+ async def login(body: LoginRequest):
30
+ user = get_user_by_email(body.email)
31
+ if not user or user.get("password") != body.password:
32
+ raise HTTPException(401, "Invalid email or password")
33
+ token = str(uuid.uuid4())
34
+ return {
35
+ "token": token,
36
+ "user": {
37
+ "id": user["id"],
38
+ "name": user["name"],
39
+ "email": user["email"],
40
+ },
41
+ }
42
+
43
+
44
+ @router.post("/signup")
45
+ async def signup(body: SignupRequest):
46
+ try:
47
+ user = create_user(body.name, body.email, body.password)
48
+ except ValueError as e:
49
+ raise HTTPException(400, str(e))
50
+ token = str(uuid.uuid4())
51
+ return {
52
+ "token": token,
53
+ "user": {
54
+ "id": user["id"],
55
+ "name": user["name"],
56
+ "email": user["email"],
57
+ },
58
+ }
59
+
60
+
61
+ @router.post("/google")
62
+ async def google_auth(body: GoogleAuthRequest):
63
+ email = body.email or f"google_{uuid.uuid4().hex[:8]}@google.com"
64
+ name = body.name or "Google User"
65
+ user = get_user_by_email(email)
66
+ if not user:
67
+ user = create_user(name, email, "")
68
+ token = str(uuid.uuid4())
69
+ return {
70
+ "token": token,
71
+ "user": {
72
+ "id": user["id"],
73
+ "name": user["name"],
74
+ "email": user["email"],
75
+ },
76
+ }
dependencies.py CHANGED
@@ -5,21 +5,32 @@ from typing import Any, Optional
5
  from src.database import get_supabase
6
 
7
  DEV_USER_ID = "00000000-0000-0000-0000-000000000001"
 
8
 
9
- oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
10
 
11
 
12
  async def get_current_user(
13
- token: str = Depends(oauth2_scheme),
14
  x_user_id: Optional[str] = Header(None),
 
 
15
  ) -> Any:
16
  client = get_supabase()
 
 
17
  if client is None:
18
  if x_user_id:
19
- return {"id": x_user_id}
20
- raise HTTPException(
21
- status.HTTP_500_INTERNAL_SERVER_ERROR, "Supabase not configured"
22
- )
 
 
 
 
 
 
23
  try:
24
  response = client.auth.get_user(token)
25
  except Exception:
 
5
  from src.database import get_supabase
6
 
7
  DEV_USER_ID = "00000000-0000-0000-0000-000000000001"
8
+ DEV_USER = {"id": DEV_USER_ID, "email": "dev@studymate.ai", "name": "Dev User"}
9
 
10
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
11
 
12
 
13
  async def get_current_user(
14
+ token: Optional[str] = Depends(oauth2_scheme),
15
  x_user_id: Optional[str] = Header(None),
16
+ x_user_name: Optional[str] = Header(None),
17
+ x_user_email: Optional[str] = Header(None),
18
  ) -> Any:
19
  client = get_supabase()
20
+
21
+ # Dev mode: no Supabase configured
22
  if client is None:
23
  if x_user_id:
24
+ return {
25
+ "id": x_user_id,
26
+ "name": x_user_name or "User",
27
+ "email": x_user_email or f"user{x_user_id}@studymate.ai",
28
+ }
29
+ return DEV_USER
30
+
31
+ # Real Supabase auth
32
+ if not token:
33
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
34
  try:
35
  response = client.auth.get_user(token)
36
  except Exception:
main.py CHANGED
@@ -1,11 +1,13 @@
1
  from contextlib import asynccontextmanager
2
  from fastapi import FastAPI
3
  from fastapi.middleware.cors import CORSMiddleware
 
4
 
5
  from src.materials.routes import router as materials_router
6
  from src.summary_generator.routes import router as summary_router
7
  from src.rag.routes import router as tutor_router
8
  from src.quiz_generator.routes import router as quiz_router
 
9
  from src.config import settings
10
 
11
 
@@ -25,7 +27,7 @@ app = FastAPI(
25
 
26
  app.add_middleware(
27
  CORSMiddleware,
28
- allow_origins=settings.cors_allowed_origins or ["https://your-frontend-domain.com"],
29
  allow_credentials=True,
30
  allow_methods=["GET", "POST"],
31
  allow_headers=["Authorization", "Content-Type"],
@@ -35,6 +37,12 @@ app.include_router(materials_router)
35
  app.include_router(summary_router)
36
  app.include_router(tutor_router)
37
  app.include_router(quiz_router)
 
 
 
 
 
 
38
 
39
 
40
  @app.get("/api/health")
 
1
  from contextlib import asynccontextmanager
2
  from fastapi import FastAPI
3
  from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.responses import RedirectResponse
5
 
6
  from src.materials.routes import router as materials_router
7
  from src.summary_generator.routes import router as summary_router
8
  from src.rag.routes import router as tutor_router
9
  from src.quiz_generator.routes import router as quiz_router
10
+ from src.auth.routes import router as auth_router
11
  from src.config import settings
12
 
13
 
 
27
 
28
  app.add_middleware(
29
  CORSMiddleware,
30
+ allow_origins=settings.cors_allowed_origins or ["*"],
31
  allow_credentials=True,
32
  allow_methods=["GET", "POST"],
33
  allow_headers=["Authorization", "Content-Type"],
 
37
  app.include_router(summary_router)
38
  app.include_router(tutor_router)
39
  app.include_router(quiz_router)
40
+ app.include_router(auth_router)
41
+
42
+
43
+ @app.get("/")
44
+ async def root():
45
+ return RedirectResponse(url="/docs")
46
 
47
 
48
  @app.get("/api/health")
materials/routes.py CHANGED
@@ -5,7 +5,7 @@ from pydantic import BaseModel
5
 
6
  from src.materials.text_utils import text_from_pdf, chunk_text, scrap_website
7
  from src.rag.rag import store_embeddings
8
- from src.store import create_material, update_material_status, save_chunks
9
  from src.dependencies import get_current_user_id, get_current_user
10
 
11
  router = APIRouter(prefix="/api/materials", tags=["Materials"])
@@ -38,6 +38,14 @@ class URLInput(BaseModel):
38
  url: str
39
 
40
 
 
 
 
 
 
 
 
 
41
  @router.post("/upload-pdf")
42
  async def upload_pdf(
43
  file: UploadFile = File(...),
 
5
 
6
  from src.materials.text_utils import text_from_pdf, chunk_text, scrap_website
7
  from src.rag.rag import store_embeddings
8
+ from src.store import create_material, update_material_status, save_chunks, list_materials
9
  from src.dependencies import get_current_user_id, get_current_user
10
 
11
  router = APIRouter(prefix="/api/materials", tags=["Materials"])
 
38
  url: str
39
 
40
 
41
+ @router.get("")
42
+ async def get_materials(
43
+ user_id: str = Depends(get_current_user_id),
44
+ current_user=Depends(get_current_user),
45
+ ):
46
+ return list_materials(user_id)
47
+
48
+
49
  @router.post("/upload-pdf")
50
  async def upload_pdf(
51
  file: UploadFile = File(...),
quiz_generator/routes.py CHANGED
@@ -3,7 +3,7 @@ from pydantic import BaseModel
3
  from typing import Optional
4
 
5
  from src.quiz_generator.quiz import smart_quiz_generator
6
- from src.store import get_material, get_chunks, get_summary, save_quiz
7
  from src.dependencies import get_current_user_id, get_current_user
8
  from src.config import settings
9
 
@@ -24,6 +24,15 @@ class QuizResponse(BaseModel):
24
  quiz_id: str
25
 
26
 
 
 
 
 
 
 
 
 
 
27
  @router.post("/generate", response_model=QuizResponse)
28
  async def generate_quiz(
29
  body: QuizRequest,
 
3
  from typing import Optional
4
 
5
  from src.quiz_generator.quiz import smart_quiz_generator
6
+ from src.store import get_material, get_chunks, get_summary, save_quiz, get_quizzes
7
  from src.dependencies import get_current_user_id, get_current_user
8
  from src.config import settings
9
 
 
24
  quiz_id: str
25
 
26
 
27
+ @router.get("/list")
28
+ async def get_quiz_list(
29
+ material_id: Optional[str] = None,
30
+ user_id: str = Depends(get_current_user_id),
31
+ current_user=Depends(get_current_user),
32
+ ):
33
+ return get_quizzes(material_id=material_id, user_id=user_id)
34
+
35
+
36
  @router.post("/generate", response_model=QuizResponse)
37
  async def generate_quiz(
38
  body: QuizRequest,
store.py CHANGED
@@ -1,33 +1,120 @@
1
  from typing import Optional
 
2
  from langchain.memory import ConversationBufferMemory
3
  from src.database import get_supabase
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def _db():
7
  client = get_supabase()
8
- if client is None:
9
- raise RuntimeError(
10
- "Supabase not configured. Set SUPABASE_URL and SUPABASE_KEY in config.env."
11
- )
12
- return client
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  # ── Materials ──────────────────────────────────────────
16
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def create_material(user_id: str, source_type: str, title: str,
18
  file_path: Optional[str] = None,
19
- url: Optional[str] = None) -> dict:
20
- data = {
21
- "user_id": user_id,
22
- "source_type": source_type,
23
- "title": title,
24
- "status": "pending",
25
- }
26
  if file_path:
27
  data["file_path"] = file_path
28
  if url:
29
  data["url"] = url
30
- result = _db().table("materials").insert(data).execute()
 
 
31
  return result.data[0]
32
 
33
 
@@ -36,11 +123,11 @@ def update_material_status(material_id: str, status: str,
36
  data = {"status": status}
37
  if error_message:
38
  data["error_message"] = error_message
39
- _db().table("materials").update(data).eq("id", material_id).execute()
40
 
41
 
42
  def get_material(material_id: str) -> Optional[dict]:
43
- result = _db().table("materials").select("*").eq("id", material_id).execute()
44
  return result.data[0] if result.data else None
45
 
46
 
@@ -51,13 +138,13 @@ def save_chunks(material_id: str, chunks: list[str]) -> list[str]:
51
  {"material_id": material_id, "chunk_index": i, "content": c}
52
  for i, c in enumerate(chunks)
53
  ]
54
- result = _db().table("material_chunks").insert(records).execute()
55
  return [r["id"] for r in result.data]
56
 
57
 
58
  def get_chunks(material_id: str) -> list[dict]:
59
  result = (
60
- _db().table("material_chunks")
61
  .select("*")
62
  .eq("material_id", material_id)
63
  .order("chunk_index")
@@ -78,12 +165,17 @@ def save_summary(material_id: str, user_id: str, summary: str,
78
  "time_taken": time_taken,
79
  "model_name": model_name,
80
  }
81
- _db().table("summaries").upsert(data, on_conflict=["material_id"]).execute()
 
 
 
 
 
82
 
83
 
84
  def get_summary(material_id: str) -> Optional[dict]:
85
  result = (
86
- _db().table("summaries")
87
  .select("*")
88
  .eq("material_id", material_id)
89
  .maybe_single()
@@ -109,10 +201,42 @@ def save_quiz(user_id: str, material_id: Optional[str], source_type: str,
109
  }
110
  if material_id:
111
  data["material_id"] = material_id
112
- result = _db().table("quizzes").insert(data).execute()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  return result.data[0]
114
 
115
 
 
 
 
 
 
 
 
 
 
 
116
  # ── Conversation Memory (in-memory, ephemeral) ─────────
117
 
118
  import uuid as _uuid
 
1
  from typing import Optional
2
+ from datetime import datetime, timezone
3
  from langchain.memory import ConversationBufferMemory
4
  from src.database import get_supabase
5
 
6
+ _in_memory: dict = {
7
+ "materials": {},
8
+ "material_chunks": {},
9
+ "summaries": {},
10
+ "quizzes": {},
11
+ "users": {},
12
+ "next_id": 0,
13
+ }
14
+
15
+
16
+ def _get_next_id() -> str:
17
+ _in_memory["next_id"] += 1
18
+ return str(_in_memory["next_id"])
19
+
20
 
21
  def _db():
22
  client = get_supabase()
23
+ if client is not None:
24
+ return client
25
+ return None
26
+
27
+
28
+ def _table_supabase(table: str):
29
+ client = _db()
30
+ if client is not None:
31
+ return client.table(table)
32
+
33
+ class _FakeTable:
34
+ def __init__(self, name):
35
+ self.name = name
36
+
37
+ def insert(self, data):
38
+ if isinstance(data, list):
39
+ for item in data:
40
+ item["id"] = item.get("id", _get_next_id())
41
+ _in_memory.setdefault(self.name, {})[item["id"]] = item
42
+ class R:
43
+ data = data
44
+ return R()
45
+ data["id"] = data.get("id", _get_next_id())
46
+ _in_memory.setdefault(self.name, {})[data["id"]] = data
47
+ class R:
48
+ data = [data]
49
+ return R()
50
+
51
+ def select(self, *args):
52
+ return self
53
+
54
+ def eq(self, field, value):
55
+ self._eq_field = field
56
+ self._eq_value = value
57
+ return self
58
+
59
+ def order(self, field):
60
+ return self
61
+
62
+ def maybe_single(self):
63
+ records = list(_in_memory.get(self.name, {}).values())
64
+ if hasattr(self, '_eq_field'):
65
+ records = [r for r in records if r.get(self._eq_field) == self._eq_value]
66
+ return self._make_response(records[0] if records else None)
67
+
68
+ def execute(self):
69
+ records = list(_in_memory.get(self.name, {}).values())
70
+ if hasattr(self, '_eq_field'):
71
+ records = [r for r in records if r.get(self._eq_field) == self._eq_value]
72
+ if hasattr(self, '_order_field'):
73
+ records.sort(key=lambda r: r.get(self._order_field, 0))
74
+ return self._make_response(records)
75
+
76
+ def update(self, data):
77
+ self._update_data = data
78
+ return self
79
+
80
+ def _make_response(self, data):
81
+ class R:
82
+ pass
83
+ r = R()
84
+ r.data = data if isinstance(data, list) else ([data] if data else [])
85
+ return r
86
+
87
+ return _FakeTable(table)
88
 
89
 
90
  # ── Materials ──────────────────────────────────────────
91
 
92
+ def list_materials(user_id: str) -> list[dict]:
93
+ sup = _table_supabase("materials")
94
+ if sup is not None and not isinstance(sup.execute().__class__.__name__, '_FakeTable'):
95
+ try:
96
+ result = sup.select("*").eq("user_id", user_id).order("created_at").execute()
97
+ return list(reversed(result.data))
98
+ except Exception:
99
+ pass
100
+ records = list(_in_memory.get("materials", {}).values())
101
+ return list(reversed([r for r in records if r.get("user_id") == user_id]))
102
+
103
+
104
  def create_material(user_id: str, source_type: str, title: str,
105
  file_path: Optional[str] = None,
106
+ url: Optional[str] = None,
107
+ topic: Optional[str] = None) -> dict:
108
+ now = datetime.now(timezone.utc).isoformat()
109
+ data = {"user_id": user_id, "source_type": source_type, "title": title, "status": "pending",
110
+ "created_at": now, "updated_at": now}
 
 
111
  if file_path:
112
  data["file_path"] = file_path
113
  if url:
114
  data["url"] = url
115
+ if topic:
116
+ data["topic"] = topic
117
+ result = _table_supabase("materials").insert(data).execute()
118
  return result.data[0]
119
 
120
 
 
123
  data = {"status": status}
124
  if error_message:
125
  data["error_message"] = error_message
126
+ _table_supabase("materials").update(data).eq("id", material_id).execute()
127
 
128
 
129
  def get_material(material_id: str) -> Optional[dict]:
130
+ result = _table_supabase("materials").select("*").eq("id", material_id).execute()
131
  return result.data[0] if result.data else None
132
 
133
 
 
138
  {"material_id": material_id, "chunk_index": i, "content": c}
139
  for i, c in enumerate(chunks)
140
  ]
141
+ result = _table_supabase("material_chunks").insert(records).execute()
142
  return [r["id"] for r in result.data]
143
 
144
 
145
  def get_chunks(material_id: str) -> list[dict]:
146
  result = (
147
+ _table_supabase("material_chunks")
148
  .select("*")
149
  .eq("material_id", material_id)
150
  .order("chunk_index")
 
165
  "time_taken": time_taken,
166
  "model_name": model_name,
167
  }
168
+ tbl = _table_supabase("summaries")
169
+ existing = tbl.select("*").eq("material_id", material_id).execute()
170
+ if existing.data:
171
+ tbl.update(data).eq("material_id", material_id).execute()
172
+ else:
173
+ tbl.insert(data).execute()
174
 
175
 
176
  def get_summary(material_id: str) -> Optional[dict]:
177
  result = (
178
+ _table_supabase("summaries")
179
  .select("*")
180
  .eq("material_id", material_id)
181
  .maybe_single()
 
201
  }
202
  if material_id:
203
  data["material_id"] = material_id
204
+ result = _table_supabase("quizzes").insert(data).execute()
205
+ return result.data[0]
206
+
207
+
208
+ def get_quizzes(material_id: Optional[str] = None, user_id: Optional[str] = None) -> list[dict]:
209
+ tbl = _table_supabase("quizzes")
210
+ result = tbl.select("*").execute()
211
+ records = result.data
212
+ if material_id:
213
+ records = [r for r in records if r.get("material_id") == material_id]
214
+ if user_id:
215
+ records = [r for r in records if r.get("user_id") == user_id]
216
+ return records
217
+
218
+
219
+ # ── Users ────────────────────────────────────────────
220
+
221
+ def create_user(name: str, email: str, password: str) -> dict:
222
+ existing = get_user_by_email(email)
223
+ if existing:
224
+ raise ValueError("Email already registered")
225
+ data = {"name": name, "email": email, "password": password}
226
+ result = _table_supabase("users").insert(data).execute()
227
  return result.data[0]
228
 
229
 
230
+ def get_user_by_email(email: str) -> Optional[dict]:
231
+ result = _table_supabase("users").select("*").eq("email", email).maybe_single().execute()
232
+ return result.data[0] if result.data else None
233
+
234
+
235
+ def get_user_by_id(user_id: str) -> Optional[dict]:
236
+ result = _table_supabase("users").select("*").eq("id", user_id).maybe_single().execute()
237
+ return result.data[0] if result.data else None
238
+
239
+
240
  # ── Conversation Memory (in-memory, ephemeral) ─────────
241
 
242
  import uuid as _uuid
summary_generator/routes.py CHANGED
@@ -1,9 +1,10 @@
1
  import time
2
  from fastapi import APIRouter, HTTPException, Depends
3
  from pydantic import BaseModel
 
4
 
5
  from src.summary_generator.summary import summarizer
6
- from src.store import get_material, get_chunks, save_summary
7
  from src.dependencies import get_current_user_id, get_current_user
8
  from src.config import settings
9
 
@@ -50,3 +51,15 @@ async def generate_summary(
50
  return SummarizeResponse(summary=summary, time_taken=elapsed)
51
  except Exception as e:
52
  raise HTTPException(500, f"Summarization failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import time
2
  from fastapi import APIRouter, HTTPException, Depends
3
  from pydantic import BaseModel
4
+ from typing import Optional
5
 
6
  from src.summary_generator.summary import summarizer
7
+ from src.store import get_material, get_chunks, save_summary, get_summary as get_stored_summary
8
  from src.dependencies import get_current_user_id, get_current_user
9
  from src.config import settings
10
 
 
51
  return SummarizeResponse(summary=summary, time_taken=elapsed)
52
  except Exception as e:
53
  raise HTTPException(500, f"Summarization failed: {e}")
54
+
55
+
56
+ @router.get("/{material_id}/summary")
57
+ async def get_material_summary(
58
+ material_id: str,
59
+ user_id: str = Depends(get_current_user_id),
60
+ current_user=Depends(get_current_user),
61
+ ):
62
+ summary = get_stored_summary(material_id)
63
+ if not summary:
64
+ raise HTTPException(404, "No summary found for this material")
65
+ return {"summary": summary["summary"], "time_taken": summary.get("time_taken", 0)}