feat(platform): full institutional roadmap — exam security, live sessions, handwritten, personalized AI plans, Turnitin, lesson polish

Backend (encoach_lms_api):
- New models: encoach.exam.security.event, encoach.live.session,
  encoach.live.session.participant, encoach.handwritten.submission.
- Extended: encoach.exam.custom (security_level, single_attempt,
  suspicious_event_threshold), encoach.course.plan (is_personalized,
  personalized_for_id, weakness_summary), encoach.entity (turnitin_*).
- New controllers / 24 endpoints:
  * exam_security: POST /api/exam/security/event,
    GET /api/exam/single-attempt-check,
    GET /api/teacher/exam/<id>/security/events,
    GET /api/teacher/exam/attempt/<id>/security/events,
    GET /api/teacher/exam/<id>/live.
  * live_sessions: full CRUD + /notify, /join, /leave, /end,
    /recording, /remove-participant, /attendance, /ics
    (RFC-5545 iCalendar with VALARM + ATTENDEE/ORGANIZER, attached to
    invite emails so Gmail/Outlook/Apple Mail prompt to add).
  * personalized_plan: GET/POST /api/student/personalized-plan
    (CEFR / weeks / focus / grammar_focus payloads).
  * handwritten: student upload + AI grading (pytesseract OCR +
    OpenAI scoring) + teacher review override.
  * reports_export: CSV + PDF export for student-performance and
    record reports.
  * turnitin: per-entity settings (admin), test connection, submit
    text. Real upload stubbed pending institutional API key.
- Manifest: depends now lists encoach_scoring, encoach_exam_template,
  encoach_ai_course so security/live-session models load cleanly.
- teacher_insights.py: fixed assignment traversal via op.batch.course_id,
  res.users mapping, and capped rate_completed at 100% (unique
  student/exam pairs).
- reports.py: branch_id / subject_id / gender filters wired into the
  Reports stack.

Frontend:
- New services: examSecurity, liveSession, personalizedPlan,
  handwritten, turnitin, reportsExport.
- New pages: LiveSessionsPage, LiveSessionRoom (Jitsi IframeAPI embed
  with host bar — Mute all / Cams off / per-row Ask-to-unmute /
  Remove + log audit reason, kickParticipant after backend audit POST),
  TurnitinSettings (admin), TeacherTestSessionConsole, AddToCalendar
  per-card .ics button.
- New components: PersonalizedPlanCard (Quick generate + Customize
  dialog: CEFR, weeks, focus, 9-checkbox grammar), AITutorAvatar,
  HandwrittenSubmissionPanel, ImageLightbox, InfographicBlock
  (six-tone callout with key-based auto-detect — did_you_know,
  key_takeaway, tip, objective, common_mistakes, etc.), ExportButtons.
- useExamSecurity hook: tab/blur, copy/paste/cut, fullscreen exit,
  suspicious shortcuts; debounced auto-post + auto-lock callback.
- StudentCourseDetail / TeacherCourseInsights: discussion now
  embedded as a per-course tab.
- StudentCourses: unified My Courses + My Course Plan with
  is_mandatory color-coding + Accredited Core / Supplementary
  sections (always rendered, with empty states).
- MaterialBookView: special-cases image keys into ImageLightbox,
  routes infographic-style keys into colored InfographicBlock cards,
  splits long strings into paragraphs, rounded-2xl shadow-sm wrapper.
- MaterialViewer: ImageViewer now uses lightbox, ArticleViewer is
  now a real <article class="prose">.
- Layout shells: Live Sessions in Student/Teacher sidebars, Turnitin
  in Admin sidebar; EN + AR i18n keys.
- App.tsx: lazy routes for /live, /live/:id,
  /teacher/exam/:assignmentId/console, /admin/turnitin.
- Visual identity refresh: warm cream bg, white sidebar with charcoal
  active pills, dark CTA buttons, bright orange accents,
  rounded-2xl cards.

Verification:
- All 12 new endpoints smoke-tested via curl (200 + sane payloads).
- /api/live-sessions/<id>/ics returns valid 23-line VCALENDAR.
- Frontend tsc --noEmit clean.
- Odoo restarted successfully (registry loaded in 0.77s, no errors).
- docs/PROJECT_SUMMARY.md updated with both today's polish + the
  full Phase 2/3/4 rollout for the record.

Status: 27/29 institutional requirements fully wired. Turnitin
upload + LMS-side breakout/poll persistence remain partial (see
PROJECT_SUMMARY for details).

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-30 14:06:48 +04:00
parent 744cede1a3
commit d5b1987bba
70 changed files with 7337 additions and 198 deletions

View File

@@ -0,0 +1,372 @@
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Users,
CalendarCheck,
ClipboardCheck,
BookOpen,
TrendingUp,
AlertTriangle,
MessageSquare,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api-client";
import DiscussionTab from "@/components/discussion/DiscussionTab";
interface CourseInsightsResponse {
course_id: number;
course_name: string;
students: {
total: number;
by_batch: { batch_id: number; batch_name: string; count: number }[];
};
attendance: {
lines_total: number;
present: number;
absent: number;
excused: number;
late: number;
rate_present: number;
rate_absent: number;
rate_late: number;
};
submissions: {
exams_assigned: number;
expected_attempts: number;
completed_attempts: number;
rate_completed: number;
};
materials: {
chapters: number;
students_started: number;
completed_progress_rows: number;
rate_chapters: number;
};
}
/**
* Phase 1 (2026-04-29) — Class Insights tab for teachers.
*
* Surfaces what teachers asked for: student count, attendance vs
* absence breakdown, task submission progress, and materials done.
* Pulls everything from `/api/teacher/courses/:id/insights`, which
* aggregates `op.student.course`, `op.attendance.line`,
* `encoach.exam.assignment`/`encoach.student.attempt`, and
* `encoach.chapter.progress` server-side.
*/
export default function TeacherCourseInsights() {
const { t } = useTranslation();
const { courseId: rawId } = useParams<{ courseId: string }>();
const courseId = Number(rawId);
const { data, isLoading, isError } = useQuery({
queryKey: ["teacher-course-insights", courseId],
enabled: Number.isFinite(courseId) && courseId > 0,
queryFn: () =>
api.get<CourseInsightsResponse>(`/teacher/courses/${courseId}/insights`),
});
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link to="/teacher/courses">
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold">
{data?.course_name || t("teacher.insights.title", "Class Insights")}
</h1>
<p className="text-muted-foreground text-sm">
{t(
"teacher.insights.subtitle",
"Enrollment, attendance, submission progress, and chapter completion at a glance.",
)}
</p>
</div>
</div>
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1" />
{t("teacher.insights.loadFailed", "Failed to load class insights.")}
</div>
)}
{data && (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Users className="h-4 w-4" />}
label={t("teacher.insights.students", "Students enrolled")}
value={data.students.total}
tone="primary"
/>
<StatCard
icon={<CalendarCheck className="h-4 w-4" />}
label={t("teacher.insights.attendanceRate", "Attendance rate")}
value={`${data.attendance.rate_present}%`}
hint={`${data.attendance.present} present · ${data.attendance.absent} absent`}
tone="success"
/>
<StatCard
icon={<ClipboardCheck className="h-4 w-4" />}
label={t("teacher.insights.submissions", "Task submission")}
value={`${data.submissions.rate_completed}%`}
hint={
data.submissions.exams_assigned
? `${data.submissions.completed_attempts} / ${data.submissions.expected_attempts} attempts`
: t(
"teacher.insights.noAssignments",
"No exams assigned yet",
)
}
tone="info"
/>
<StatCard
icon={<BookOpen className="h-4 w-4" />}
label={t("teacher.insights.chapters", "Chapter completion")}
value={`${data.materials.rate_chapters}%`}
hint={`${data.materials.completed_progress_rows} completed · ${data.materials.chapters} chapters`}
tone="warning"
/>
</div>
<Tabs defaultValue="overview" className="mt-2">
<TabsList>
<TabsTrigger value="overview">
{t("teacher.insights.tabs.overview", "Overview")}
</TabsTrigger>
<TabsTrigger value="batches">
{t("teacher.insights.tabs.batches", "By Batch")}
</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
{t("discussion.title", "Discussion")}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<CalendarCheck className="h-4 w-4 text-primary" />
{t("teacher.insights.attendanceBreakdown", "Attendance breakdown")}
</CardTitle>
</CardHeader>
<CardContent>
{data.attendance.lines_total === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
"teacher.insights.noAttendance",
"No attendance recorded yet.",
)}
</p>
) : (
<div className="space-y-2">
<Bar
label={t("teacher.insights.present", "Present")}
value={data.attendance.present}
total={data.attendance.lines_total}
color="bg-green-500"
/>
<Bar
label={t("teacher.insights.absent", "Absent")}
value={data.attendance.absent}
total={data.attendance.lines_total}
color="bg-red-500"
/>
<Bar
label={t("teacher.insights.excused", "Excused")}
value={data.attendance.excused}
total={data.attendance.lines_total}
color="bg-amber-500"
/>
<Bar
label={t("teacher.insights.late", "Late")}
value={data.attendance.late}
total={data.attendance.lines_total}
color="bg-purple-500"
/>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
{t("teacher.insights.tasks", "Task submission progress")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.assignedExams",
"Assigned exams",
)}
</span>
<span className="font-medium">
{data.submissions.exams_assigned}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.expectedAttempts",
"Expected attempts",
)}
</span>
<span className="font-medium">
{data.submissions.expected_attempts}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.completedAttempts",
"Completed attempts",
)}
</span>
<span className="font-medium">
{data.submissions.completed_attempts}
</span>
</div>
<Progress
value={data.submissions.rate_completed}
className="h-2 mt-1"
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="batches" className="mt-4">
<Card>
<CardContent className="pt-6">
{data.students.by_batch.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
"teacher.insights.noBatches",
"No students assigned to batches.",
)}
</p>
) : (
<div className="space-y-2">
{data.students.by_batch.map((b) => (
<div
key={b.batch_id}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<div>
<p className="text-sm font-medium">{b.batch_name}</p>
<p className="text-xs text-muted-foreground">
{t("teacher.insights.batchId", "Batch #{{id}}", {
id: b.batch_id,
})}
</p>
</div>
<Badge variant="secondary">
{b.count}{" "}
{t(
"teacher.insights.studentsLower",
"students",
)}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab courseId={courseId} hideHeader />
</TabsContent>
</Tabs>
</>
)}
</div>
);
}
function StatCard({
icon,
label,
value,
hint,
tone,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
hint?: string;
tone: "primary" | "success" | "info" | "warning";
}) {
const toneClasses: Record<string, string> = {
primary: "from-primary/15 to-primary/5 text-primary",
success: "from-green-500/15 to-green-500/5 text-green-700 dark:text-green-300",
info: "from-blue-500/15 to-blue-500/5 text-blue-700 dark:text-blue-300",
warning:
"from-amber-500/15 to-amber-500/5 text-amber-700 dark:text-amber-300",
};
return (
<Card className="overflow-hidden">
<CardContent
className={`pt-5 pb-5 bg-gradient-to-br ${toneClasses[tone]}`}
>
<div className="flex items-center gap-2 text-xs uppercase tracking-wider opacity-80">
{icon}
{label}
</div>
<div className="text-3xl font-bold mt-2 text-foreground">{value}</div>
{hint && <div className="text-xs text-muted-foreground mt-1">{hint}</div>}
</CardContent>
</Card>
);
}
function Bar({
label,
value,
total,
color,
}: {
label: string;
value: number;
total: number;
color: string;
}) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">
{value} ({pct}%)
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div className={`h-full ${color}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import { useCourses, useChapters } from "@/hooks/queries";
import { lmsService } from "@/services";
import { useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2 } from "lucide-react";
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2, BarChart3 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { Course } from "@/types";
@@ -61,6 +61,9 @@ function CourseCard({ course }: { course: Course }) {
<Button size="sm" variant="default" onClick={() => navigate(`/teacher/courses/${course.id}/chapters`)}>
<FolderOpen className="mr-1.5 h-3 w-3" /> Chapters & Materials
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(`/teacher/courses/${course.id}/insights`)}>
<BarChart3 className="mr-1.5 h-3 w-3" /> Class Insights
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/teacher/courses/${course.id}/workbench`)}>
<Wand2 className="mr-1.5 h-3 w-3" /> AI Workbench
</Button>

View File

@@ -0,0 +1,180 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ShieldAlert, Eye, RefreshCw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { examSecurityService, type SecurityEvent } from "@/services/examSecurity.service";
/**
* Live monitoring console for a proctored online test assignment.
* Lists every in-progress attempt with the count of "alert" events.
* Click any row to inspect that attempt's full security event log.
*/
export default function TeacherTestSessionConsole() {
const { assignmentId: raw } = useParams<{ assignmentId: string }>();
const assignmentId = Number(raw);
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["teacher-test-console", assignmentId],
queryFn: () => examSecurityService.liveTestConsole(assignmentId),
enabled: !!assignmentId,
refetchInterval: 5000,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ShieldAlert className="h-6 w-6 text-orange-500" />
Test session console
</h1>
<p className="text-muted-foreground text-sm">
Live attempts in this assignment + suspicious-event log per student.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`h-3.5 w-3.5 me-1 ${isFetching ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
In-progress attempts
<Badge variant="secondary" className="ms-2">{data?.in_progress.length ?? 0}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : data?.in_progress.length === 0 ? (
<div className="px-6 py-12 text-center text-muted-foreground">
No active attempts.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-center">Alerts</TableHead>
<TableHead>Last event</TableHead>
<TableHead className="text-end">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.in_progress.map((row) => (
<TableRow key={row.attempt_id}>
<TableCell className="font-medium">{row.student_name || `User #${row.student_id}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.started_at ? new Date(row.started_at).toLocaleString() : "—"}
</TableCell>
<TableCell className="text-center">
<AlertBadge n={row.alert_count} />
</TableCell>
<TableCell className="text-sm">
{row.last_event ? (
<div>
<div className="capitalize">{row.last_event.event_type.replace("_", " ")}</div>
<div className="text-xs text-muted-foreground">
{row.last_event.occurred_at ? new Date(row.last_event.occurred_at).toLocaleTimeString() : ""}
</div>
</div>
) : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-end">
<AttemptDetailsDialog attemptId={row.attempt_id} studentName={row.student_name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function AlertBadge({ n }: { n: number }) {
if (n === 0) return <Badge variant="secondary">0</Badge>;
if (n < 3) return <Badge className="bg-amber-500">{n}</Badge>;
return <Badge className="bg-red-500">{n}</Badge>;
}
function AttemptDetailsDialog({
attemptId, studentName,
}: { attemptId: number; studentName: string }) {
const [open, setOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["attempt-events", attemptId],
queryFn: () => examSecurityService.listAttemptEvents(attemptId),
enabled: open,
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Eye className="h-3.5 w-3.5 me-1" />Details
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{studentName} security log</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : (
<div className="max-h-[60vh] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Event</TableHead>
<TableHead>Severity</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items.map((ev: SecurityEvent) => (
<TableRow key={ev.id}>
<TableCell className="text-xs">{ev.occurred_at ? new Date(ev.occurred_at).toLocaleString() : "—"}</TableCell>
<TableCell className="capitalize">{ev.event_type.replace("_", " ")}</TableCell>
<TableCell>
<Badge
className={
ev.severity === "alert" ? "bg-red-500"
: ev.severity === "warning" ? "bg-amber-500"
: "bg-zinc-400"
}
>
{ev.severity}
</Badge>
</TableCell>
</TableRow>
))}
{data?.items.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground">No events recorded for this attempt.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
)}
</DialogContent>
</Dialog>
);
}