Skip to content

Commit

Permalink
Merge pull request #84 from admisio/xlsx_export
Browse files Browse the repository at this point in the history
(backend) (frontend)  XLSX export
  • Loading branch information
EETagent authored Mar 5, 2024
2 parents 04ae0b8 + 87367d2 commit 50ec888
Show file tree
Hide file tree
Showing 7 changed files with 335 additions and 14 deletions.
24 changes: 24 additions & 0 deletions apps/web/src/routes/admin/(authenticated)/export/csv/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RequestHandler } from './$types';

import { router } from '@testy/trpc/server/router';
import { createContext } from '@testy/trpc/server/createContext';

export const GET: RequestHandler = async (event) => {
try {
const trpc = router.createCaller(await createContext(event));

await trpc.auth.admin();

const xlsx = await trpc.users.csv();
const date = new Date().toISOString().split('.')[0];
return new Response(xlsx, {
status: 200,
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="vysledky_${date}.csv"`
}
});
} catch {
return new Response('Čus 👀', { status: 403 });
}
};
24 changes: 24 additions & 0 deletions apps/web/src/routes/admin/(authenticated)/export/xlsx/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RequestHandler } from './$types';

import { router } from '@testy/trpc/server/router';
import { createContext } from '@testy/trpc/server/createContext';

export const GET: RequestHandler = async (event) => {
try {
const trpc = router.createCaller(await createContext(event));

await trpc.auth.admin();

const xlsx = await trpc.users.xlsx();
return new Response(xlsx, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// TODO: Můžeme doplnit čas atd.
'Content-Disposition': `attachment; filename="vysledky.xlsx"`
}
});
} catch {
return new Response('Čus 👀', { status: 403 });
}
};
21 changes: 8 additions & 13 deletions apps/web/src/routes/admin/(authenticated)/users/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import Button from '$lib/components/buttons/Button.svelte';
import Submit from '$lib/components/buttons/Submit.svelte';
import TextInput from '$lib/components/inputs/TextInput.svelte';
import Modal from '$lib/components/Modal.svelte';
import UserListItem from '$lib/components/userlist/UserListItem.svelte';
import { trpc } from '$lib/trpc/client';
import type { ActionData, PageServerData } from './$types';
export let data: PageServerData;
const downloadCsv = async () => {
const csv = await trpc().users.csv.query();
const blob = new Blob([csv], { type: 'text/csv' });
// download the file
const anchor = window.document.createElement('a');
anchor.href = window.URL.createObjectURL(blob);
anchor.download = 'users.csv';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(anchor.href);
const downloadXlsx = async () => {
window.open(`/admin/export/xlsx`, '_blank');
};
const openModal = async () => {
Expand Down Expand Up @@ -76,7 +67,11 @@
icon="material-symbols:add-circle-outline-rounded"
title="Přidat uživatele"
/>
<Button on:click={downloadCsv} icon="material-symbols:download" title="Stáhnout CSV" />
<Button
on:click={downloadXlsx}
icon="material-symbols:download"
title="Stáhnout výsledky"
/>
</div>
</div>
<div class="mx-auto mx-auto mb-6 flex max-w-screen-xl flex-col px-4 py-3 md:px-6 md:px-6">
Expand Down
1 change: 1 addition & 0 deletions packages/trpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"csv-stringify": "^6.3.0",
"date-fns": "^2.29.3",
"jsonwebtoken": "^9.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.1/xlsx-0.20.1.tgz",
"zod": "^3.21.4"
},
"devDependencies": {
Expand Down
6 changes: 6 additions & 0 deletions packages/trpc/server/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import prisma from '../../prisma';
import { exportCsv } from '../../utils/csvExport';
import bcrypt from 'bcrypt';
import { trpcInfo } from '../../utils/logging';
import { exportXlsx } from '../services/testService';

export const users = t.router({
list: t.procedure
Expand Down Expand Up @@ -61,6 +62,11 @@ export const users = t.router({
trpcInfo(ctx, `Created user ${input.username}`);
}),
csv: t.procedure.use(adminAuth).query(async () => exportCsv()),
/**
* @description Export users to xlsx, Only SSR
* @returns {Promise<ArrayBuffer>} ArrayBuffer, fallback array of 8-bit unsigned int
*/
xlsx: t.procedure.use(adminAuth).query(async () => exportXlsx()),
resetPassword: t.procedure
.use(adminAuth)
.input(
Expand Down
262 changes: 261 additions & 1 deletion packages/trpc/server/services/testService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import prisma from '../../prisma';
import type { TemplateType } from '../../model/Template';
import { Question } from '@testy/database';
import { Answer, Assignment, Question, Submission, User } from '@testy/database';
import * as fs from 'fs';
import * as XLXS from 'xlsx';

export const createTest = async (templateData: TemplateType): Promise<void> => {
const {
Expand Down Expand Up @@ -69,3 +71,261 @@ export const createTest = async (templateData: TemplateType): Promise<void> => {
}
});
};

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';

const getUserData = ([user, assignments, submissions, answers]: [
User,
Assignment[],
Submission[],
Answer[]
]): {
submission: Submission;
answers: Answer[];
} | null => {
const assignment = assignments.find((a) => a.groupId === user.groupId && a.started);
if (!assignment) return null;
const submission = submissions.find(
(s) => s.assignmentId === assignment.id && s.userId === user.id // TODO maybe not working
);
if (!submission) return null;
const filteredAnswers = answers.filter(
(a) => a.userId == user.id && a.assignmentId === assignment.id
);
return { submission, answers: filteredAnswers };
};

const getAnswersRow = (answers: Answer[]): { [key: string]: number } => {
return answers
.map((a) => {
return {
[`Q${a.questionId}`]: a.index
};
})
.reduce((o, a, _) => {
return {
...o,
...a
};
});
};

type Record = { cell: string; templateType: string };
class ResultCellMap extends Map<string, Record[]> {
addTemplate(key: string, d: Record): void {
const item = this.get(key);
super.set(key, item?.concat(d) ?? [d]);
}
}

export const exportXlsx = async (): Promise<ArrayBuffer> => {
const workbook = XLXS.utils.book_new();
XLXS.set_fs(fs);

const templates = await prisma.template.findMany({
include: {
questions: true
}
});
const assignmentsDb = await prisma.assignment.findMany({});
const submissionsDb = await prisma.submission.findMany({});
const answersDb = await prisma.answer.findMany({});
const usersDb = await prisma.user.findMany({});
const resultCell = new ResultCellMap();

templates.forEach((template, i) => {
const headers = [
'username',
'group',
'original_score',
'original_max_score',
'score',
'max_score',
'percent',
template.questions.map((q) => `Q${q.id}`)
].flat();
const scoreCol = ALPHABET[headers.indexOf('score')];

const templateAssignments = assignmentsDb.filter((a) => a.testId === template.id);
const groupUsers = usersDb.filter((u) =>
templateAssignments.some((a) => a.groupId === u.groupId)
);
const rows = groupUsers.map((user) => {
const d = getUserData([user, templateAssignments, submissionsDb, answersDb]);
if (!d) return { username: user.username, group: user.groupId };
const { submission, answers } = d;

const answersRow = getAnswersRow(answers);
return {
username: user.username,
group: user.groupId,
...answersRow,
original_score: submission.evaluation,
original_max_score: template.maxScore
};
});
// Add correct answers row
rows.unshift({
username: template.title,
group: 0,
...getCorrectAnswersRow(template.questions),
original_max_score: template.maxScore
});

const sheet = XLXS.utils.json_to_sheet(rows, { header: headers });
const sheetName = `test${i}`;

const percentCol = ALPHABET[headers.indexOf('percent')];
const maxScoreCol = ALPHABET[headers.indexOf('max_score')];
const maxScoreCell = `${maxScoreCol}2`;
XLXS.utils.sheet_set_array_formula(
sheet,
maxScoreCell,
getCountblankXlsxFnString(headers, 2)
);
XLXS.utils.sheet_set_array_formula(
sheet,
`${ALPHABET[headers.indexOf('percent')]}2`,
getPercentXlsxFnString(
`${ALPHABET[headers.indexOf('score')]}2`,
`${ALPHABET[headers.indexOf('max_score')]}2`
)
);
for (let R = 3; R < rows.length + 2; R++) {
// read username cell
const username = sheet[`${ALPHABET[0]}${R}`].v;
const scoreCell = `${scoreCol}${R}`;
resultCell.addTemplate(username, {
cell: `${sheetName}!${scoreCell}`,
templateType: template.type
});
XLXS.utils.sheet_set_array_formula(sheet, scoreCell, getSumXlsxFnString(headers, R, 2));
XLXS.utils.sheet_set_array_formula(sheet, `${maxScoreCol}${R}`, maxScoreCell);
XLXS.utils.sheet_set_array_formula(
sheet,
`${percentCol}${R}`,
getPercentXlsxFnString(scoreCell, maxScoreCell)
);
}

headers
.map((h, i) => {
return {
s: h,
i
};
})
.filter((h) => h.s.startsWith('Q'))
.forEach(({ s, i }) => {
const question = template.questions.find((q) => q.id === Number(s.slice(1)));
if (question) {
sheet[`${ALPHABET[i]}1`].v += '(' + question.title.slice(0, 120) + ')';
}
});
sheet['!cols'] = [{ wch: 50 }];

XLXS.utils.book_append_sheet(workbook, sheet, sheetName);
});

const userSheet = getUserSheet(usersDb, resultCell);
XLXS.utils.book_append_sheet(workbook, userSheet, 'users');

return XLXS.write(workbook, { bookType: 'xlsx', type: 'buffer' });
};

const getUserSheet = (users: User[], resultCell: ResultCellMap): XLXS.WorkSheet => {
const headers = [
'username',
'group',
'score_g',
'max_score_g',
'score_it',
'max_score_it',
'score_kb',
'max_score_kb'
];
const sheet = XLXS.utils.json_to_sheet(
users.map((u) => {
return { username: u.username, group: u.groupId };
}),
{ header: headers }
);

for (let R = 2; R < users.length + 2; R++) {
const username = sheet[`A${R}`].v;

const userTemplates = resultCell.get(username);
[
userTemplates?.find((t) => t.templateType === 'G')?.cell,
userTemplates?.find((t) => t.templateType === 'IT')?.cell,
userTemplates?.find((t) => t.templateType === 'KB')?.cell
].forEach((c, i) => {
if (c) {
const C = ALPHABET[i * 2 + 2];
XLXS.utils.sheet_set_array_formula(sheet, `${C}${R}`, `${c}`);
XLXS.utils.sheet_set_array_formula(
sheet,
`${ALPHABET[i * 2 + 3]}${R}`,
`${c.split('!')[0]}!F2` // TODO: not fixed cell
);
}
});
}
return sheet;
};

const getSumXlsxFnString = (headers: string[], row: number, correctRow: number): string => {
const colRange = headers
.map((h, i) => {
return {
s: h,
i
};
})
.filter((h) => h.s.startsWith('Q'))
.map(({ i }) => i);

const [colMin, colMax] = [colRange[0], colRange[colRange.length - 1]].map((c) => ALPHABET[c]);
const correctRange = `${colMin}$${correctRow}:${colMax}$${correctRow}`;
const range = `${colMin}${row}:${colMax}${row}`;
const sumFn = `SUM(IF(${range}="",0,IF(${range}=${correctRange},1,0)))`;
return sumFn;
};

const getCountblankXlsxFnString = (headers: string[], row: number): string => {
const sumFn = headers
.map((h, i) => {
return {
s: h,
i
};
})
.filter((h) => h.s.startsWith('Q'))
.map((h) => {
const col = ALPHABET[h.i];
const cell = `${col}${row}`;
return `IF(ISBLANK(${cell}),0,1)`;
})
.join('+');
return sumFn;
};

const getPercentXlsxFnString = (cellA: string, cellB: string): string => {
return `${cellA}/${cellB}`;
};

const getCorrectAnswersRow = (questions: Question[]): { [x: string]: string | number } => {
const row = questions
.map((q) => {
return {
[`Q${q.id}`]: q.templateAnswers.indexOf(q.correctAnswer)
};
})
.reduce((o, a, _) => {
return {
...o,
...a
};
});
return row;
};
Loading

0 comments on commit 50ec888

Please sign in to comment.