Merge branch 'develop'

This commit is contained in:
Tiago Ribeiro
2024-01-26 16:33:55 +00:00
11 changed files with 121 additions and 69 deletions

View File

@@ -132,7 +132,14 @@ export default function Writing({
<Button <Button
color="purple" color="purple"
disabled={!isSubmitEnabled} disabled={!isSubmitEnabled}
onClick={() => onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})} onClick={() =>
onNext({
exercise: id,
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -27,7 +27,7 @@ export default function InteractiveSpeaking({
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0); const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0) { if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then( Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
(values) => { (values) => {
setSolutionsURL( setSolutionsURL(
@@ -239,7 +239,11 @@ export default function InteractiveSpeaking({
onNext({ onNext({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type, type,
}) })
} }

View File

@@ -19,7 +19,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0) { if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -29,10 +29,6 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
} }
}, [userSolutions]); }, [userSolutions]);
useEffect(() => {
console.log(userSolutions);
}, [userSolutions]);
return ( return (
<> <>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}> <Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
@@ -205,7 +201,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
onNext({ onNext({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type, type,
}) })
} }

View File

@@ -76,7 +76,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div> </div>
</> </>
)} )}
{showDiff && ( {showDiff && userSolutions[0].evaluation && (
<> <>
<span>Correction:</span> <span>Correction:</span>
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl"> <div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
@@ -191,7 +191,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
onNext({ onNext({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type, type,
}) })
} }

View File

@@ -19,6 +19,7 @@ import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {Variant} from "@/interfaces/exam";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
@@ -38,6 +39,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const [endDate, setEndDate] = useState<Date | null>( const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
); );
const [variant, setVariant] = useState<Variant>("full");
// creates a new exam for each assignee or just one exam for all assignees // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
@@ -60,6 +62,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
endDate, endDate,
selectedModules, selectedModules,
generateMultiple, generateMultiple,
variant,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -279,7 +282,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
))} ))}
</div> </div>
</section> </section>
<div className="flex gap-4 w-full justify-end"> <div className="flex flex-col gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
Full length exams
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}> <Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
Generate different exams Generate different exams
</Checkbox> </Checkbox>

View File

@@ -1,7 +1,7 @@
import {Module} from "."; import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "full" | "diagnostic" | "partial"; export type Variant = "full" | "partial";
export interface ReadingExam { export interface ReadingExam {
parts: ReadingPart[]; parts: ReadingPart[];

View File

@@ -58,15 +58,16 @@ export default function BatchCodeGenerator({user}: {user: User}) {
if (filesContent.length > 0) { if (filesContent.length > 0) {
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile(file.content).then((rows) => { readXlsxFile(file.content).then((rows) => {
try {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [firstName, lastName, country, passport_id, email, phone] = row as string[]; const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) return EMAIL_REGEX.test(email.toString().trim()) && !users.map((u) => u.email).includes(email.toString().trim())
? { ? {
email: email.toString(), email: email.toString().trim(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id.toString(), passport_id: passport_id?.toString().trim() || undefined,
} }
: undefined; : undefined;
}) })
@@ -82,21 +83,34 @@ export default function BatchCodeGenerator({user}: {user: User}) {
} }
setInfos(information); setInfos(information);
} catch {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
const generateCode = (type: Type) => { const generateCode = (type: Type) => {
if (!confirm(`You are about to generate ${infos.length} codes, are you sure you want to continue?`)) return;
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const codes = infos.map(() => uid.randomUUID(6)); const codes = infos.map(() => uid.randomUUID(6));
setIsLoading(true); setIsLoading(true);
axios axios
.post("/api/code", {type, codes, infos: infos, expiryDate}) .post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {type, codes, infos: infos, expiryDate})
.then(({data, status}) => { .then(({data, status}) => {
if (data.ok) { if (data.ok) {
toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"}); toast.success(
`Successfully generated${data.valid ? ` ${data.valid}/${infos.length}` : ""} ${capitalize(
type,
)} codes and they have been notified by e-mail!`,
{toastId: "success"},
);
return; return;
} }
@@ -112,7 +126,10 @@ export default function BatchCodeGenerator({user}: {user: User}) {
toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
}) })
.finally(() => setIsLoading(false)); .finally(() => {
setIsLoading(false);
return clear();
});
}; };
return ( return (

View File

@@ -7,7 +7,7 @@ import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be"; import {getExams} from "@/utils/exams.be";
import {Exam} from "@/interfaces/exam"; import {Exam, Variant} from "@/interfaces/exam";
import {capitalize, flatten} from "lodash"; import {capitalize, flatten} from "lodash";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import moment from "moment"; import moment from "moment";
@@ -52,13 +52,18 @@ function getRandomIndex(arr: any[]): number {
return randomIndex; return randomIndex;
} }
const generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): Promise<ExamWithUser[]> => { const generateExams = async (
generateMultiple: Boolean,
selectedModules: Module[],
assignees: string[],
variant?: Variant,
): Promise<ExamWithUser[]> => {
if (generateMultiple) { if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once // for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = await assignees.map(async (assignee) => { const allExams = await assignees.map(async (assignee) => {
const selectedModulePromises = await selectedModules.map(async (module: Module) => { const selectedModulePromises = await selectedModules.map(async (module: Module) => {
try { try {
const exams: Exam[] = await getExams(db, module, "true", assignee); const exams: Exam[] = await getExams(db, module, "true", assignee, variant);
const exam = exams[getRandomIndex(exams)]; const exam = exams[getRandomIndex(exams)];
if (exam) { if (exam) {
@@ -101,6 +106,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// Generate multiple true would generate an unique exam for each user // Generate multiple true would generate an unique exam for each user
// false would generate the same exam for all users // false would generate the same exam for all users
generateMultiple = false, generateMultiple = false,
variant,
...body ...body
} = req.body as { } = req.body as {
selectedModules: Module[]; selectedModules: Module[];
@@ -109,9 +115,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
name: string; name: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
variant?: Variant;
}; };
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees); const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant);
if (exams.length === 0) { if (exams.length === 0) {
res.status(400).json({ok: false, error: "No exams found for the selected modules"}); res.status(400).json({ok: false, error: "No exams found for the selected modules"});

View File

@@ -42,7 +42,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const {type, codes, infos, expiryDate} = req.body as { const {type, codes, infos, expiryDate} = req.body as {
type: Type; type: Type;
codes: string[]; codes: string[];
infos?: {email: string; name: string; passport_id: string}[]; infos?: {email: string; name: string; passport_id?: string}[];
expiryDate: null | Date; expiryDate: null | Date;
}; };
const permission = PERMISSIONS.generateCode[type]; const permission = PERMISSIONS.generateCode[type];
@@ -70,11 +70,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const codePromises = codes.map(async (code, index) => { const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code); const codeRef = doc(db, "codes", code);
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate}); const codeInformation = {type, code, creator: req.session.user!.id, expiryDate};
if (infos && infos.length > index) { if (infos && infos.length > index) {
const {email, name, passport_id} = infos[index]; const {email, name, passport_id} = infos[index];
await setDoc(codeRef, {email: email.trim(), name: name.trim(), passport_id: passport_id.trim()}, {merge: true});
const transport = prepareMailer(); const transport = prepareMailer();
const mailOptions = prepareMailOptions( const mailOptions = prepareMailOptions(
@@ -87,11 +86,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
"main", "main",
); );
try {
await transport.sendMail(mailOptions); await transport.sendMail(mailOptions);
await setDoc(
codeRef,
{...codeInformation, email: email.trim(), name: name.trim(), ...(passport_id ? {passport_id: passport_id.trim()} : {})},
{merge: true},
);
return true;
} catch (e) {
return false;
}
} else {
await setDoc(codeRef, codeInformation);
} }
}); });
Promise.all(codePromises).then(() => { Promise.all(codePromises).then((results) => {
res.status(200).json({ok: true}); res.status(200).json({ok: true, valid: results.filter((x) => x).length});
}); });
} }

View File

@@ -1,23 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "stats", id as string));
res.status(200).json({...snapshot.data(), id: snapshot.id});
}

View File

@@ -30,6 +30,9 @@ import AgentDashboard from "@/dashboards/Agent";
import PaymentDue from "./(status)/PaymentDue"; import PaymentDue from "./(status)/PaymentDue";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js"; import {PayPalScriptProvider} from "@paypal/react-paypal-js";
import {CorporateUser, Type, userTypes} from "@/interfaces/user";
import Select from "react-select";
import {USER_TYPE_LABELS} from "@/resources/user";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -61,8 +64,9 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
export default function Home({envVariables}: {envVariables: {[key: string]: string}}) { export default function Home({envVariables}: {envVariables: {[key: string]: string}}) {
const [showDiagnostics, setShowDiagnostics] = useState(false); const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showDemographicInput, setShowDemographicInput] = useState(false); const [showDemographicInput, setShowDemographicInput] = useState(false);
const [selectedScreen, setSelectedScreen] = useState<Type>("admin");
const {user, mutateUser} = useUser({redirectTo: "/login"}); const {user, mutateUser} = useUser({redirectTo: "/login"});
const {stats} = useStats(user?.id);
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
@@ -176,7 +180,21 @@ export default function Home({envVariables}: {envVariables: {[key: string]: stri
{user.type === "corporate" && <CorporateDashboard user={user} />} {user.type === "corporate" && <CorporateDashboard user={user} />}
{user.type === "agent" && <AgentDashboard user={user} />} {user.type === "agent" && <AgentDashboard user={user} />}
{user.type === "admin" && <AdminDashboard user={user} />} {user.type === "admin" && <AdminDashboard user={user} />}
{user.type === "developer" && <AdminDashboard user={user} />} {user.type === "developer" && (
<>
<Select
options={userTypes.map((u) => ({value: u, label: USER_TYPE_LABELS[u]}))}
value={{value: selectedScreen, label: USER_TYPE_LABELS[selectedScreen]}}
onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))}
/>
{selectedScreen === "student" && <StudentDashboard user={user} />}
{selectedScreen === "teacher" && <TeacherDashboard user={user} />}
{selectedScreen === "corporate" && <CorporateDashboard user={user as unknown as CorporateUser} />}
{selectedScreen === "agent" && <AgentDashboard user={user} />}
{selectedScreen === "admin" && <AdminDashboard user={user} />}
</>
)}
</Layout> </Layout>
)} )}
</> </>