mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-14 05:45:18 +02:00
feat: add GDPR data export (download data) button (#198)
* feat: add GDPR data export (download data) button Wires up the previously disabled "Download data" button on the settings page. Downloads a zip with a profile.xlsx (account + owned seasons), and per owned season a folder with each quiz's xlsx (questions, results, eliminations tabs) and a candidates.xlsx (candidates + season info tabs). Soft-deleted rows are included and flagged so the export reflects everything the app still holds about the user. * feat: include question bank in GDPR data export Adds a question-bank.xlsx per season folder with Questions (bank questions, answers, reusable/complete flags, labels, and which quizzes they've been used in) and Labels tabs, since BankQuestion/BankAnswer content was previously missing from the export. * fix: hard-delete quiz/audit-log data when deleting an account QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their removal through Season -> Quiz/Candidate only set deletedAt instead of physically deleting the row. Since Candidate and Answer are hard-deleted via orphanRemoval, this broke their foreign keys and rolled back the entire account deletion whenever a candidate had actually participated in a quiz. Bulk DQL deletes now purge these rows before the cascade runs. Also purge BankQuestion audit-log rows (ext_log_entries), which store the editor's username/email but aren't foreign-keyed to the entity they log, so they were never cleaned up and would otherwise keep a deleted user's email around indefinitely. * style: make the download data button primary (blue) * feat: add raw answers crosstab to quiz export Adds a "Raw answers" tab to each quiz xlsx: one row per candidate, one column per question, with the given answer text in each cell — the raw data behind the aggregated Results tab. * i18n: translate new settings page string to Dutch * refactor: sanitize filenames with Symfony's AsciiSlugger instead of a hand-rolled regex Extracts a shared Tvdt\Helpers\FilenameSanitizer (backed by symfony/string's AsciiSlugger) and uses it everywhere user-controlled text (season/quiz names, account email) ends up in a zip entry path or a downloaded filename. AsciiSlugger is allowlist-based (only A-Z/0-9 survive; everything else, including unicode and path-traversal sequences, is folded or stripped) rather than a denylist of "unsafe" characters, and it's an officially maintained Symfony component already present as a transitive dependency. Also fixes BackofficeController::exportQuiz(), a pre-existing endpoint that built its Content-Disposition filename directly from an unsanitized quiz name — the same class of risk the data export already guarded against. Naming note: sanitized names are now slugs (spaces become dashes, unicode is ASCII-transliterated), e.g. "Krtek Weekend" -> "Krtek-Weekend". * fix: check ZipArchive open/close results and clean up temp files on failure Addresses CodeRabbit findings on the GDPR export: - ZipArchive::open() and close() can both return false without throwing; neither was checked, so a failure silently produced an empty or corrupt zip, and the temp zip path was never cleaned up on a mid-build exception. - writeToTempFile() leaked its tempnam()'d file if Writer\Xlsx::save() threw before the caller could track it for cleanup. * fix: drop public link identifier from the candidates export sheet The nameHash is a public quiz-access token, not something a data export should hand out — remove it from candidates.xlsx. * feat: add Quiz info tab covering dropouts, finalization, and disabled questions An entity-by-entity audit of the export vs. delete flows found the delete flow fully covered, but three Quiz/Question fields missing from the export: dropouts, finalizedAt, and Question.enabled. Adds a new "Quiz info" tab (first sheet) to each quiz's xlsx with this data, without touching the shared fillQuestionsSheet() used by the existing single-quiz template export/import feature. Deliberately left out per user decision: the BankQuestion audit log (would leak other owners' emails, consistent with hiding co-owner identities elsewhere in this export) and a few low-value timestamp fields already covered by existing Started/time-taken columns. * feat: require a confirmed email before exporting data Antispam measure: both the full data export (SettingsController::downloadData) and the single-quiz export (BackofficeController::exportQuiz) now redirect with a flash warning instead of exporting when the account's email isn't verified yet. Adds a matching hint on the settings page next to the download button.
This commit is contained in:
@@ -14,10 +14,13 @@ use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Form\CreateSeasonFormType;
|
||||
use Tvdt\Helpers\FilenameSanitizer;
|
||||
use Tvdt\Repository\SeasonRepository;
|
||||
use Tvdt\Security\Voter\SeasonVoter;
|
||||
use Tvdt\Service\QuizSpreadsheetService;
|
||||
@@ -31,6 +34,7 @@ final class BackofficeController extends AbstractController
|
||||
private readonly Security $security,
|
||||
private readonly QuizSpreadsheetService $excel,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly TranslatorInterface $translator,
|
||||
) {}
|
||||
|
||||
#[Route('/backoffice/', name: 'tvdt_backoffice_index')]
|
||||
@@ -83,11 +87,17 @@ final class BackofficeController extends AbstractController
|
||||
requirements: ['quiz' => Requirement::UUID],
|
||||
methods: ['GET'],
|
||||
)]
|
||||
public function exportQuiz(Quiz $quiz): StreamedResponse
|
||||
public function exportQuiz(Quiz $quiz): Response
|
||||
{
|
||||
if (!$this->authenticatedUser->isVerified) {
|
||||
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before exporting a quiz.'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
|
||||
}
|
||||
|
||||
$response = new StreamedResponse($this->excel->quizToXlsx($quiz));
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx'));
|
||||
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, FilenameSanitizer::sanitize($quiz->name).'.xlsx'));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -21,8 +24,10 @@ use Tvdt\Entity\User;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Form\ChangeEmailFormType;
|
||||
use Tvdt\Form\ChangeUserPasswordFormType;
|
||||
use Tvdt\Helpers\FilenameSanitizer;
|
||||
use Tvdt\Repository\UserRepository;
|
||||
use Tvdt\Security\EmailVerifier;
|
||||
use Tvdt\Service\DataExportService;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
@@ -33,6 +38,7 @@ final class SettingsController extends AbstractController
|
||||
private readonly EmailVerifier $emailVerifier,
|
||||
private readonly Security $security,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly DataExportService $dataExportService,
|
||||
) {}
|
||||
|
||||
#[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])]
|
||||
@@ -148,6 +154,34 @@ final class SettingsController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
||||
}
|
||||
|
||||
#[Route('/backoffice/settings/download-data', name: 'tvdt_backoffice_settings_download_data', methods: ['GET'])]
|
||||
public function downloadData(): Response
|
||||
{
|
||||
if (!$this->authenticatedUser->isVerified) {
|
||||
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before downloading your data.'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_settings');
|
||||
}
|
||||
|
||||
$zipPath = $this->dataExportService->exportForUser($this->authenticatedUser);
|
||||
|
||||
$filename = \sprintf(
|
||||
'tijd-voor-de-test-data-%s-%s.zip',
|
||||
FilenameSanitizer::sanitize($this->authenticatedUser->email),
|
||||
new DateTimeImmutable()->format('Y-m-d_H-i-s'),
|
||||
);
|
||||
|
||||
$response = new BinaryFileResponse($zipPath);
|
||||
$response->deleteFileAfterSend(true);
|
||||
$response->headers->set('Content-Type', 'application/zip');
|
||||
$response->headers->set(
|
||||
'Content-Disposition',
|
||||
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename),
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('delete_account')]
|
||||
#[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])]
|
||||
public function deleteAccount(Request $request): Response
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Helpers;
|
||||
|
||||
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||
|
||||
class FilenameSanitizer
|
||||
{
|
||||
/** Slugs user-supplied text (e.g. a season/quiz name) into a string safe to use as a zip entry path segment or a downloaded filename. */
|
||||
public static function sanitize(string $value): string
|
||||
{
|
||||
$slug = new AsciiSlugger()->slug($value)->toString();
|
||||
|
||||
return '' === $slug ? 'unnamed' : $slug;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,15 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
/** @extends ServiceEntityRepository<User> */
|
||||
@@ -44,8 +50,11 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
|
||||
$em->wrapInTransaction(function () use ($em, $user): void {
|
||||
$this->invalidateResetPasswordRequests($user);
|
||||
|
||||
$bankQuestionIds = [];
|
||||
foreach ($user->seasons->toArray() as $season) {
|
||||
if (1 === $season->owners->count()) {
|
||||
$this->purgeSoftDeletableData($em, $season);
|
||||
array_push($bankQuestionIds, ...$this->bankQuestionIds($season));
|
||||
$em->remove($season);
|
||||
|
||||
continue;
|
||||
@@ -56,9 +65,63 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
|
||||
|
||||
$em->remove($user);
|
||||
$em->flush();
|
||||
|
||||
// Gedmo\Loggable writes its own "removed" log entry as part of the flush above, so the
|
||||
// audit-log purge must happen after — purging first would just leave that final row behind.
|
||||
$this->purgeBankQuestionAuditLog($em, $bankQuestionIds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their
|
||||
* removal through the season/quiz/candidate relations only sets deletedAt — it never removes
|
||||
* the row. That leaves personal data behind indefinitely and, since Candidate/Answer are hard
|
||||
* deleted via orphanRemoval, it also breaks their foreign keys and rolls back the whole
|
||||
* deletion. Bulk DQL deletes bypass the Gedmo listener and physically remove these rows first.
|
||||
*/
|
||||
private function purgeSoftDeletableData(EntityManagerInterface $em, Season $season): void
|
||||
{
|
||||
foreach ([QuizCandidate::class, GivenAnswer::class, Elimination::class] as $class) {
|
||||
$em->createQuery(<<<DQL
|
||||
delete from {$class} e
|
||||
where e.quiz in (select q from Tvdt\Entity\Quiz q where q.season = :season)
|
||||
DQL)
|
||||
->setParameter('season', $season)
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function bankQuestionIds(Season $season): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (BankQuestion $bankQuestion): string => $bankQuestion->id->toString(),
|
||||
$season->bankQuestions->toArray(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gedmo\Loggable audit rows (ext_log_entries) aren't foreign-keyed to the entity they log —
|
||||
* object_id is a plain string — so removing a BankQuestion never cleans up its history, and
|
||||
* the editor's username/email would otherwise remain in those rows forever.
|
||||
*
|
||||
* @param list<string> $bankQuestionIds
|
||||
*/
|
||||
private function purgeBankQuestionAuditLog(EntityManagerInterface $em, array $bankQuestionIds): void
|
||||
{
|
||||
if ([] === $bankQuestionIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em->createQuery(<<<'DQL'
|
||||
delete from Tvdt\Entity\LogEntry l
|
||||
where l.objectClass = :class and l.objectId in (:ids)
|
||||
DQL)
|
||||
->setParameter('class', BankQuestion::class)
|
||||
->setParameter('ids', $bankQuestionIds)
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function makeAdmin(string $email): void
|
||||
{
|
||||
$user = $this->findOneBy(['email' => $email]);
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer;
|
||||
use Safe\Exceptions\FilesystemException;
|
||||
use Tvdt\Dto\Result;
|
||||
use Tvdt\Entity\BankQuestionUsage;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Helpers\FilenameSanitizer;
|
||||
use Tvdt\Repository\QuizRepository;
|
||||
|
||||
use function Safe\tempnam;
|
||||
use function Safe\unlink;
|
||||
|
||||
/** Builds a GDPR data-portability export (a zip of xlsx files) for everything owned by a single user. */
|
||||
class DataExportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly QuizSpreadsheetService $quizSpreadsheetService,
|
||||
private readonly QuizRepository $quizRepository,
|
||||
) {}
|
||||
|
||||
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
|
||||
public function exportForUser(User $user): string
|
||||
{
|
||||
$filter = $this->entityManager->getFilters();
|
||||
$filter->disable('softdeleteable');
|
||||
|
||||
try {
|
||||
return $this->buildZip($user);
|
||||
} finally {
|
||||
$filter->enable('softdeleteable');
|
||||
}
|
||||
}
|
||||
|
||||
private function buildZip(User $user): string
|
||||
{
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'tvdt_export_');
|
||||
$tempXlsxFiles = [];
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if (true !== $zip->open($zipPath, \ZipArchive::OVERWRITE)) {
|
||||
unlink($zipPath);
|
||||
|
||||
throw new \RuntimeException('Could not create the export zip archive.');
|
||||
}
|
||||
|
||||
try {
|
||||
$profilePath = $this->writeToTempFile($this->buildProfileWorkbook($user));
|
||||
$tempXlsxFiles[] = $profilePath;
|
||||
$zip->addFile($profilePath, 'profile.xlsx');
|
||||
|
||||
foreach ($user->seasons as $season) {
|
||||
$folder = FilenameSanitizer::sanitize($season->seasonCode.'-'.$season->name).'/';
|
||||
|
||||
foreach ($season->quizzes as $quiz) {
|
||||
$quizPath = $this->writeToTempFile($this->buildQuizWorkbook($quiz));
|
||||
$tempXlsxFiles[] = $quizPath;
|
||||
$zip->addFile($quizPath, $folder.FilenameSanitizer::sanitize($quiz->name).'.xlsx');
|
||||
}
|
||||
|
||||
$candidatesPath = $this->writeToTempFile($this->buildCandidatesWorkbook($season));
|
||||
$tempXlsxFiles[] = $candidatesPath;
|
||||
$zip->addFile($candidatesPath, $folder.'candidates.xlsx');
|
||||
|
||||
$questionBankPath = $this->writeToTempFile($this->buildQuestionBankWorkbook($season));
|
||||
$tempXlsxFiles[] = $questionBankPath;
|
||||
$zip->addFile($questionBankPath, $folder.'question-bank.xlsx');
|
||||
}
|
||||
|
||||
if (!$zip->close()) {
|
||||
throw new \RuntimeException('Could not finalize the export zip archive.');
|
||||
}
|
||||
} catch (\Throwable $throwable) {
|
||||
unlink($zipPath);
|
||||
|
||||
throw $throwable;
|
||||
} finally {
|
||||
foreach ($tempXlsxFiles as $tempXlsxFile) {
|
||||
unlink($tempXlsxFile);
|
||||
}
|
||||
}
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
private function buildProfileWorkbook(User $user): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
$account = $spreadsheet->getActiveSheet();
|
||||
$account->setTitle('Account');
|
||||
$account->getStyle('A:A')->getFont()->setBold(true);
|
||||
$account->fromArray([
|
||||
['Email', $user->email],
|
||||
['Roles', implode(', ', $user->getRoles())],
|
||||
['Email verified', $user->isVerified ? 'Yes' : 'No'],
|
||||
['Account ID', $user->id->toString()],
|
||||
], null, 'A1');
|
||||
$account->getColumnDimension('A')->setAutoSize(true);
|
||||
$account->getColumnDimension('B')->setAutoSize(true);
|
||||
|
||||
$seasons = $spreadsheet->createSheet();
|
||||
$seasons->setTitle('Seasons');
|
||||
$seasons->fromArray(['Season', 'Season code', 'Quizzes', 'Candidates', 'Shared with other owners'], null, 'A1');
|
||||
$seasons->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
$row = 2;
|
||||
foreach ($user->seasons as $season) {
|
||||
$seasons->fromArray([
|
||||
$season->name,
|
||||
$season->seasonCode,
|
||||
$season->quizzes->count(),
|
||||
$season->candidates->count(),
|
||||
$season->owners->count() > 1 ? 'Yes' : 'No',
|
||||
], null, 'A'.$row);
|
||||
++$row;
|
||||
}
|
||||
|
||||
foreach (['A', 'B', 'C', 'D', 'E'] as $column) {
|
||||
$seasons->getColumnDimension($column)->setAutoSize(true);
|
||||
}
|
||||
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
private function buildQuizWorkbook(Quiz $quiz): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
$info = $spreadsheet->getActiveSheet();
|
||||
$info->setTitle('Quiz info');
|
||||
$this->fillQuizInfoSheet($info, $quiz);
|
||||
|
||||
$questions = $spreadsheet->createSheet();
|
||||
$questions->setTitle('Questions');
|
||||
|
||||
$this->quizSpreadsheetService->fillQuestionsSheet($questions, $quiz);
|
||||
|
||||
$rawAnswers = $spreadsheet->createSheet();
|
||||
$rawAnswers->setTitle('Raw answers');
|
||||
$this->fillRawAnswersSheet($rawAnswers, $quiz);
|
||||
|
||||
$results = $spreadsheet->createSheet();
|
||||
$results->setTitle('Results');
|
||||
$this->fillResultsSheet($results, $quiz);
|
||||
|
||||
$eliminations = $spreadsheet->createSheet();
|
||||
$eliminations->setTitle('Eliminations');
|
||||
$this->fillEliminationsSheet($eliminations, $quiz);
|
||||
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
private function fillQuizInfoSheet(Worksheet $sheet, Quiz $quiz): void
|
||||
{
|
||||
$disabledQuestions = $quiz->questions
|
||||
->filter(static fn (Question $question): bool => !$question->enabled)
|
||||
->map(static fn (Question $question): string => $question->question)
|
||||
->toArray();
|
||||
|
||||
$sheet->getStyle('A:A')->getFont()->setBold(true);
|
||||
$sheet->fromArray([
|
||||
['Quiz name', $quiz->name],
|
||||
['Number of dropouts', $quiz->dropouts],
|
||||
['Finalized', $quiz->isFinalized ? 'Yes' : 'No'],
|
||||
['Finalized at', $quiz->finalizedAt?->format(\DateTimeInterface::ATOM) ?? ''],
|
||||
['Disabled questions', implode(', ', $disabledQuestions)],
|
||||
], null, 'A1');
|
||||
$sheet->getColumnDimension('A')->setAutoSize(true);
|
||||
$sheet->getColumnDimension('B')->setAutoSize(true);
|
||||
}
|
||||
|
||||
private function fillResultsSheet(Worksheet $sheet, Quiz $quiz): void
|
||||
{
|
||||
$sheet->fromArray(['Candidate', 'Correct answers', 'Corrections', 'Penalty (s)', 'Score', 'Time', 'Started', 'Active', 'Deleted'], null, 'A1');
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
/** @var array<string, Result> $scoresByCandidateId */
|
||||
$scoresByCandidateId = [];
|
||||
foreach ($this->quizRepository->getScores($quiz) as $result) {
|
||||
$scoresByCandidateId[$result->id->toString()] = $result;
|
||||
}
|
||||
|
||||
$row = 2;
|
||||
foreach ($quiz->candidateData as $quizCandidate) {
|
||||
$candidate = $quizCandidate->candidate;
|
||||
$result = $scoresByCandidateId[$candidate->id->toString()] ?? null;
|
||||
|
||||
$sheet->fromArray([
|
||||
$candidate->name,
|
||||
$result?->correct,
|
||||
$result?->corrections,
|
||||
$result?->penaltySeconds,
|
||||
$result?->score,
|
||||
$result instanceof Result ? $result->time->format('%i:%S') : null,
|
||||
$quizCandidate->started?->format(\DateTimeInterface::ATOM),
|
||||
$quizCandidate->active ? 'Yes' : 'No',
|
||||
$quizCandidate->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
|
||||
], null, 'A'.$row);
|
||||
++$row;
|
||||
}
|
||||
|
||||
foreach (range('A', 'I') as $column) {
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */
|
||||
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
|
||||
{
|
||||
/** @var list<Question> $questions */
|
||||
$questions = $quiz->questions->toArray();
|
||||
|
||||
$header = ['Candidate'];
|
||||
foreach ($questions as $question) {
|
||||
$header[] = $question->question;
|
||||
}
|
||||
|
||||
$sheet->fromArray($header, null, 'A1');
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
$sheet->getStyle('1:1')->getAlignment()->setWrapText(true);
|
||||
|
||||
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
|
||||
$answersByCandidateAndQuestion = [];
|
||||
foreach ($questions as $question) {
|
||||
foreach ($question->answers as $answer) {
|
||||
foreach ($answer->givenAnswers as $givenAnswer) {
|
||||
$candidateId = $givenAnswer->candidate->id->toString();
|
||||
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$row = 2;
|
||||
foreach ($quiz->candidateData as $quizCandidate) {
|
||||
$candidate = $quizCandidate->candidate;
|
||||
|
||||
$line = [$candidate->name];
|
||||
foreach ($questions as $question) {
|
||||
$line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? '';
|
||||
}
|
||||
|
||||
$sheet->fromArray($line, null, 'A'.$row);
|
||||
++$row;
|
||||
}
|
||||
|
||||
$lastColumnIndex = 1 + \count($questions);
|
||||
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
|
||||
$sheet->getColumnDimension($column)->setWidth(30);
|
||||
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
|
||||
}
|
||||
}
|
||||
|
||||
private function fillEliminationsSheet(Worksheet $sheet, Quiz $quiz): void
|
||||
{
|
||||
/** @var list<Candidate> $candidates */
|
||||
$candidates = $quiz->season->candidates->toArray();
|
||||
|
||||
$header = ['Prepared at', 'Deleted'];
|
||||
foreach ($candidates as $candidate) {
|
||||
$header[] = $candidate->name;
|
||||
}
|
||||
|
||||
$sheet->fromArray($header, null, 'A1');
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
$row = 2;
|
||||
foreach ($quiz->eliminations as $elimination) {
|
||||
$line = [
|
||||
$elimination->getCreatedAt()?->format(\DateTimeInterface::ATOM) ?? '',
|
||||
$elimination->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$line[] = $elimination->getScreenColour($candidate->name) ?? '';
|
||||
}
|
||||
|
||||
$sheet->fromArray($line, null, 'A'.$row);
|
||||
++$row;
|
||||
}
|
||||
|
||||
foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) {
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildCandidatesWorkbook(Season $season): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
$candidatesSheet = $spreadsheet->getActiveSheet();
|
||||
$candidatesSheet->setTitle('Candidates');
|
||||
$candidatesSheet->fromArray(['Name'], null, 'A1');
|
||||
$candidatesSheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
$row = 2;
|
||||
foreach ($season->candidates as $candidate) {
|
||||
$candidatesSheet->fromArray([$candidate->name], null, 'A'.$row);
|
||||
++$row;
|
||||
}
|
||||
|
||||
$candidatesSheet->getColumnDimension('A')->setAutoSize(true);
|
||||
|
||||
$infoSheet = $spreadsheet->createSheet();
|
||||
$infoSheet->setTitle('Season info');
|
||||
$infoSheet->getStyle('A:A')->getFont()->setBold(true);
|
||||
$infoSheet->fromArray([
|
||||
['Season name', $season->name],
|
||||
['Season code', $season->seasonCode],
|
||||
['Number of quizzes', $season->quizzes->count()],
|
||||
['Number of candidates', $season->candidates->count()],
|
||||
['Active quiz', $season->activeQuiz instanceof Quiz ? $season->activeQuiz->name : ''],
|
||||
['Show numbers', $season->settings?->showNumbers ? 'Yes' : 'No'],
|
||||
['Confirm answers', $season->settings?->confirmAnswers ? 'Yes' : 'No'],
|
||||
['Shared with other owners', $season->owners->count() > 1 ? 'Yes' : 'No'],
|
||||
], null, 'A1');
|
||||
$infoSheet->getColumnDimension('A')->setAutoSize(true);
|
||||
$infoSheet->getColumnDimension('B')->setAutoSize(true);
|
||||
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
private function buildQuestionBankWorkbook(Season $season): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
$questions = $spreadsheet->getActiveSheet();
|
||||
$questions->setTitle('Questions');
|
||||
$this->fillBankQuestionsSheet($questions, $season);
|
||||
|
||||
$labels = $spreadsheet->createSheet();
|
||||
$labels->setTitle('Labels');
|
||||
$this->fillQuestionLabelsSheet($labels, $season);
|
||||
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
private function fillBankQuestionsSheet(Worksheet $sheet, Season $season): void
|
||||
{
|
||||
$metaColumns = ['Question', 'Reusable', 'Complete for quiz', 'Labels', 'Used in quizzes'];
|
||||
$sheet->fromArray($metaColumns, null, 'A1');
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
$answerStartColumnIndex = \count($metaColumns);
|
||||
$maxAnswers = 0;
|
||||
$row = 2;
|
||||
|
||||
foreach ($season->bankQuestions as $bankQuestion) {
|
||||
$labels = implode(', ', array_map(
|
||||
static fn (QuestionLabel $label): string => $label->name,
|
||||
$bankQuestion->labels->toArray(),
|
||||
));
|
||||
$usedInQuizzes = implode(', ', array_map(
|
||||
static fn (BankQuestionUsage $usage): string => $usage->quiz->name,
|
||||
$bankQuestion->usages->toArray(),
|
||||
));
|
||||
|
||||
$sheet->fromArray([
|
||||
$bankQuestion->question,
|
||||
$bankQuestion->reusable ? 'Yes' : 'No',
|
||||
$bankQuestion->isCompleteForQuiz ? 'Yes' : 'No',
|
||||
$labels,
|
||||
$usedInQuizzes,
|
||||
], null, 'A'.$row);
|
||||
|
||||
$col = 0;
|
||||
foreach ($bankQuestion->answers as $answer) {
|
||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $col).$row, $answer->text);
|
||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $col).$row, $answer->isRightAnswer);
|
||||
++$col;
|
||||
}
|
||||
|
||||
$maxAnswers = max($maxAnswers, $col);
|
||||
++$row;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $maxAnswers; ++$i) {
|
||||
$answerCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $i);
|
||||
$correctCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $i);
|
||||
|
||||
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
|
||||
$sheet->setCellValue($correctCol.'1', 'Correct');
|
||||
}
|
||||
|
||||
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
|
||||
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
}
|
||||
}
|
||||
|
||||
private function fillQuestionLabelsSheet(Worksheet $sheet, Season $season): void
|
||||
{
|
||||
$sheet->fromArray(['Name', 'Colour', 'Slug'], null, 'A1');
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
$row = 2;
|
||||
foreach ($season->questionLabels as $label) {
|
||||
$sheet->fromArray([$label->name, $label->colour->name, $label->slug], null, 'A'.$row);
|
||||
++$row;
|
||||
}
|
||||
|
||||
foreach (['A', 'B', 'C'] as $column) {
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws FilesystemException */
|
||||
private function writeToTempFile(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_sheet_');
|
||||
|
||||
try {
|
||||
new Writer\Xlsx($spreadsheet)->save($path);
|
||||
} catch (\Throwable $throwable) {
|
||||
unlink($path);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Tvdt\Service;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Reader;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Tvdt\Entity\Answer;
|
||||
@@ -117,8 +118,13 @@ class QuizSpreadsheetService
|
||||
public function quizToXlsx(Quiz $quiz): \Closure
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$this->fillQuestionsSheet($spreadsheet->getActiveSheet(), $quiz);
|
||||
|
||||
return $this->toXlsx($spreadsheet);
|
||||
}
|
||||
|
||||
public function fillQuestionsSheet(Worksheet $sheet, Quiz $quiz): void
|
||||
{
|
||||
// Write data rows first so we know the maximum answer count.
|
||||
$maxAnswers = 0;
|
||||
$row = 2;
|
||||
@@ -153,11 +159,9 @@ class QuizSpreadsheetService
|
||||
$sheet->setCellValue($correctCol.'1', 'Correct');
|
||||
$sheet->getColumnDimension($correctCol)->setAutoSize(true);
|
||||
}
|
||||
|
||||
return $this->toXlsx($spreadsheet);
|
||||
}
|
||||
|
||||
private function toXlsx(Spreadsheet $spreadsheet): \Closure
|
||||
public function toXlsx(Spreadsheet $spreadsheet): \Closure
|
||||
{
|
||||
$writer = new Writer\Xlsx($spreadsheet);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user