Initial draft of level test report
This commit is contained in:
184
src/exams/pdf/level.test.report.tsx
Normal file
184
src/exams/pdf/level.test.report.tsx
Normal 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;
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import {Module} from ".";
|
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 {
|
||||||
@@ -12,8 +19,8 @@ export interface BasicUser {
|
|||||||
id: string;
|
id: string;
|
||||||
isFirstLogin: boolean;
|
isFirstLogin: boolean;
|
||||||
focus: "academic" | "general";
|
focus: "academic" | "general";
|
||||||
levels: {[key in Module]: number};
|
levels: { [key in Module]: number };
|
||||||
desiredLevels: {[key in Module]: number};
|
desiredLevels: { [key in Module]: number };
|
||||||
type: Type;
|
type: Type;
|
||||||
bio: string;
|
bio: string;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
@@ -106,15 +113,22 @@ 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"
|
||||||
{status: "student", label: "Student"},
|
| "student"
|
||||||
{status: "employed", label: "Employed"},
|
| "self-employed"
|
||||||
{status: "unemployed", label: "Unemployed"},
|
| "unemployed"
|
||||||
{status: "self-employed", label: "Self-employed"},
|
| "retired"
|
||||||
{status: "retired", label: "Retired"},
|
| "other";
|
||||||
{status: "other", label: "Other"},
|
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
|
||||||
];
|
[
|
||||||
|
{ status: "student", label: "Student" },
|
||||||
|
{ status: "employed", label: "Employed" },
|
||||||
|
{ status: "unemployed", label: "Unemployed" },
|
||||||
|
{ status: "self-employed", label: "Self-employed" },
|
||||||
|
{ status: "retired", label: "Retired" },
|
||||||
|
{ status: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
export interface Stat {
|
export interface Stat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -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",
|
||||||
|
];
|
||||||
|
|||||||
@@ -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 {
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
getFirestore,
|
||||||
import {sessionOptions} from "@/lib/session";
|
doc,
|
||||||
|
getDoc,
|
||||||
|
updateDoc,
|
||||||
|
getDocs,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
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 {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
import LevelTestReport from "@/exams/pdf/level.test.report";
|
||||||
import {DemographicInformation, User} from "@/interfaces/user";
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
import {Module} from "@/interfaces";
|
import {
|
||||||
import {ModuleScore} from "@/interfaces/module.scores";
|
DemographicInformation,
|
||||||
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
Stat,
|
||||||
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
StudentUser,
|
||||||
import {calculateBandScore} from "@/utils/score";
|
User,
|
||||||
|
} from "@/interfaces/user";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { ModuleScore } from "@/interfaces/module.scores";
|
||||||
|
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||||
|
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);
|
||||||
@@ -85,19 +103,21 @@ interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
|
|||||||
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
||||||
const backendRequest = await axios.post(
|
const backendRequest = await axios.post(
|
||||||
`${process.env.BACKEND_URL}/grading_summary`,
|
`${process.env.BACKEND_URL}/grading_summary`,
|
||||||
{sections},
|
{ sections },
|
||||||
{
|
{
|
||||||
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) => {
|
}, [])
|
||||||
const {score, total} = moduleScore;
|
.map((moduleScore: 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,
|
||||||
@@ -205,21 +194,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
// get the skills feedback from the backend based on the module grade
|
// get the skills feedback from the backend based on the module grade
|
||||||
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
||||||
results.map(({code, bandScore}) => ({
|
results.map(({ code, bandScore }) => ({
|
||||||
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 {
|
||||||
@@ -234,11 +224,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// calculate the overall score out of all the aggregated results
|
// calculate the overall score out of all the aggregated results
|
||||||
const overallScore = results.reduce((accm, {score}) => accm + score, 0);
|
const overallScore = results.reduce((accm, { score }) => accm + score, 0);
|
||||||
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,26 +294,128 @@ 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;
|
||||||
}
|
|
||||||
|
|
||||||
res.status(401).json({ok: false});
|
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;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ok: false});
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user