Admin fixes, minio texts removed, added is_active to User model
This commit is contained in:
@@ -307,6 +307,16 @@ def bulk_delete_students(payload: DeleteUsersRequest, session: Session = Depends
|
|||||||
session.commit()
|
session.commit()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
@app.put("/api/admin/students/{user_id}/toggle", tags=["Admin"])
|
||||||
|
def toggle_student_active(user_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
user = session.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
user.is_active = not getattr(user, 'is_active', True)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return {"success": True, "is_active": user.is_active}
|
||||||
|
|
||||||
@app.post("/api/auth/register", tags=["Authentication"])
|
@app.post("/api/auth/register", tags=["Authentication"])
|
||||||
def register(payload: RegisterPayload, session: Session = Depends(get_session)):
|
def register(payload: RegisterPayload, session: Session = Depends(get_session)):
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class User(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
last_active: datetime = Field(default_factory=datetime.utcnow)
|
last_active: datetime = Field(default_factory=datetime.utcnow)
|
||||||
active_session_token: Optional[str] = None
|
active_session_token: Optional[str] = None
|
||||||
|
is_active: bool = Field(default=True)
|
||||||
|
|
||||||
class Parent(SQLModel, table=True):
|
class Parent(SQLModel, table=True):
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
<nav className="flex-1 px-4 space-y-1.5 mt-6">
|
<nav className="flex-1 px-4 space-y-1.5 mt-6">
|
||||||
<NavItem href="/admin" icon={<LayoutDashboard size={18} />} label="Command Center" active={pathname === "/admin"} />
|
<NavItem href="/admin" icon={<LayoutDashboard size={18} />} label="Command Center" active={pathname === "/admin"} />
|
||||||
<NavItem href="/admin/tests" icon={<FileText size={18} />} label="Tests & DPPs" active={pathname.startsWith("/admin/tests")} />
|
<NavItem href="/admin/tests" icon={<FileText size={18} />} label="Tests & DPPs" active={pathname.startsWith("/admin/tests")} />
|
||||||
<NavItem href="/admin/questions" icon={<Database size={18} />} label="Question Bank" active={pathname.startsWith("/admin/questions")} />
|
|
||||||
<NavItem href="/admin/agents" icon={<Terminal size={18} />} label="AI Agents" active={pathname.startsWith("/admin/agents")} />
|
|
||||||
<NavItem href="/admin/students" icon={<Users size={18} />} label="Students" active={pathname.startsWith("/admin/students")} />
|
<NavItem href="/admin/students" icon={<Users size={18} />} label="Students" active={pathname.startsWith("/admin/students")} />
|
||||||
<NavItem href="/admin/coupons" icon={<Tag size={18} />} label="Coupons" active={pathname.startsWith("/admin/coupons")} />
|
<NavItem href="/admin/coupons" icon={<Tag size={18} />} label="Coupons" active={pathname.startsWith("/admin/coupons")} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { fetchWithAuth } from "@/lib/apiClient";
|
import { fetchWithAuth } from "@/lib/apiClient";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
@@ -65,24 +65,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="border-border bg-secondary/10">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>AI Inference Configuration</CardTitle>
|
|
||||||
<CardDescription>Manage the underlying LLM models and API keys for the AI agents.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-bold">Primary Model Name</label>
|
|
||||||
<input type="text" defaultValue="gemini-1.5-pro-experimental" className="w-full bg-background border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--cyan-ai)]" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-bold">OpenAI API Key (Fallback)</label>
|
|
||||||
<input type="password" defaultValue="sk-........................" className="w-full bg-background border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--cyan-ai)]" />
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => alert("Configuration Saved via Admin Panel")} className="bg-[var(--cyan-ai)] text-background hover:bg-[var(--cyan-ai-80)] font-bold">Save Configuration</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-border bg-secondary/10">
|
<Card className="border-border bg-secondary/10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Trial Management</CardTitle>
|
<CardTitle>Trial Management</CardTitle>
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ export default function StudentsPage() {
|
|||||||
const [students, setStudents] = useState<any[]>([])
|
const [students, setStudents] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const [tierFilter, setTierFilter] = useState<string>("All")
|
||||||
|
const [courseFilter, setCourseFilter] = useState<string>("All")
|
||||||
|
|
||||||
|
const handleToggleActive = async (studentId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetchWithAuth(/api/admin/students/\/toggle, {
|
||||||
|
method: "PUT"
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const fresh = await fetchWithAuth("/api/admin/students").then(r => r.json())
|
||||||
|
setStudents(fresh)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to toggle student:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
@@ -118,14 +136,18 @@ export default function StudentsPage() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const filtered = students.filter(s =>
|
|
||||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
s.email.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
s.phone.includes(search)
|
|
||||||
)
|
|
||||||
|
|
||||||
const formatDate = (iso: string) => {
|
const filtered = students.filter(s => {
|
||||||
try { return new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) }
|
const searchMatch = (s.name || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(s.email || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(s.phone || "").includes(search)
|
||||||
|
|
||||||
|
const tierMatch = tierFilter === "All" || s.tier === tierFilter
|
||||||
|
const courseMatch = courseFilter === "All" || s.target_exam === courseFilter
|
||||||
|
|
||||||
|
return searchMatch && tierMatch && courseMatch
|
||||||
|
})
|
||||||
|
}
|
||||||
catch { return iso }
|
catch { return iso }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +181,9 @@ export default function StudentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-secondary/10 border border-border rounded-xl overflow-hidden backdrop-blur-sm">
|
<div className="bg-secondary/10 border border-border rounded-xl overflow-hidden backdrop-blur-sm">
|
||||||
<div className="p-4 border-b border-border flex items-center gap-4">
|
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="p-4 border-b border-border flex flex-col md:flex-row items-center gap-4">
|
||||||
|
<div className="relative flex-1 w-full max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -170,8 +193,30 @@ export default function StudentsPage() {
|
|||||||
className="w-full bg-background border border-border rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
className="w-full bg-background border border-border rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
className="w-full md:w-auto bg-background border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
value={tierFilter}
|
||||||
|
onChange={e => setTierFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="All">All Plans</option>
|
||||||
|
<option value="AI Coach">AI Coach</option>
|
||||||
|
<option value="Pro">Pro</option>
|
||||||
|
<option value="Free">Free</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="w-full md:w-auto bg-background border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
value={courseFilter}
|
||||||
|
onChange={e => setCourseFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20 text-muted-foreground gap-3">
|
<div className="flex items-center justify-center py-20 text-muted-foreground gap-3">
|
||||||
@@ -192,13 +237,16 @@ export default function StudentsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th className="px-6 py-4 font-semibold">Student</th>
|
<th className="px-6 py-4 font-semibold">Student</th>
|
||||||
<th className="px-6 py-4 font-semibold">Plan</th>
|
<th className="px-6 py-4 font-semibold">Plan</th>
|
||||||
<th className="px-6 py-4 font-semibold">Target</th>
|
<th className="px-6 py-4 font-semibold">Target</th>
|
||||||
<th className="px-6 py-4 font-semibold">Tests</th>
|
<th className="px-6 py-4 font-semibold">Tests</th>
|
||||||
<th className="px-6 py-4 font-semibold">Accuracy</th>
|
<th className="px-6 py-4 font-semibold">Accuracy</th>
|
||||||
<th className="px-6 py-4 font-semibold text-center">Streak</th>
|
<th className="px-6 py-4 font-semibold text-center">Streak</th>
|
||||||
<th className="px-6 py-4 font-semibold">Last Active</th>
|
<th className="px-6 py-4 font-semibold">Dates</th>
|
||||||
|
<th className="px-6 py-4 font-semibold">Status</th>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -255,7 +303,24 @@ export default function StudentsPage() {
|
|||||||
{s.streak_count}
|
{s.streak_count}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-muted-foreground text-xs">{formatDate(s.last_active)}</td>
|
|
||||||
|
<td className="px-6 py-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs">Joined: {formatDate(s.created_at)}</span>
|
||||||
|
<span className="text-xs">Active: {formatDate(s.last_active)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold">{s.is_active !== false ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
<div
|
||||||
|
onClick={() => handleToggleActive(s.id)}
|
||||||
|
className={h-5 w-9 rounded-full relative cursor-pointer transition-colors }>
|
||||||
|
<div className={bsolute top-0.5 size-4 rounded-full transition-all }></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -172,6 +172,45 @@ export default function AdminTestsPage() {
|
|||||||
|
|
||||||
const handleUpload = async (e: React.FormEvent) => {
|
const handleUpload = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (testType === 'DPP_PDF') {
|
||||||
|
const file = fileInputRef.current?.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
alert("Please select a PDF file to upload to Minio.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!userId) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
formData.append("title", title)
|
||||||
|
formData.append("exam_pattern", examPattern)
|
||||||
|
formData.append("time_limit", timeLimit)
|
||||||
|
formData.append("admin_id", userId as string)
|
||||||
|
if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString())
|
||||||
|
|
||||||
|
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
alert("PDF uploaded successfully to Minio and connected!")
|
||||||
|
setTitle("")
|
||||||
|
removeFile()
|
||||||
|
fetchTests()
|
||||||
|
} else {
|
||||||
|
const err = await res.json()
|
||||||
|
alert(`Upload failed: ${JSON.stringify(err)}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Upload failed")
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!validQuestions || validQuestions.length === 0) {
|
if (!validQuestions || validQuestions.length === 0) {
|
||||||
alert("Please upload a valid JSON file with questions first.")
|
alert("Please upload a valid JSON file with questions first.")
|
||||||
return
|
return
|
||||||
@@ -251,13 +290,14 @@ export default function AdminTestsPage() {
|
|||||||
value={testType}
|
value={testType}
|
||||||
onChange={(e) => setTestType(e.target.value)}
|
onChange={(e) => setTestType(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="CBT">🎯 CBT Mock Test — appears in Mock Tests section</option>
|
<option value="CBT">🎯 CBT Test (JSON) — appears in Mock Tests section</option>
|
||||||
<option value="DPS">📋 DPP (Daily Practice) — appears in PrepPilot AI section</option>
|
<option value="PREP_PILOT">🤖 Prep Pilot Test (as CBT JSON) — appears in AI Coach</option>
|
||||||
|
<option value="DPP_PDF">📋 DPP PDF (Minio Upload) — Class/Plan aligned</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{testType === 'CBT'
|
{testType === 'CBT' && '✅ Purely for CBT. Appears under Dashboard → Mock Tests.'}
|
||||||
? '✅ This will appear under Dashboard → Mock Tests (CBT) for students to attempt.'
|
{testType === 'PREP_PILOT' && '✅ Purely for CBT. Appears under AI Coach → PrepPilot AI.'}
|
||||||
: '✅ This will appear under AI Coach → PrepPilot AI as a DPP with full CBT interface.'}
|
{testType === 'DPP_PDF' && '✅ Uploads a PDF to Minio storage for Class/Plan aligned DPP.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -313,7 +353,21 @@ export default function AdminTestsPage() {
|
|||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<label className="text-sm font-medium">Questions JSON File</label>
|
<label className="text-sm font-medium">Questions JSON File</label>
|
||||||
|
|
||||||
{validQuestions ? (
|
{testType === 'DPP_PDF' ? (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<label className="text-sm font-medium">PDF File (Minio Upload)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer file:border-0 file:bg-primary file:text-primary-foreground file:h-full file:px-4 file:mr-4 file:font-semibold hover:file:bg-primary/90"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if(file) setValidationErrors([])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : validQuestions ? (
|
||||||
<div className="flex items-center justify-between p-3 border border-green-500/50 bg-green-500/10 rounded-md">
|
<div className="flex items-center justify-between p-3 border border-green-500/50 bg-green-500/10 rounded-md">
|
||||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
<CheckCircle2 className="w-5 h-5" />
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
@@ -364,9 +418,9 @@ export default function AdminTestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={uploading || !validQuestions}>
|
<Button type="submit" className="w-full" disabled={uploading || (!validQuestions && testType !== 'DPP_PDF')}>
|
||||||
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
{uploading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FileText className="w-4 h-4 mr-2" />}
|
||||||
{uploading ? "Creating Test..." : "Create Test from JSON"}
|
{uploading ? "Creating Test..." : (testType === 'DPP_PDF' ? "Upload PDF to Minio" : "Create Test from JSON")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function SettingsPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAvatarUrl(data.url);
|
setAvatarUrl(data.url);
|
||||||
localStorage.setItem(`user_avatar_${userId}`, data.url);
|
localStorage.setItem(`user_avatar_${userId}`, data.url);
|
||||||
alert("Profile picture uploaded successfully to Minio!");
|
alert("Profile picture uploaded successfully!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert("Failed to upload profile picture.");
|
alert("Failed to upload profile picture.");
|
||||||
@@ -127,7 +127,7 @@ export default function SettingsPage() {
|
|||||||
<Card className="border-border bg-background shadow-sm md:col-span-1 h-fit">
|
<Card className="border-border bg-background shadow-sm md:col-span-1 h-fit">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Profile Photo</CardTitle>
|
<CardTitle>Profile Photo</CardTitle>
|
||||||
<CardDescription>Upload photo to Minio storage.</CardDescription>
|
<CardDescription>Upload your profile photo.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col items-center space-y-4">
|
<CardContent className="flex flex-col items-center space-y-4">
|
||||||
<div className="relative size-32 rounded-full border border-border bg-secondary/50 flex items-center justify-center overflow-hidden">
|
<div className="relative size-32 rounded-full border border-border bg-secondary/50 flex items-center justify-center overflow-hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user