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

@@ -3,6 +3,8 @@ import { cn } from "@/lib/utils";
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
import ImageLightbox from "@/components/lesson/ImageLightbox";
import InfographicBlock, { inferTone } from "@/components/lesson/InfographicBlock";
export const SKILL_STYLE: Record<string, string> = {
reading: "bg-blue-100 text-blue-800 border-blue-200",
@@ -25,15 +27,68 @@ export function SkillBadge({ skill }: { skill: string }) {
);
}
const IMAGE_KEYS = new Set([
"image", "image_url", "imageurl", "img", "illustration",
"hero_image", "hero", "picture", "thumbnail",
]);
const IMAGES_KEYS = new Set([
"images", "illustrations", "pictures", "gallery",
]);
function isImageUrl(v: unknown): v is string {
if (typeof v !== "string") return false;
if (/^data:image\//.test(v)) return true;
if (/^https?:\/\//i.test(v) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(v)) return true;
return false;
}
function renderImage(v: unknown, alt?: string): React.ReactNode {
if (typeof v === "string" && isImageUrl(v)) {
return <ImageLightbox src={v} alt={alt} />;
}
if (v && typeof v === "object" && !Array.isArray(v)) {
const obj = v as Record<string, unknown>;
const src = (obj.url || obj.src || obj.image_url || obj.image) as string | undefined;
const cap = (obj.caption || obj.description || obj.alt) as string | undefined;
if (typeof src === "string" && isImageUrl(src)) {
return <ImageLightbox src={src} alt={alt || cap} caption={cap} />;
}
}
return null;
}
function isLikelyParagraph(s: string): boolean {
return s.length > 80 || /[.!?]\s/.test(s);
}
function renderString(value: string): React.ReactNode {
if (isImageUrl(value)) {
return <ImageLightbox src={value} />;
}
if (!isLikelyParagraph(value)) {
return <p className="leading-7">{value}</p>;
}
return (
<div className="space-y-3 leading-7">
{value.split(/\n{2,}/).map((para, i) => (
<p key={i} className="whitespace-pre-wrap">{para.trim()}</p>
))}
</div>
);
}
function renderAny(value: unknown): React.ReactNode {
if (value == null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
if (typeof value === "string") {
return renderString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return <p className="leading-7">{String(value)}</p>;
}
if (Array.isArray(value)) {
if (value.length === 0) return null;
return (
<ul className="list-disc ps-5 space-y-1">
<ul className="list-disc ps-5 space-y-1.5 leading-7">
{value.map((item, idx) => (
<li key={idx}>{renderAny(item)}</li>
))}
@@ -43,21 +98,78 @@ function renderAny(value: unknown): React.ReactNode {
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
return (
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k}>
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
{k.replace(/_/g, " ")}
</h5>
<div className="text-sm">{renderAny(v)}</div>
</div>
))}
<div className="space-y-4">
{entries.map(([k, v]) => renderEntry(k, v))}
</div>
);
}
return null;
}
function renderEntry(key: string, value: unknown): React.ReactNode {
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
const tone = inferTone(key);
const label = key.replace(/_/g, " ");
if (IMAGE_KEYS.has(norm)) {
const node = renderImage(value, label);
if (node) return <div key={key}>{node}</div>;
}
if (IMAGES_KEYS.has(norm) && Array.isArray(value)) {
return (
<div key={key} className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{value.map((item, i) => {
const node = renderImage(item, `${label} ${i + 1}`);
return node
? <div key={i}>{node}</div>
: <div key={i} className="text-sm text-muted-foreground">{renderAny(item)}</div>;
})}
</div>
);
}
if (tone) {
if (Array.isArray(value)) {
const items = value.map((v) =>
typeof v === "string" ? v
: (v && typeof v === "object" && "label" in (v as object) && "value" in (v as object))
? v as { label?: string; value?: string }
: { value: typeof v === "string" ? v : JSON.stringify(v) },
);
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
items={items}
/>
);
}
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
>
{renderAny(value)}
</InfographicBlock>
);
}
return (
<div key={key}>
<h5 className="font-semibold capitalize text-sm text-foreground/80 mb-1.5">
{label}
</h5>
<div className="text-[0.95rem]">{renderAny(value)}</div>
</div>
);
}
function titleCase(s: string): string {
return s.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1));
}
type MaterialForBook = Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
@@ -108,18 +220,23 @@ export default function MaterialBookView({
typeof material.plan_id === "number";
return (
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
<div className="rounded-2xl border bg-card p-5 sm:p-6 space-y-5 shadow-sm leading-relaxed">
{hasStructured ? (
renderAny(stripEmbedded(body))
) : (
<p className="text-sm whitespace-pre-wrap leading-7">
{material.body_text || "No content available yet."}
</p>
<div className="space-y-3 text-[0.95rem] whitespace-pre-wrap leading-7">
{(material.body_text || "No content available yet.")
.split(/\n{2,}/)
.map((p, i) => <p key={i}>{p}</p>)}
</div>
)}
{hasEmbedded && (
<div className="border-t pt-3">
<div className="text-sm font-medium mb-2">Practice</div>
<div className="border-t pt-4">
<div className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
Practice
</div>
<InteractiveWorkbook
material={material as MaterialForBook}
bodyOverride={embeddedWorkbook!}