Hamdy005 commited on
Commit
2690ca5
·
1 Parent(s): d3e7277

feat: implement password update functionality and add signup, forgot-password, and update-password pages

Browse files
Files changed (5) hide show
  1. auth/routes.py +65 -21
  2. auth/schemas.py +5 -1
  3. config.py +12 -1
  4. main.py +1 -1
  5. rag/rag.py +23 -26
auth/routes.py CHANGED
@@ -1,8 +1,11 @@
1
  import uuid
 
 
2
  from fastapi import APIRouter, HTTPException, UploadFile, File
3
  from fastapi import Depends
4
  from typing import Optional
5
 
 
6
  from src.database import get_auth_supabase, get_supabase
7
  from src.store import create_user, get_user_by_email, delete_user_data, update_user_profile, get_user_by_id
8
  from src.dependencies import get_current_user_id, get_current_user
@@ -22,7 +25,7 @@ async def upload_avatar(
22
  file: UploadFile = File(...),
23
  user_id: str = Depends(get_current_user_id),
24
  ):
25
- """Upload a profile avatar image to Supabase Storage and return its public URL."""
26
 
27
  # 1. Validate MIME type
28
  if file.content_type not in ALLOWED_MIME_TYPES:
@@ -40,31 +43,33 @@ async def upload_avatar(
40
  f"File is too large ({len(content) // 1024} KB). Maximum allowed size is 6 MB.",
41
  )
42
 
43
- # 3. Build a unique storage path: avatars/<user_id>/<uuid>.<ext>
44
- ext = (file.filename or "image").rsplit(".", 1)[-1].lower()
45
- if ext not in {"jpg", "jpeg", "png", "webp", "gif", "avif", "svg"}:
46
- ext = "jpg" # safe fallback
47
- storage_path = f"{user_id}/{uuid.uuid4()}.{ext}"
48
 
49
- # 4. Upload to Supabase Storage using the service-role client
50
- client = get_supabase()
51
- if client is None:
52
- raise HTTPException(503, "Storage service unavailable")
 
 
53
 
 
54
  try:
55
- client.storage.from_(AVATAR_BUCKET).upload(
56
- path=storage_path,
57
- file=content,
58
- file_options={"content-type": file.content_type, "upsert": "true"},
 
 
 
 
 
59
  )
 
 
60
  except Exception as e:
61
- raise HTTPException(500, f"Failed to upload avatar: {e}")
62
-
63
- # 5. Get the public URL from the bucket
64
- public_url_resp = client.storage.from_(AVATAR_BUCKET).get_public_url(storage_path)
65
- public_url = public_url_resp if isinstance(public_url_resp, str) else str(public_url_resp)
66
-
67
- return {"status": "success", "avatar_url": public_url}
68
 
69
 
70
  @router.delete("/me")
@@ -164,6 +169,44 @@ async def get_profile(
164
 
165
  @router.patch("/profile")
166
  async def update_profile(body: ProfileUpdateRequest, user_id: str = Depends(get_current_user_id)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  try:
168
  updated_user = update_user_profile(
169
  user_id,
@@ -176,3 +219,4 @@ async def update_profile(body: ProfileUpdateRequest, user_id: str = Depends(get_
176
  raise HTTPException(404, str(e))
177
  except Exception as e:
178
  raise HTTPException(500, f"Failed to update profile: {e}")
 
 
1
  import uuid
2
+ import cloudinary
3
+ import cloudinary.uploader
4
  from fastapi import APIRouter, HTTPException, UploadFile, File
5
  from fastapi import Depends
6
  from typing import Optional
7
 
8
+ from src.config import settings
9
  from src.database import get_auth_supabase, get_supabase
10
  from src.store import create_user, get_user_by_email, delete_user_data, update_user_profile, get_user_by_id
11
  from src.dependencies import get_current_user_id, get_current_user
 
25
  file: UploadFile = File(...),
26
  user_id: str = Depends(get_current_user_id),
27
  ):
28
+ """Upload a profile avatar image to Cloudinary and return its public URL."""
29
 
30
  # 1. Validate MIME type
31
  if file.content_type not in ALLOWED_MIME_TYPES:
 
43
  f"File is too large ({len(content) // 1024} KB). Maximum allowed size is 6 MB.",
44
  )
45
 
46
+ # 3. Configure Cloudinary
47
+ if not settings.cloudinary_cloud_name or not settings.cloudinary_api_key or not settings.cloudinary_api_secret:
48
+ raise HTTPException(503, "Cloudinary service is not configured")
 
 
49
 
50
+ cloudinary.config(
51
+ cloud_name=settings.cloudinary_cloud_name,
52
+ api_key=settings.cloudinary_api_key,
53
+ api_secret=settings.cloudinary_api_secret,
54
+ secure=True,
55
+ )
56
 
57
+ # 4. Upload to Cloudinary with smart face-cropping & auto webp conversion
58
  try:
59
+ response = cloudinary.uploader.upload(
60
+ content,
61
+ folder="avatars",
62
+ public_id=f"avatar_{user_id}",
63
+ overwrite=True,
64
+ transformation=[
65
+ {"width": 300, "height": 300, "crop": "fill", "gravity": "face"},
66
+ {"fetch_format": "auto", "quality": "auto"}
67
+ ]
68
  )
69
+ public_url = response.get("secure_url") or response.get("url")
70
+ return {"status": "success", "avatar_url": public_url}
71
  except Exception as e:
72
+ raise HTTPException(500, f"Failed to upload avatar to Cloudinary: {e}")
 
 
 
 
 
 
73
 
74
 
75
  @router.delete("/me")
 
169
 
170
  @router.patch("/profile")
171
  async def update_profile(body: ProfileUpdateRequest, user_id: str = Depends(get_current_user_id)):
172
+ # Reject raw base64 image data — images must be uploaded via /upload-avatar first
173
+ if body.avatar_url and body.avatar_url.startswith("data:"):
174
+ raise HTTPException(
175
+ 400,
176
+ "Storing raw image data is not allowed. "
177
+ "Upload the image via POST /api/auth/upload-avatar and use the returned URL instead."
178
+ )
179
+
180
+ # Handle password update if password is provided
181
+ if body.password is not None:
182
+ # Verify current password if provided
183
+
184
+ if body.current_password:
185
+ user_data = get_user_by_id(user_id)
186
+ user_email = user_data.get("email") if user_data else None
187
+ if user_email and not any(domain in user_email for domain in PLACEHOLDER_DOMAINS):
188
+ auth_client = get_auth_supabase()
189
+ if auth_client:
190
+ try:
191
+ res = auth_client.auth.sign_in_with_password({
192
+ "email": user_email,
193
+ "password": body.current_password
194
+ })
195
+ if hasattr(res, "error") and res.error:
196
+ raise HTTPException(400, "Current password is incorrect.")
197
+ except HTTPException:
198
+ raise
199
+ except Exception:
200
+ raise HTTPException(400, "Current password is incorrect.")
201
+
202
+ # Update password in Supabase Auth
203
+ admin_client = get_supabase()
204
+ if admin_client:
205
+ try:
206
+ admin_client.auth.admin.update_user_by_id(user_id, {"password": body.password})
207
+ except Exception as e:
208
+ raise HTTPException(500, f"Failed to update password: {e}")
209
+
210
  try:
211
  updated_user = update_user_profile(
212
  user_id,
 
219
  raise HTTPException(404, str(e))
220
  except Exception as e:
221
  raise HTTPException(500, f"Failed to update profile: {e}")
222
+
auth/schemas.py CHANGED
@@ -1,7 +1,11 @@
1
- from pydantic import BaseModel
2
  from typing import Optional
3
 
4
  class ProfileUpdateRequest(BaseModel):
5
  name: Optional[str] = None
6
  avatar_url: Optional[str] = None
7
  theme: Optional[str] = None
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
  from typing import Optional
3
 
4
  class ProfileUpdateRequest(BaseModel):
5
  name: Optional[str] = None
6
  avatar_url: Optional[str] = None
7
  theme: Optional[str] = None
8
+ current_password: Optional[str] = None
9
+ password: Optional[str] = Field(None, min_length=8)
10
+
11
+
config.py CHANGED
@@ -24,7 +24,18 @@ class Settings:
24
  os.getenv("SUPABASE_ANON_KEY")
25
  or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
26
  )
27
- groq_api_key: str = os.getenv("GROQ_API_KEY", "")
 
 
 
 
 
 
 
 
 
 
 
28
  model_name: str = os.getenv("MODEL_NAME", "gemini-3.1-flash-lite")
29
  transformers_no_tf: str = os.getenv("TRANSFORMERS_NO_TF", "1")
30
  cors_allowed_origins: list[str] = [
 
24
  os.getenv("SUPABASE_ANON_KEY")
25
  or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
26
  )
27
+ cloudinary_cloud_name: str = (
28
+ os.getenv("CLOUDINARY_CLOUD_NAME")
29
+ or os.getenv("CLOUD_NAME", "")
30
+ )
31
+ cloudinary_api_key: str = (
32
+ os.getenv("CLOUDINARY_API_KEY")
33
+ or os.getenv("CLOUD_API_KEY", "")
34
+ )
35
+ cloudinary_api_secret: str = (
36
+ os.getenv("CLOUDINARY_API_SECRET")
37
+ or os.getenv("CLOUD_SECRET", "")
38
+ )
39
  model_name: str = os.getenv("MODEL_NAME", "gemini-3.1-flash-lite")
40
  transformers_no_tf: str = os.getenv("TRANSFORMERS_NO_TF", "1")
41
  cors_allowed_origins: list[str] = [
main.py CHANGED
@@ -67,7 +67,7 @@ async def lifespan(app: FastAPI):
67
  logger.warning(f"English ASR model failed to load: {e}")
68
 
69
  try:
70
- from src.asr.models import get_audio_model_ard
71
  get_audio_model_ar()
72
  except Exception as e:
73
  logger.warning(f"Arabic ASR model failed to load: {e}")
 
67
  logger.warning(f"English ASR model failed to load: {e}")
68
 
69
  try:
70
+ from src.asr.models import get_audio_model_ar
71
  get_audio_model_ar()
72
  except Exception as e:
73
  logger.warning(f"Arabic ASR model failed to load: {e}")
rag/rag.py CHANGED
@@ -12,7 +12,6 @@ from langchain.prompts import PromptTemplate
12
  from langchain.memory import ConversationBufferMemory, ConversationBufferWindowMemory
13
  from langchain_core.retrievers import BaseRetriever
14
  from langchain_core.documents import Document
15
- from langchain_openai import ChatOpenAI
16
  from langchain_google_genai import ChatGoogleGenerativeAI
17
 
18
  from src.config import settings
@@ -164,23 +163,25 @@ def get_quiz_llm():
164
  )
165
 
166
 
167
- def get_groq_llm():
168
- if not settings.groq_api_key:
169
- raise ValueError("GROQ_API_KEY not found. Please set it in config.env.")
170
- return ChatOpenAI(
171
- model="llama-3.1-8b-instant",
172
- base_url="https://api.groq.com/openai/v1",
173
- api_key=settings.groq_api_key,
174
- max_tokens=600,
 
 
175
  )
176
 
177
 
178
- def get_fallback_gemma_llm():
179
  if not os.environ.get("GEMINI_API_KEY"):
180
  raise ValueError("GEMINI_API_KEY not found. Please set it in config.env.")
181
- logger.info("Initializing fallback LLM with model: google/gemma-4-31b-it")
182
  return ChatGoogleGenerativeAI(
183
- model="google/gemma-4-31b-it",
184
  api_key=settings.gemini_api_key,
185
  temperature=0.3,
186
  max_output_tokens=2500,
@@ -298,8 +299,6 @@ def rag_answer(
298
 
299
  is_topic = not (material_id and mat and mat.get("source_type") != "topic")
300
 
301
- llm = get_groq_llm()
302
-
303
  context_parts = []
304
  has_chunks = False
305
 
@@ -368,9 +367,8 @@ def rag_answer(
368
  return any(t.startswith(p) for p in _REFUSAL_PREFIXES)
369
 
370
  try:
371
- # All queries now use the simple LLM call (no agent loop).
372
- # DDG results are pre-fetched and injected into context above.
373
- chain = prompt | llm
374
 
375
  memory_vars = memory.load_memory_variables({"input": query})
376
  chat_history = memory_vars.get("chat_history", [])
@@ -388,9 +386,9 @@ def rag_answer(
388
  memory.save_context({"input": query}, {"output": answer})
389
  return answer, memory
390
  except Exception as e:
391
- logger.warning(f"Groq API call failed or rate-limited: {e}. Falling back to google/gemma-4-31b-it immediately.")
392
  try:
393
- fallback_llm = get_fallback_gemma_llm()
394
  chain = prompt | fallback_llm
395
  memory_vars = memory.load_memory_variables({"input": query})
396
  chat_history = memory_vars.get("chat_history", [])
@@ -407,12 +405,10 @@ def rag_answer(
407
  memory.save_context({"input": query}, {"output": answer})
408
  return answer, memory
409
  except Exception as fallback_err:
410
- logger.error(f"Fallback LLM call also failed: {fallback_err}")
411
  raise fallback_err
412
 
413
  def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
414
- llm = get_groq_llm()
415
-
416
  topic_context = ""
417
  if material_title:
418
  topic_context = f"\nNote: The user is discussing the topic '{material_title}'. If their query uses pronouns like 'its' or 'this', assume it refers to this topic. If the topic name '{material_title}' appears to be a random string or dummy name, do not use it directly; instead, create a general title related to their query, such as 'Types of the topic' or 'Elements of the topic'."
@@ -425,16 +421,17 @@ def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
425
  )
426
 
427
  try:
428
- chain = prompt | llm
 
429
  response = chain.invoke({"query": query})
430
  except Exception as e:
431
- logger.warning(f"Groq API call failed or rate-limited in extract_chat_title: {e}. Falling back to google/gemma-4-31b-it immediately.")
432
  try:
433
- fallback_llm = get_fallback_gemma_llm()
434
  chain = prompt | fallback_llm
435
  response = chain.invoke({"query": query})
436
  except Exception as fallback_err:
437
- logger.error(f"Fallback LLM call also failed in extract_chat_title: {fallback_err}")
438
  raise fallback_err
439
 
440
  title = response.content.strip().strip('"').strip("'")
 
12
  from langchain.memory import ConversationBufferMemory, ConversationBufferWindowMemory
13
  from langchain_core.retrievers import BaseRetriever
14
  from langchain_core.documents import Document
 
15
  from langchain_google_genai import ChatGoogleGenerativeAI
16
 
17
  from src.config import settings
 
163
  )
164
 
165
 
166
+ def get_gemma_31b_llm():
167
+ if not os.environ.get("GEMINI_API_KEY"):
168
+ raise ValueError("GEMINI_API_KEY not found. Please set it in config.env.")
169
+ logger.info("Initializing primary LLM with model: google/gemma-4-31b-it")
170
+ return ChatGoogleGenerativeAI(
171
+ model="google/gemma-4-31b-it",
172
+ api_key=settings.gemini_api_key,
173
+ temperature=0.3,
174
+ max_output_tokens=2500,
175
+ timeout=120,
176
  )
177
 
178
 
179
+ def get_gemma_26b_llm():
180
  if not os.environ.get("GEMINI_API_KEY"):
181
  raise ValueError("GEMINI_API_KEY not found. Please set it in config.env.")
182
+ logger.info("Initializing fallback LLM with model: google/gemma-4-26b-it")
183
  return ChatGoogleGenerativeAI(
184
+ model="google/gemma-4-26b-it",
185
  api_key=settings.gemini_api_key,
186
  temperature=0.3,
187
  max_output_tokens=2500,
 
299
 
300
  is_topic = not (material_id and mat and mat.get("source_type") != "topic")
301
 
 
 
302
  context_parts = []
303
  has_chunks = False
304
 
 
367
  return any(t.startswith(p) for p in _REFUSAL_PREFIXES)
368
 
369
  try:
370
+ primary_llm = get_gemma_31b_llm()
371
+ chain = prompt | primary_llm
 
372
 
373
  memory_vars = memory.load_memory_variables({"input": query})
374
  chat_history = memory_vars.get("chat_history", [])
 
386
  memory.save_context({"input": query}, {"output": answer})
387
  return answer, memory
388
  except Exception as e:
389
+ logger.warning(f"Gemma 4 31B API call failed or rate-limited: {e}. Falling back to google/gemma-4-26b-it immediately.")
390
  try:
391
+ fallback_llm = get_gemma_26b_llm()
392
  chain = prompt | fallback_llm
393
  memory_vars = memory.load_memory_variables({"input": query})
394
  chat_history = memory_vars.get("chat_history", [])
 
405
  memory.save_context({"input": query}, {"output": answer})
406
  return answer, memory
407
  except Exception as fallback_err:
408
+ logger.error(f"Fallback Gemma 4 26B LLM call also failed: {fallback_err}")
409
  raise fallback_err
410
 
411
  def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
 
 
412
  topic_context = ""
413
  if material_title:
414
  topic_context = f"\nNote: The user is discussing the topic '{material_title}'. If their query uses pronouns like 'its' or 'this', assume it refers to this topic. If the topic name '{material_title}' appears to be a random string or dummy name, do not use it directly; instead, create a general title related to their query, such as 'Types of the topic' or 'Elements of the topic'."
 
421
  )
422
 
423
  try:
424
+ primary_llm = get_gemma_31b_llm()
425
+ chain = prompt | primary_llm
426
  response = chain.invoke({"query": query})
427
  except Exception as e:
428
+ logger.warning(f"Gemma 4 31B API call failed or rate-limited in extract_chat_title: {e}. Falling back to google/gemma-4-26b-it immediately.")
429
  try:
430
+ fallback_llm = get_gemma_26b_llm()
431
  chain = prompt | fallback_llm
432
  response = chain.invoke({"query": query})
433
  except Exception as fallback_err:
434
+ logger.error(f"Fallback Gemma 4 26B LLM call also failed in extract_chat_title: {fallback_err}")
435
  raise fallback_err
436
 
437
  title = response.content.strip().strip('"').strip("'")