mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-11 12:28:23 +02:00
0ee15e3cbb
* 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.
445 lines
16 KiB
PHP
445 lines
16 KiB
PHP
<?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;
|
|
}
|
|
}
|