Merged in bug-fixing-16-jan-24 (pull request #26)

Report PDF improvements / bugs

Approved-by: Tiago Ribeiro
This commit is contained in:
João Ramos
2024-01-16 23:20:00 +00:00
committed by Tiago Ribeiro
6 changed files with 368 additions and 353 deletions

View File

@@ -33,6 +33,7 @@ interface Props {
summaryPNG: string; summaryPNG: string;
summaryScore: string; summaryScore: string;
groupScoreSummary: any[]; groupScoreSummary: any[];
passportId: string;
} }
const customStyles = StyleSheet.create({ const customStyles = StyleSheet.create({
@@ -81,6 +82,7 @@ const GroupTestReport = ({
summaryPNG, summaryPNG,
summaryScore, summaryScore,
groupScoreSummary, groupScoreSummary,
passportId,
}: Props) => { }: Props) => {
const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
return ( return (
@@ -114,6 +116,7 @@ const GroupTestReport = ({
<Text style={defaultTextStyle}>ID: {id}</Text> <Text style={defaultTextStyle}>ID: {id}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text> <Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text> <Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
<Text style={defaultTextStyle}> <Text style={defaultTextStyle}>
Total Number of Students: {numberOfStudents} Total Number of Students: {numberOfStudents}
</Text> </Text>
@@ -203,7 +206,7 @@ const GroupTestReport = ({
percentage={percent} percentage={percent}
/> />
</View> </View>
<Text style={[customStyles.tableCell, { maxWidth: "24px" }]}> <Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
{percent}% {percent}%
</Text> </Text>
<Text style={customStyles.tableCell}>{description}</Text> <Text style={customStyles.tableCell}>{description}</Text>

View File

@@ -28,6 +28,9 @@ export const styles = StyleSheet.create({
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
fontWeight: "bold", fontWeight: "bold",
}, },
textNormal: {
fontWeight: "normal",
},
textColor: { textColor: {
color: "#4e4969", color: "#4e4969",
}, },

View File

@@ -18,18 +18,18 @@ const TestReportFooter = () => (
> >
<View style={[styles.spacedRow, styles.textMargin]}> <View style={[styles.spacedRow, styles.textMargin]}>
<View> <View>
<Text>Validity</Text> <Text style={styles.textBold}>Validity</Text>
<Text> <Text>
This report remains valid for a duration of three months from the test This report remains valid for a duration of three months from the test
date. date.
</Text> </Text>
</View> </View>
<View> <View>
<Text>Confidential circulated for concern people</Text> <Text style={styles.textBold}>Confidential <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
</View> </View>
</View> </View>
<View style={{ paddingTop: 10 }}> <View style={{ paddingTop: 10 }}>
<Text>Declaration</Text> <Text style={styles.textBold}>Declaration</Text>
<Text style={{ paddingTop: 5 }}> <Text style={{ paddingTop: 5 }}>
We hereby declare that exam results on our platform, assessed by AI, are We hereby declare that exam results on our platform, assessed by AI, are
not the sole determinants of candidates&apos; English proficiency not the sole determinants of candidates&apos; English proficiency

View File

@@ -27,6 +27,7 @@ interface Props {
title: string; title: string;
summaryPNG: string; summaryPNG: string;
summaryScore: string; summaryScore: string;
passportId: string;
} }
const TestReport = ({ const TestReport = ({
@@ -43,6 +44,7 @@ const TestReport = ({
renderDetails, renderDetails,
summaryPNG, summaryPNG,
summaryScore, summaryScore,
passportId,
}: Props) => { }: Props) => {
const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }];
@@ -83,6 +85,7 @@ const TestReport = ({
<Text style={defaultTextStyle}>ID: {id}</Text> <Text style={defaultTextStyle}>ID: {id}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text> <Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text> <Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
</View> </View>
<View style={{ height: "120px" }}> <View style={{ height: "120px" }}>
<Text <Text

View File

@@ -1,33 +1,20 @@
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase"; import {app, storage} from "@/firebase";
import { import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where, documentId} from "firebase/firestore";
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
documentId,
} 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 GroupTestReport from "@/exams/pdf/group.test.report"; import GroupTestReport from "@/exams/pdf/group.test.report";
import {ref, uploadBytes, getDownloadURL} from "firebase/storage"; import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
import { Stat } from "@/interfaces/user"; import {Stat, CorporateUser} from "@/interfaces/user";
import { User } from "@/interfaces/user"; import {User, DemographicInformation} from "@/interfaces/user";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {ModuleScore, StudentData} from "@/interfaces/module.scores"; import {ModuleScore, StudentData} 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 {LevelExamDetails} from "@/exams/pdf/details/level.exam";
import {calculateBandScore, getLevelScore} from "@/utils/score"; import {calculateBandScore, getLevelScore} from "@/utils/score";
import { import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
generateQRCode, import {Group} from "@/interfaces/user";
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import moment from "moment-timezone"; import moment from "moment-timezone";
interface GroupScoreSummaryHelper { interface GroupScoreSummaryHelper {
@@ -98,7 +85,7 @@ const getScoreAndTotal = (stats: Stat[]) => {
total: acc.total + score.total, total: acc.total + score.total,
}; };
}, },
{ correct: 0, total: 0 } {correct: 0, total: 0},
); );
}; };
@@ -146,29 +133,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const user = docUser.data() as User; const user = docUser.data() as User;
// generate the QR code for the report // generate the QR code for the report
const qrcode = await generateQRCode( const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
(req.headers.origin || "") + req.url
);
if (!qrcode) { if (!qrcode) {
res.status(500).json({ok: false}); res.status(500).json({ok: false});
return; return;
} }
const flattenResults = data.results.reduce( const flattenResults = data.results.reduce((accm: Stat[], entry: any) => {
(accm: Stat[], entry: any) => {
const stats = entry.stats as Stat[]; const stats = entry.stats as Stat[];
return [...accm, ...stats]; return [...accm, ...stats];
}, }, []) as Stat[];
[]
) as Stat[];
const docsSnap = await getDocs( const docsSnap = await getDocs(query(collection(db, "users"), where(documentId(), "in", data.assignees)));
query(
collection(db, "users"),
where(documentId(), "in", data.assignees)
)
);
const users = docsSnap.docs.map((d) => ({ const users = docsSnap.docs.map((d) => ({
...d.data(), ...d.data(),
id: d.id, id: d.id,
@@ -176,24 +153,15 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const flattenResultsWithGrade = flattenResults.map((e) => { const flattenResultsWithGrade = flattenResults.map((e) => {
const focus = users.find((u) => u.id === e.user)?.focus || "academic"; const focus = users.find((u) => u.id === e.user)?.focus || "academic";
const bandScore = calculateBandScore( const bandScore = calculateBandScore(e.score.correct, e.score.total, e.module, focus);
e.score.correct,
e.score.total,
e.module,
focus
);
return {...e, bandScore}; return {...e, bandScore};
}); });
const moduleResults = data.exams.map(({module}) => { const moduleResults = data.exams.map(({module}) => {
const moduleResults = flattenResultsWithGrade.filter( const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
(e) => e.module === module
);
const baseBandScore = const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) /
moduleResults.length;
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore; const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
const {correct, total} = getScoreAndTotal(moduleResults); const {correct, total} = getScoreAndTotal(moduleResults);
const png = getRadialProgressPNG("azul", correct, total); const png = getRadialProgressPNG("azul", correct, total);
@@ -208,16 +176,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}; };
}) as ModuleScore[]; }) as ModuleScore[];
const { correct: overallCorrect, total: overallTotal } = const {correct: overallCorrect, total: overallTotal} = getScoreAndTotal(flattenResults);
getScoreAndTotal(flattenResults);
const baseOverallResult = overallCorrect / overallTotal; const baseOverallResult = overallCorrect / overallTotal;
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult; const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
const overallPNG = getRadialProgressPNG( const overallPNG = getRadialProgressPNG("laranja", overallCorrect, overallTotal);
"laranja",
overallCorrect,
overallTotal
);
// generate the overall detail report // generate the overall detail report
const overallDetail = { const overallDetail = {
module: "Overall", module: "Overall",
@@ -234,7 +197,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
// or X modules, either way // or X modules, either way
// as long as I verify the first entry I should be fine // as long as I verify the first entry I should be fine
baseStat.module, baseStat.module,
overallResult overallResult,
); );
const showLevel = baseStat.module === "level"; const showLevel = baseStat.module === "level";
@@ -244,12 +207,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (showLevel) { if (showLevel) {
return { return {
title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ", title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ",
details: ( details: <LevelExamDetails detail={overallDetail} title="Group Average CEFR" />,
<LevelExamDetails
detail={overallDetail}
title="Group Average CEFR"
/>
),
}; };
} }
@@ -276,11 +234,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
day: "numeric", day: "numeric",
}); });
const bandScore = const bandScore = exams.length === 0 ? 0 : exams.reduce((accm, curr) => accm + curr.bandScore, 0) / exams.length;
exams.length === 0
? 0
: exams.reduce((accm, curr) => accm + curr.bandScore, 0) /
exams.length;
const {correct, total} = getScoreAndTotal(exams); const {correct, total} = getScoreAndTotal(exams);
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`; const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
@@ -292,9 +246,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
gender: user?.demographicInformation?.gender || "N/A", gender: user?.demographicInformation?.gender || "N/A",
date, date,
result, result,
level: showLevel level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
? getLevelScoreForUserExams(bandScore)
: undefined,
bandScore, bandScore,
}; };
}); });
@@ -303,8 +255,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const studentsData = await getStudentsData(); const studentsData = await getStudentsData();
const getGroupScoreSummary = () => { const getGroupScoreSummary = () => {
const resultHelper = studentsData.reduce( const resultHelper = studentsData.reduce((accm: GroupScoreSummaryHelper[], curr) => {
(accm: GroupScoreSummaryHelper[], curr) => {
const {bandScore, id} = curr; const {bandScore, id} = curr;
const flooredScore = Math.floor(bandScore); const flooredScore = Math.floor(bandScore);
@@ -331,9 +282,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
sessions: [id], sessions: [id],
}, },
]; ];
}, }, []) as GroupScoreSummaryHelper[];
[]
) as GroupScoreSummaryHelper[];
const result = resultHelper.map(({score, label, sessions}) => { const result = resultHelper.map(({score, label, sessions}) => {
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label; const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
@@ -346,28 +295,83 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return result; return result;
}; };
const groupScoreSummary = getGroupScoreSummary(); const getInstitution = async () => {
try {
// due to database inconsistencies, I'll be overprotective here
const assignerUserSnap = await getDoc(doc(db, "users", data.assigner));
if (assignerUserSnap.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const assignerUser = assignerUserSnap.data() as User;
if (assignerUser.type === "teacher") {
// also search for groups where this user belongs
const queryGroups = query(collection(db, "groups"), where("participants", "array-contains", assignerUser.id));
const groupSnapshot = await getDocs(queryGroups);
const groups = groupSnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
if (groups.length > 0) {
const adminQuery = query(
collection(db, "users"),
where(
documentId(),
"in",
groups.map((g) => g.admin),
),
);
const adminUsersSnap = await getDocs(adminQuery);
const admins = adminUsersSnap.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as CorporateUser[];
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
if (adminData) {
return adminData.corporateInformation.companyInformation.name;
}
}
}
if (assignerUser.type === "corporate" && assignerUser.corporateInformation?.companyInformation?.name) {
return assignerUser.corporateInformation.companyInformation.name;
}
}
} catch (err) {
console.error(err);
}
return "";
};
const institution = await getInstitution();
const groupScoreSummary = getGroupScoreSummary();
const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream( const pdfStream = await ReactPDF.renderToStream(
<GroupTestReport <GroupTestReport
title={title} title={title}
date={moment(data.startDate).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')} date={moment(data.startDate)
.tz(user.demographicInformation?.timezone || "UTC")
.format("ll HH:mm:ss")}
name={user.name} name={user.name}
email={user.email} email={user.email}
id={user.id} id={user.id}
gender={user.demographicInformation?.gender} gender={demographicInformation?.gender}
summary={performanceSummary} summary={performanceSummary}
renderDetails={details} renderDetails={details}
logo={"public/logo_title.png"} logo={"public/logo_title.png"}
qrcode={qrcode} qrcode={qrcode}
numberOfStudents={numberOfStudents} numberOfStudents={numberOfStudents}
institution="TODO: PLACEHOLDER" institution={institution}
studentsData={studentsData} studentsData={studentsData}
showLevel={showLevel} showLevel={showLevel}
summaryPNG={overallPNG} summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`} summaryScore={`${(overallResult * 100).toFixed(0)}%`}
groupScoreSummary={groupScoreSummary} groupScoreSummary={groupScoreSummary}
/> passportId={demographicInformation?.passport_id || ""}
/>,
); );
// generate the file ref for storage // generate the file ref for storage

View File

@@ -15,7 +15,7 @@ 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 { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import { User } from "@/interfaces/user"; import { DemographicInformation, 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";
@@ -305,6 +305,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const { title, details } = getCustomData(); const { title, details } = getCustomData();
const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream( const pdfStream = await ReactPDF.renderToStream(
<TestReport <TestReport
title={title} title={title}
@@ -312,7 +313,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
name={user.name} name={user.name}
email={user.email} email={user.email}
id={user.id} id={user.id}
gender={user.demographicInformation?.gender} gender={demographicInformation?.gender}
summary={performanceSummary} summary={performanceSummary}
testDetails={testDetails} testDetails={testDetails}
renderDetails={details} renderDetails={details}
@@ -320,6 +321,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
qrcode={qrcode} qrcode={qrcode}
summaryPNG={overallPNG} summaryPNG={overallPNG}
summaryScore={`${(overallResult * 100).toFixed(0)}%`} summaryScore={`${(overallResult * 100).toFixed(0)}%`}
passportId={demographicInformation?.passport_id || ""}
/> />
); );