feat: setup admin JSON generation and pdf viewer for free plan DPPs
This commit is contained in:
241
backend/main.py
241
backend/main.py
@@ -16,6 +16,22 @@ from billing import generate_mock_order, verify_webhook_signature
|
||||
from llm import stream_tutor_response
|
||||
from predictor import calculate_readiness_score
|
||||
from growth import generate_referral_link, process_referral_reward
|
||||
import boto3
|
||||
import uuid
|
||||
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
endpoint_url='http://localhost:9000',
|
||||
aws_access_key_id='minioadmin',
|
||||
aws_secret_access_key='minioadmin',
|
||||
region_name='us-east-1'
|
||||
)
|
||||
BUCKET_NAME = "avatars"
|
||||
|
||||
try:
|
||||
s3_client.create_bucket(Bucket=BUCKET_NAME)
|
||||
except Exception:
|
||||
pass # Bucket might already exist
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -575,6 +591,41 @@ def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)):
|
||||
|
||||
# --- ADMIN ROUTES ---
|
||||
|
||||
@app.get("/api/admin/settings", tags=["Admin"])
|
||||
def get_system_settings(session: Session = Depends(get_session)):
|
||||
settings = session.get(models.SystemSettings, "global")
|
||||
if not settings:
|
||||
settings = models.SystemSettings()
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
session.refresh(settings)
|
||||
return settings
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
maintenance_mode: Optional[bool] = None
|
||||
allow_new_registrations: Optional[bool] = None
|
||||
enable_free_trial: Optional[bool] = None
|
||||
trial_duration_days: Optional[int] = None
|
||||
trial_tier: Optional[models.TierEnum] = None
|
||||
|
||||
@app.put("/api/admin/settings", tags=["Admin"])
|
||||
def update_system_settings(payload: SettingsUpdate, session: Session = Depends(get_session)):
|
||||
settings = session.get(models.SystemSettings, "global")
|
||||
if not settings:
|
||||
settings = models.SystemSettings()
|
||||
|
||||
if payload.maintenance_mode is not None: settings.maintenance_mode = payload.maintenance_mode
|
||||
if payload.allow_new_registrations is not None: settings.allow_new_registrations = payload.allow_new_registrations
|
||||
if payload.enable_free_trial is not None: settings.enable_free_trial = payload.enable_free_trial
|
||||
if payload.trial_duration_days is not None: settings.trial_duration_days = payload.trial_duration_days
|
||||
if payload.trial_tier is not None: settings.trial_tier = payload.trial_tier
|
||||
settings.updated_at = datetime.utcnow()
|
||||
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
session.refresh(settings)
|
||||
return settings
|
||||
|
||||
@app.get("/api/admin/tests", tags=["Admin"])
|
||||
def get_admin_tests(session: Session = Depends(get_session)):
|
||||
tests = session.exec(select(models.Test)).all()
|
||||
@@ -832,4 +883,192 @@ def delete_coupon(coupon_id: UUID, session: Session = Depends(get_session)):
|
||||
|
||||
session.delete(coupon)
|
||||
session.commit()
|
||||
return {"status": "success", "message": "Coupon deleted successfully"}
|
||||
return {"status": "success", "message": "Coupon deleted successfully"}@app.post("/api/users/{user_id}/avatar", tags=["Users"])
|
||||
async def upload_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")
|
||||
|
||||
file_extension = file.filename.split(".")[-1]
|
||||
object_name = f"{user_id}_{uuid.uuid4().hex[:8]}.{file_extension}"
|
||||
|
||||
try:
|
||||
s3_client.upload_fileobj(
|
||||
file.file,
|
||||
BUCKET_NAME,
|
||||
object_name,
|
||||
ExtraArgs={"ContentType": file.content_type}
|
||||
)
|
||||
url = f"http://localhost:9000/{BUCKET_NAME}/{object_name}"
|
||||
user.profile_image_url = url
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return {"url": url}
|
||||
except Exception as e:
|
||||
print(f"S3 Upload failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to upload image")
|
||||
|
||||
|
||||
# --- ADMIN STUDENT MANAGEMENT ---
|
||||
|
||||
class AdminUserPatch(BaseModel):
|
||||
name: Optional[str] = None
|
||||
tier: Optional[models.TierEnum] = None
|
||||
streak_count: Optional[int] = None
|
||||
target_exam: Optional[models.ExamEnum] = None
|
||||
school: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
|
||||
@app.get("/api/admin/students", tags=["Admin"])
|
||||
def list_all_students(session: Session = Depends(get_session)):
|
||||
"""Return all users with basic stats for the admin student directory."""
|
||||
users = session.exec(select(models.User)).all()
|
||||
result = []
|
||||
for u in users:
|
||||
attempt_count = session.exec(
|
||||
select(models.Attempt).where(models.Attempt.user_id == u.id)
|
||||
).all()
|
||||
mistake_count = session.exec(
|
||||
select(models.Mistake).where(models.Mistake.user_id == u.id)
|
||||
).all()
|
||||
avg_accuracy = 0.0
|
||||
if attempt_count:
|
||||
submitted = [a for a in attempt_count if a.status == "Submitted"]
|
||||
if submitted:
|
||||
avg_accuracy = round(sum(a.accuracy for a in submitted) / len(submitted), 1)
|
||||
result.append({
|
||||
"id": str(u.id),
|
||||
"name": u.name,
|
||||
"email": u.email or "",
|
||||
"phone": u.phone or "",
|
||||
"tier": u.tier.value,
|
||||
"target_exam": u.target_exam.value,
|
||||
"city": u.city,
|
||||
"state": u.state,
|
||||
"streak_count": u.streak_count,
|
||||
"longest_streak": u.longest_streak,
|
||||
"last_active": u.last_active.isoformat(),
|
||||
"created_at": u.created_at.isoformat(),
|
||||
"total_tests": len(attempt_count),
|
||||
"avg_accuracy": avg_accuracy,
|
||||
"total_mistakes": len(mistake_count),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/admin/students/{user_id}", tags=["Admin"])
|
||||
def get_student_detail(user_id: UUID, session: Session = Depends(get_session)):
|
||||
"""Full student profile for admin detail view."""
|
||||
user = session.get(models.User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Student not found")
|
||||
|
||||
attempts = session.exec(
|
||||
select(models.Attempt).where(models.Attempt.user_id == user_id)
|
||||
.order_by(models.Attempt.submitted_at.desc()) # type: ignore
|
||||
).all()
|
||||
|
||||
mistakes = session.exec(
|
||||
select(models.Mistake).where(models.Mistake.user_id == user_id)
|
||||
).all()
|
||||
|
||||
subscriptions = session.exec(
|
||||
select(models.Subscription).where(models.Subscription.user_id == user_id)
|
||||
).all()
|
||||
|
||||
transactions = session.exec(
|
||||
select(models.Transaction).where(models.Transaction.user_id == user_id)
|
||||
).all()
|
||||
|
||||
submitted = [a for a in attempts if a.status == "Submitted"]
|
||||
avg_accuracy = round(sum(a.accuracy for a in submitted) / len(submitted), 1) if submitted else 0.0
|
||||
mastered_count = len([m for m in mistakes if m.is_mastered])
|
||||
weak_topics = list({m.topic for m in mistakes if not m.is_mastered})[:5]
|
||||
|
||||
recent_attempts = []
|
||||
for a in attempts[:5]:
|
||||
# Try to find the test name from Test table
|
||||
test_name = "Practice Session"
|
||||
if a.source_type.value == "Test":
|
||||
test = session.get(models.Test, a.source_id)
|
||||
if test:
|
||||
test_name = test.title
|
||||
recent_attempts.append({
|
||||
"id": str(a.id),
|
||||
"name": test_name,
|
||||
"source_type": a.source_type.value,
|
||||
"score": a.score,
|
||||
"total_questions": a.total_questions,
|
||||
"accuracy": a.accuracy,
|
||||
"submitted_at": a.submitted_at.isoformat() if a.submitted_at else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"name": user.name,
|
||||
"email": user.email or "",
|
||||
"phone": user.phone or "",
|
||||
"tier": user.tier.value,
|
||||
"target_exam": user.target_exam.value,
|
||||
"student_class": user.student_class.value,
|
||||
"school": user.school or "",
|
||||
"city": user.city,
|
||||
"state": user.state,
|
||||
"streak_count": user.streak_count,
|
||||
"longest_streak": user.longest_streak,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_active": user.last_active.isoformat(),
|
||||
"subscription_end": user.subscription_end.isoformat() if user.subscription_end else None,
|
||||
"profile_image_url": user.profile_image_url,
|
||||
"stats": {
|
||||
"total_tests": len(attempts),
|
||||
"avg_accuracy": avg_accuracy,
|
||||
"total_mistakes": len(mistakes),
|
||||
"mastered_mistakes": mastered_count,
|
||||
},
|
||||
"recent_attempts": recent_attempts,
|
||||
"weak_topics": weak_topics,
|
||||
"subscriptions": [
|
||||
{
|
||||
"id": str(s.id),
|
||||
"tier": s.tier.value,
|
||||
"billing_cycle": s.billing_cycle,
|
||||
"start_date": s.start_date.isoformat(),
|
||||
"end_date": s.end_date.isoformat(),
|
||||
"amount": s.amount,
|
||||
"status": s.status,
|
||||
}
|
||||
for s in subscriptions
|
||||
],
|
||||
"transactions": [
|
||||
{
|
||||
"id": str(t.id),
|
||||
"amount": t.amount,
|
||||
"tier": t.tier,
|
||||
"status": t.status,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
for t in transactions
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/api/admin/students/{user_id}", tags=["Admin"])
|
||||
def patch_student(user_id: UUID, payload: AdminUserPatch, session: Session = Depends(get_session)):
|
||||
"""Admin update: tier, name, streak, exam, location."""
|
||||
user = session.get(models.User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Student not found")
|
||||
if payload.name is not None: user.name = payload.name
|
||||
if payload.tier is not None: user.tier = payload.tier
|
||||
if payload.streak_count is not None: user.streak_count = payload.streak_count
|
||||
if payload.target_exam is not None: user.target_exam = payload.target_exam
|
||||
if payload.school is not None: user.school = payload.school
|
||||
if payload.city is not None: user.city = payload.city
|
||||
if payload.state is not None: user.state = payload.state
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return {"status": "ok", "user_id": str(user.id), "tier": user.tier.value}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ class User(SQLModel, table=True):
|
||||
tier: TierEnum = Field(default=TierEnum.FREE)
|
||||
trial_end: Optional[datetime] = None
|
||||
subscription_end: Optional[datetime] = None
|
||||
profile_image_url: Optional[str] = None
|
||||
streak_count: int = Field(default=0)
|
||||
longest_streak: int = Field(default=0)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
@@ -186,3 +187,13 @@ class Subscription(SQLModel, table=True):
|
||||
status: str # Active, Expired, Cancelled, Paused
|
||||
auto_renew: bool = Field(default=True)
|
||||
coupon_code: Optional[str] = None
|
||||
|
||||
class SystemSettings(SQLModel, table=True):
|
||||
id: str = Field(default="global", primary_key=True)
|
||||
maintenance_mode: bool = Field(default=False)
|
||||
allow_new_registrations: bool = Field(default=True)
|
||||
enable_free_trial: bool = Field(default=True)
|
||||
trial_duration_days: int = Field(default=5)
|
||||
trial_tier: TierEnum = Field(default=TierEnum.AI_COACH)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -62,6 +62,56 @@ def seed_local_database():
|
||||
)
|
||||
session.add(pro_student)
|
||||
|
||||
# Neha Verma — full AI Coach demo profile for manager presentations
|
||||
neha_id = UUID("33333333-3333-3333-3333-333333333333")
|
||||
neha_student = models.User(
|
||||
id=neha_id,
|
||||
phone="6666666666",
|
||||
email="neha@pragativeer.com",
|
||||
password_hash="mock_hash",
|
||||
name="Neha Verma",
|
||||
student_class=models.ClassEnum.TWELFTH,
|
||||
medium=models.MediumEnum.ENGLISH,
|
||||
target_exam=models.ExamEnum.NEET,
|
||||
school="Delhi Public School, R.K. Puram",
|
||||
city="New Delhi",
|
||||
state="Delhi",
|
||||
tier=models.TierEnum.AI_COACH,
|
||||
trial_end=None,
|
||||
subscription_end=datetime.utcnow() + timedelta(days=345),
|
||||
streak_count=62,
|
||||
longest_streak=62,
|
||||
last_active=datetime.utcnow()
|
||||
)
|
||||
session.add(neha_student)
|
||||
|
||||
# Seed Neha's Subscription record
|
||||
neha_subscription = models.Subscription(
|
||||
id=uuid4(),
|
||||
user_id=neha_id,
|
||||
tier=models.TierEnum.AI_COACH,
|
||||
billing_cycle="Annual",
|
||||
start_date=datetime.utcnow() - timedelta(days=20),
|
||||
end_date=datetime.utcnow() + timedelta(days=345),
|
||||
amount=1199900, # ₹11,999 in paise
|
||||
payment_id="PAY_NEHA_DEMO_001",
|
||||
status="Active",
|
||||
auto_renew=True,
|
||||
)
|
||||
session.add(neha_subscription)
|
||||
|
||||
# Seed Neha's Transaction record
|
||||
neha_txn = models.Transaction(
|
||||
id=uuid4(),
|
||||
user_id=neha_id,
|
||||
amount=11999.0,
|
||||
tier="Al_Coach",
|
||||
status="Success",
|
||||
created_at=datetime.utcnow() - timedelta(days=20),
|
||||
)
|
||||
session.add(neha_txn)
|
||||
|
||||
|
||||
student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b")
|
||||
student_user = models.User(
|
||||
id=student_id,
|
||||
|
||||
Reference in New Issue
Block a user