feat: institutional + support + training admin sections (backend + frontend)
Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs. Backend (new `encoach_lms_api` addon + existing addons): - Institutional: academic years/terms, departments, admission registers & admissions, courses/batches, lessons, fees (terms + student fees + invoicing with income-account auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset), student leave, result templates + marksheets (incl. delete-with-cascade). - Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived from `op.student.fees.details` and `account.move`; platform settings backed by `encoach.code` and `ir.config_parameter` (packages + grading config). - Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models) with CRUD, pagination, search/level filters, and upsert-style progress endpoints. Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`; `ValidationError`/`UserError` mapped to HTTP 400. Frontend: - Rewire institutional admin pages (Academic Year Manager, Admissions, Courses, Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets, Taxonomy, Resources) to real APIs with React Query invalidation and dialogs. - New typed services: `payments.service.ts`, `platformSettings.service.ts`, `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/ resources/student-progress/generation` services + related types. - Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`, `TicketsPage` to consume live data with search/filter/progress/CRUD flows. - New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`. - Favicons/branding assets and misc. UX polish across teacher/student pages. Tooling & QA: - Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent, covers institutional + support + training fixtures incl. income-account wiring). - API write-flow test suites: `test_write_flows.py` (institutional), `test_support_flows.py` (support), `test_training_flows.py` (training), `test_ai_full.py`. All suites pass end-to-end. - Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA. - `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`, `frontend/node_modules/`. Made-with: Cursor
This commit is contained in:
@@ -436,12 +436,60 @@ export default function GenerationPage() {
|
||||
|
||||
const currentState = activeModule ? getModuleState(activeModule) : null;
|
||||
|
||||
/**
|
||||
* Build the rich "persona context" that the backend AI controller uses to
|
||||
* build its system prompt. Every parameter the user has configured in the
|
||||
* UI (exam mode, structure, entity, rubric, grading system, access type,
|
||||
* passage category/type, module, subject) flows through here so the
|
||||
* generated questions actually reflect those choices.
|
||||
*/
|
||||
const buildPersonaContext = useCallback(
|
||||
(mod: ModuleKey): Record<string, unknown> => {
|
||||
const st = getModuleState(mod);
|
||||
const entityIdNum = st.entity && st.entity !== "" && st.entity !== "none"
|
||||
? Number(st.entity)
|
||||
: undefined;
|
||||
const entityObj = entityIdNum
|
||||
? entities.find((e) => e.id === entityIdNum)
|
||||
: undefined;
|
||||
|
||||
let rubricIdNum: number | undefined;
|
||||
if (st.rubricId && st.rubricId.startsWith("rubric-")) {
|
||||
const parsed = Number(st.rubricId.split("-")[1]);
|
||||
if (!Number.isNaN(parsed)) rubricIdNum = parsed;
|
||||
}
|
||||
const rubricObj = rubricIdNum
|
||||
? rubrics.find((r) => r.id === rubricIdNum)
|
||||
: undefined;
|
||||
|
||||
const structureObj = selectedStructure;
|
||||
|
||||
return {
|
||||
module: mod,
|
||||
exam_mode: examMode,
|
||||
exam_title: title || undefined,
|
||||
exam_label: examLabel || undefined,
|
||||
structure_name: structureObj?.name,
|
||||
entity_id: entityIdNum,
|
||||
entity_name: entityObj?.name,
|
||||
rubric_id: rubricIdNum,
|
||||
rubric_name: rubricObj?.name,
|
||||
grading_system: st.gradingSystem || undefined,
|
||||
access_type: st.accessType || undefined,
|
||||
};
|
||||
},
|
||||
[getModuleState, entities, rubrics, selectedStructure, examMode, title, examLabel],
|
||||
);
|
||||
|
||||
const generatePassageMut = useMutation({
|
||||
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number }) =>
|
||||
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number; category: string; passageType: string }) =>
|
||||
generationService.generatePassage({
|
||||
...buildPersonaContext(activeModule ?? "reading"),
|
||||
topic: params.topic,
|
||||
difficulty: params.difficulty,
|
||||
word_count: params.wordCount,
|
||||
category: params.category,
|
||||
passage_type: params.passageType,
|
||||
}),
|
||||
onSuccess: (res, vars) => {
|
||||
if (!activeModule) return;
|
||||
@@ -463,19 +511,36 @@ export default function GenerationPage() {
|
||||
types: string[];
|
||||
typeCounts: Record<string, number>;
|
||||
typeInstructions: Record<string, string>;
|
||||
typeDifficulties?: Record<string, string>;
|
||||
passageText: string;
|
||||
difficulty: string;
|
||||
}) => {
|
||||
const mod = params.module === "level" || params.module === "industry" ? "reading" : params.module;
|
||||
const totalCount = Object.values(params.typeCounts).reduce((s, n) => s + n, 0);
|
||||
const st = getModuleState(params.module);
|
||||
let category: string | undefined;
|
||||
let passageType: string | undefined;
|
||||
if (params.module === "listening") {
|
||||
const sec = st.listeningSections[params.passageIndex];
|
||||
category = sec?.category;
|
||||
passageType = sec?.type;
|
||||
} else {
|
||||
const p = st.passages[params.passageIndex];
|
||||
category = p?.category;
|
||||
passageType = p?.type;
|
||||
}
|
||||
return generationService.generateExercises(mod as "reading" | "listening" | "writing" | "speaking", {
|
||||
...buildPersonaContext(params.module),
|
||||
passage_index: params.passageIndex,
|
||||
exercise_types: params.types,
|
||||
count_per_type: totalCount,
|
||||
passage_text: params.passageText,
|
||||
type_counts: params.typeCounts,
|
||||
type_instructions: params.typeInstructions,
|
||||
type_difficulties: params.typeDifficulties,
|
||||
difficulty: params.difficulty,
|
||||
category,
|
||||
passage_type: passageType,
|
||||
} as Record<string, unknown> & { passage_index: number; exercise_types: string[] });
|
||||
},
|
||||
onSuccess: (res, vars) => {
|
||||
@@ -536,8 +601,18 @@ export default function GenerationPage() {
|
||||
});
|
||||
|
||||
const generateWritingMut = useMutation({
|
||||
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) =>
|
||||
generationService.generateWritingInstructions({ topic: params.topic, difficulty: params.difficulty, task_type: "letter" }),
|
||||
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) => {
|
||||
const st = getModuleState("writing");
|
||||
const task = st.writingTasks[params.taskIndex];
|
||||
return generationService.generateWritingInstructions({
|
||||
...buildPersonaContext("writing"),
|
||||
topic: params.topic,
|
||||
difficulty: params.difficulty,
|
||||
task_type: task?.type || "essay",
|
||||
category: task?.category || undefined,
|
||||
word_limit: task?.wordLimit,
|
||||
});
|
||||
},
|
||||
onSuccess: (res, vars) => {
|
||||
const st = getModuleState("writing");
|
||||
const tasks = [...st.writingTasks];
|
||||
@@ -551,8 +626,17 @@ export default function GenerationPage() {
|
||||
});
|
||||
|
||||
const generateSpeakingMut = useMutation({
|
||||
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
|
||||
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
|
||||
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) => {
|
||||
const st = getModuleState("speaking");
|
||||
const part = st.speakingParts[params.partIndex];
|
||||
return generationService.generateSpeakingScript({
|
||||
...buildPersonaContext("speaking"),
|
||||
topics: params.topics.filter(Boolean),
|
||||
difficulty: params.difficulty,
|
||||
part: part?.type || "speaking_1",
|
||||
category: part?.category || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: (res, vars) => {
|
||||
const r = res as Record<string, unknown>;
|
||||
if (r.error) {
|
||||
@@ -874,7 +958,14 @@ export default function GenerationPage() {
|
||||
}} />
|
||||
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
|
||||
onClick={() => {
|
||||
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
|
||||
generatePassageMut.mutate({
|
||||
index: pi,
|
||||
topic: passage.category || "",
|
||||
difficulty: st.difficulty[0] || "B2",
|
||||
wordCount: Number(passage.divider) || 300,
|
||||
category: passage.category || "",
|
||||
passageType: passage.type || "academic",
|
||||
});
|
||||
}}>
|
||||
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
||||
Generate
|
||||
@@ -1126,7 +1217,13 @@ export default function GenerationPage() {
|
||||
}} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1 text-xs" onClick={() => {
|
||||
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
|
||||
generationService.generateListeningContext({
|
||||
...buildPersonaContext("listening"),
|
||||
topic: sec.category || "general conversation",
|
||||
section_type: sec.type,
|
||||
category: sec.category || undefined,
|
||||
difficulty: st.difficulty[0] || "B1",
|
||||
})
|
||||
.then((res) => {
|
||||
const r = res as Record<string, unknown>;
|
||||
const sections = [...st.listeningSections];
|
||||
@@ -2680,9 +2777,11 @@ export default function GenerationPage() {
|
||||
const types = wizardEnabledTypes.map(([key]) => key);
|
||||
const typeCounts: Record<string, number> = {};
|
||||
const typeInstructions: Record<string, string> = {};
|
||||
const typeDifficulties: Record<string, string> = {};
|
||||
for (const [key, cfg] of wizardEnabledTypes) {
|
||||
typeCounts[key] = cfg.count;
|
||||
if (cfg.instructions.trim()) typeInstructions[key] = cfg.instructions.trim();
|
||||
if (cfg.difficulty) typeDifficulties[key] = cfg.difficulty;
|
||||
}
|
||||
const difficulty = wizardEnabledTypes[0]?.[1]?.difficulty || st.difficulty[0] || "B2";
|
||||
generateExercisesMut.mutate({
|
||||
@@ -2691,6 +2790,7 @@ export default function GenerationPage() {
|
||||
types,
|
||||
typeCounts,
|
||||
typeInstructions,
|
||||
typeDifficulties,
|
||||
passageText: text,
|
||||
difficulty,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user