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:
@@ -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!}
|
||||
|
||||
Reference in New Issue
Block a user