feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
sources and persists grounded_on_json metadata
- security: access rules for new models
Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints
Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)
Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
course-plan RAG pipeline
Made-with: Cursor
This commit is contained in:
@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
|
||||
import { lmsService, taxonomyService, resourcesService } from "@/services";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
|
||||
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap, Layers, Sparkles, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { Course, CourseCreateRequest } from "@/types";
|
||||
import type { Course, CourseCreateRequest, CourseSection } from "@/types";
|
||||
import type { ResourceTag } from "@/types/adaptive";
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
@@ -409,10 +409,335 @@ function courseToFormData(c: Course): CourseFormData {
|
||||
};
|
||||
}
|
||||
|
||||
function CourseSectionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
course,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
course: Course;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newCode, setNewCode] = useState("");
|
||||
const [newSeq, setNewSeq] = useState<number>(10);
|
||||
const [editing, setEditing] = useState<CourseSection | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editCode, setEditCode] = useState("");
|
||||
const [editSeq, setEditSeq] = useState<number>(10);
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const sectionsQ = useQuery({
|
||||
queryKey: ["course-sections", course.id],
|
||||
queryFn: () => lmsService.listCourseSections(course.id),
|
||||
enabled: open,
|
||||
});
|
||||
const sections = sectionsQ.data?.items ?? [];
|
||||
|
||||
function refreshAll() {
|
||||
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
|
||||
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
|
||||
}
|
||||
|
||||
async function handleGenerateDefaults() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await lmsService.generateDefaultCourseSections(course.id);
|
||||
toast({
|
||||
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
|
||||
});
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Generation failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const code = newCode.trim().toUpperCase();
|
||||
const name = newName.trim() || `Section ${code}`;
|
||||
if (!code) {
|
||||
toast({ title: "Code is required", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.createCourseSection(course.id, {
|
||||
name,
|
||||
code,
|
||||
sequence: newSeq || 10,
|
||||
active: true,
|
||||
});
|
||||
toast({ title: `Section ${code} added` });
|
||||
setNewName("");
|
||||
setNewCode("");
|
||||
setNewSeq(10);
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Add failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function startEdit(s: CourseSection) {
|
||||
setEditing(s);
|
||||
setEditName(s.name);
|
||||
setEditCode(s.code);
|
||||
setEditSeq(s.sequence || 10);
|
||||
setEditActive(s.active);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.updateCourseSection(course.id, editing.id, {
|
||||
name: editName.trim() || editing.name,
|
||||
code: editCode.trim().toUpperCase() || editing.code,
|
||||
sequence: editSeq || editing.sequence,
|
||||
active: editActive,
|
||||
});
|
||||
toast({ title: "Section updated" });
|
||||
setEditing(null);
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function handleDelete(s: CourseSection) {
|
||||
if ((s.batch_count ?? 0) > 0) {
|
||||
toast({
|
||||
title: "Cannot delete",
|
||||
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.deleteCourseSection(course.id, s.id);
|
||||
toast({ title: "Section deleted" });
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" /> Sections — {course.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Course sections (A, B, C…) are templates. Each section can be hosted by a classroom,
|
||||
which auto-creates a batch and enrolls the classroom roster.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Quick start</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create the standard <strong>A · B · C</strong> sections for this course in one click.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerateDefaults}
|
||||
disabled={busy || sectionsQ.isLoading}
|
||||
variant="secondary"
|
||||
>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
Generate A / B / C
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Existing sections</Label>
|
||||
{sectionsQ.isLoading ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : sections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-3">
|
||||
No sections yet. Add one below or click <em>Generate A / B / C</em>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 border rounded-md divide-y">
|
||||
{sections.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{s.code}
|
||||
</Badge>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
seq {s.sequence}
|
||||
{s.batch_count != null && (
|
||||
<span className="ml-2">· {s.batch_count} batch(es)</span>
|
||||
)}
|
||||
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Edit section"
|
||||
onClick={() => startEdit(s)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Delete section"
|
||||
onClick={() => handleDelete(s)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-3">
|
||||
<Label className="text-sm font-medium">Add a new section</Label>
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
<div className="col-span-3">
|
||||
<Label className="text-[11px] text-muted-foreground">Code *</Label>
|
||||
<Input
|
||||
placeholder="A"
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-7">
|
||||
<Label className="text-[11px] text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
placeholder="Section A"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[11px] text-muted-foreground">Seq</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newSeq}
|
||||
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
|
||||
Add section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pencil className="h-4 w-4" /> Edit section
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={editCode}
|
||||
onChange={(e) => setEditCode(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sequence</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editSeq}
|
||||
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={editActive}
|
||||
onCheckedChange={(v) => setEditActive(Boolean(v))}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveEdit} disabled={busy}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminCourses() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
|
||||
const [enrollOpen, setEnrollOpen] = useState(false);
|
||||
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
|
||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||
@@ -539,10 +864,11 @@ export default function AdminCourses() {
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Subject / Tags</TableHead>
|
||||
<TableHead>Difficulty</TableHead>
|
||||
<TableHead>Sections</TableHead>
|
||||
<TableHead>Chapters</TableHead>
|
||||
<TableHead>Enrolled / Cap</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[150px]" />
|
||||
<TableHead className="w-[180px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -594,6 +920,36 @@ export default function AdminCourses() {
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSectionsCourse(c)}
|
||||
className="group flex items-center gap-1 text-xs hover:underline"
|
||||
title="Manage course sections (A/B/C…)"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
|
||||
<span className="text-muted-foreground">sections</span>
|
||||
{c.sections && c.sections.length > 0 && (
|
||||
<span className="ml-1 flex flex-wrap gap-0.5">
|
||||
{c.sections.slice(0, 4).map((s) => (
|
||||
<Badge
|
||||
key={s.id}
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-none"
|
||||
>
|
||||
{s.code || s.name}
|
||||
</Badge>
|
||||
))}
|
||||
{c.sections.length > 4 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{c.sections.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -627,6 +983,14 @@ export default function AdminCourses() {
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Manage sections (A/B/C…)"
|
||||
onClick={() => setSectionsCourse(c)}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -653,7 +1017,7 @@ export default function AdminCourses() {
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
No courses found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -679,6 +1043,16 @@ export default function AdminCourses() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{sectionsCourse && (
|
||||
<CourseSectionsDialog
|
||||
open={!!sectionsCourse}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSectionsCourse(null);
|
||||
}}
|
||||
course={sectionsCourse}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
|
||||
Reference in New Issue
Block a user