Merged in feature/ExamGenRework (pull request #128)

ENCOA-283 or ENCOA-282, I don't know someone deleted the issue

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-12-23 09:37:30 +00:00
committed by Tiago Ribeiro
6 changed files with 314 additions and 56 deletions

View File

@@ -195,6 +195,13 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</div> </div>
</li> </li>
)} )}
{state.alreadyInClass && state.alreadyInClass.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.alreadyInClass.length}</span> users that are already assigned to the classroom
</div>
</li>
)}
{errorCount > 0 && ( {errorCount > 0 && (
<li> <li>
<div className="text-gray-700"> <div className="text-gray-700">
@@ -221,7 +228,7 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</> </>
</Modal> </Modal>
<Modal isOpen={showNotFoundModal} onClose={() => setShowNotFoundModal(false)}> <Modal isOpen={showNotFoundModal} onClose={() => setShowNotFoundModal(false)} maxWidth="max-w-[85%]">
<> <>
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<FaTimesCircle className="w-5 h-5 text-red-500" /> <FaTimesCircle className="w-5 h-5 text-red-500" />
@@ -231,7 +238,7 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</> </>
</Modal> </Modal>
<Modal isOpen={showOtherEntityModal} onClose={() => setShowOtherEntityModal(false)}> <Modal isOpen={showOtherEntityModal} onClose={() => setShowOtherEntityModal(false)} maxWidth="max-w-[85%]">
<> <>
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<FaExclamationCircle className="w-5 h-5 text-yellow-500" /> <FaExclamationCircle className="w-5 h-5 text-yellow-500" />
@@ -241,7 +248,7 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</> </>
</Modal> </Modal>
<Modal isOpen={showAlreadyInClassModal} onClose={() => setShowAlreadyInClassModal(false)}> <Modal isOpen={showAlreadyInClassModal} onClose={() => setShowAlreadyInClassModal(false)} maxWidth="max-w-[85%]">
<> <>
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<FaUsers className="w-5 h-5 text-blue-500" /> <FaUsers className="w-5 h-5 text-blue-500" />

View File

@@ -162,7 +162,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
file.content, { schema, ignoreEmptyRows: false }) file.content, { schema, ignoreEmptyRows: false })
.then((data) => { .then((data) => {
setClassroomTransferState((prev) => ({ ...prev, parsedExcel: data })) setClassroomTransferState((prev) => ({ ...prev, parsedExcel: data }))
console.log(data);
}); });
} }
@@ -236,7 +235,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
} as UserImport; } as UserImport;
}) })
.filter((item): item is UserImport => item !== undefined); .filter((item): item is UserImport => item !== undefined);
console.log(infos);
// On import reset state except excel parsing // On import reset state except excel parsing
setClassroomTransferState((prev) => ({ setClassroomTransferState((prev) => ({
@@ -260,26 +258,26 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
const emails = classroomTransferState.imports.map((i) => i.email); const emails = classroomTransferState.imports.map((i) => i.email);
const crossRefUsers = async () => { const crossRefUsers = async () => {
try { try {
console.log(user.entities);
const { data: nonExistantUsers } = await axios.post("/api/users/controller?op=dontExist", { emails }); const { data: nonExistantUsers } = await axios.post("/api/users/controller?op=dontExist", { emails });
const { data: nonEntityUsers } = await axios.post("/api/users/controller?op=entityCheck", { entities: user.entities, emails }); const { data: nonEntityUsers } = await axios.post("/api/users/controller?op=entityCheck", { entities: user.entities, emails });
const { data: alreadyPlaced } = await axios.post("/api/users/controller?op=crossRefClassrooms", { const { data: alreadyPlaced } = await axios.post("/api/users/controller?op=crossRefClassrooms", {
sets: classroomTransferState.imports.map((info) => ({ sets: classroomTransferState.imports.map((info) => ({
email: info.email, email: info.email,
classroom: info.groupName classroom: info.groupName
})) })),
entity
}); });
const excludeEmails = new Set([ const excludeEmails = new Set([
...nonExistantUsers, ...nonExistantUsers,
...nonEntityUsers, ...nonEntityUsers.map((o: any) => o.email),
...alreadyPlaced ...alreadyPlaced
]); ]);
const filteredImports = classroomTransferState.imports.filter(i => !excludeEmails.has(i.email)); const filteredImports = classroomTransferState.imports.filter(i => !excludeEmails.has(i.email));
const nonExistantEmails = new Set(nonExistantUsers); const nonExistantEmails = new Set(nonExistantUsers);
const nonEntityEmails = new Set(nonEntityUsers); const nonEntityEmails = new Set(nonEntityUsers.map((o: any) => o.email));
const alreadyPlacedEmails = new Set(alreadyPlaced); const alreadyPlacedEmails = new Set(alreadyPlaced);
setClassroomTransferState((prev) => ({ setClassroomTransferState((prev) => ({
@@ -287,7 +285,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
stage: 2, stage: 2,
imports: filteredImports, imports: filteredImports,
notFoundUsers: classroomTransferState.imports.filter(i => nonExistantEmails.has(i.email)), notFoundUsers: classroomTransferState.imports.filter(i => nonExistantEmails.has(i.email)),
otherEntityUsers: classroomTransferState.imports.filter(i => nonEntityEmails.has(i.email)), otherEntityUsers: classroomTransferState.imports.filter(i => nonEntityEmails.has(i.email))
.map(user => {
const nonEntityUser = nonEntityUsers.find((o: any) => o.email === user.email);
return {
...user,
entityLabels: nonEntityUser?.names || []
};
}),
alreadyInClass: classroomTransferState.imports.filter(i => alreadyPlacedEmails.has(i.email)), alreadyInClass: classroomTransferState.imports.filter(i => alreadyPlacedEmails.has(i.email)),
})); }));
} catch (error) { } catch (error) {
@@ -299,14 +304,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
crossRefUsers(); crossRefUsers();
} }
}, [classroomTransferState.imports, user.entities, classroomTransferState.stage]) }, [classroomTransferState.imports, user.entities, classroomTransferState.stage, entity])
// Stage 3 - Classroom Filter // Stage 3 - Classroom Filter
// - See if there are classrooms with same name but different admin // - See if there are classrooms with same name but different admin
// - Find which new classrooms need to be created // - Find which new classrooms need to be created
useEffect(() => { useEffect(() => {
const crossRefClassrooms = async () => { const crossRefClassrooms = async () => {
const classrooms = new Set(classroomTransferState.imports.map((i) => i.groupName)); const classrooms = Array.from(new Set(classroomTransferState.imports.map((i) => i.groupName)));
try { try {
const { data: notOwnedClassroomsSameName } = await axios.post("/api/groups/controller?op=crossRefOwnership", { const { data: notOwnedClassroomsSameName } = await axios.post("/api/groups/controller?op=crossRefOwnership", {
@@ -362,7 +367,42 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
try { try {
setIsLoading(true); setIsLoading(true);
const groupedUsers = classroomTransferState.imports.reduce((acc, user) => {
const getIds = async () => {
try {
const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", {
emails: classroomTransferState.imports.map((u) => u.email)
});
return classroomTransferState.imports.map(importUser => {
const matchingUser = emailIdMap.find((mapping: any) => mapping.email === importUser.email);
return {
...importUser,
id: matchingUser?.id || undefined
};
});
} catch (error) {
toast.error("Something went wrong, please try again later!");
}
};
const imports = await getIds();
if (!imports) return;
await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", {
ids: imports.map((u) => u.id),
entity
});
await axios.post("/api/users/controller?op=assignToEntity", {
ids: imports.map((u) => u.id),
entity
});
const groupedUsers = imports.reduce((acc, user) => {
if (!acc[user.groupName]) { if (!acc[user.groupName]) {
acc[user.groupName] = []; acc[user.groupName] = [];
} }
@@ -385,11 +425,15 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
return axios.post('/api/groups', groupData); return axios.post('/api/groups', groupData);
}); });
const existingGroupUsers = Object.fromEntries( const existingGroupUsers = Object.fromEntries(
Object.entries(groupedUsers) Object.entries(groupedUsers)
.filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName)) .filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName))
); );
let updatePromises: Promise<any>[] = [];
if (Object.keys(existingGroupUsers).length > 0) {
const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', { const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', {
names: Object.keys(existingGroupUsers), names: Object.keys(existingGroupUsers),
userEmails: Object.values(existingGroupUsers).flat().map(user => user.email) userEmails: Object.values(existingGroupUsers).flat().map(user => user.email)
@@ -400,12 +444,13 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
return acc; return acc;
}, {} as Record<string, any[]>); }, {} as Record<string, any[]>);
const updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => { updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => {
const userIds = users.map(user => userEmailToId[user.email]); const userIds = users.map(user => userEmailToId[user.email]);
return axios.patch(`/api/groups/${groupId}`, { return axios.patch(`/api/groups/${groupId}`, {
participants: userIds participants: userIds
}); });
}); });
}
await Promise.all([ await Promise.all([
...createGroupPromises, ...createGroupPromises,

View File

@@ -51,12 +51,28 @@ const columns = [
]; ];
const institutionsColumn = columnHelper.accessor('entityLabels', {
cell: (info): string => {
const value = info.getValue();
if (!value || value.length === 0) {
return 'None';
}
return value.join(', ');
},
header: () => 'Institutions',
}) as unknown as typeof columns[0];
const UserTable: React.FC<{ users: UserImport[] }> = ({ users }) => { const UserTable: React.FC<{ users: UserImport[] }> = ({ users }) => {
const [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState('');
const tableColumns = [...columns];
if (users.some(user => 'entityLabels' in user)) {
tableColumns.push(institutionsColumn);
}
const table = useReactTable({ const table = useReactTable({
data: users, data: users,
columns, columns: tableColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),

View File

@@ -16,4 +16,5 @@ export interface UserImport {
passport_id: string; passport_id: string;
phone: string; phone: string;
}; };
entityLabels?: string[];
} }

View File

@@ -25,6 +25,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
case 'getIds': case 'getIds':
res.status(200).json(await getIds(req.body)); res.status(200).json(await getIds(req.body));
break; break;
case 'deletePriorEntitiesGroups':
await deletePriorEntitiesGroups(req.body);
res.status(200).json({ ok: true });
break;
default: default:
res.status(400).json({ error: 'Invalid operation!' }) res.status(400).json({ error: 'Invalid operation!' })
} }
@@ -34,18 +38,42 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function crossRefOwnership(body: any): Promise<string[]> { async function crossRefOwnership(body: any): Promise<string[]> {
const { userId, classrooms } = body; const { userId, classrooms, entity } = body;
// First find which classrooms from input exist
const existingClassrooms = await db.collection('groups') const existingClassrooms = await db.collection('groups')
.find({ name: { $in: classrooms } }) .find({
name: { $in: classrooms },
admin: { $ne: userId }
})
.project({ name: 1, admin: 1, _id: 0 }) .project({ name: 1, admin: 1, _id: 0 })
.toArray(); .toArray();
// From those existing classrooms, return the ones where user is NOT the admin if (existingClassrooms.length === 0) {
return existingClassrooms return [];
.filter(classroom => classroom.admin !== userId) }
.map(classroom => classroom.name);
const adminUsers = await db.collection('users')
.find({
id: { $in: existingClassrooms.map(classroom => classroom.admin) }
})
.project({ id: 1, entities: 1, _id: 0 })
.toArray();
const adminEntitiesMap = new Map(
adminUsers.map(admin => [
admin.id,
admin.entities?.map((e: any) => e.id) || []
])
);
return Array.from(new Set(
existingClassrooms
.filter(classroom => {
const adminEntities = adminEntitiesMap.get(classroom.admin) || [];
return adminEntities.includes(entity);
})
.map(classroom => classroom.name)
));
} }
async function getIds(body: any): Promise<Record<string, string>> { async function getIds(body: any): Promise<Record<string, string>> {
@@ -72,3 +100,31 @@ async function getIds(body: any): Promise<Record<string, string>> {
}, {} as Record<string, string>) }, {} as Record<string, string>)
}; };
} }
async function deletePriorEntitiesGroups(body: any) {
const { ids, entity } = body;
if (!Array.isArray(ids) || ids.length === 0 || !entity) {
return;
}
const users = await db.collection('users')
.find({ id: { $in: ids } })
.project({ id: 1, entities: 1, _id: 0 })
.toArray();
// if the user doesn't have the target entity mark them for all groups deletion
const toDeleteUserIds = users
.filter(user => !user.entities?.some((e: any) => e.id === entity))
.map(user => user.id);
if (toDeleteUserIds.length === 0) {
return;
}
db.collection('groups').updateMany(
{ participants: { $in: toDeleteUserIds } },
{ $pull: { participants: { $in: toDeleteUserIds } } } as any
);
}

View File

@@ -29,7 +29,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json(await entityCheck(req.body)); res.status(200).json(await entityCheck(req.body));
break; break;
case 'crossRefClassrooms': case 'crossRefClassrooms':
res.status(200).json(await crossRefClassrooms(req.body.sets)); res.status(200).json(await crossRefClassrooms(req.body));
break;
case 'getIds':
res.status(200).json(await getIds(req.body.emails));
break;
case 'assignToEntity':
await assignToEntity(req.body);
res.status(200).json({"ok": true});
break; break;
default: default:
res.status(400).json({ error: 'Invalid operation!' }) res.status(400).json({ error: 'Invalid operation!' })
@@ -68,7 +75,10 @@ async function dontExist(emails: string[]): Promise<string[]> {
} }
async function entityCheck(body: Record<string, any>): Promise<string[]> { async function entityCheck(body: Record<string, any>): Promise<Array<{
email: string;
names?: string[];
}>> {
const { entities, emails } = body; const { entities, emails } = body;
const pipeline = [ const pipeline = [
@@ -99,20 +109,65 @@ async function entityCheck(body: Record<string, any>): Promise<string[]> {
] ]
} }
}, },
// Project only the email field // Unwind the entities array (if it exists)
{
$unwind: {
path: "$entities",
preserveNullAndEmptyArrays: true
}
},
// Lookup entity details from entities collection
{
$lookup: {
from: 'entities',
localField: 'entities.id',
foreignField: 'id',
as: 'entityDetails'
}
},
// Unwind the entityDetails array
{
$unwind: {
path: "$entityDetails",
preserveNullAndEmptyArrays: true
}
},
// Group by email to collect all entity names in an array
{
$group: {
_id: "$email",
email: { $first: "$email" },
name: {
$push: {
$cond: [
{ $ifNull: ["$entityDetails.label", false] },
"$entityDetails.label",
"$$REMOVE"
]
}
}
}
},
// Final projection to clean up
{ {
$project: { $project: {
_id: 0, _id: 0,
email: 1 email: 1,
name: 1
} }
} }
]; ];
const results = await db.collection('users').aggregate(pipeline).toArray(); const results = await db.collection('users').aggregate(pipeline).toArray();
return results.map((result: any) => result.email); return results.map(result => ({
email: result.email as string,
names: result.name as string[] | undefined
}));
} }
async function crossRefClassrooms(sets: { email: string, classroom: string }[]) { async function crossRefClassrooms(body: any) {
const { sets, entity } = body as { sets: { email: string, classroom: string }[], entity: string };
const pipeline = [ const pipeline = [
// Match users with the provided emails // Match users with the provided emails
{ {
@@ -132,7 +187,6 @@ async function crossRefClassrooms(sets: { email: string, classroom: string }[])
$and: [ $and: [
{ $in: ['$$userId', '$participants'] }, { $in: ['$$userId', '$participants'] },
{ {
// Match the classroom that corresponds to this user's email
$let: { $let: {
vars: { vars: {
matchingSet: { matchingSet: {
@@ -153,6 +207,38 @@ async function crossRefClassrooms(sets: { email: string, classroom: string }[])
] ]
} }
} }
},
// Lookup admin's entities
{
$lookup: {
from: 'users',
let: { adminId: '$admin' },
pipeline: [
{
$match: {
$expr: { $eq: ['$id', '$$adminId'] }
}
}
],
as: 'adminInfo'
}
},
// Filter where admin has the target entity
{
$match: {
$expr: {
$in: [
entity,
{
$map: {
input: { $arrayElemAt: ['$adminInfo.entities', 0] },
as: 'entityObj',
in: '$$entityObj.id'
}
}
]
}
}
} }
], ],
as: 'matchingGroups' as: 'matchingGroups'
@@ -176,3 +262,50 @@ async function crossRefClassrooms(sets: { email: string, classroom: string }[])
const results = await db.collection('users').aggregate(pipeline).toArray(); const results = await db.collection('users').aggregate(pipeline).toArray();
return results.map((result: any) => result.email); return results.map((result: any) => result.email);
} }
async function getIds(emails: string[]): Promise<Array<{ email: string; id: string }>> {
const users = await db.collection('users')
.find({ email: { $in: emails } })
.project({ email: 1, id: 1, _id: 0 })
.toArray();
return users.map(user => ({
email: user.email,
id: user.id
}));
}
async function assignToEntity(body: any) {
const { ids, entity } = body;
if (!Array.isArray(ids) || ids.length === 0 || !entity) {
return;
}
const users = await db.collection('users')
.find({ id: { $in: ids } })
.project({ id: 1, entities: 1 })
.toArray();
const toUpdateUsers = users.filter((u) => u.entities[0].id !== entity);
if (toUpdateUsers.length > 0) {
const writes = users.map(user => ({
updateOne: {
filter: { id: user.id },
update: {
$set: {
entities: [{
id: entity,
role: user.entities?.[0]?.role
}]
}
}
}
}));
db.collection('users').bulkWrite(writes);
}
}