Initial draft of level test report

This commit is contained in:
Joao Ramos
2024-08-20 23:40:54 +01:00
parent 3367384791
commit 65f8368708
4 changed files with 693 additions and 393 deletions

View File

@@ -0,0 +1,184 @@
/* eslint-disable jsx-a11y/alt-text */
import React from "react";
import { Document, Page, View, Text, Image } from "@react-pdf/renderer";
import { ModuleScore } from "@/interfaces/module.scores";
import { styles } from "./styles";
import { StyleSheet } from "@react-pdf/renderer";
const customStyles = StyleSheet.create({
testDetails: {
display: "flex",
gap: 4,
},
testDetailsContainer: {
display: "flex",
gap: 16,
},
table: {
width: "100%",
margin: "10px",
},
tableRow: {
flexDirection: "row",
},
tableCol50: {
width: "50%", // First column width (50%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCol25: {
width: "16.67%", // Remaining four columns each get 1/6 of the total width (50% / 3 = 16.67%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCol20: {
width: "20%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCellHeader: {
backgroundColor: "#d3d3d3",
fontWeight: "bold",
textAlign: "center",
},
tableCell: {
fontSize: 10,
textAlign: "center",
},
});
interface Props {
date: string;
name: string;
email: string;
id: string;
gender?: string;
passportId: string;
corporateName: string;
downloadDate: string;
userId: string;
uniqueExercises: { name: string; result: string }[];
timeSpent: string;
score: string;
}
const LevelTestReport = ({
date,
name,
email,
id,
gender,
passportId,
corporateName,
downloadDate,
userId,
uniqueExercises,
timeSpent,
score,
}: Props) => {
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
return (
<Document>
<Page style={styles.body}>
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Corporate Name: {corporateName}
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>
Report Download date: {downloadDate}
</Text>
</View>
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Test Information: {id}
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
<Text style={defaultTextStyle}>Candidates name: {name}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>National ID: {passportId}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Candidate ID: {userId}</Text>
</View>
<View style={customStyles.table}>
{/* Header Row */}
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol50}>
<Text style={customStyles.tableCellHeader}>Test sections</Text>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCellHeader}>Time spent</Text>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCellHeader}>Score</Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol50}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View style={customStyles.tableCol20} key={index}>
<Text style={customStyles.tableCell}>Part {index + 1}</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCell}></Text>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCell}></Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol50}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View style={customStyles.tableCol20} key={index}>
<Text style={customStyles.tableCell}>{exercise.name}</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCell}></Text>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCell}></Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol50}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View style={customStyles.tableCol20} key={index}>
<Text style={customStyles.tableCell}>
{exercise.result}
</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCell}>{timeSpent}</Text>
</View>
<View style={customStyles.tableCol25}>
<Text style={customStyles.tableCell}>{score}</Text>
</View>
</View>
</View>
</Page>
</Document>
);
};
export default LevelTestReport;

View File

@@ -2,7 +2,14 @@ import {Module} from ".";
import { InstructorGender, ShuffleMap } from "./exam"; import { InstructorGender, ShuffleMap } from "./exam";
import { PermissionType } from "./permissions"; import { PermissionType } from "./permissions";
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser | MasterCorporateUser; export type User =
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser
| MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
@@ -106,8 +113,15 @@ export interface DemographicCorporateInformation {
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other"; export type EmploymentStatus =
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [ | "employed"
| "student"
| "self-employed"
| "unemployed"
| "retired"
| "other";
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
[
{ status: "student", label: "Student" }, { status: "student", label: "Student" },
{ status: "employed", label: "Employed" }, { status: "employed", label: "Employed" },
{ status: "unemployed", label: "Unemployed" }, { status: "unemployed", label: "Unemployed" },
@@ -136,6 +150,10 @@ export interface Stat {
}; };
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[]; shuffleMaps?: ShuffleMap[];
pdf?: {
path: string;
version: string;
};
} }
export interface Group { export interface Group {
@@ -158,5 +176,20 @@ export interface Code {
passport_id?: string; passport_id?: string;
} }
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate"; export type Type =
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; | "student"
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent"
| "mastercorporate";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
"mastercorporate",
];

View File

@@ -1,20 +1,38 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase"; import { app, storage } from "@/firebase";
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore"; import {
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import ReactPDF from "@react-pdf/renderer"; import ReactPDF from "@react-pdf/renderer";
import TestReport from "@/exams/pdf/test.report"; import TestReport from "@/exams/pdf/test.report";
import LevelTestReport from "@/exams/pdf/level.test.report";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import {DemographicInformation, User} from "@/interfaces/user"; import {
DemographicInformation,
Stat,
StudentUser,
User,
} from "@/interfaces/user";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { ModuleScore } from "@/interfaces/module.scores"; import { ModuleScore } from "@/interfaces/module.scores";
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
import { calculateBandScore } from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import axios from "axios"; import axios from "axios";
import { moduleLabels } from "@/utils/moduleUtils"; import { moduleLabels } from "@/utils/moduleUtils";
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf"; import {
generateQRCode,
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import moment from "moment-timezone"; import moment from "moment-timezone";
const db = getFirestore(app); const db = getFirestore(app);
@@ -90,14 +108,16 @@ const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, },
}, }
); );
return backendRequest.data?.sections; return backendRequest.data?.sections;
}; };
// perform the request with several retries if needed // perform the request with several retries if needed
const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => { const handleSkillsFeedbackRequest = async (
sections: SkillsFeedbackRequest[]
): Promise<SkillsFeedbackResponse[] | null> => {
let i = 0; let i = 0;
try { try {
const data = await getSkillsFeedback(sections); const data = await getSkillsFeedback(sections);
@@ -112,59 +132,24 @@ const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): P
} }
}; };
async function post(req: NextApiRequest, res: NextApiResponse) { async function getDefaultPDFStream(
// verify if it's a logged user that is trying to export stats: Stat[],
if (req.session.user) { user: User,
const {id} = req.query as {id: string}; qrcodeUrl: string
// fetch stats entries for this particular user with the requested exam session ) {
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id))); const [stat] = stats;
if (docsSnap.empty) {
res.status(400).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data());
// verify if the stats already have a pdf generated
const hasPDF = stats.find((s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION);
// find the user that generated the stats
const statIndex = stats.findIndex((s) => s.user);
if(statIndex === -1) {
res.status(401).json({ok: false});
return;
}
const userId = stats[statIndex].user;
if (hasPDF) {
// if it does, return the pdf url
const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
try {
// generate the pdf report
const docUser = await getDoc(doc(db, "users", userId));
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User;
// generate the QR code for the report // generate the QR code for the report
const qrcode = await generateQRCode((req.headers.origin || "") + req.url); const qrcode = await generateQRCode(qrcodeUrl);
if (!qrcode) { if (!qrcode) {
res.status(500).json({ok: false}); throw new Error("Failed to generate QR code");
return;
} }
// stats may contain multiple exams of the same type so we need to aggregate them // stats may contain multiple exams of the same type so we need to aggregate them
const results = ( const results = stats
stats.reduce((accm: ModuleScore[], {module, score}) => { .reduce((accm: ModuleScore[], stat: Stat) => {
const { module, score } = stat;
const fixedModuleStr = module[0].toUpperCase() + module.substring(1); const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
return accm.map((e: ModuleScore) => { return accm.map((e: ModuleScore) => {
@@ -180,20 +165,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}); });
} }
return [ const value = {
...accm,
{
module: fixedModuleStr, module: fixedModuleStr,
score: score.correct, score: score.correct,
total: score.total, total: score.total,
code: module, code: module,
}, } as ModuleScore;
];
}, []) as ModuleScore[] return [...accm, value];
).map((moduleScore) => { }, [])
.map((moduleScore: ModuleScore) => {
const { score, total } = moduleScore; const { score, total } = moduleScore;
// with all the scores aggreated we can calculate the band score for each module // with all the scores aggreated we can calculate the band score for each module
const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus); const bandScore = calculateBandScore(
score,
total,
moduleScore.code as Module,
user.focus
);
return { return {
...moduleScore, ...moduleScore,
@@ -209,17 +198,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
code, code,
name: moduleLabels[code], name: moduleLabels[code],
grade: bandScore, grade: bandScore,
})), }))
)) as SkillsFeedbackResponse[]; )) as SkillsFeedbackResponse[];
if (!skillsFeedback) { if (!skillsFeedback) {
res.status(500).json({ok: false}); throw new Error("Failed to get skills feedback");
return;
} }
// assign the feedback to the results // assign the feedback to the results
const finalResults = results.map((result) => { const finalResults = results.map((result) => {
const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code); const feedback = skillsFeedback.find(
(f: SkillsFeedbackResponse) => f.code === result.code
);
if (feedback) { if (feedback) {
return { return {
@@ -238,7 +228,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const overallTotal = results.reduce((accm, { total }) => accm + total, 0); const overallTotal = results.reduce((accm, { total }) => accm + total, 0);
const overallResult = overallScore / overallTotal; const overallResult = overallScore / overallTotal;
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal); const overallPNG = getRadialProgressPNG(
"laranja",
overallScore,
overallTotal
);
// generate the overall detail report // generate the overall detail report
const overallDetail = { const overallDetail = {
@@ -249,30 +243,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
} as ModuleScore; } as ModuleScore;
const testDetails = [overallDetail, ...finalResults]; const testDetails = [overallDetail, ...finalResults];
const [stat] = stats;
// generate the performance summary based on the overall result // generate the performance summary based on the overall result
const performanceSummary = getPerformanceSummary(stat.module, overallResult); const performanceSummary = getPerformanceSummary(stat.module, overallResult);
// level exams have a different report structure than the skill exams const title = "ENGLISH SKILLS TEST RESULT REPORT";
const getCustomData = () => { const details = <SkillExamDetails testDetails={testDetails} />;
if (stat.module === "level") {
return {
title: "ENGLISH LEVEL TEST RESULT REPORT ",
details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
};
}
return { const demographicInformation =
title: "ENGLISH SKILLS TEST RESULT REPORT", user.demographicInformation as DemographicInformation;
details: <SkillExamDetails testDetails={testDetails} />, return ReactPDF.renderToStream(
};
};
const {title, details} = getCustomData();
const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream(
<TestReport <TestReport
title={title} title={title}
date={moment(stat.date) date={moment(stat.date)
@@ -280,7 +259,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
.format("ll HH:mm:ss")} .format("ll HH:mm:ss")}
name={user.name} name={user.name}
email={user.email} email={user.email}
id={userId} id={user.id}
gender={demographicInformation?.gender} gender={demographicInformation?.gender}
summary={performanceSummary} summary={performanceSummary}
testDetails={testDetails} testDetails={testDetails}
@@ -290,9 +269,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
summaryPNG={overallPNG} summaryPNG={overallPNG}
summaryScore={`${Math.floor(overallResult * 100)}%`} summaryScore={`${Math.floor(overallResult * 100)}%`}
passportId={demographicInformation?.passport_id || ""} passportId={demographicInformation?.passport_id || ""}
/>, />
); );
}
async function getPdfUrl(pdfStream: any, docsSnap: any) {
// generate the file ref for storage // generate the file ref for storage
const fileName = `${Date.now().toString()}.pdf`; const fileName = `${Date.now().toString()}.pdf`;
const refName = `exam_report/${fileName}`; const refName = `exam_report/${fileName}`;
@@ -305,7 +286,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}); });
// update the stats entries with the pdf url to prevent duplication // update the stats entries with the pdf url to prevent duplication
docsSnap.docs.forEach(async (doc) => { docsSnap.docs.forEach(async (doc: any) => {
await updateDoc(doc.ref, { await updateDoc(doc.ref, {
pdf: { pdf: {
path: refName, path: refName,
@@ -313,14 +294,114 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}, },
}); });
}); });
const url = await getDownloadURL(fileRef); return getDownloadURL(fileRef);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export
if (req.session.user) {
const { id } = req.query as { id: string };
// fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs(
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) {
res.status(400).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data()) as Stat[];
// verify if the stats already have a pdf generated
const hasPDF = stats.find(
(s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION
);
// find the user that generated the stats
const statIndex = stats.findIndex((s) => s.user);
if (statIndex === -1) {
res.status(401).json({ ok: false });
return;
}
const userId = stats[statIndex].user;
// if (hasPDF) {
// // if it does, return the pdf url
// const fileRef = ref(storage, hasPDF.pdf!.path);
// const url = await getDownloadURL(fileRef);
// res.status(200).end(url);
// return;
// }
try {
// generate the pdf report
const docUser = await getDoc(doc(db, "users", userId));
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const [stat] = stats;
if (stat.module === "level") {
const user = docUser.data() as StudentUser;
const uniqueExercises = stats.map((s) => ({
name: "Gramar & Vocabulary",
result: `${s.score.correct}/${s.score.total}`,
}));
const dates = stats.map((s) => moment(s.date));
const timeSpent = `${
stats.reduce((accm, s: Stat) => accm + (s.timeSpent || 0), 0) / 60
} minutes`;
const score = stats.reduce((accm, s) => accm + s.score.correct, 0);
const pdfStream = await ReactPDF.renderToStream(
<LevelTestReport
date={moment.max(dates).format("DD/MM/YYYY")}
name={user.name}
email={user.email}
id={stat.exam}
gender={user.demographicInformation?.gender || ""}
passportId={user.demographicInformation?.passport_id || ""}
corporateName="TODO"
downloadDate={moment().format("DD/MM/YYYY")}
userId={userId}
uniqueExercises={uniqueExercises}
timeSpent={timeSpent}
score={score.toString()}
/>
);
const url = await getPdfUrl(pdfStream, docsSnap);
res.status(200).end(url); res.status(200).end(url);
return; return;
return;
}
const user = docUser.data() as User;
try {
const pdfStream = await getDefaultPDFStream(
stats,
user,
`${req.headers.origin || ""}${req.url}`
);
const url = await getPdfUrl(pdfStream, docsSnap);
res.status(200).end(url);
return;
} catch (err) {
console.error(err);
res.status(500).json({ ok: false });
return;
}
} }
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} catch (err) { } catch (err) {
console.error(err);
res.status(500).json({ ok: false }); res.status(500).json({ ok: false });
return; return;
} }
@@ -332,7 +413,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id))); const docsSnap = await getDocs(
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) { if (docsSnap.empty) {
res.status(404).end(); res.status(404).end();

View File

@@ -67,7 +67,7 @@ export default function History({user}: {user: User}) {
const {assignments} = useAssignments({}); const {assignments} = useAssignments({});
const {users} = useUsers(); const {users} = useUsers();
const {stats, isLoading: isStatsLoading} = useStats(statsUserId); const {stats, isLoading: isStatsLoading} = useStats(statsUserId || user.id);
const {groups: allGroups} = useGroups({}); const {groups: allGroups} = useGroups({});
const groups = allGroups.filter((x) => x.admin === user.id); const groups = allGroups.filter((x) => x.admin === user.id);