Browse Source

feat(tests): общие тесты с автором, в истории только результаты ученика (для всех ролей)

Made-with: Cursor
master
Константин Лебединский 2 weeks ago
parent
commit
35f27a6eb7
  1. 26
      backend/prisma/migrations/20260402180000_tests_shared_author_student_results/migration.sql
  2. 13
      backend/prisma/schema.prisma
  3. 47
      backend/src/routes/tests.ts
  4. 12
      frontend/src/pages/TestPage.tsx

26
backend/prisma/migrations/20260402180000_tests_shared_author_student_results/migration.sql

@ -0,0 +1,26 @@
-- Общие тесты в контексте пары: studentId = id назначенного ученика; authorId = кто создал.
-- Результаты (TestResult) привязаны к ученику (studentId).
ALTER TABLE "Test" ADD COLUMN "authorId" INTEGER;
UPDATE "Test" SET "authorId" = "studentId";
UPDATE "Test" AS t
SET "studentId" = ta."studentId"
FROM "TutorAssignment" AS ta
WHERE t."authorId" = ta."tutorId";
ALTER TABLE "Test" ALTER COLUMN "authorId" SET NOT NULL;
ALTER TABLE "Test" ADD CONSTRAINT "Test_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TestResult" ADD COLUMN "studentId" INTEGER;
UPDATE "TestResult" AS tr
SET "studentId" = t."studentId"
FROM "Test" AS t
WHERE tr."testId" = t."id";
ALTER TABLE "TestResult" ALTER COLUMN "studentId" SET NOT NULL;
ALTER TABLE "TestResult" ADD CONSTRAINT "TestResult_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

13
backend/prisma/schema.prisma

@ -26,7 +26,9 @@ model User {
questions Question[] questions Question[]
chatMessages ChatMessage[] chatMessages ChatMessage[]
textbooks Textbook[] textbooks Textbook[]
tests Test[] testsInScope Test[] @relation("TestStudentScope")
testsAuthored Test[] @relation("TestAuthor")
testResults TestResult[]
reports Report[] reports Report[]
hallPhotos HallPhoto[] hallPhotos HallPhoto[]
} }
@ -75,8 +77,11 @@ model Textbook {
model Test { model Test {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
/// Контекст пары наставник–ученик (id назначенного ученика); общие тесты для обоих
studentId Int studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade) student User @relation("TestStudentScope", fields: [studentId], references: [id], onDelete: Cascade)
authorId Int
author User @relation("TestAuthor", fields: [authorId], references: [id], onDelete: Cascade)
topic String topic String
questions String questions String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -86,11 +91,13 @@ model Test {
model TestResult { model TestResult {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
testId Int testId Int
test Test @relation(fields: [testId], references: [id], onDelete: Cascade)
studentId Int
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
answers String answers String
score Int score Int
total Int total Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
test Test @relation(fields: [testId], references: [id], onDelete: Cascade)
} }
model Report { model Report {

47
backend/src/routes/tests.ts

@ -17,27 +17,41 @@ const DEFAULT_TEST_PROMPT = `Ты — составитель тестов. Сг
ВАЖНО: Верни ТОЛЬКО JSON-массив, без markdown-обёрток (\`\`\`json), без пояснений, ЧИСТЫЙ JSON: ВАЖНО: Верни ТОЛЬКО JSON-массив, без markdown-обёрток (\`\`\`json), без пояснений, ЧИСТЫЙ JSON:
[{"question":"текст вопроса","options":{"a":"вариант А","b":"вариант Б","c":"вариант В","d":"вариант Г"},"correct":"a"}]`; [{"question":"текст вопроса","options":{"a":"вариант А","b":"вариант Б","c":"вариант В","d":"вариант Г"},"correct":"a"}]`;
/** Тесты привязаны к текущему пользователю (ученик — свои; наставник — свои черновые, не к ученику). */ /** Контекст пары: id назначенного ученика (общие тесты для наставника и ученика). */
function testOwnerId(req: AuthRequest): number { function pairStudentScopeId(req: AuthRequest): number {
return req.user!.id; return req.studentId!;
} }
const authorSelect = { username: true, displayName: true } as const;
router.get("/", async (req: AuthRequest, res: Response) => { router.get("/", async (req: AuthRequest, res: Response) => {
const ownerId = testOwnerId(req); const scopeId = pairStudentScopeId(req);
const tests = await prisma.test.findMany({ const tests = await prisma.test.findMany({
where: { studentId: ownerId }, where: { studentId: scopeId },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { results: true }, include: {
author: { select: authorSelect },
results: {
where: { studentId: scopeId },
orderBy: { createdAt: "asc" },
},
},
}); });
res.json(tests); res.json(tests);
}); });
router.get("/:id", async (req: AuthRequest, res: Response) => { router.get("/:id", async (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10); const id = parseInt(req.params.id as string, 10);
const ownerId = testOwnerId(req); const scopeId = pairStudentScopeId(req);
const test = await prisma.test.findFirst({ const test = await prisma.test.findFirst({
where: { id, studentId: ownerId }, where: { id, studentId: scopeId },
include: { results: true }, include: {
author: { select: authorSelect },
results: {
where: { studentId: scopeId },
orderBy: { createdAt: "asc" },
},
},
}); });
if (!test) { if (!test) {
@ -50,8 +64,7 @@ router.get("/:id", async (req: AuthRequest, res: Response) => {
router.post("/generate", async (req: AuthRequest, res: Response) => { router.post("/generate", async (req: AuthRequest, res: Response) => {
const { topic, fromQuestions } = req.body; const { topic, fromQuestions } = req.body;
const ownerId = testOwnerId(req); const scopeId = pairStudentScopeId(req);
/** Вопросы «по моим вопросам» берутся из базы назначенного ученика (для наставника — его ученик). */
const questionBankStudentId = req.studentId!; const questionBankStudentId = req.studentId!;
const client = await getDeepSeekClient(); const client = await getDeepSeekClient();
@ -92,7 +105,12 @@ router.post("/generate", async (req: AuthRequest, res: Response) => {
data: { data: {
topic: topic || "По прошлым вопросам", topic: topic || "По прошлым вопросам",
questions: questionsJson, questions: questionsJson,
studentId: ownerId, studentId: scopeId,
authorId: req.user!.id,
},
include: {
author: { select: authorSelect },
results: true,
}, },
}); });
@ -104,10 +122,10 @@ router.post("/generate", async (req: AuthRequest, res: Response) => {
router.post("/:id/submit", async (req: AuthRequest, res: Response) => { router.post("/:id/submit", async (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10); const id = parseInt(req.params.id as string, 10);
const ownerId = testOwnerId(req); const scopeId = pairStudentScopeId(req);
const { answers } = req.body as { answers: Record<string, string> }; const { answers } = req.body as { answers: Record<string, string> };
const test = await prisma.test.findFirst({ where: { id, studentId: ownerId } }); const test = await prisma.test.findFirst({ where: { id, studentId: scopeId } });
if (!test) { if (!test) {
res.status(404).json({ error: "Test not found" }); res.status(404).json({ error: "Test not found" });
return; return;
@ -131,6 +149,7 @@ router.post("/:id/submit", async (req: AuthRequest, res: Response) => {
const result = await prisma.testResult.create({ const result = await prisma.testResult.create({
data: { data: {
testId: id, testId: id,
studentId: req.user!.id,
answers: JSON.stringify(answers), answers: JSON.stringify(answers),
score, score,
total, total,

12
frontend/src/pages/TestPage.tsx

@ -17,6 +17,7 @@ interface Test {
questions: string; questions: string;
createdAt: string; createdAt: string;
results: TestResult[]; results: TestResult[];
author?: { username: string; displayName: string | null };
} }
interface TestResult { interface TestResult {
@ -248,7 +249,9 @@ export default function TestPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-xl font-semibold tracking-tight">Тестирование</h1> <h1 className="text-xl font-semibold tracking-tight">Тестирование</h1>
<p className="text-sm text-muted-foreground mt-1">Проверьте знания 10 вопросов с вариантами ответов</p> <p className="text-sm text-muted-foreground mt-1">
Проверьте знания 10 вопросов с вариантами ответов. Тесты общие для пары; в истории только попытки ученика (видны всем).
</p>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@ -298,8 +301,13 @@ export default function TestPage() {
</h2> </h2>
{tests.map((t) => ( {tests.map((t) => (
<div key={t.id} className="flex items-center justify-between p-3.5 rounded-xl border text-sm"> <div key={t.id} className="flex items-center justify-between p-3.5 rounded-xl border text-sm">
<div> <div className="min-w-0">
<span className="font-medium">{t.topic}</span> <span className="font-medium">{t.topic}</span>
{t.author && (
<span className="text-xs text-muted-foreground ml-2">
· автор: {t.author.displayName?.trim() || t.author.username}
</span>
)}
<span className="text-xs text-muted-foreground ml-2"> <span className="text-xs text-muted-foreground ml-2">
{new Date(t.createdAt).toLocaleDateString("ru")} {new Date(t.createdAt).toLocaleDateString("ru")}
</span> </span>

Loading…
Cancel
Save