Fix UI bugs, admin bulk upload limits, test ingestion bugs, and reset demo users

This commit is contained in:
2026-09-25 14:21:48 +05:30
parent 08e54ac055
commit eefcb12ffc
9 changed files with 5911 additions and 146 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -236,9 +236,13 @@ async def bulk_register_students(file: UploadFile = File(...), session: Session
decoded = content.decode('utf-8-sig')
csv_reader = csv.DictReader(io.StringIO(decoded))
rows = list(csv_reader)
if len(rows) > 500:
raise HTTPException(status_code=400, detail="Maximum limit of 500 users per bulk registration exceeded.")
results = []
for row in csv_reader:
for row in rows:
email = row.get("email", "").strip()
name = row.get("name", "").strip()
phone = row.get("phone", "").strip()
@@ -436,7 +440,7 @@ def login(payload: LoginPayload, session: Session = Depends(get_session)):
# --- CBT ENGINE ---
@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"])
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
query = select(models.Test).where(models.Test.is_active == True)
if exam_pattern:
if exam_pattern == 'JEE_NEET':
@@ -450,14 +454,14 @@ def list_active_tests(exam_pattern: Optional[str] = None, type: Optional[str] =
return session.exec(query).all()
@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"])
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
def get_test_by_id(test_id: UUID, user_id: Optional[UUID] = None, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
test = session.get(models.Test, test_id)
if not test:
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
return test
@app.get("/api/tests/{test_id}/questions", response_model=List[models.Question], tags=["CBT Engine"])
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
def get_questions_by_test_id(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
test = session.get(models.Test, test_id)
if not test:
raise HTTPException(status_code=404, detail="Requested examination structure not found.")
@@ -498,7 +502,7 @@ def get_question_by_id(question_id: UUID, session: Session = Depends(get_session
return question
@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"])
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))):
def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)):
user_id = payload.get("user_id")
source_type_raw = payload.get("source_type", "Test")
source_id = payload.get("source_id")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
from uuid import UUID
from sqlmodel import Session
from database import engine
import models
from main import ensure_demo_user
demo_ids = [
UUID("11111111-1111-1111-1111-111111111111"),
UUID("22222222-2222-2222-2222-222222222222"),
UUID("33333333-3333-3333-3333-333333333333"),
UUID("44444444-4444-4444-4444-444444444444")
]
with Session(engine) as session:
for uid in demo_ids:
existing = session.get(models.User, uid)
if existing:
print(f"Deleting {existing.name}...")
session.exec(models.Subscription.__table__.delete().where(models.Subscription.user_id == uid))
session.exec(models.Mistake.__table__.delete().where(models.Mistake.user_id == uid))
session.exec(models.Attempt.__table__.delete().where(models.Attempt.user_id == uid))
session.exec(models.StudyPlan.__table__.delete().where(models.StudyPlan.user_id == uid))
session.exec(models.Transaction.__table__.delete().where(models.Transaction.user_id == uid))
session.delete(existing)
session.commit()
user = ensure_demo_user(uid, session)
print(f"Recreated: {user.name}")

64
backend/upload_test.py Normal file
View File

@@ -0,0 +1,64 @@
import sys
sys.path.append('.')
from sqlmodel import Session, select
from database import engine
from models import User, Test, Question, QuestionTypeEnum, ExamEnum
def upload_json():
import json
with open('neet_180_questions.json', 'r', encoding='utf-8') as f:
questions = json.load(f)
with Session(engine) as session:
admin = session.exec(select(User).where(User.tier == 'AI Coach')).first()
if not admin:
print('No admin found')
return
payload = {
'title': 'Test NEET Mock Upload',
'type': 'Mock',
'exam_pattern': 'NEET',
'time_limit': 180,
'admin_id': str(admin.id),
'questions_json': questions
}
test_obj = Test(
title=payload['title'],
type=payload['type'],
exam_pattern=ExamEnum.NEET,
sections={},
total_time=payload['time_limit'],
difficulty='Hard',
is_active=True,
created_by=admin.id
)
session.add(test_obj)
session.flush()
for q in questions:
q_obj = Question(
subject=q.get('subject'),
topic=q.get('topic', 'General'),
subtopic=q.get('subtopic', ''),
difficulty=q.get('difficulty', 'Medium'),
type=QuestionTypeEnum(q.get('type', 'SCQ')),
question_text=q.get('question_text', ''),
options_json=q.get('options', []),
correct_answer=q.get('correct_answer', ''),
solution_text=q.get('explanation', ''), language='English',
test_id=test_obj.id, created_by=admin.id,
section_name=q.get('subject')
)
session.add(q_obj)
session.commit()
print('Successfully added test with ID:', test_obj.id)
upload_json()

View File

@@ -138,14 +138,6 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card className="border-red-500/20 bg-red-500/5">
<CardHeader>
<CardTitle className="text-red-500">Danger Zone</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={() => alert("Cache Purged.")} className="bg-red-500 text-white hover:bg-red-600 font-bold">Purge Generated Cache</Button>
</CardContent>
</Card>
</div>
</div>
)

View File

@@ -46,14 +46,31 @@ export default function StudentsPage() {
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [deleting, setDeleting] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadTimeLeft, setUploadTimeLeft] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleBulkUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsUploading(true)
setLoading(true)
setUploadProgress(0)
setUploadTimeLeft(15) // Simulate 15s max parsing time
const progressInterval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 95) return prev;
return prev + 5;
});
setUploadTimeLeft(prev => {
if (prev <= 1) return 1;
return prev - 1;
});
}, 1000);
try {
const formData = new FormData()
formData.append("file", file)
@@ -62,35 +79,48 @@ export default function StudentsPage() {
body: formData,
})
if (!rawRes.ok) throw new Error("Upload failed")
if (!rawRes.ok) {
const errorData = await rawRes.json().catch(() => ({}));
throw new Error(errorData.detail || "Upload failed");
}
setUploadProgress(100);
const results = await rawRes.json()
alert(`Successfully registered ${results.length} students!`)
// Download CSV with passwords using Blob to fix formatting on Windows
const csvRows = ["name,email,password"];
results.forEach((row: any) => {
csvRows.push(`${row.name},${row.email},${row.password}`);
});
const csvContent = csvRows.join("\r\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", "bulk_registered_passwords.csv");
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
// Delay alert slightly to let progress bar reach 100% visually
setTimeout(async () => {
alert(`Successfully registered ${results.length} students!`)
// Download CSV with passwords using Blob to fix formatting on Windows
const csvRows = ["name,email,password"];
results.forEach((row: any) => {
csvRows.push(`${row.name},${row.email},${row.password}`);
});
const csvContent = csvRows.join("\r\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", "bulk_registered_passwords.csv");
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
// Refresh students
const fresh = await fetchWithAuth("/api/admin/students").then(r => r.json())
setStudents(fresh)
setIsUploading(false)
setLoading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}, 500);
// Refresh students
const fresh = await fetchWithAuth("/api/admin/students").then(r => r.json())
setStudents(fresh)
} catch (err: any) {
alert("Error: " + err.message)
} finally {
setIsUploading(false)
setLoading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
} finally {
clearInterval(progressInterval);
}
}
@@ -136,6 +166,46 @@ export default function StudentsPage() {
.finally(() => setLoading(false))
}, [])
const exportCSV = () => {
if (filtered.length === 0) {
alert("No data to export");
return;
}
const headers = ["Name", "Email", "Phone", "Plan", "Target", "Tests Taken", "Accuracy", "Streak", "Joined Date", "Status"];
const csvRows = [headers.join(",")];
filtered.forEach(s => {
const status = s.is_active !== false ? "Enabled" : "Disabled";
const date = formatDate(s.created_at);
const plan = s.tier;
const target = s.target_exam;
const row = [
`"${s.name}"`,
`"${s.email || ''}"`,
`"${s.phone || ''}"`,
`"${plan}"`,
`"${target}"`,
s.total_tests || 0,
`${s.avg_accuracy || 0}%`,
s.streak_count || 0,
`"${date}"`,
`"${status}"`
];
csvRows.push(row.join(","));
});
const csvContent = csvRows.join("\r\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", "students_export.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
const filtered = students.filter(s => {
const searchMatch = (s.name || "").toLowerCase().includes(search.toLowerCase()) ||
@@ -162,25 +232,43 @@ export default function StudentsPage() {
{loading ? "Loading..." : `${students.length} students enrolled`}
</p>
</div>
<div className="flex gap-2">
{selectedIds.length > 0 && (
<Button variant="outline" className="text-red-500 border-red-500 hover:bg-red-500 hover:text-white" disabled={deleting} onClick={handleDeleteSelected}>
<Trash2 size={16} className="mr-2" /> Delete ({selectedIds.length})
</Button>
)}
<input type="file" accept=".csv" className="hidden" ref={fileInputRef} onChange={handleBulkUpload} disabled={isUploading} />
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" disabled={isUploading} onClick={() => fileInputRef.current?.click()}>
{isUploading ? <RefreshCw size={16} className="mr-2 animate-spin" /> : <Upload size={16} className="mr-2" />}
{isUploading ? "Uploading..." : "Bulk Register CSV"}
</Button>
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" onClick={downloadTemplate}>
<Download size={16} className="mr-2" /> Download Template
</Button>
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white">
<Download size={16} className="mr-2" /> Export CSV
</Button>
<div className="flex flex-col items-end gap-1">
<div className="flex gap-2">
{selectedIds.length > 0 && (
<Button variant="outline" className="text-red-500 border-red-500 hover:bg-red-500 hover:text-white" disabled={deleting} onClick={handleDeleteSelected}>
<Trash2 size={16} className="mr-2" /> Delete ({selectedIds.length})
</Button>
)}
<input type="file" accept=".csv" className="hidden" ref={fileInputRef} onChange={handleBulkUpload} disabled={isUploading} />
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" disabled={isUploading} onClick={() => fileInputRef.current?.click()}>
{isUploading ? <RefreshCw size={16} className="mr-2 animate-spin" /> : <Upload size={16} className="mr-2" />}
{isUploading ? "Uploading..." : "Bulk Register CSV"}
</Button>
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" onClick={downloadTemplate}>
<Download size={16} className="mr-2" /> Download Template
</Button>
<Button variant="outline" className="border-border hover:bg-secondary hover:text-white" onClick={exportCSV}>
<Download size={16} className="mr-2" /> Export CSV
</Button>
</div>
<span className="text-[10px] text-muted-foreground mr-1">Max 500 students per upload</span>
</div>
</div>
</div>
{isUploading && (
<div className="bg-secondary/20 border border-primary/20 rounded-xl p-4 flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="font-semibold text-primary">Parsing and registering students...</span>
<span className="text-muted-foreground">Estimated time: ~{uploadTimeLeft}s</span>
</div>
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
<div
className="bg-primary h-2 rounded-full transition-all duration-1000 ease-in-out"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
</div>
)}
<div className="bg-secondary/10 border border-border rounded-xl overflow-hidden backdrop-blur-sm">
@@ -212,7 +300,6 @@ export default function StudentsPage() {
>
<option value="All">All Courses</option>
<option value="JEE_Main">JEE Main</option>
<option value="JEE_Advanced">JEE Advanced</option>
<option value="NEET">NEET</option>
<option value="JEE_NEET">JEE + NEET</option>
</select>
@@ -336,3 +423,4 @@ export default function StudentsPage() {
</div>
)
}

View File

@@ -129,7 +129,7 @@ export default function CBTPage() {
}
return (
<PremiumGate featureKey="weekly-test-series">
<>
<div className="p-8 space-y-8 animate-in fade-in zoom-in-95 origin-top duration-500 max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
@@ -360,6 +360,6 @@ export default function CBTPage() {
</div>
)}
</div>
</PremiumGate>
</>
);
}

View File

@@ -1,6 +1,7 @@
"use client"
import { fetchWithAuth } from "@/lib/apiClient";
import Link from "next/link";
import { useRef, useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
@@ -14,6 +15,8 @@ export default function DashboardPage() {
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
const [userStats, setUserStats] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [dpps, setDpps] = useState<any[]>([])
const [cbts, setCbts] = useState<any[]>([])
const [downloading, setDownloading] = useState(false)
const [shareMenuOpen, setShareMenuOpen] = useState(false)
const progressRef = useRef<HTMLDivElement>(null)
@@ -25,20 +28,26 @@ export default function DashboardPage() {
setLoading(false)
return
}
async function fetchStats() {
async function fetchStatsAndTests() {
try {
const res = await fetchWithAuth(`/api/users/${currentUserId}/stats`)
if (res.ok) {
const data = await res.json()
setUserStats(data)
}
const pattern = user?.target_exam || "JEE_Main";
const [statsRes, dppRes, cbtRes] = await Promise.all([
fetchWithAuth(`/api/users/${currentUserId}/stats`),
fetchWithAuth(`/api/tests?type=DPS&exam_pattern=${pattern}`),
fetchWithAuth(`/api/tests?type=Mock&exam_pattern=${pattern}`)
]);
if (statsRes.ok) setUserStats(await statsRes.json());
if (dppRes.ok) setDpps((await dppRes.json()).slice(0, 2));
if (cbtRes.ok) setCbts((await cbtRes.json()).slice(0, 2));
} catch (e) {
console.error("Failed to load user stats", e)
console.error("Failed to load user data", e)
} finally {
setLoading(false)
}
}
fetchStats()
fetchStatsAndTests()
}, [userLoading, user?.id, userId])
const getShareUrl = () => {
@@ -227,61 +236,29 @@ export default function DashboardPage() {
<Button variant="ghost" size="sm" className="text-muted-foreground font-medium">View All</Button>
</div>
<div className="space-y-4">
<Card className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
<CardContent className="p-5 flex items-center justify-between">
<div className="flex gap-4 items-center">
<div className="p-3 bg-secondary rounded-xl text-foreground">
<BrainCircuit size={20} />
</div>
<div>
<h3 className="font-medium text-base group-hover:text-primary transition-colors">Physics: Rotational Dynamics</h3>
<p className="text-sm text-muted-foreground mt-0.5">20 Questions • High Priority</p>
</div>
</div>
<Button variant="outline" size="sm" className="gap-2 rounded-xl group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all">
Start <ArrowRight size={14} />
</Button>
</CardContent>
</Card>
{hasAccessTo("topic-wise-microtests-unlimited") ? (
<Card className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
<CardContent className="p-5 flex items-center justify-between">
<div className="flex gap-4 items-center">
<div className="p-3 bg-secondary rounded-xl text-foreground">
<BrainCircuit size={20} />
{dpps.length > 0 ? (
dpps.map((dpp) => (
<Card key={dpp.id} className="hover:border-primary/30 hover:shadow-md cursor-pointer transition-all duration-300 group">
<CardContent className="p-5 flex items-center justify-between">
<div className="flex gap-4 items-center">
<div className="p-3 bg-secondary rounded-xl text-foreground">
<BrainCircuit size={20} />
</div>
<div>
<h3 className="font-medium text-base group-hover:text-primary transition-colors">{dpp.title}</h3>
<p className="text-sm text-muted-foreground mt-0.5">{dpp.total_time} Minutes • {dpp.difficulty || "Medium"} Priority</p>
</div>
</div>
<div>
<h3 className="font-medium text-base group-hover:text-primary transition-colors">
{isNeet ? "Biology: Human Physiology" : "Maths: Integral Calculus"}
</h3>
<p className="text-sm text-muted-foreground mt-0.5">30 Questions • Medium Priority</p>
</div>
</div>
<Button variant="outline" size="sm" className="gap-2 rounded-xl group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all">
Start <ArrowRight size={14} />
</Button>
</CardContent>
</Card>
<Link href={`/dashboard/dpp`}>
<Button variant="outline" size="sm" className="gap-2 rounded-xl group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all">
Start <ArrowRight size={14} />
</Button>
</Link>
</CardContent>
</Card>
))
) : (
<Card className="relative opacity-60 cursor-not-allowed border-dashed">
<CardContent className="p-5 flex items-center justify-between">
<div className="flex gap-4 items-center">
<div className="p-3 bg-secondary rounded-xl text-muted-foreground">
<Lock size={20} />
</div>
<div>
<h3 className="font-medium text-base text-muted-foreground">
{isNeet ? "Biology: Human Physiology" : "Maths: Integral Calculus"}
</h3>
<p className="text-sm text-muted-foreground mt-0.5 flex items-center gap-1"><Lock size={12}/> Pro Feature</p>
</div>
</div>
<Button variant="outline" size="sm" disabled className="gap-2 rounded-xl">
Locked
</Button>
</CardContent>
</Card>
<p className="text-muted-foreground text-sm py-4">No DPPs scheduled for today.</p>
)}
</div>
</section>
@@ -293,38 +270,33 @@ export default function DashboardPage() {
Upcoming CBTs
</h2>
</div>
<Card className="overflow-hidden">
<div className="p-6 border-b border-border flex items-center justify-between bg-background">
<div>
<div className="flex items-center gap-3 mb-2">
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">Mock Test</Badge>
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Tomorrow, 10:00 AM</span>
</div>
<h3 className="font-semibold text-lg tracking-tight">{isNeet ? 'NEET Mock Test 4' : 'JEE Mock Test 4'}</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
<Clock size={14} /> 180 Minutes • 720 Marks
</p>
</div>
<Button variant="secondary" className="rounded-xl font-medium">Remind Me</Button>
</div>
{hasAccessTo("weekly-test-series") ? (
<div className="p-5 bg-secondary/50 flex items-center justify-between transition-colors hover:bg-secondary/70 cursor-pointer group">
<div>
<h3 className="font-medium text-sm text-foreground flex items-center gap-2"><Activity size={14} className="text-primary group-hover:animate-pulse"/> Part Test 3: Mechanics</h3>
<p className="text-xs text-muted-foreground mt-0.5 font-medium ml-6">Oct 24, 2026</p>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs rounded-lg group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary">Start <ArrowRight size={12} className="ml-1" /></Button>
</div>
<div className="space-y-4">
{cbts.length > 0 ? (
cbts.map((cbt, idx) => (
<Card key={cbt.id} className="overflow-hidden">
<div className="p-6 border-b border-border flex items-center justify-between bg-background">
<div>
<div className="flex items-center gap-3 mb-2">
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">{cbt.exam_pattern}</Badge>
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">{cbt.scheduled_date ? new Date(cbt.scheduled_date).toLocaleDateString() : 'Available Now'}</span>
</div>
<h3 className="font-semibold text-lg tracking-tight">{cbt.title}</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
<Clock size={14} /> {cbt.total_time} Minutes • {cbt.difficulty || 'Medium'}
</p>
</div>
<Link href={`/dashboard/cbt`}>
<Button variant={idx === 0 ? "secondary" : "outline"} className="rounded-xl font-medium">Start</Button>
</Link>
</div>
</Card>
))
) : (
<div className="p-5 bg-secondary/50 flex items-center justify-between transition-colors hover:bg-secondary/70 opacity-75">
<div>
<h3 className="font-medium text-sm text-foreground flex items-center gap-2"><Lock size={14} className="text-muted-foreground"/> Part Test 3: Mechanics</h3>
<p className="text-xs text-muted-foreground mt-0.5 font-medium ml-6">Oct 24, 2026</p>
</div>
<Badge variant="secondary" className="text-xs">Pro Only</Badge>
</div>
<Card className="overflow-hidden p-6 border-dashed opacity-70">
<p className="text-muted-foreground text-sm">No upcoming CBTs at the moment.</p>
</Card>
)}
</Card>
</div>
</section>
</div>
@@ -340,3 +312,4 @@ export default function DashboardPage() {
</div>
)
}