Spaces:
Running
Running
feat: implement password update functionality and add signup, forgot-password, and update-password pages
Browse files- auth/routes.py +65 -21
- auth/schemas.py +5 -1
- config.py +12 -1
- main.py +1 -1
- 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
|
| 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.
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
ext = "jpg" # safe fallback
|
| 47 |
-
storage_path = f"{user_id}/{uuid.uuid4()}.{ext}"
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
|
|
|
|
| 54 |
try:
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 168 |
-
if not
|
| 169 |
-
raise ValueError("
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
api_key=settings.
|
| 174 |
-
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
|
| 177 |
|
| 178 |
-
def
|
| 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-
|
| 182 |
return ChatGoogleGenerativeAI(
|
| 183 |
-
model="google/gemma-4-
|
| 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 |
-
|
| 372 |
-
|
| 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"
|
| 392 |
try:
|
| 393 |
-
fallback_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 |
-
|
|
|
|
| 429 |
response = chain.invoke({"query": query})
|
| 430 |
except Exception as e:
|
| 431 |
-
logger.warning(f"
|
| 432 |
try:
|
| 433 |
-
fallback_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("'")
|