Implement active user tiers, class-based Tailwind theme switcher, 3-tiled static dashboard layout, Minio object storage integration, school & state settings database sync, and streak wishes onboarding modal
This commit is contained in:
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -10,3 +10,4 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
celerybeat-schedule*
|
||||
|
||||
Binary file not shown.
@@ -456,6 +456,8 @@ class UserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
target_exam: Optional[models.ExamEnum] = None
|
||||
student_class: Optional[models.ClassEnum] = None
|
||||
school: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
|
||||
@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"])
|
||||
def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)):
|
||||
@@ -468,14 +470,36 @@ def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = D
|
||||
if payload.target_exam is not None:
|
||||
user.target_exam = payload.target_exam
|
||||
if payload.student_class is not None:
|
||||
# Note: mapping alias 'class' in User class to 'student_class'
|
||||
user.student_class = payload.student_class
|
||||
if payload.school is not None:
|
||||
user.school = payload.school
|
||||
if payload.state is not None:
|
||||
user.state = payload.state
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
from fastapi import UploadFile, File
|
||||
from storage import upload_file
|
||||
|
||||
@app.post("/api/users/{user_id}/upload-avatar", tags=["Users"])
|
||||
async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session)):
|
||||
user = session.get(models.User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
contents = await file.read()
|
||||
file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
|
||||
object_name = f"avatar_{user_id}.{file_extension}"
|
||||
public_url = upload_file(contents, object_name, content_type=file.content_type)
|
||||
|
||||
if not public_url:
|
||||
raise HTTPException(status_code=500, detail="Failed to upload image to Minio.")
|
||||
|
||||
return {"url": public_url}
|
||||
|
||||
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
|
||||
def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)):
|
||||
query = select(models.Test)
|
||||
|
||||
@@ -10,3 +10,4 @@ pyjwt==2.8.0
|
||||
celery==5.3.6
|
||||
openai==1.14.0
|
||||
httpx==0.27.2
|
||||
boto3==1.34.0
|
||||
|
||||
@@ -10,24 +10,65 @@ def seed_local_database():
|
||||
# Ensure tables are created
|
||||
init_db()
|
||||
|
||||
has_data = False
|
||||
with Session(engine) as session:
|
||||
# Prevent double seeding in local testing environment
|
||||
if session.exec(select(models.Question)).first() is not None:
|
||||
print("Local environment database already contains data components. Dropping all and reseeding...")
|
||||
import sqlmodel
|
||||
sqlmodel.SQLModel.metadata.drop_all(engine)
|
||||
init_db()
|
||||
has_data = True
|
||||
|
||||
if has_data:
|
||||
print("Local environment database already contains data components. Dropping all and reseeding...")
|
||||
import sqlmodel
|
||||
sqlmodel.SQLModel.metadata.drop_all(engine)
|
||||
init_db()
|
||||
|
||||
print("Seeding baseline content entities...")
|
||||
|
||||
with Session(engine) as session:
|
||||
# 0. Create the baseline student users (Free, Pro, AI Coach)
|
||||
free_student_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||
free_student = models.User(
|
||||
id=free_student_id,
|
||||
phone="8888888888",
|
||||
email="free@pragativeer.com",
|
||||
password_hash="mock_hash",
|
||||
name="Amit Kumar (Free Tier)",
|
||||
student_class=models.ClassEnum.TWELFTH,
|
||||
medium=models.MediumEnum.ENGLISH,
|
||||
target_exam=models.ExamEnum.JEE_MAIN,
|
||||
city="Patna",
|
||||
state="Bihar",
|
||||
tier=models.TierEnum.FREE,
|
||||
streak_count=0,
|
||||
last_active=datetime.utcnow()
|
||||
)
|
||||
session.add(free_student)
|
||||
|
||||
pro_student_id = UUID("22222222-2222-2222-2222-222222222222")
|
||||
pro_student = models.User(
|
||||
id=pro_student_id,
|
||||
phone="7777777777",
|
||||
email="pro@pragativeer.com",
|
||||
password_hash="mock_hash",
|
||||
name="Priya Singh (Pro Tier)",
|
||||
student_class=models.ClassEnum.TWELFTH,
|
||||
medium=models.MediumEnum.ENGLISH,
|
||||
target_exam=models.ExamEnum.NEET,
|
||||
city="Kota",
|
||||
state="Rajasthan",
|
||||
tier=models.TierEnum.PRO,
|
||||
streak_count=3,
|
||||
last_active=datetime.utcnow()
|
||||
)
|
||||
session.add(pro_student)
|
||||
|
||||
print("Seeding baseline content entities...")
|
||||
|
||||
# 0. Create the baseline student user (seeded target: JEE_MAIN)
|
||||
student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b")
|
||||
student_user = models.User(
|
||||
id=student_id,
|
||||
phone="9999999999",
|
||||
email="student@pragativeer.com",
|
||||
password_hash="mock_hash",
|
||||
name="Rahul Sharma",
|
||||
name="Rahul Sharma (AI Coach Tier)",
|
||||
student_class=models.ClassEnum.TWELFTH,
|
||||
medium=models.MediumEnum.ENGLISH,
|
||||
target_exam=models.ExamEnum.JEE_MAIN,
|
||||
|
||||
91
backend/storage.py
Normal file
91
backend/storage.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
import logging
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from botocore.exceptions import ClientError
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("storage")
|
||||
|
||||
# Read settings from environment
|
||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minio_admin")
|
||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minio_secure_password_2026")
|
||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "pragativeer-uploads")
|
||||
|
||||
# Initialize S3/Minio client
|
||||
# Since backend runs inside Docker, endpoint_url must point to 'http://minio:9000'
|
||||
# However, from the client browser, it will be reached via 'http://localhost:9000'
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"http://{MINIO_ENDPOINT}",
|
||||
aws_access_key_id=MINIO_ACCESS_KEY,
|
||||
aws_secret_access_key=MINIO_SECRET_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
region_name="us-east-1"
|
||||
)
|
||||
|
||||
def ensure_bucket_exists():
|
||||
try:
|
||||
# Check if bucket exists
|
||||
s3_client.head_bucket(Bucket=MINIO_BUCKET)
|
||||
logger.info(f"Minio bucket '{MINIO_BUCKET}' already exists.")
|
||||
except ClientError as e:
|
||||
error_code = e.response["Error"]["Code"]
|
||||
if error_code == "404":
|
||||
try:
|
||||
# Create bucket
|
||||
s3_client.create_bucket(Bucket=MINIO_BUCKET)
|
||||
logger.info(f"Successfully created Minio bucket '{MINIO_BUCKET}'.")
|
||||
|
||||
# Set public read policy for direct browser access
|
||||
bucket_policy = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PublicReadGetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [f"arn:aws:s3:::{MINIO_BUCKET}/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
s3_client.put_bucket_policy(
|
||||
Bucket=MINIO_BUCKET,
|
||||
Policy=json.dumps(bucket_policy)
|
||||
)
|
||||
logger.info(f"Public read policy applied to Minio bucket '{MINIO_BUCKET}'.")
|
||||
except Exception as ex:
|
||||
logger.error(f"Failed to create Minio bucket: {ex}")
|
||||
else:
|
||||
logger.error(f"Error checking Minio bucket: {e}")
|
||||
|
||||
def upload_file(file_content: bytes, object_name: str, content_type: str = "application/octet-stream") -> str:
|
||||
"""Uploads file content to Minio and returns the direct public URL."""
|
||||
try:
|
||||
ensure_bucket_exists()
|
||||
s3_client.put_object(
|
||||
Bucket=MINIO_BUCKET,
|
||||
Key=object_name,
|
||||
Body=file_content,
|
||||
ContentType=content_type
|
||||
)
|
||||
|
||||
# Construct public URL (from browser's point of view, it connects to localhost:9000)
|
||||
# We can resolve this dynamically by returning a path like /api/uploads/ or direct localhost:9000 link
|
||||
public_url = f"http://localhost:9000/{MINIO_BUCKET}/{object_name}"
|
||||
logger.info(f"Successfully uploaded {object_name} to Minio. URL: {public_url}")
|
||||
return public_url
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload file to Minio: {e}")
|
||||
return ""
|
||||
|
||||
def get_file(object_name: str) -> bytes:
|
||||
"""Downloads file content from Minio."""
|
||||
try:
|
||||
response = s3_client.get_object(Bucket=MINIO_BUCKET, Key=object_name)
|
||||
return response["Body"].read()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get file from Minio: {e}")
|
||||
return b""
|
||||
Reference in New Issue
Block a user