From 29fa01d5bd5938f6d2d516f7dd367be79250ce05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 13 Nov 2025 00:12:49 +0100 Subject: [PATCH 01/25] Renaming supplementary files to exercise files (preparing for supplementary/attachment files unification). --- .../presenters/ExerciseFilesPresenter.php | 84 +++--- .../presenters/PipelinesPresenter.php | 64 ++--- .../presenters/UploadedFilesPresenter.php | 20 +- .../presenters/WorkerFilesPresenter.php | 10 +- app/V1Module/router/RouterFactory.php | 39 ++- .../security/ACL/IUploadedFilePermissions.php | 4 +- .../Policies/UploadedFilePermissionPolicy.php | 10 +- app/commands/cleanup/CleanupExerciseFiles.php | 90 +------ app/commands/runtimes/RuntimeExport.php | 12 +- app/commands/runtimes/RuntimeImport.php | 28 +- app/config/config.local.neon.example | 4 +- app/config/config.neon | 6 +- app/config/permissions.neon | 10 +- app/helpers/Config/ExercisesConfig.php | 16 +- app/helpers/Evaluation/IExercise.php | 2 +- .../Compilation/PipelinesMerger.php | 2 +- app/helpers/ExerciseConfig/Compiler.php | 2 +- .../Validation/EnvironmentConfigValidator.php | 2 +- .../Validation/ExerciseConfigValidator.php | 7 +- .../Validation/PipelineValidator.php | 7 +- app/helpers/ExerciseConfig/Validator.php | 2 +- app/helpers/FileStorageManager.php | 24 +- app/helpers/JobConfig/Generator.php | 2 +- app/model/entity/Assignment.php | 20 +- app/model/entity/Exercise.php | 8 +- ...ntaryExerciseFile.php => ExerciseFile.php} | 16 +- app/model/entity/Pipeline.php | 38 +-- app/model/entity/base/ExerciseData.php | 33 +-- ...aryExerciseFiles.php => ExerciseFiles.php} | 14 +- app/model/view/AssignmentViewFactory.php | 2 +- app/model/view/ExerciseViewFactory.php | 2 +- app/model/view/PipelineViewFactory.php | 2 +- docs/swagger.yaml | 245 +++++++++++++++--- fixtures/demo/25-exercises.neon | 4 +- migrations/Version20251112225318.php | 87 +++++++ .../Compilation/BaseCompiler.phpt | 4 +- .../Compilation/PipelinesMerger.phpt | 4 +- .../EnvironmentConfigValidator.phpt | 5 +- .../Validation/PipelineValidator.phpt | 4 +- tests/Presenters/ExerciseFilesPresenter.phpt | 79 +++--- tests/Presenters/ExercisesPresenter.phpt | 6 +- tests/Presenters/PipelinesPresenter.phpt | 20 +- tests/Presenters/UploadedFilesPresenter.phpt | 31 ++- tests/Presenters/WorkerFilesPresenter.phpt | 24 +- 44 files changed, 661 insertions(+), 434 deletions(-) rename app/model/entity/{SupplementaryExerciseFile.php => ExerciseFile.php} (83%) rename app/model/repository/{SupplementaryExerciseFiles.php => ExerciseFiles.php} (64%) create mode 100644 migrations/Version20251112225318.php diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index f1f67c37a..19211dfc1 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -15,14 +15,15 @@ use App\Helpers\ExerciseConfig\ExerciseConfigChecker; use App\Helpers\ExercisesConfig; use App\Helpers\FileStorageManager; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\Assignment; +use App\Model\Entity\ExerciseFile; use App\Model\Entity\UploadedFile; use App\Model\Entity\AttachmentFile; use App\Model\Repository\Assignments; use App\Model\Repository\AttachmentFiles; use App\Model\Repository\Exercises; use App\Model\Entity\Exercise; -use App\Model\Repository\SupplementaryExerciseFiles; +use App\Model\Repository\ExerciseFiles; use App\Model\Repository\UploadedFiles; use App\Security\ACL\IExercisePermissions; use Exception; @@ -52,10 +53,10 @@ class ExerciseFilesPresenter extends BasePresenter public $uploadedFiles; /** - * @var SupplementaryExerciseFiles + * @var ExerciseFiles * @inject */ - public $supplementaryFiles; + public $exerciseFiles; /** * @var AttachmentFiles @@ -87,7 +88,7 @@ class ExerciseFilesPresenter extends BasePresenter */ public $configChecker; - public function checkUploadSupplementaryFiles(string $id) + public function checkUploadExerciseFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); if (!$this->exerciseAcl->canUpdate($exercise)) { @@ -96,29 +97,29 @@ public function checkUploadSupplementaryFiles(string $id) } /** - * Associate supplementary files with an exercise and upload them to remote file server + * Associate exercise files with an exercise and upload them to remote file server * @POST * @throws ForbiddenRequestException * @throws InvalidApiArgumentException * @throws SubmissionFailedException */ - #[Post("files", new VMixed(), "Identifiers of supplementary files", nullable: true)] + #[Post("files", new VMixed(), "Identifiers of exercise files", nullable: true)] #[Path("id", new VUuid(), "identification of exercise", required: true)] - public function actionUploadSupplementaryFiles(string $id) + public function actionUploadExerciseFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); $files = $this->uploadedFiles->findAllById($this->getRequest()->getPost("files")); - $currentSupplementaryFiles = []; + $currentFiles = []; $totalFileSize = 0; - /** @var SupplementaryExerciseFile $file */ - foreach ($exercise->getSupplementaryEvaluationFiles() as $file) { - $currentSupplementaryFiles[$file->getName()] = $file; + /** @var ExerciseFile $file */ + foreach ($exercise->getExerciseFiles() as $file) { + $currentFiles[$file->getName()] = $file; $totalFileSize += $file->getFileSize(); } - $totalFileCount = count($exercise->getSupplementaryEvaluationFiles()); + $totalFileCount = count($exercise->getExerciseFiles()); /** @var UploadedFile $file */ foreach ($files as $file) { @@ -126,10 +127,10 @@ public function actionUploadSupplementaryFiles(string $id) throw new ForbiddenRequestException("File {$file->getId()} was already used somewhere else"); } - if (array_key_exists($file->getName(), $currentSupplementaryFiles)) { - /** @var SupplementaryExerciseFile $currentFile */ - $currentFile = $currentSupplementaryFiles[$file->getName()]; - $exercise->getSupplementaryEvaluationFiles()->removeElement($currentFile); + if (array_key_exists($file->getName(), $currentFiles)) { + /** @var ExerciseFile $currentFile */ + $currentFile = $currentFiles[$file->getName()]; + $exercise->getExerciseFiles()->removeElement($currentFile); $totalFileSize -= $currentFile->getFileSize(); } else { $totalFileCount += 1; @@ -138,7 +139,7 @@ public function actionUploadSupplementaryFiles(string $id) $totalFileSize += $file->getFileSize(); } - $fileCountLimit = $this->restrictionsConfig->getSupplementaryFileCountLimit(); + $fileCountLimit = $this->restrictionsConfig->getExerciseFileCountLimit(); if ($totalFileCount > $fileCountLimit) { throw new InvalidApiArgumentException( 'files', @@ -146,7 +147,7 @@ public function actionUploadSupplementaryFiles(string $id) ); } - $sizeLimit = $this->restrictionsConfig->getSupplementaryFileSizeLimit(); + $sizeLimit = $this->restrictionsConfig->getExerciseFileSizeLimit(); if ($totalFileSize > $sizeLimit) { throw new InvalidApiArgumentException( 'files', @@ -156,8 +157,8 @@ public function actionUploadSupplementaryFiles(string $id) /** @var UploadedFile $file */ foreach ($files as $file) { - $hash = $this->fileStorage->storeUploadedSupplementaryFile($file); - $exerciseFile = SupplementaryExerciseFile::fromUploadedFileAndExercise($file, $exercise, $hash); + $hash = $this->fileStorage->storeUploadedExerciseFile($file); + $exerciseFile = ExerciseFile::fromUploadedFileAndExercise($file, $exercise, $hash); $this->uploadedFiles->persist($exerciseFile, false); $this->uploadedFiles->remove($file, false); } @@ -169,50 +170,50 @@ public function actionUploadSupplementaryFiles(string $id) $this->configChecker->check($exercise); $this->exercises->flush(); - $this->sendSuccessResponse($exercise->getSupplementaryEvaluationFiles()->getValues()); + $this->sendSuccessResponse($exercise->getExerciseFiles()->getValues()); } - public function checkGetSupplementaryFiles(string $id) + public function checkGetExerciseFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); if (!$this->exerciseAcl->canViewDetail($exercise)) { - throw new ForbiddenRequestException("You cannot view supplementary files for this exercise."); + throw new ForbiddenRequestException("You cannot view exercise files for this exercise."); } } /** - * Get list of all supplementary files for an exercise + * Get list of all exercise files for an exercise * @GET */ #[Path("id", new VUuid(), "identification of exercise", required: true)] - public function actionGetSupplementaryFiles(string $id) + public function actionGetExerciseFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); - $this->sendSuccessResponse($exercise->getSupplementaryEvaluationFiles()->getValues()); + $this->sendSuccessResponse($exercise->getExerciseFiles()->getValues()); } - public function checkDeleteSupplementaryFile(string $id, string $fileId) + public function checkDeleteExerciseFile(string $id, string $fileId) { $exercise = $this->exercises->findOrThrow($id); if (!$this->exerciseAcl->canUpdate($exercise)) { - throw new ForbiddenRequestException("You cannot delete supplementary files for this exercise."); + throw new ForbiddenRequestException("You cannot delete exercise files for this exercise."); } } /** - * Delete supplementary exercise file with given id + * Delete exercise file with given id * @DELETE * @throws ForbiddenRequestException */ #[Path("id", new VUuid(), "identification of exercise", required: true)] #[Path("fileId", new VString(), "identification of file", required: true)] - public function actionDeleteSupplementaryFile(string $id, string $fileId) + public function actionDeleteExerciseFile(string $id, string $fileId) { $exercise = $this->exercises->findOrThrow($id); - $file = $this->supplementaryFiles->findOrThrow($fileId); + $file = $this->exerciseFiles->findOrThrow($fileId); $exercise->updatedNow(); - $exercise->removeSupplementaryEvaluationFile($file); + $exercise->removeExerciseFile($file); $this->exercises->flush(); $this->configChecker->check($exercise); @@ -221,16 +222,16 @@ public function actionDeleteSupplementaryFile(string $id, string $fileId) $this->sendSuccessResponse("OK"); } - public function checkDownloadSupplementaryFilesArchive(string $id) + public function checkDownloadExerciseFilesArchive(string $id) { $exercise = $this->exercises->findOrThrow($id); if (!$this->exerciseAcl->canViewDetail($exercise)) { - throw new ForbiddenRequestException("You cannot access archive of exercise supplementary files"); + throw new ForbiddenRequestException("You cannot access archive of exercise files"); } } /** - * Download archive containing all supplementary files for exercise. + * Download archive containing all files for exercise. * @GET * @throws ForbiddenRequestException * @throws NotFoundException @@ -238,16 +239,17 @@ public function checkDownloadSupplementaryFilesArchive(string $id) * @throws \Nette\Application\AbortException */ #[Path("id", new VUuid(), "of exercise", required: true)] - public function actionDownloadSupplementaryFilesArchive(string $id) + public function actionDownloadExerciseFilesArchive(string $id) { $exercise = $this->exercises->findOrThrow($id); $files = []; - foreach ($exercise->getSupplementaryEvaluationFiles() as $file) { + foreach ($exercise->getExerciseFiles() as $file) { + /** @var ExerciseFile $file */ $files[$file->getName()] = $file->getFile($this->fileStorage); } - $this->sendZipFilesResponse($files, "exercise-supplementary-{$id}.zip", true); + $this->sendZipFilesResponse($files, "exercise-files-{$id}.zip", true); } public function checkUploadAttachmentFiles(string $id) @@ -362,6 +364,7 @@ public function actionDeleteAttachmentFile(string $id, string $fileId) // file has no attachments to exercises, let's check the assignments $isUsed = false; foreach ($file->getAssignments() as $assignment) { + /** @var Assignment $assignment */ $group = $assignment->getGroup(); if ($group && !$group->isArchived()) { $isUsed = true; // only non-archived assignments are considered relevant @@ -376,10 +379,12 @@ public function actionDeleteAttachmentFile(string $id, string $fileId) // only if no attachments exists (except for deleted ones) // remove all links to deleted entities and remove the file record foreach ($file->getExercisesAndIReallyMeanAllOkay() as $exercise) { + /** @var Exercise $exercise */ $exercise->removeAttachmentFile($file); $this->exercises->persist($exercise, false); } foreach ($file->getAssignmentsAndIReallyMeanAllOkay() as $assignment) { + /** @var Assignment $assignment */ $assignment->removeAttachmentFile($file); $this->assignments->persist($assignment, false); } @@ -414,6 +419,7 @@ public function actionDownloadAttachmentFilesArchive(string $id) $files = []; foreach ($exercise->getAttachmentFiles() as $file) { + /** @var AttachmentFile $file */ $files[$file->getName()] = $file->getFile($this->fileStorage); } $this->sendZipFilesResponse($files, "exercise-attachment-{$id}.zip"); diff --git a/app/V1Module/presenters/PipelinesPresenter.php b/app/V1Module/presenters/PipelinesPresenter.php index 8eba2bd63..bae42172d 100644 --- a/app/V1Module/presenters/PipelinesPresenter.php +++ b/app/V1Module/presenters/PipelinesPresenter.php @@ -22,9 +22,9 @@ use App\Helpers\ExerciseConfig\Pipeline\Box\BoxService; use App\Helpers\FileStorageManager; use App\Model\Entity\PipelineConfig; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use App\Model\Entity\UploadedFile; -use App\Model\Repository\SupplementaryExerciseFiles; +use App\Model\Repository\ExerciseFiles; use App\Model\Repository\Exercises; use App\Model\Repository\UploadedFiles; use App\Model\Repository\RuntimeEnvironments; @@ -97,10 +97,10 @@ class PipelinesPresenter extends BasePresenter public $uploadedFiles; /** - * @var SupplementaryExerciseFiles + * @var ExerciseFiles * @inject */ - public $supplementaryFiles; + public $exerciseFiles; /** * @var PipelineViewFactory @@ -453,7 +453,7 @@ public function actionValidatePipeline(string $id) ); } - public function checkUploadSupplementaryFiles(string $id) + public function checkUploadExerciseFiles(string $id) { $pipeline = $this->pipelines->findOrThrow($id); if (!$this->pipelineAcl->canUpdate($pipeline)) { @@ -462,24 +462,24 @@ public function checkUploadSupplementaryFiles(string $id) } /** - * Associate supplementary files with a pipeline and upload them to remote file server + * Associate exercise files with a pipeline and upload them to remote file server * @POST * @throws ForbiddenRequestException * @throws SubmissionFailedException * @throws NotFoundException */ - #[Post("files", new VMixed(), "Identifiers of supplementary files", nullable: true)] + #[Post("files", new VMixed(), "Identifiers of exercise files", nullable: true)] #[Path("id", new VUuid(), "identification of pipeline", required: true)] - public function actionUploadSupplementaryFiles(string $id) + public function actionUploadExerciseFiles(string $id) { $pipeline = $this->pipelines->findOrThrow($id); $files = $this->uploadedFiles->findAllById($this->getRequest()->getPost("files")); - $supplementaryFiles = []; - $currentSupplementaryFiles = []; + $exerciseFiles = []; + $currentFiles = []; - /** @var SupplementaryExerciseFile $file */ - foreach ($pipeline->getSupplementaryEvaluationFiles() as $file) { - $currentSupplementaryFiles[$file->getName()] = $file; + /** @var ExerciseFile $file */ + foreach ($pipeline->getExerciseFiles() as $file) { + $currentFiles[$file->getName()] = $file; } /** @var UploadedFile $file */ @@ -488,15 +488,15 @@ public function actionUploadSupplementaryFiles(string $id) throw new ForbiddenRequestException("File {$file->getId()} was already used somewhere else"); } - if (array_key_exists($file->getName(), $currentSupplementaryFiles)) { - /** @var SupplementaryExerciseFile $currentFile */ - $currentFile = $currentSupplementaryFiles[$file->getName()]; - $pipeline->getSupplementaryEvaluationFiles()->removeElement($currentFile); + if (array_key_exists($file->getName(), $currentFiles)) { + /** @var ExerciseFile $currentFile */ + $currentFile = $currentFiles[$file->getName()]; + $pipeline->getExerciseFiles()->removeElement($currentFile); } - $hash = $this->fileStorage->storeUploadedSupplementaryFile($file); - $pipelineFile = SupplementaryExerciseFile::fromUploadedFileAndPipeline($file, $pipeline, $hash); - $supplementaryFiles[] = $pipelineFile; + $hash = $this->fileStorage->storeUploadedExerciseFile($file); + $pipelineFile = ExerciseFile::fromUploadedFileAndPipeline($file, $pipeline, $hash); + $exerciseFiles[] = $pipelineFile; $this->uploadedFiles->persist($pipelineFile, false); $this->uploadedFiles->remove($file, false); @@ -506,51 +506,51 @@ public function actionUploadSupplementaryFiles(string $id) $this->pipelines->flush(); $this->uploadedFiles->flush(); - $this->sendSuccessResponse($pipeline->getSupplementaryEvaluationFiles()->getValues()); + $this->sendSuccessResponse($pipeline->getExerciseFiles()->getValues()); } - public function checkGetSupplementaryFiles(string $id) + public function checkGetExerciseFiles(string $id) { $pipeline = $this->pipelines->findOrThrow($id); if (!$this->pipelineAcl->canViewDetail($pipeline)) { - throw new ForbiddenRequestException("You cannot view supplementary files for this pipeline."); + throw new ForbiddenRequestException("You cannot view exercise files for this pipeline."); } } /** - * Get list of all supplementary files for a pipeline + * Get list of all exercise files for a pipeline * @GET * @throws NotFoundException */ #[Path("id", new VUuid(), "identification of pipeline", required: true)] - public function actionGetSupplementaryFiles(string $id) + public function actionGetExerciseFiles(string $id) { $pipeline = $this->pipelines->findOrThrow($id); - $this->sendSuccessResponse($pipeline->getSupplementaryEvaluationFiles()->getValues()); + $this->sendSuccessResponse($pipeline->getExerciseFiles()->getValues()); } - public function checkDeleteSupplementaryFile(string $id, string $fileId) + public function checkDeleteExerciseFile(string $id, string $fileId) { $pipeline = $this->pipelines->findOrThrow($id); if (!$this->pipelineAcl->canUpdate($pipeline)) { - throw new ForbiddenRequestException("You cannot delete supplementary files for this pipeline."); + throw new ForbiddenRequestException("You cannot delete exercise files for this pipeline."); } } /** - * Delete supplementary pipeline file with given id + * Delete exercise file with given id * @DELETE * @throws NotFoundException */ #[Path("id", new VUuid(), "identification of pipeline", required: true)] #[Path("fileId", new VString(), "identification of file", required: true)] - public function actionDeleteSupplementaryFile(string $id, string $fileId) + public function actionDeleteExerciseFile(string $id, string $fileId) { $pipeline = $this->pipelines->findOrThrow($id); - $file = $this->supplementaryFiles->findOrThrow($fileId); + $file = $this->exerciseFiles->findOrThrow($fileId); $pipeline->updatedNow(); - $pipeline->getSupplementaryEvaluationFiles()->removeElement($file); + $pipeline->getExerciseFiles()->removeElement($file); $this->pipelines->flush(); $this->sendSuccessResponse("OK"); diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index aa5c978b9..80715c57d 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -22,7 +22,7 @@ use App\Helpers\UploadsConfig; use App\Model\Repository\Assignments; use App\Model\Repository\AssignmentSolutions; -use App\Model\Repository\SupplementaryExerciseFiles; +use App\Model\Repository\ExerciseFiles; use App\Model\Repository\UploadedFiles; use App\Model\Repository\UploadedPartialFiles; use App\Model\Repository\PlagiarismDetectedSimilarFiles; @@ -95,10 +95,10 @@ class UploadedFilesPresenter extends BasePresenter public $assignmentSolutionAcl; /** - * @var SupplementaryExerciseFiles + * @var ExerciseFiles * @inject */ - public $supplementaryFiles; + public $exerciseFiles; /** * @var PlagiarismDetectedSimilarFiles @@ -583,28 +583,28 @@ public function actionCompletePartial(string $id) $this->sendSuccessResponse($uploadedFile); } - public function checkDownloadSupplementaryFile(string $id) + public function checkDownloadExerciseFile(string $id) { - $file = $this->supplementaryFiles->findOrThrow($id); - if (!$this->uploadedFileAcl->canDownloadSupplementaryFile($file)) { + $file = $this->exerciseFiles->findOrThrow($id); + if (!$this->uploadedFileAcl->canDownloadExerciseFile($file)) { throw new ForbiddenRequestException("You are not allowed to download file '{$file->getId()}"); } } /** - * Download supplementary file + * Download exercise file * @GET * @throws ForbiddenRequestException * @throws NotFoundException * @throws \Nette\Application\AbortException */ #[Path("id", new VUuid(), "Identifier of the file", required: true)] - public function actionDownloadSupplementaryFile(string $id) + public function actionDownloadExerciseFile(string $id) { - $fileEntity = $this->supplementaryFiles->findOrThrow($id); + $fileEntity = $this->exerciseFiles->findOrThrow($id); $file = $fileEntity->getFile($this->fileStorage); if (!$file) { - throw new NotFoundException("Supplementary file not found in the storage"); + throw new NotFoundException("Exercise file not found in the storage"); } $this->sendStorageFileResponse($file, $fileEntity->getName()); } diff --git a/app/V1Module/presenters/WorkerFilesPresenter.php b/app/V1Module/presenters/WorkerFilesPresenter.php index 9fd219a90..467aa3e89 100644 --- a/app/V1Module/presenters/WorkerFilesPresenter.php +++ b/app/V1Module/presenters/WorkerFilesPresenter.php @@ -112,15 +112,15 @@ public function actionDownloadSubmissionArchive(string $type, string $id) } /** - * Sends over an exercise supplementary file (a data file required by the tests). + * Sends over an exercise file (a data file required by the tests). * @GET */ - #[Path("hash", new VString(), "identification of the supplementary file", required: true)] - public function actionDownloadSupplementaryFile(string $hash) + #[Path("hash", new VString(), "identification of the exercise file", required: true)] + public function actionDownloadExerciseFile(string $hash) { - $file = $this->fileStorage->getSupplementaryFileByHash($hash); + $file = $this->fileStorage->getExerciseFileByHash($hash); if (!$file) { - throw new NotFoundException("Supplementary file not found in the storage"); + throw new NotFoundException("Exercise file not found in the storage"); } $this->sendStorageFileResponse($file, $hash); } diff --git a/app/V1Module/router/RouterFactory.php b/app/V1Module/router/RouterFactory.php index ef07b77a8..b3c5a13aa 100644 --- a/app/V1Module/router/RouterFactory.php +++ b/app/V1Module/router/RouterFactory.php @@ -162,16 +162,29 @@ private static function createExercisesRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix//admins", "Exercises:setAdmins"); $router[] = new PostRoute("$prefix//notification", "Exercises:sendNotification"); - $router[] = new GetRoute("$prefix//supplementary-files", "ExerciseFiles:getSupplementaryFiles"); - $router[] = new PostRoute("$prefix//supplementary-files", "ExerciseFiles:uploadSupplementaryFiles"); + $router[] = new GetRoute("$prefix//exercise-files", "ExerciseFiles:getExerciseFiles"); + $router[] = new PostRoute("$prefix//exercise-files", "ExerciseFiles:uploadExerciseFiles"); + $router[] = new DeleteRoute( + "$prefix//exercise-files/", + "ExerciseFiles:deleteExerciseFile" + ); + $router[] = new GetRoute( + "$prefix//exercise-files/download-archive", + "ExerciseFiles:downloadExerciseFilesArchive" + ); + + // deprecated routes for supplementary files + $router[] = new GetRoute("$prefix//supplementary-files", "ExerciseFiles:getExerciseFiles"); + $router[] = new PostRoute("$prefix//supplementary-files", "ExerciseFiles:uploadExerciseFiles"); $router[] = new DeleteRoute( "$prefix//supplementary-files/", - "ExerciseFiles:deleteSupplementaryFile" + "ExerciseFiles:deleteExerciseFile" ); $router[] = new GetRoute( "$prefix//supplementary-files/download-archive", - "ExerciseFiles:downloadSupplementaryFilesArchive" + "ExerciseFiles:downloadExerciseFilesArchive" ); + $router[] = new GetRoute("$prefix//attachment-files", "ExerciseFiles:getAttachmentFiles"); $router[] = new PostRoute("$prefix//attachment-files", "ExerciseFiles:uploadAttachmentFiles"); $router[] = new DeleteRoute("$prefix//attachment-files/", "ExerciseFiles:deleteAttachmentFile"); @@ -457,7 +470,7 @@ private static function createUploadedFilesRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix/partial/", "UploadedFiles:completePartial"); $router[] = new PostRoute("$prefix", "UploadedFiles:upload"); - $router[] = new GetRoute("$prefix/supplementary-file//download", "UploadedFiles:downloadSupplementaryFile"); + $router[] = new GetRoute("$prefix/supplementary-file//download", "UploadedFiles:downloadExerciseFile"); $router[] = new GetRoute("$prefix/", "UploadedFiles:detail"); $router[] = new GetRoute("$prefix//download", "UploadedFiles:download"); $router[] = new GetRoute("$prefix//content", "UploadedFiles:content"); @@ -571,10 +584,15 @@ private static function createPipelinesRoutes(string $prefix): RouteList $router[] = new DeleteRoute("$prefix/", "Pipelines:removePipeline"); $router[] = new PostRoute("$prefix//runtime-environments", "Pipelines:updateRuntimeEnvironments"); $router[] = new PostRoute("$prefix//validate", "Pipelines:validatePipeline"); - $router[] = new GetRoute("$prefix//supplementary-files", "Pipelines:getSupplementaryFiles"); - $router[] = new PostRoute("$prefix//supplementary-files", "Pipelines:uploadSupplementaryFiles"); - $router[] = new DeleteRoute("$prefix//supplementary-files/", "Pipelines:deleteSupplementaryFile"); + $router[] = new GetRoute("$prefix//exercise-files", "Pipelines:getExerciseFiles"); + $router[] = new PostRoute("$prefix//exercise-files", "Pipelines:uploadExerciseFiles"); + $router[] = new DeleteRoute("$prefix//exercise-files/", "Pipelines:deleteExerciseFile"); $router[] = new GetRoute("$prefix//exercises", "Pipelines:getPipelineExercises"); + + // deprecated routes for supplementary files + $router[] = new GetRoute("$prefix//supplementary-files", "Pipelines:getExerciseFiles"); + $router[] = new PostRoute("$prefix//supplementary-files", "Pipelines:uploadExerciseFiles"); + $router[] = new DeleteRoute("$prefix//supplementary-files/", "Pipelines:deleteExerciseFile"); return $router; } @@ -642,8 +660,11 @@ private static function createWorkerFilesRoutes(string $prefix): RouteList { $router = new RouteList(); $router[] = new GetRoute("$prefix/submission-archive//", "WorkerFiles:downloadSubmissionArchive"); - $router[] = new GetRoute("$prefix/supplementary-file/", "WorkerFiles:downloadSupplementaryFile"); + $router[] = new GetRoute("$prefix/exercise-file/", "WorkerFiles:downloadExerciseFile"); $router[] = new PutRoute("$prefix/result//", "WorkerFiles:uploadResultsFile"); + + // deprecated route for supplementary files + $router[] = new GetRoute("$prefix/supplementary-file/", "WorkerFiles:downloadExerciseFile"); return $router; } diff --git a/app/V1Module/security/ACL/IUploadedFilePermissions.php b/app/V1Module/security/ACL/IUploadedFilePermissions.php index b9a2d429c..982e6688b 100644 --- a/app/V1Module/security/ACL/IUploadedFilePermissions.php +++ b/app/V1Module/security/ACL/IUploadedFilePermissions.php @@ -2,7 +2,7 @@ namespace App\Security\ACL; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use App\Model\Entity\UploadedFile; interface IUploadedFilePermissions @@ -13,5 +13,5 @@ public function canDownload(UploadedFile $file): bool; public function canUpload(): bool; - public function canDownloadSupplementaryFile(SupplementaryExerciseFile $file): bool; + public function canDownloadExerciseFile(ExerciseFile $file): bool; } diff --git a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php index afdfcbe73..4c4466bff 100644 --- a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php +++ b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php @@ -5,7 +5,7 @@ use App\Model\Entity\Assignment; use App\Model\Entity\AttachmentFile; use App\Model\Entity\Exercise; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use App\Model\Entity\UploadedFile; use App\Model\Repository\Assignments; use App\Model\Repository\UploadedFiles; @@ -49,9 +49,9 @@ function ($i, Exercise $exercise) { ); } - public function isAuthorOfSupplementaryFileExercises(Identity $identity, UploadedFile $file) + public function isAuthorOfFileExercises(Identity $identity, UploadedFile $file) { - if (!($file instanceof SupplementaryExerciseFile)) { + if (!($file instanceof ExerciseFile)) { return false; } @@ -70,9 +70,9 @@ public function isAuthorOfSupplementaryFileExercises(Identity $identity, Uploade } - public function isSupplementaryInGroupUserSupervises(Identity $identity, UploadedFile $file) + public function isExerciseFileInGroupUserSupervises(Identity $identity, UploadedFile $file) { - if (!($file instanceof SupplementaryExerciseFile)) { + if (!($file instanceof ExerciseFile)) { return false; } diff --git a/app/commands/cleanup/CleanupExerciseFiles.php b/app/commands/cleanup/CleanupExerciseFiles.php index d4db32a3a..dcd336141 100644 --- a/app/commands/cleanup/CleanupExerciseFiles.php +++ b/app/commands/cleanup/CleanupExerciseFiles.php @@ -2,108 +2,42 @@ namespace App\Console; -use App\Helpers\FileStorageManager; -use App\Helpers\FileStorage\FileStorageException; -use App\Model\Repository\SupplementaryExerciseFiles; -use App\Model\Repository\AttachmentFiles; +use App\Model\Repository\ExerciseFiles; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Tracy\ILogger; #[AsCommand( name: 'db:cleanup:exercise-files', - description: 'Remove unused supplementary and attachment files ' - . '(only DB records are removed in case of supplementary files).' + description: 'Remove unused exercise files (only DB records are removed).' )] class CleanupExercisesFiles extends Command { /** - * @var SupplementaryExerciseFiles + * @var ExerciseFiles */ - private $supplementaryFiles; + private $files; - /** - * @var AttachmentFiles - */ - private $attachmentFiles; - - /** - * @var ILogger - */ - private $logger; - - /** - * @var FileStorageManager - */ - private $fileStorage; - - - public function __construct( - SupplementaryExerciseFiles $supplementaryFiles, - AttachmentFiles $attachmentFiles, - ILogger $logger, - FileStorageManager $fileStorage - ) { - parent::__construct(); - $this->supplementaryFiles = $supplementaryFiles; - $this->attachmentFiles = $attachmentFiles; - $this->logger = $logger; - $this->fileStorage = $fileStorage; - } - - private function removeUnusedSupplementaryFiles(OutputInterface $output) + public function __construct(ExerciseFiles $files) { - $unused = $this->supplementaryFiles->findUnused(); - foreach ($unused as $file) { - $this->supplementaryFiles->remove($file); - } - - $output->writeln(sprintf("Removed %d unused supplementary file records.", count($unused))); + parent::__construct(); + $this->files = $files; } - private function removeUnusedAttachmentFiles(OutputInterface $output) + private function removeUnusedFiles(OutputInterface $output) { - $unused = $this->attachmentFiles->findUnused(); - $deleted = 0; - $missing = 0; - $errors = 0; - + $unused = $this->files->findUnused(); foreach ($unused as $file) { - try { - if (!$this->fileStorage->deleteAttachmentFile($file)) { - $id = $file->getId(); - $name = $file->getName(); - $this->logger->log("Attachment file '$name' ($id) has been already deleted.", ILogger::WARNING); - ++$missing; - } else { - ++$deleted; - } - } catch (FileStorageException $e) { - $this->logger->log($e->getMessage(), ILogger::EXCEPTION); - ++$errors; - } - $this->attachmentFiles->remove($file); + $this->files->remove($file); } - $output->writeln(sprintf( - "Removed %d unused attachment file records, %d actual files deleted from the storage.", - count($unused), - $deleted - )); - if ($missing) { - $output->writeln("Total $missing files were missing (only DB records have been deleted)."); - } - if ($errors) { - $output->writeln("Total $errors errors encountered and duly logged."); - } + $output->writeln(sprintf("Removed %d unused exercise file records.", count($unused))); } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->removeUnusedSupplementaryFiles($output); - $this->removeUnusedAttachmentFiles($output); + $this->removeUnusedFiles($output); return 0; } } diff --git a/app/commands/runtimes/RuntimeExport.php b/app/commands/runtimes/RuntimeExport.php index 00f29b119..0d1a3af27 100644 --- a/app/commands/runtimes/RuntimeExport.php +++ b/app/commands/runtimes/RuntimeExport.php @@ -48,9 +48,9 @@ protected function configure() protected static function preprocessPipeline(Pipeline $pipeline) { - $supplementaryFiles = []; - foreach ($pipeline->getSupplementaryEvaluationFiles()->getValues() as $file) { - $supplementaryFiles[] = [ + $files = []; + foreach ($pipeline->getExerciseFiles()->getValues() as $file) { + $files[] = [ "name" => $file->getName(), "uploadedAt" => $file->getUploadedAt()->getTimestamp(), "size" => $file->getFileSize(), @@ -65,7 +65,7 @@ protected static function preprocessPipeline(Pipeline $pipeline) "createdAt" => $pipeline->getCreatedAt()->getTimestamp(), "updatedAt" => $pipeline->getUpdatedAt()->getTimestamp(), "description" => $pipeline->getDescription(), - "supplementaryFiles" => $supplementaryFiles, + "supplementaryFiles" => $files, "parameters" => array_merge(Pipeline::DEFAULT_PARAMETERS, $pipeline->getParameters()->toArray()), ]; } @@ -122,9 +122,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int self::addJsonFile($zip, $pipeline->getId() . ".json", $config); } - // Add supplementary pipeline files + // Add pipeline evaluation files foreach ($pipelines as $pipeline) { - $files = $pipeline->getSupplementaryEvaluationFiles()->getValues(); + $files = $pipeline->getExerciseFiles()->getValues(); foreach ($files as $supFile) { $name = $supFile->getName(); $pid = $pipeline->getId(); diff --git a/app/commands/runtimes/RuntimeImport.php b/app/commands/runtimes/RuntimeImport.php index cafbee1cd..d21fcd2fc 100644 --- a/app/commands/runtimes/RuntimeImport.php +++ b/app/commands/runtimes/RuntimeImport.php @@ -7,7 +7,7 @@ use App\Model\Repository\UploadedFiles; use App\Model\Entity\RuntimeEnvironment; use App\Model\Entity\Pipeline; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use App\Helpers\TmpFilesHelper; use App\Helpers\FileStorageManager; use App\Helpers\FileStorage\ZipFileStorage; @@ -130,7 +130,7 @@ protected function configure() ], ]; - private const SUPPLEMENTARY_FILE_SCHEMA = [ + private const FILE_SCHEMA = [ 'name' => 'string', 'uploadedAt' => 'integer', 'size' => 'integer', @@ -212,13 +212,13 @@ protected static function loadManifest(ZipArchive $zip): array foreach ($pipeline['supplementaryFiles'] as $fidx => $file) { self::validate( $file, - self::SUPPLEMENTARY_FILE_SCHEMA, + self::FILE_SCHEMA, ['pipelines', $pidx, 'supplementaryFiles', $fidx] ); } } - // verify existence of pipelines and supplementary files + // verify existence of pipelines and exercise files foreach ($manifest['pipelines'] as $pipeline) { $pipelineEntry = $pipeline['id'] . '.json'; if ($zip->statName($pipelineEntry) === false) { @@ -393,11 +393,11 @@ protected function updatePipeline(?Pipeline $pipeline, array $data, RuntimeEnvir $pipeline->overrideCreatedFrom(null); $pipeline->addRuntimeEnvironment($runtime); - // make sure list of supplementary files is empty (they will be added later) - foreach ($pipeline->getSupplementaryEvaluationFiles()->toArray() as $file) { + // make sure list of exercise files is empty (they will be added later) + foreach ($pipeline->getExerciseFiles()->toArray() as $file) { $this->uploadedFiles->remove($file, false); } - $pipeline->getSupplementaryEvaluationFiles()->clear(); + $pipeline->getExerciseFiles()->clear(); $this->pipelines->persist($pipeline); return $pipeline; @@ -435,25 +435,25 @@ protected function getTargetPipeline(array $data): ?Pipeline } /** - * Save supplementary files in file storage and update their DB records associated with pipeline. + * Save exercise files in file storage and update their DB records associated with pipeline. * @param ZipArchive $zip * @param array $data of pipeline loaded from manifest - * @param Pipeline $pipeline entity to which the supplementary files are associated + * @param Pipeline $pipeline entity to which the exercise files are associated */ - protected function updatePipelineSupplementaryFiles(ZipArchive $zip, array $data, Pipeline $pipeline): void + protected function updatePipelineExerciseFiles(ZipArchive $zip, array $data, Pipeline $pipeline): void { // copy all files from zip to hash storage $id = $data['id']; foreach ($data['supplementaryFiles'] as &$supFile) { $tmp = $this->tmpFilesHelper->createTmpFile('rexcmd'); ZipFileStorage::extractZipEntryToFile($zip, '', "$id/{$supFile['name']}", $tmp); - $supFile['hash'] = $this->fileManager->storeSupplementaryFile($tmp, true); // true = move (to save time) + $supFile['hash'] = $this->fileManager->storeExerciseFile($tmp, true); // true = move (to save time) } unset($supFile); // safely dispose of a reference - // create new supplementary files records + // create new exercise files records foreach ($data['supplementaryFiles'] as $supFile) { - $file = new SupplementaryExerciseFile( + $file = new ExerciseFile( $supFile['name'], DateTime::createFromFormat('U', $supFile['uploadedAt']), $supFile['size'], @@ -519,7 +519,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $pipeline = $this->updatePipeline($pipeline, $pipelineData, $runtime); // files must go first, so they are up to date for pipeline verification - $this->updatePipelineSupplementaryFiles($zip, $pipelineData, $pipeline); + $this->updatePipelineExerciseFiles($zip, $pipelineData, $pipeline); // update config structure $pipelineConfigData = $this->loadPipeline($zip, $pipelineData['id'], $pipeline); diff --git a/app/config/config.local.neon.example b/app/config/config.local.neon.example index a6228d35e..68bdd41ca 100644 --- a/app/config/config.local.neon.example +++ b/app/config/config.local.neon.example @@ -65,8 +65,8 @@ parameters: exercises: # Restrictions testCountLimit: 100 # maximal number of tests in one exercise - supplementaryFileCountLimit: 200 # maximal number of test files - supplementaryFileSizeLimit: 268435456 # 256 MiB, max. total size of all test files + exerciseFileCountLimit: 200 # maximal number of test files + exerciseFileSizeLimit: 268435456 # 256 MiB, max. total size of all test files # Default values for newly created exercises solutionFilesLimitDefault: 10 # at most 10 files per solution (default, configurable per assignment) solutionSizeLimitDefault: 262144 # 256 KiB, max. size for all submitted files (default, configurable per assignment) diff --git a/app/config/config.neon b/app/config/config.neon index 95f141d1e..472ccae4d 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -187,8 +187,8 @@ parameters: exercises: # Restrictions testCountLimit: 100 - supplementaryFileCountLimit: 200 - supplementaryFileSizeLimit: 268435456 # 256 MiB + exerciseFileCountLimit: 200 + exerciseFileSizeLimit: 268435456 # 256 MiB # Default values for newly created exercises solutionFilesLimitDefault: 10 # at most 10 files per solution solutionSizeLimitDefault: 262144 # 256 KiB is the maximal size for all submitted files @@ -455,7 +455,7 @@ services: - App\Model\Repository\Solutions - App\Model\Repository\GroupMemberships - App\Model\Repository\HardwareGroups - - App\Model\Repository\SupplementaryExerciseFiles + - App\Model\Repository\ExerciseFiles - App\Model\Repository\AttachmentFiles - App\Model\Repository\Pipelines - App\Model\Repository\SecurityEvents diff --git a/app/config/permissions.neon b/app/config/permissions.neon index 744f17136..87ee4c418 100644 --- a/app/config/permissions.neon +++ b/app/config/permissions.neon @@ -1030,7 +1030,7 @@ permissions: actions: - download - viewDetail - - downloadSupplementaryFile + - downloadExerciseFile - allow: true role: scope-ref-solutions @@ -1038,7 +1038,7 @@ permissions: actions: - download - viewDetail - - downloadSupplementaryFile + - downloadExerciseFile - upload - allow: true @@ -1095,11 +1095,11 @@ permissions: resource: uploadedFile actions: - viewDetail - - downloadSupplementaryFile + - downloadExerciseFile conditions: or: - - file.isAuthorOfSupplementaryFileExercises - - file.isSupplementaryInGroupUserSupervises + - file.isAuthorOfFileExercises + - file.isExerciseFileInGroupUserSupervises ##################################### # Runtime environments permissions # diff --git a/app/helpers/Config/ExercisesConfig.php b/app/helpers/Config/ExercisesConfig.php index e1e860b80..72c244eea 100644 --- a/app/helpers/Config/ExercisesConfig.php +++ b/app/helpers/Config/ExercisesConfig.php @@ -11,8 +11,8 @@ class ExercisesConfig // Restrictions private $testCountLimit; - private $supplementaryFileCountLimit; - private $supplementaryFileSizeLimit; + private $exerciseFileCountLimit; + private $exerciseFileSizeLimit; // Defaults private $solutionFilesLimitDefault; @@ -22,8 +22,8 @@ class ExercisesConfig public function __construct(array $config) { $this->testCountLimit = Arrays::get($config, "testCountLimit", 100); - $this->supplementaryFileCountLimit = Arrays::get($config, "supplementaryFileCountLimit", 200); - $this->supplementaryFileSizeLimit = Arrays::get($config, "supplementaryFileSizeLimit", 256 * 1024 * 1024); + $this->exerciseFileCountLimit = Arrays::get($config, "exerciseFileCountLimit", 200); + $this->exerciseFileSizeLimit = Arrays::get($config, "exerciseFileSizeLimit", 256 * 1024 * 1024); $this->solutionFilesLimitDefault = Arrays::get($config, "solutionFilesLimitDefault", 10); $this->solutionSizeLimitDefault = Arrays::get($config, "solutionSizeLimitDefault", 256 * 1024); } @@ -33,14 +33,14 @@ public function getTestCountLimit(): int return $this->testCountLimit; } - public function getSupplementaryFileCountLimit(): int + public function getExerciseFileCountLimit(): int { - return $this->supplementaryFileCountLimit; + return $this->exerciseFileCountLimit; } - public function getSupplementaryFileSizeLimit() + public function getExerciseFileSizeLimit() { - return $this->supplementaryFileSizeLimit; + return $this->exerciseFileSizeLimit; } public function getSolutionFilesLimitDefault(): ?int diff --git a/app/helpers/Evaluation/IExercise.php b/app/helpers/Evaluation/IExercise.php index ade0d436d..ba84cbed2 100644 --- a/app/helpers/Evaluation/IExercise.php +++ b/app/helpers/Evaluation/IExercise.php @@ -90,7 +90,7 @@ public function getConfigurationType(): string; * Returns array indexed by the name of the file which contains hash of file. * @return string[] */ - public function getHashedSupplementaryFiles(): array; + public function getHashedExerciseFiles(): array; /** * Get tests indexed by entity id and containing actual test name. diff --git a/app/helpers/ExerciseConfig/Compilation/PipelinesMerger.php b/app/helpers/ExerciseConfig/Compilation/PipelinesMerger.php index 8b8e15230..3a53b1bbf 100644 --- a/app/helpers/ExerciseConfig/Compilation/PipelinesMerger.php +++ b/app/helpers/ExerciseConfig/Compilation/PipelinesMerger.php @@ -281,7 +281,7 @@ private function processPipeline( try { $pipelineEntity = $this->pipelinesCache->getPipeline($pipelineId); $pipelineConfig = $this->pipelinesCache->getNewPipelineConfig($pipelineId); - $pipelineFiles = $pipelineEntity->getHashedSupplementaryFiles(); + $pipelineFiles = $pipelineEntity->getHashedExerciseFiles(); } catch (NotFoundException $e) { throw new ExerciseCompilationException("Pipeline '$pipelineId' not found in environment"); } diff --git a/app/helpers/ExerciseConfig/Compiler.php b/app/helpers/ExerciseConfig/Compiler.php index faac68db6..49732fd43 100644 --- a/app/helpers/ExerciseConfig/Compiler.php +++ b/app/helpers/ExerciseConfig/Compiler.php @@ -109,7 +109,7 @@ public function compile( $exerciseConfig, $environmentConfigVariables, $limits, - $exercise->getHashedSupplementaryFiles(), + $exercise->getHashedExerciseFiles(), $exercise->getExerciseTestsNames(), $runtimeEnvironment->getId() ); diff --git a/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php b/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php index 8f93bb59a..0c91a041d 100644 --- a/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php +++ b/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php @@ -21,7 +21,7 @@ class EnvironmentConfigValidator */ public function validate(Exercise $exercise, VariablesTable $table) { - $exerciseFiles = $exercise->getHashedSupplementaryFiles(); + $exerciseFiles = $exercise->getHashedExerciseFiles(); foreach ($table->getAll() as $variable) { ValidationUtils::checkRemoteFilePresence($variable, $exerciseFiles, "exercise"); } diff --git a/app/helpers/ExerciseConfig/Validation/ExerciseConfigValidator.php b/app/helpers/ExerciseConfig/Validation/ExerciseConfigValidator.php index 3932539c6..ea20b6c5d 100644 --- a/app/helpers/ExerciseConfig/Validation/ExerciseConfigValidator.php +++ b/app/helpers/ExerciseConfig/Validation/ExerciseConfigValidator.php @@ -3,11 +3,9 @@ namespace App\Helpers\ExerciseConfig\Validation; use App\Exceptions\ExerciseConfigException; -use App\Exceptions\NotFoundException; use App\Helpers\ExerciseConfig\ExerciseConfig; use App\Helpers\ExerciseConfig\Helper; use App\Helpers\ExerciseConfig\Loader; -use App\Helpers\ExerciseConfig\PipelinesCache; use App\Helpers\ExerciseConfig\PipelineVars; use App\Helpers\ExerciseConfig\Variable; use App\Helpers\ExerciseConfig\VariablesTable; @@ -19,7 +17,6 @@ */ class ExerciseConfigValidator { - /** * @var Loader */ @@ -121,7 +118,7 @@ private function checkPipelinesSection( VariablesTable $environmentVariables, ?string $environment = null ) { - $exerciseFiles = $exercise->getHashedSupplementaryFiles(); + $exerciseFiles = $exercise->getHashedExerciseFiles(); // load pipeline configurations from database $pipelinesIds = []; @@ -166,7 +163,7 @@ function (string $name) use ($variable) { } ); - // check supplementary remote files if exists in exercise entity + // check exercise files if exists in exercise entity ValidationUtils::checkRemoteFilePresence($variable, $exerciseFiles, "exercise"); } diff --git a/app/helpers/ExerciseConfig/Validation/PipelineValidator.php b/app/helpers/ExerciseConfig/Validation/PipelineValidator.php index 75b8bb833..15b99016d 100644 --- a/app/helpers/ExerciseConfig/Validation/PipelineValidator.php +++ b/app/helpers/ExerciseConfig/Validation/PipelineValidator.php @@ -12,20 +12,19 @@ */ class PipelineValidator { - /** * Validate pipeline. * For more detailed description look at @ref App\Helpers\ExerciseConfig\Validator * @param PipelineEntity $pipeline * @param Pipeline $pipelineConfig - * @param array|null $pipelineFiles supplementary files of pipeline [ fileName => fileHash] + * @param array|null $pipelineFiles exercise files of pipeline [ fileName => fileHash] * if null, the array is automatically loaded from the pipeline entity * @throws ExerciseConfigException */ public function validate(PipelineEntity $pipeline, Pipeline $pipelineConfig, ?array $pipelineFiles = null): void { $variables = $pipelineConfig->getVariablesTable(); - $pipelineFiles = $pipelineFiles ?? $pipeline->getHashedSupplementaryFiles(); + $pipelineFiles = $pipelineFiles ?? $pipeline->getHashedExerciseFiles(); // Check ports of all boxes foreach ($pipelineConfig->getAll() as $box) { @@ -100,7 +99,7 @@ public function validate(PipelineEntity $pipeline, Pipeline $pipelineConfig, ?ar throw new ExerciseConfigException(sprintf("No port uses variable %s", $variableName)); } - // check supplementary remote files if exists in pipeline entity + // check exercise remote files if exists in pipeline entity ValidationUtils::checkRemoteFilePresence($variable, $pipelineFiles, "pipeline"); } } diff --git a/app/helpers/ExerciseConfig/Validator.php b/app/helpers/ExerciseConfig/Validator.php index b688d8004..35d0e59ea 100644 --- a/app/helpers/ExerciseConfig/Validator.php +++ b/app/helpers/ExerciseConfig/Validator.php @@ -75,7 +75,7 @@ public function __construct( * database entity. * @param PipelineEntity $pipeline * @param Pipeline $pipelineConfig - * @param array|null $pipelineFiles supplementary files of pipeline [ fileName => fileHash] + * @param array|null $pipelineFiles exercise files of pipeline [ fileName => fileHash ] * if null, the array is automatically loaded from the pipeline entity * @throws ExerciseConfigException */ diff --git a/app/helpers/FileStorageManager.php b/app/helpers/FileStorageManager.php index c1aac9168..d0f4e597f 100644 --- a/app/helpers/FileStorageManager.php +++ b/app/helpers/FileStorageManager.php @@ -311,23 +311,23 @@ public function deleteUploadedFile(UploadedFile $file): bool } /** - * Save a regular file to persistent hash storage for supplementary files. - * Used for special situations, storeUploadedSupplementaryFile() is used in most cases. + * Save a regular file to persistent hash storage for exercise files. + * Used for special situations, storeUploadedExerciseFile() is used in most cases. * @param string $pathToFile file to be stored * @param bool $move whether the file should be moved instead of copied (may save time) - * @return string hash identifying stored supplementary file + * @return string hash identifying stored exercise file */ - public function storeSupplementaryFile(string $pathToFile, bool $move = false): string + public function storeExerciseFile(string $pathToFile, bool $move = false): string { return $this->hashStorage->storeFile($pathToFile, $move); } /** - * Move uploaded file to persistent hash storage for supplementary files. + * Move uploaded file to persistent hash storage for exercise files. * @param UploadedFile $uploadedFile to be moved from tmp upload storage to hash storage - * @return string hash identifying stored supplementary file + * @return string hash identifying stored exercise file */ - public function storeUploadedSupplementaryFile(UploadedFile $uploadedFile): string + public function storeUploadedExerciseFile(UploadedFile $uploadedFile): string { $tmp = $this->tmpFilesHelper->createTmpFile('rexfsm'); $this->fileStorage->extract($this->getUploadedFilePath($uploadedFile), $tmp, true); @@ -335,11 +335,11 @@ public function storeUploadedSupplementaryFile(UploadedFile $uploadedFile): stri } /** - * Retrieve a supplementary file by its hash and return an immutable file object. + * Retrieve an exercise file by its hash and return an immutable file object. * @param string $hash hash identification of the file * @return IImmutableFile|null a file object or null if no such file exists */ - public function getSupplementaryFileByHash(string $hash): ?IImmutableFile + public function getExerciseFileByHash(string $hash): ?IImmutableFile { return $this->hashStorage->fetch($hash); } @@ -682,12 +682,12 @@ public function getWorkerResultExternalUrl(string $type, string $id): string } /** - * Generator for external URL prefixes for downloading supplementary files. + * Generator for external URL prefixes for downloading exercise files. * The worker only appends / to the URL prefix. * @return string URL prefix */ - public function getWorkerSupplementaryFilesExternalUrlPrefix(): string + public function getWorkerExerciseFilesExternalUrlPrefix(): string { - return "$this->apiUrl/v1/worker-files/supplementary-file"; + return "$this->apiUrl/v1/worker-files/exercise-file"; } } diff --git a/app/helpers/JobConfig/Generator.php b/app/helpers/JobConfig/Generator.php index 20b6515dc..469ba5006 100644 --- a/app/helpers/JobConfig/Generator.php +++ b/app/helpers/JobConfig/Generator.php @@ -61,7 +61,7 @@ public function generateJobConfig( ): JobConfig { $jobConfig = $this->compiler->compile($exerciseAssignment, $runtimeEnvironment, $params); $jobConfig->getSubmissionHeader()->setId($submission->getId())->setType($submission::JOB_TYPE); - $jobConfig->setFileCollector($this->fileStorage->getWorkerSupplementaryFilesExternalUrlPrefix()); + $jobConfig->setFileCollector($this->fileStorage->getWorkerExerciseFilesExternalUrlPrefix()); $this->fileStorage->storeJobConfig($submission, (string)$jobConfig); return $jobConfig; } diff --git a/app/model/entity/Assignment.php b/app/model/entity/Assignment.php index fc41cc01d..d2f4ccc47 100644 --- a/app/model/entity/Assignment.php +++ b/app/model/entity/Assignment.php @@ -74,7 +74,7 @@ private function __construct( $this->updatedAt = $this->createdAt; $this->syncedAt = $this->createdAt; $this->configurationType = $exercise->getConfigurationType(); - $this->supplementaryEvaluationFiles = $exercise->getSupplementaryEvaluationFiles(); + $this->exerciseFiles = $exercise->getExerciseFiles(); $this->attachmentFiles = $exercise->getAttachmentFiles(); $this->solutionFilesLimit = $exercise->getSolutionFilesLimit(); $this->solutionSizeLimit = $exercise->getSolutionSizeLimit(); @@ -450,15 +450,15 @@ function ($key, ExerciseTest $test) use ($exercise) { ); } - public function areSupplementaryFilesInSync(): bool + public function areExerciseFilesInSync(): bool { $exercise = $this->getExercise(); return $exercise - && $this->getSupplementaryEvaluationFiles()->count() - === $exercise->getSupplementaryEvaluationFiles()->count() - && $this->getSupplementaryEvaluationFiles()->forAll( - function ($key, SupplementaryExerciseFile $file) use ($exercise) { - return $exercise->getSupplementaryEvaluationFiles()->contains($file); + && $this->getExerciseFiles()->count() + === $exercise->getExerciseFiles()->count() + && $this->getExerciseFiles()->forAll( + function ($key, ExerciseFile $file) use ($exercise) { + return $exercise->getExerciseFiles()->contains($file); } ); } @@ -526,9 +526,9 @@ public function syncWithExercise() $this->exerciseTests->add($test); } - $this->supplementaryEvaluationFiles->clear(); - foreach ($exercise->getSupplementaryEvaluationFiles() as $file) { - $this->supplementaryEvaluationFiles->add($file); + $this->exerciseFiles->clear(); + foreach ($exercise->getExerciseFiles() as $file) { + $this->exerciseFiles->add($file); } $this->attachmentFiles->clear(); diff --git a/app/model/entity/Exercise.php b/app/model/entity/Exercise.php index 045ae472c..162bc2f40 100644 --- a/app/model/entity/Exercise.php +++ b/app/model/entity/Exercise.php @@ -118,7 +118,7 @@ class Exercise implements IExercise * @param Collection $localizedTexts * @param Collection $runtimeEnvironments * @param Collection $hardwareGroups - * @param Collection $supplementaryEvaluationFiles + * @param Collection $exerciseFiles * @param Collection $attachmentFiles * @param Collection $exerciseLimits * @param Collection $exerciseEnvironmentConfigs @@ -139,7 +139,7 @@ private function __construct( Collection $localizedTexts, Collection $runtimeEnvironments, Collection $hardwareGroups, - Collection $supplementaryEvaluationFiles, + Collection $exerciseFiles, Collection $attachmentFiles, Collection $exerciseLimits, Collection $exerciseEnvironmentConfigs, @@ -164,7 +164,7 @@ private function __construct( $this->exercise = $exercise; $this->author = $user; $this->admins = new ArrayCollection(); - $this->supplementaryEvaluationFiles = $supplementaryEvaluationFiles; + $this->exerciseFiles = $exerciseFiles; $this->isPublic = $isPublic; $this->isLocked = $isLocked; $this->isBroken = false; @@ -224,7 +224,7 @@ public static function forkFrom(Exercise $exercise, User $user, Group $group): E $exercise->localizedTexts, $exercise->runtimeEnvironments, $exercise->hardwareGroups, - $exercise->supplementaryEvaluationFiles, + $exercise->exerciseFiles, $exercise->attachmentFiles, $exercise->exerciseLimits, $exercise->exerciseEnvironmentConfigs, diff --git a/app/model/entity/SupplementaryExerciseFile.php b/app/model/entity/ExerciseFile.php similarity index 83% rename from app/model/entity/SupplementaryExerciseFile.php rename to app/model/entity/ExerciseFile.php index daad4244f..bc3350817 100644 --- a/app/model/entity/SupplementaryExerciseFile.php +++ b/app/model/entity/ExerciseFile.php @@ -13,7 +13,7 @@ /** * @ORM\Entity */ -class SupplementaryExerciseFile extends UploadedFile implements JsonSerializable +class ExerciseFile extends UploadedFile implements JsonSerializable { /** * @ORM\Column(type="string") @@ -21,7 +21,7 @@ class SupplementaryExerciseFile extends UploadedFile implements JsonSerializable protected $hashName; /** - * @ORM\ManyToMany(targetEntity="Exercise", mappedBy="supplementaryEvaluationFiles") + * @ORM\ManyToMany(targetEntity="Exercise", mappedBy="exerciseFiles") */ protected $exercises; @@ -38,7 +38,7 @@ function (Exercise $exercise) { } /** - * @ORM\ManyToMany(targetEntity="Assignment", mappedBy="supplementaryEvaluationFiles") + * @ORM\ManyToMany(targetEntity="Assignment", mappedBy="exerciseFiles") */ protected $assignments; @@ -55,13 +55,13 @@ function (Assignment $assignment) { } /** - * @ORM\ManyToMany(targetEntity="Pipeline", mappedBy="supplementaryEvaluationFiles") + * @ORM\ManyToMany(targetEntity="Pipeline", mappedBy="exerciseFiles") */ protected $pipelines; /** - * SupplementaryExerciseFile constructor. + * ExerciseFile constructor. * @param string $name * @param DateTime $uploadedAt * @param int $fileSize @@ -88,12 +88,12 @@ public function __construct( if ($exercise) { $this->exercises->add($exercise); - $exercise->addSupplementaryEvaluationFile($this); + $exercise->addExerciseFile($this); } if ($pipeline) { $this->pipelines->add($pipeline); - $pipeline->addSupplementaryEvaluationFile($this); + $pipeline->addExerciseFile($this); } } @@ -132,7 +132,7 @@ public function jsonSerialize(): mixed public function getFile(FileStorageManager $manager): ?IImmutableFile { - return $manager->getSupplementaryFileByHash($this->getHashName()); + return $manager->getExerciseFileByHash($this->getHashName()); } /* diff --git a/app/model/entity/Pipeline.php b/app/model/entity/Pipeline.php index 72941c3fa..922c7cf55 100644 --- a/app/model/entity/Pipeline.php +++ b/app/model/entity/Pipeline.php @@ -87,10 +87,10 @@ public function overrideCreatedFrom(?Pipeline $pipeline): void } /** - * @ORM\ManyToMany(targetEntity="SupplementaryExerciseFile", inversedBy="pipelines") + * @ORM\ManyToMany(targetEntity="ExerciseFile", inversedBy="pipelines") * @var Collection */ - protected $supplementaryEvaluationFiles; + protected $exerciseFiles; /** * @ORM\OneToMany(targetEntity="PipelineParameter", mappedBy="pipeline", indexBy="name", @@ -121,7 +121,7 @@ public function overrideCreatedFrom(?Pipeline $pipeline): void * @param int $version * @param string $description * @param PipelineConfig $pipelineConfig - * @param Collection $supplementaryEvaluationFiles + * @param Collection $exerciseFiles * @param User $author * @param Pipeline|null $createdFrom * @param Collection|null $runtimeEnvironments @@ -132,7 +132,7 @@ private function __construct( int $version, string $description, PipelineConfig $pipelineConfig, - Collection $supplementaryEvaluationFiles, + Collection $exerciseFiles, ?User $author = null, ?Pipeline $createdFrom = null, ?Collection $runtimeEnvironments = null @@ -146,7 +146,7 @@ private function __construct( $this->pipelineConfig = $pipelineConfig; $this->author = $author; $this->createdFrom = $createdFrom; - $this->supplementaryEvaluationFiles = $supplementaryEvaluationFiles; + $this->exerciseFiles = $exerciseFiles; $this->parameters = new ArrayCollection(); $this->runtimeEnvironments = new ArrayCollection(); if ($runtimeEnvironments) { @@ -156,28 +156,28 @@ private function __construct( } } - public function getSupplementaryEvaluationFiles(): Collection + public function getExerciseFiles(): Collection { - return $this->supplementaryEvaluationFiles; + return $this->exerciseFiles; } /** - * Add supplementary file which should be accessible within pipeline. - * @param SupplementaryExerciseFile $exerciseFile + * Add exercise file which should be accessible within pipeline. + * @param ExerciseFile $exerciseFile */ - public function addSupplementaryEvaluationFile(SupplementaryExerciseFile $exerciseFile) + public function addExerciseFile(ExerciseFile $exerciseFile) { - $this->supplementaryEvaluationFiles->add($exerciseFile); + $this->exerciseFiles->add($exerciseFile); } /** - * Get array of identifications of supplementary files + * Get array of identifications of exercise files * @return array */ - public function getSupplementaryFilesIds() + public function getExerciseFilesIds() { - return $this->supplementaryEvaluationFiles->map( - function (SupplementaryExerciseFile $file) { + return $this->exerciseFiles->map( + function (ExerciseFile $file) { return $file->getId(); } )->getValues(); @@ -187,11 +187,11 @@ function (SupplementaryExerciseFile $file) { * Get array containing hashes of files indexed by the name. * @return array */ - public function getHashedSupplementaryFiles(): array + public function getHashedExerciseFiles(): array { $files = []; - /** @var SupplementaryExerciseFile $file */ - foreach ($this->supplementaryEvaluationFiles as $file) { + /** @var ExerciseFile $file */ + foreach ($this->exerciseFiles as $file) { $files[$file->getName()] = $file->getHashName(); } return $files; @@ -255,7 +255,7 @@ public static function forkFrom(?User $user, Pipeline $pipeline): Pipeline $pipeline->getVersion(), $pipeline->getDescription(), $pipeline->getPipelineConfig(), - $pipeline->getSupplementaryEvaluationFiles(), + $pipeline->getExerciseFiles(), $user, $pipeline, ); diff --git a/app/model/entity/base/ExerciseData.php b/app/model/entity/base/ExerciseData.php index 209e16a39..4b1a5d609 100644 --- a/app/model/entity/base/ExerciseData.php +++ b/app/model/entity/base/ExerciseData.php @@ -39,6 +39,7 @@ public function getLocalizedTextsAssocArray(): array { $result = []; foreach ($this->getLocalizedTexts() as $text) { + /** @var LocalizedExercise $text */ $result[$text->getLocale()] = $text; } return $result; @@ -278,48 +279,48 @@ function (ExerciseTest $test) { } /** - * @ORM\ManyToMany(targetEntity="SupplementaryExerciseFile") + * @ORM\ManyToMany(targetEntity="ExerciseFile") * @var Collection */ - protected $supplementaryEvaluationFiles; + protected $exerciseFiles; - public function getSupplementaryEvaluationFiles(): Collection + public function getExerciseFiles(): Collection { - return $this->supplementaryEvaluationFiles; + return $this->exerciseFiles; } - public function addSupplementaryEvaluationFile(SupplementaryExerciseFile $exerciseFile) + public function addExerciseFile(ExerciseFile $exerciseFile) { - $this->supplementaryEvaluationFiles->add($exerciseFile); + $this->exerciseFiles->add($exerciseFile); } /** - * @param SupplementaryExerciseFile $file + * @param ExerciseFile $file * @return bool */ - public function removeSupplementaryEvaluationFile(SupplementaryExerciseFile $file) + public function removeExerciseFile(ExerciseFile $file) { - return $this->supplementaryEvaluationFiles->removeElement($file); + return $this->exerciseFiles->removeElement($file); } /** - * Get identifications of supplementary evaluation files. + * Get identifications of exercise files. * @return array */ - public function getSupplementaryFilesIds() + public function getExerciseFilesIds() { - return $this->supplementaryEvaluationFiles->map( - function (SupplementaryExerciseFile $file) { + return $this->exerciseFiles->map( + function (ExerciseFile $file) { return $file->getId(); } )->getValues(); } - public function getHashedSupplementaryFiles(): array + public function getHashedExerciseFiles(): array { $files = []; - /** @var SupplementaryExerciseFile $file */ - foreach ($this->supplementaryEvaluationFiles as $file) { + /** @var ExerciseFile $file */ + foreach ($this->exerciseFiles as $file) { $files[$file->getName()] = $file->getHashName(); } return $files; diff --git a/app/model/repository/SupplementaryExerciseFiles.php b/app/model/repository/ExerciseFiles.php similarity index 64% rename from app/model/repository/SupplementaryExerciseFiles.php rename to app/model/repository/ExerciseFiles.php index bbdffe846..218b09123 100644 --- a/app/model/repository/SupplementaryExerciseFiles.php +++ b/app/model/repository/ExerciseFiles.php @@ -2,27 +2,27 @@ namespace App\Model\Repository; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use Doctrine\ORM\EntityManagerInterface; /** - * @extends BaseRepository + * @extends BaseRepository */ -class SupplementaryExerciseFiles extends BaseRepository +class ExerciseFiles extends BaseRepository { public function __construct(EntityManagerInterface $em) { - parent::__construct($em, SupplementaryExerciseFile::class); + parent::__construct($em, ExerciseFile::class); } /** - * Find supplementary files that are not used in any exercise nor assignment. - * @return SupplementaryExerciseFile[] + * Find exercise files that are not used in any exercise nor assignment. + * @return ExerciseFile[] */ public function findUnused(): array { $query = $this->em->createQuery(" - SELECT f FROM App\Model\Entity\SupplementaryExerciseFile f + SELECT f FROM App\Model\Entity\ExerciseFile f WHERE NOT EXISTS (SELECT e FROM App\Model\Entity\Exercise e WHERE e MEMBER OF f.exercises AND e.deletedAt IS NULL) AND NOT EXISTS diff --git a/app/model/view/AssignmentViewFactory.php b/app/model/view/AssignmentViewFactory.php index 992269f22..5ea2f3a63 100644 --- a/app/model/view/AssignmentViewFactory.php +++ b/app/model/view/AssignmentViewFactory.php @@ -102,7 +102,7 @@ function (LocalizedExercise $text) use ($assignment) { "upToDate" => $assignment->areExerciseTestsInSync() ], "supplementaryFiles" => [ - "upToDate" => $assignment->areSupplementaryFilesInSync() + "upToDate" => $assignment->areExerciseFilesInSync() ], "attachmentFiles" => [ "upToDate" => $assignment->areAttachmentFilesInSync() diff --git a/app/model/view/ExerciseViewFactory.php b/app/model/view/ExerciseViewFactory.php index 4a1d4002c..d46dd57ec 100644 --- a/app/model/view/ExerciseViewFactory.php +++ b/app/model/view/ExerciseViewFactory.php @@ -57,7 +57,7 @@ public function getExercise(Exercise $exercise) "groupsIds" => $exercise->getGroupsIds(), "mergeJudgeLogs" => $exercise->getMergeJudgeLogs(), "description" => $primaryLocalization ? $primaryLocalization->getDescription() : "", // BC - "supplementaryFilesIds" => $exercise->getSupplementaryFilesIds(), + "supplementaryFilesIds" => $exercise->getExerciseFilesIds(), "attachmentFilesIds" => $exercise->getAttachmentFilesIds(), "configurationType" => $exercise->getConfigurationType(), "isPublic" => $exercise->isPublic(), diff --git a/app/model/view/PipelineViewFactory.php b/app/model/view/PipelineViewFactory.php index 913898c66..67e28242d 100644 --- a/app/model/view/PipelineViewFactory.php +++ b/app/model/view/PipelineViewFactory.php @@ -43,7 +43,7 @@ public function getPipeline(Pipeline $pipeline) "description" => $pipeline->getDescription(), "author" => $pipeline->getAuthor() ? $pipeline->getAuthor()->getId() : null, "forkedFrom" => $pipeline->getCreatedFrom() ? $pipeline->getCreatedFrom()->getId() : null, - "supplementaryFilesIds" => $pipeline->getSupplementaryFilesIds(), + "supplementaryFilesIds" => $pipeline->getExerciseFilesIds(), "pipeline" => $pipeline->getPipelineConfig()->getParsedPipeline(), "parameters" => array_merge(Pipeline::DEFAULT_PARAMETERS, $pipeline->getParameters()->toArray()), "runtimeEnvironmentIds" => $pipeline->getRuntimeEnvironments()->map( diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a9e852a15..f9d93d76a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -986,11 +986,102 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/exercises/{id}/exercise-files': + get: + summary: 'Get list of all exercise files for an exercise' + description: 'Get list of all exercise files for an exercise' + operationId: exerciseFilesPresenterActionGetExerciseFiles + parameters: + - + name: id + in: path + description: 'identification of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + responses: + '200': + description: 'Placeholder response' + post: + summary: 'Associate exercise files with an exercise and upload them to remote file server' + description: 'Associate exercise files with an exercise and upload them to remote file server' + operationId: exerciseFilesPresenterActionUploadExerciseFiles + parameters: + - + name: id + in: path + description: 'identification of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + requestBody: + content: + application/json: + schema: + required: + - files + properties: + files: + description: 'Identifiers of exercise files' + type: string + nullable: true + type: object + responses: + '200': + description: 'Placeholder response' + '/v1/exercises/{id}/exercise-files/{fileId}': + delete: + summary: 'Delete exercise file with given id' + description: 'Delete exercise file with given id' + operationId: exerciseFilesPresenterActionDeleteExerciseFile + parameters: + - + name: id + in: path + description: 'identification of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + - + name: fileId + in: path + description: 'identification of file' + required: true + schema: + type: string + nullable: false + responses: + '200': + description: 'Placeholder response' + '/v1/exercises/{id}/exercise-files/download-archive': + get: + summary: 'Download archive containing all files for exercise.' + description: 'Download archive containing all files for exercise.' + operationId: exerciseFilesPresenterActionDownloadExerciseFilesArchive + parameters: + - + name: id + in: path + description: 'of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + responses: + '200': + description: 'Placeholder response' '/v1/exercises/{id}/supplementary-files': get: - summary: 'Get list of all supplementary files for an exercise' - description: 'Get list of all supplementary files for an exercise' - operationId: exerciseFilesPresenterActionGetSupplementaryFiles + summary: 'Get list of all exercise files for an exercise' + description: 'Get list of all exercise files for an exercise' + operationId: exerciseFilesPresenterActionGetExerciseFiles parameters: - name: id @@ -1005,9 +1096,9 @@ paths: '200': description: 'Placeholder response' post: - summary: 'Associate supplementary files with an exercise and upload them to remote file server' - description: 'Associate supplementary files with an exercise and upload them to remote file server' - operationId: exerciseFilesPresenterActionUploadSupplementaryFiles + summary: 'Associate exercise files with an exercise and upload them to remote file server' + description: 'Associate exercise files with an exercise and upload them to remote file server' + operationId: exerciseFilesPresenterActionUploadExerciseFiles parameters: - name: id @@ -1026,7 +1117,7 @@ paths: - files properties: files: - description: 'Identifiers of supplementary files' + description: 'Identifiers of exercise files' type: string nullable: true type: object @@ -1035,9 +1126,9 @@ paths: description: 'Placeholder response' '/v1/exercises/{id}/supplementary-files/{fileId}': delete: - summary: 'Delete supplementary exercise file with given id' - description: 'Delete supplementary exercise file with given id' - operationId: exerciseFilesPresenterActionDeleteSupplementaryFile + summary: 'Delete exercise file with given id' + description: 'Delete exercise file with given id' + operationId: exerciseFilesPresenterActionDeleteExerciseFile parameters: - name: id @@ -1061,9 +1152,9 @@ paths: description: 'Placeholder response' '/v1/exercises/{id}/supplementary-files/download-archive': get: - summary: 'Download archive containing all supplementary files for exercise.' - description: 'Download archive containing all supplementary files for exercise.' - operationId: exerciseFilesPresenterActionDownloadSupplementaryFilesArchive + summary: 'Download archive containing all files for exercise.' + description: 'Download archive containing all files for exercise.' + operationId: exerciseFilesPresenterActionDownloadExerciseFilesArchive parameters: - name: id @@ -4535,9 +4626,9 @@ paths: description: 'Placeholder response' '/v1/uploaded-files/supplementary-file/{id}/download': get: - summary: 'Download supplementary file' - description: 'Download supplementary file' - operationId: uploadedFilesPresenterActionDownloadSupplementaryFile + summary: 'Download exercise file' + description: 'Download exercise file' + operationId: uploadedFilesPresenterActionDownloadExerciseFile parameters: - name: id @@ -5814,11 +5905,11 @@ paths: responses: '200': description: 'Placeholder response' - '/v1/pipelines/{id}/supplementary-files': + '/v1/pipelines/{id}/exercise-files': get: - summary: 'Get list of all supplementary files for a pipeline' - description: 'Get list of all supplementary files for a pipeline' - operationId: pipelinesPresenterActionGetSupplementaryFiles + summary: 'Get list of all exercise files for a pipeline' + description: 'Get list of all exercise files for a pipeline' + operationId: pipelinesPresenterActionGetExerciseFiles parameters: - name: id @@ -5833,9 +5924,9 @@ paths: '200': description: 'Placeholder response' post: - summary: 'Associate supplementary files with a pipeline and upload them to remote file server' - description: 'Associate supplementary files with a pipeline and upload them to remote file server' - operationId: pipelinesPresenterActionUploadSupplementaryFiles + summary: 'Associate exercise files with a pipeline and upload them to remote file server' + description: 'Associate exercise files with a pipeline and upload them to remote file server' + operationId: pipelinesPresenterActionUploadExerciseFiles parameters: - name: id @@ -5854,18 +5945,18 @@ paths: - files properties: files: - description: 'Identifiers of supplementary files' + description: 'Identifiers of exercise files' type: string nullable: true type: object responses: '200': description: 'Placeholder response' - '/v1/pipelines/{id}/supplementary-files/{fileId}': + '/v1/pipelines/{id}/exercise-files/{fileId}': delete: - summary: 'Delete supplementary pipeline file with given id' - description: 'Delete supplementary pipeline file with given id' - operationId: pipelinesPresenterActionDeleteSupplementaryFile + summary: 'Delete exercise file with given id' + description: 'Delete exercise file with given id' + operationId: pipelinesPresenterActionDeleteExerciseFile parameters: - name: id @@ -5905,6 +5996,79 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/pipelines/{id}/supplementary-files': + get: + summary: 'Get list of all exercise files for a pipeline' + description: 'Get list of all exercise files for a pipeline' + operationId: pipelinesPresenterActionGetExerciseFiles + parameters: + - + name: id + in: path + description: 'identification of pipeline' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + responses: + '200': + description: 'Placeholder response' + post: + summary: 'Associate exercise files with a pipeline and upload them to remote file server' + description: 'Associate exercise files with a pipeline and upload them to remote file server' + operationId: pipelinesPresenterActionUploadExerciseFiles + parameters: + - + name: id + in: path + description: 'identification of pipeline' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + requestBody: + content: + application/json: + schema: + required: + - files + properties: + files: + description: 'Identifiers of exercise files' + type: string + nullable: true + type: object + responses: + '200': + description: 'Placeholder response' + '/v1/pipelines/{id}/supplementary-files/{fileId}': + delete: + summary: 'Delete exercise file with given id' + description: 'Delete exercise file with given id' + operationId: pipelinesPresenterActionDeleteExerciseFile + parameters: + - + name: id + in: path + description: 'identification of pipeline' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + - + name: fileId + in: path + description: 'identification of file' + required: true + schema: + type: string + nullable: false + responses: + '200': + description: 'Placeholder response' /v1/extensions/sis/status/: get: summary: '* @OA\Get(path="/v1/extensions/sis/status/", operationId="sisPresenterActionStatus", @OA\Response(response="200",description="Placeholder response"))' @@ -6726,16 +6890,33 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/worker-files/exercise-file/{hash}': + get: + summary: 'Sends over an exercise file (a data file required by the tests).' + description: 'Sends over an exercise file (a data file required by the tests).' + operationId: workerFilesPresenterActionDownloadExerciseFile + parameters: + - + name: hash + in: path + description: 'identification of the exercise file' + required: true + schema: + type: string + nullable: false + responses: + '200': + description: 'Placeholder response' '/v1/worker-files/supplementary-file/{hash}': get: - summary: 'Sends over an exercise supplementary file (a data file required by the tests).' - description: 'Sends over an exercise supplementary file (a data file required by the tests).' - operationId: workerFilesPresenterActionDownloadSupplementaryFile + summary: 'Sends over an exercise file (a data file required by the tests).' + description: 'Sends over an exercise file (a data file required by the tests).' + operationId: workerFilesPresenterActionDownloadExerciseFile parameters: - name: hash in: path - description: 'identification of the supplementary file' + description: 'identification of the exercise file' required: true schema: type: string diff --git a/fixtures/demo/25-exercises.neon b/fixtures/demo/25-exercises.neon index 389fc7fd8..4bd5c3af6 100644 --- a/fixtures/demo/25-exercises.neon +++ b/fixtures/demo/25-exercises.neon @@ -207,8 +207,8 @@ App\Model\Entity\AttachmentFile: - @demoAdmin - @demoExercise -App\Model\Entity\SupplementaryExerciseFile: - demoSupplementaryExerciseFile: +App\Model\Entity\ExerciseFile: + demoExerciseFile: __construct: - "input.txt" - "" diff --git a/migrations/Version20251112225318.php b/migrations/Version20251112225318.php new file mode 100644 index 000000000..6bdeea7dd --- /dev/null +++ b/migrations/Version20251112225318.php @@ -0,0 +1,87 @@ +addSql('ALTER TABLE assignment_supplementary_exercise_file RENAME TO assignment_exercise_file'); + $this->addSql('ALTER TABLE exercise_supplementary_exercise_file RENAME TO exercise_exercise_file'); + $this->addSql('ALTER TABLE pipeline_supplementary_exercise_file RENAME TO pipeline_exercise_file'); + + $this->addSql('ALTER TABLE assignment_exercise_file DROP FOREIGN KEY FK_D6457EA62D777971'); + $this->addSql('DROP INDEX IDX_D6457EA62D777971 ON assignment_exercise_file'); + $this->addSql('DROP INDEX `primary` ON assignment_exercise_file'); + $this->addSql('ALTER TABLE assignment_exercise_file CHANGE supplementary_exercise_file_id exercise_file_id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE assignment_exercise_file ADD CONSTRAINT FK_1D217A6049DE8E29 FOREIGN KEY (exercise_file_id) REFERENCES `uploaded_file` (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_1D217A6049DE8E29 ON assignment_exercise_file (exercise_file_id)'); + $this->addSql('ALTER TABLE assignment_exercise_file ADD PRIMARY KEY (assignment_id, exercise_file_id)'); + $this->addSql('ALTER TABLE assignment_exercise_file RENAME INDEX idx_d6457ea6d19302f8 TO IDX_1D217A60D19302F8'); + $this->addSql('ALTER TABLE exercise_exercise_file DROP FOREIGN KEY FK_42359992D777971'); + $this->addSql('DROP INDEX IDX_42359992D777971 ON exercise_exercise_file'); + $this->addSql('DROP INDEX `primary` ON exercise_exercise_file'); + $this->addSql('ALTER TABLE exercise_exercise_file CHANGE supplementary_exercise_file_id exercise_file_id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE exercise_exercise_file ADD CONSTRAINT FK_97E2547B49DE8E29 FOREIGN KEY (exercise_file_id) REFERENCES `uploaded_file` (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_97E2547B49DE8E29 ON exercise_exercise_file (exercise_file_id)'); + $this->addSql('ALTER TABLE exercise_exercise_file ADD PRIMARY KEY (exercise_id, exercise_file_id)'); + $this->addSql('ALTER TABLE exercise_exercise_file RENAME INDEX idx_4235999e934951a TO IDX_97E2547BE934951A'); + $this->addSql('ALTER TABLE pipeline_exercise_file DROP FOREIGN KEY FK_DCF572882D777971'); + $this->addSql('DROP INDEX IDX_DCF572882D777971 ON pipeline_exercise_file'); + $this->addSql('DROP INDEX `primary` ON pipeline_exercise_file'); + $this->addSql('ALTER TABLE pipeline_exercise_file CHANGE supplementary_exercise_file_id exercise_file_id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE pipeline_exercise_file ADD CONSTRAINT FK_2A806B5C49DE8E29 FOREIGN KEY (exercise_file_id) REFERENCES `uploaded_file` (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_2A806B5C49DE8E29 ON pipeline_exercise_file (exercise_file_id)'); + $this->addSql('ALTER TABLE pipeline_exercise_file ADD PRIMARY KEY (pipeline_id, exercise_file_id)'); + $this->addSql('ALTER TABLE pipeline_exercise_file RENAME INDEX idx_dcf57288e80b93 TO IDX_2A806B5CE80B93'); + + $this->addSql('UPDATE uploaded_file SET discriminator = \'exercisefile\' WHERE discriminator = \'supplementaryexercisefile\''); + } + + public function down(Schema $schema): void + { + $this->addSql('UPDATE uploaded_file SET discriminator = \'supplementaryexercisefile\' WHERE discriminator = \'exercisefile\''); + + $this->addSql('ALTER TABLE assignment_exercise_file DROP FOREIGN KEY FK_1D217A6049DE8E29'); + $this->addSql('DROP INDEX IDX_1D217A6049DE8E29 ON assignment_exercise_file'); + $this->addSql('DROP INDEX `PRIMARY` ON assignment_exercise_file'); + $this->addSql('ALTER TABLE assignment_exercise_file CHANGE exercise_file_id supplementary_exercise_file_id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE assignment_exercise_file ADD CONSTRAINT FK_D6457EA62D777971 FOREIGN KEY (supplementary_exercise_file_id) REFERENCES uploaded_file (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_D6457EA62D777971 ON assignment_exercise_file (supplementary_exercise_file_id)'); + $this->addSql('ALTER TABLE assignment_exercise_file ADD PRIMARY KEY (assignment_id, supplementary_exercise_file_id)'); + $this->addSql('ALTER TABLE assignment_exercise_file RENAME INDEX idx_1d217a60d19302f8 TO IDX_D6457EA6D19302F8'); + $this->addSql('ALTER TABLE exercise_exercise_file DROP FOREIGN KEY FK_97E2547B49DE8E29'); + $this->addSql('DROP INDEX IDX_97E2547B49DE8E29 ON exercise_exercise_file'); + $this->addSql('DROP INDEX `PRIMARY` ON exercise_exercise_file'); + $this->addSql('ALTER TABLE exercise_exercise_file CHANGE exercise_file_id supplementary_exercise_file_id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE exercise_exercise_file ADD CONSTRAINT FK_42359992D777971 FOREIGN KEY (supplementary_exercise_file_id) REFERENCES uploaded_file (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_42359992D777971 ON exercise_exercise_file (supplementary_exercise_file_id)'); + $this->addSql('ALTER TABLE exercise_exercise_file ADD PRIMARY KEY (exercise_id, supplementary_exercise_file_id)'); + $this->addSql('ALTER TABLE exercise_exercise_file RENAME INDEX idx_97e2547be934951a TO IDX_4235999E934951A'); + $this->addSql('ALTER TABLE pipeline_exercise_file DROP FOREIGN KEY FK_2A806B5C49DE8E29'); + $this->addSql('DROP INDEX IDX_2A806B5C49DE8E29 ON pipeline_exercise_file'); + $this->addSql('DROP INDEX `PRIMARY` ON pipeline_exercise_file'); + $this->addSql('ALTER TABLE pipeline_exercise_file CHANGE exercise_file_id supplementary_exercise_file_id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE pipeline_exercise_file ADD CONSTRAINT FK_DCF572882D777971 FOREIGN KEY (supplementary_exercise_file_id) REFERENCES uploaded_file (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_DCF572882D777971 ON pipeline_exercise_file (supplementary_exercise_file_id)'); + $this->addSql('ALTER TABLE pipeline_exercise_file ADD PRIMARY KEY (pipeline_id, supplementary_exercise_file_id)'); + $this->addSql('ALTER TABLE pipeline_exercise_file RENAME INDEX idx_2a806b5ce80b93 TO IDX_DCF57288E80B93'); + + $this->addSql('ALTER TABLE assignment_exercise_file RENAME TO assignment_supplementary_exercise_file'); + $this->addSql('ALTER TABLE exercise_exercise_file RENAME TO exercise_supplementary_exercise_file'); + $this->addSql('ALTER TABLE pipeline_exercise_file RENAME TO pipeline_supplementary_exercise_file'); + } +} diff --git a/tests/ExerciseConfig/Compilation/BaseCompiler.phpt b/tests/ExerciseConfig/Compilation/BaseCompiler.phpt index bb65d09f2..8f697c9c9 100644 --- a/tests/ExerciseConfig/Compilation/BaseCompiler.phpt +++ b/tests/ExerciseConfig/Compilation/BaseCompiler.phpt @@ -260,9 +260,9 @@ class TestBaseCompiler extends Tester\TestCase // mock entities and stuff $this->mockCompilationPipeline = Mockery::mock(\App\Model\Entity\Pipeline::class); - $this->mockCompilationPipeline->shouldReceive("getHashedSupplementaryFiles")->andReturn(self::$pipelineFiles); + $this->mockCompilationPipeline->shouldReceive("getHashedExerciseFiles")->andReturn(self::$pipelineFiles); $this->mockTestPipeline = Mockery::mock(\App\Model\Entity\Pipeline::class); - $this->mockTestPipeline->shouldReceive("getHashedSupplementaryFiles")->andReturn(self::$pipelineFiles); + $this->mockTestPipeline->shouldReceive("getHashedExerciseFiles")->andReturn(self::$pipelineFiles); $this->mockPipelinesCache = Mockery::mock(PipelinesCache::class); $this->mockPipelinesCache->shouldReceive("getPipeline")->with("2341b599-c388-4357-8fea-be1e3bb182e0")->andReturn( diff --git a/tests/ExerciseConfig/Compilation/PipelinesMerger.phpt b/tests/ExerciseConfig/Compilation/PipelinesMerger.phpt index a79d00659..dbe84fc31 100644 --- a/tests/ExerciseConfig/Compilation/PipelinesMerger.phpt +++ b/tests/ExerciseConfig/Compilation/PipelinesMerger.phpt @@ -65,9 +65,9 @@ class TestPipelinesMerger extends Tester\TestCase // mock entities and stuff $this->mockCompilationPipeline = Mockery::mock(\App\Model\Entity\Pipeline::class); - $this->mockCompilationPipeline->shouldReceive("getHashedSupplementaryFiles")->andReturn(self::$pipelineFiles); + $this->mockCompilationPipeline->shouldReceive("getHashedExerciseFiles")->andReturn(self::$pipelineFiles); $this->mockTestPipeline = Mockery::mock(\App\Model\Entity\Pipeline::class); - $this->mockTestPipeline->shouldReceive("getHashedSupplementaryFiles")->andReturn(self::$pipelineFiles); + $this->mockTestPipeline->shouldReceive("getHashedExerciseFiles")->andReturn(self::$pipelineFiles); } protected function setUp() diff --git a/tests/ExerciseConfig/Validation/EnvironmentConfigValidator.phpt b/tests/ExerciseConfig/Validation/EnvironmentConfigValidator.phpt index c3b1b9721..0d71e6eed 100644 --- a/tests/ExerciseConfig/Validation/EnvironmentConfigValidator.phpt +++ b/tests/ExerciseConfig/Validation/EnvironmentConfigValidator.phpt @@ -10,7 +10,7 @@ use App\Helpers\ExerciseConfig\VariablesTable; use App\Model\Entity\Exercise; use App\Model\Entity\Group; use App\Model\Entity\Instance; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use App\Model\Entity\UploadedFile; use App\Model\Entity\User; use Tester\Assert; @@ -102,7 +102,7 @@ class TestEnvironmentConfigValidator extends Tester\TestCase $exercise = Exercise::create($user, new Group("ext", new Instance())); $uploadedFile = new UploadedFile("input.name", new DateTime(), 234, $user); - SupplementaryExerciseFile::fromUploadedFileAndExercise( + ExerciseFile::fromUploadedFileAndExercise( $uploadedFile, $exercise, "input.hash" @@ -110,7 +110,6 @@ class TestEnvironmentConfigValidator extends Tester\TestCase return $exercise; } - } $testCase = new TestEnvironmentConfigValidator(); diff --git a/tests/ExerciseConfig/Validation/PipelineValidator.phpt b/tests/ExerciseConfig/Validation/PipelineValidator.phpt index 923bc427d..5d8a080fa 100644 --- a/tests/ExerciseConfig/Validation/PipelineValidator.phpt +++ b/tests/ExerciseConfig/Validation/PipelineValidator.phpt @@ -16,7 +16,7 @@ use App\Helpers\ExerciseConfig\Validation\PipelineValidator; use App\Helpers\ExerciseConfig\Variable; use App\Model\Entity\Instance; use App\Model\Entity\Pipeline as PipelineEntity; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use App\Model\Entity\UploadedFile; use App\Model\Entity\User; use Tester\Assert; @@ -283,7 +283,7 @@ class TestPipelineValidator extends Tester\TestCase $pipeline = PipelineEntity::create($user); $uploadedFile = new UploadedFile("input.name", new DateTime(), 234, $user); - SupplementaryExerciseFile::fromUploadedFileAndPipeline( + ExerciseFile::fromUploadedFileAndPipeline( $uploadedFile, $pipeline, "input.hash", diff --git a/tests/Presenters/ExerciseFilesPresenter.phpt b/tests/Presenters/ExerciseFilesPresenter.phpt index b8c3fbd72..4d5cdacda 100644 --- a/tests/Presenters/ExerciseFilesPresenter.phpt +++ b/tests/Presenters/ExerciseFilesPresenter.phpt @@ -2,7 +2,6 @@ $container = require_once __DIR__ . "/../bootstrap.php"; -use App\Exceptions\NotFoundException; use App\Helpers\FileStorageManager; use App\Helpers\FileStorage\LocalFileStorage; use App\Helpers\FileStorage\LocalHashFileStorage; @@ -12,7 +11,7 @@ use App\Helpers\TmpFilesHelper; use App\Model\Entity\AttachmentFile; use App\Model\Entity\UploadedFile; use App\V1Module\Presenters\ExerciseFilesPresenter; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use Doctrine\ORM\EntityManagerInterface; use Tester\Assert; @@ -32,8 +31,8 @@ class TestExerciseFilesPresenter extends Tester\TestCase /** @var Nette\DI\Container */ protected $container; - /** @var App\Model\Repository\SupplementaryExerciseFiles */ - protected $supplementaryFiles; + /** @var App\Model\Repository\ExerciseFiles */ + protected $exerciseFiles; /** @var App\Model\Repository\Logins */ protected $logins; @@ -53,7 +52,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase $this->container = $container; $this->em = PresenterTestHelper::getEntityManager($container); $this->user = $container->getByType(\Nette\Security\User::class); - $this->supplementaryFiles = $container->getByType(\App\Model\Repository\SupplementaryExerciseFiles::class); + $this->exerciseFiles = $container->getByType(\App\Model\Repository\ExerciseFiles::class); $this->logins = $container->getByType(\App\Model\Repository\Logins::class); $this->exercises = $container->getByType(App\Model\Repository\Exercises::class); $this->attachmentFiles = $container->getByType(\App\Model\Repository\AttachmentFiles::class); @@ -85,7 +84,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase } } - public function testSupplementaryFilesUpload() + public function testExerciseFilesUpload() { $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); @@ -99,8 +98,8 @@ class TestExerciseFilesPresenter extends Tester\TestCase $this->presenter->uploadedFiles->flush(); $fileStorage = Mockery::mock(FileStorageManager::class); - $fileStorage->shouldReceive("storeUploadedSupplementaryFile")->with($file1)->once(); - $fileStorage->shouldReceive("storeUploadedSupplementaryFile")->with($file2)->once(); + $fileStorage->shouldReceive("storeUploadedExerciseFile")->with($file1)->once(); + $fileStorage->shouldReceive("storeUploadedExerciseFile")->with($file2)->once(); $this->presenter->fileStorage = $fileStorage; // Finally, the test itself @@ -109,7 +108,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase $exercise = current(array_filter( $this->presenter->exercises->findAll(), function ($exercise) { - return $exercise->getSupplementaryEvaluationFiles()->count() === 0; + return $exercise->getExerciseFiles()->count() === 0; } )); Assert::truthy($exercise); @@ -122,7 +121,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase "V1:ExerciseFiles", "POST", [ - "action" => 'uploadSupplementaryFiles', + "action" => 'uploadExerciseFiles', 'id' => $exercise->getId() ], [ @@ -137,18 +136,18 @@ class TestExerciseFilesPresenter extends Tester\TestCase Assert::count(2, $payload); foreach ($payload as $item) { - Assert::type(App\Model\Entity\SupplementaryExerciseFile::class, $item); + Assert::type(App\Model\Entity\ExerciseFile::class, $item); } } - public function testUploadTooManySupplementaryFiles() + public function testUploadTooManyExerciseFiles() { $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); $fileLimit = 10; $restrictions = new ExercisesConfig( [ - "supplementaryFileCountLimit" => $fileLimit + "exerciseFileCountLimit" => $fileLimit ] ); @@ -162,7 +161,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase $fileStorage = Mockery::mock(FileStorageManager::class); $fileStorage->makePartial(); - $fileStorage->shouldNotReceive("storeUploadedSupplementaryFile"); + $fileStorage->shouldNotReceive("storeUploadedExerciseFile"); $this->presenter->fileStorage = $fileStorage; // Finally, the test itself @@ -184,7 +183,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase "V1:ExerciseFiles", "POST", [ - "action" => 'uploadSupplementaryFiles', + "action" => 'uploadExerciseFiles', 'id' => $exercise->getId() ], [ @@ -197,14 +196,14 @@ class TestExerciseFilesPresenter extends Tester\TestCase ); } - public function testUploadTooBigSupplementaryFiles() + public function testUploadTooBigExerciseFiles() { $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); $sizeLimit = 5 * 1024; $restrictions = new ExercisesConfig( [ - "supplementaryFileSizeLimit" => $sizeLimit + "exerciseFileSizeLimit" => $sizeLimit ] ); @@ -218,7 +217,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase $fileStorage = Mockery::mock(FileStorageManager::class); $fileStorage->makePartial(); - $fileStorage->shouldNotReceive("storeUploadedSupplementaryFile"); + $fileStorage->shouldNotReceive("storeUploadedExerciseFile"); $this->presenter->fileStorage = $fileStorage; // Finally, the test itself @@ -240,7 +239,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase "V1:ExerciseFiles", "POST", [ - "action" => 'uploadSupplementaryFiles', + "action" => 'uploadExerciseFiles', 'id' => $exercise->getId() ], [ @@ -253,7 +252,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase ); } - public function testGetSupplementaryFiles() + public function testGetExerciseFiles() { $token = PresenterTestHelper::loginDefaultAdmin($this->container); @@ -262,12 +261,12 @@ class TestExerciseFilesPresenter extends Tester\TestCase $exercise = current(array_filter( $this->presenter->exercises->findAll(), function ($exercise) { - return $exercise->getSupplementaryEvaluationFiles()->count() === 0; + return $exercise->getExerciseFiles()->count() === 0; } )); Assert::truthy($exercise); - $expectedFile1 = new SupplementaryExerciseFile( + $expectedFile1 = new ExerciseFile( "name1", new DateTime(), 1, @@ -275,7 +274,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase $user, $exercise ); - $expectedFile2 = new SupplementaryExerciseFile( + $expectedFile2 = new ExerciseFile( "name2", new DateTime(), 2, @@ -283,14 +282,14 @@ class TestExerciseFilesPresenter extends Tester\TestCase $user, $exercise ); - $this->supplementaryFiles->persist($expectedFile1, false); - $this->supplementaryFiles->persist($expectedFile2, false); - $this->supplementaryFiles->flush(); + $this->exerciseFiles->persist($expectedFile1, false); + $this->exerciseFiles->persist($expectedFile2, false); + $this->exerciseFiles->flush(); $request = new Nette\Application\Request( "V1:ExerciseFiles", 'GET', - ['action' => 'getSupplementaryFiles', 'id' => $exercise->getId()] + ['action' => 'getExerciseFiles', 'id' => $exercise->getId()] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); @@ -305,14 +304,14 @@ class TestExerciseFilesPresenter extends Tester\TestCase Assert::equal($expectedFiles, $result['payload']); } - public function testDeleteSupplementaryFile() + public function testDeleteExerciseFile() { PresenterTestHelper::loginDefaultAdmin($this->container); $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); $exercise = current($this->presenter->exercises->findAll()); - $filesCount = $exercise->getSupplementaryEvaluationFiles()->count(); - $file = new SupplementaryExerciseFile( + $filesCount = $exercise->getExerciseFiles()->count(); + $file = new ExerciseFile( "name1", new DateTime(), 1, @@ -320,14 +319,14 @@ class TestExerciseFilesPresenter extends Tester\TestCase $user, $exercise ); - $this->supplementaryFiles->persist($file); - Assert::count($filesCount + 1, $exercise->getSupplementaryEvaluationFiles()); + $this->exerciseFiles->persist($file); + Assert::count($filesCount + 1, $exercise->getExerciseFiles()); $request = new Nette\Application\Request( "V1:ExerciseFiles", 'DELETE', [ - 'action' => 'deleteSupplementaryFile', + 'action' => 'deleteExerciseFile', 'id' => $exercise->getId(), 'fileId' => $file->getId() ] @@ -338,35 +337,35 @@ class TestExerciseFilesPresenter extends Tester\TestCase $result = $response->getPayload(); Assert::equal(200, $result['code']); Assert::equal("OK", $result['payload']); - Assert::count($filesCount, $exercise->getSupplementaryEvaluationFiles()); + Assert::count($filesCount, $exercise->getExerciseFiles()); } - public function testDownloadSupplementaryFilesArchive() + public function testDownloadExerciseFilesArchive() { PresenterTestHelper::loginDefaultAdmin($this->container); $exercise = current(array_filter( $this->presenter->exercises->findAll(), function ($exercise) { - return $exercise->getSupplementaryEvaluationFiles()->count() > 0; + return $exercise->getExerciseFiles()->count() > 0; } )); Assert::truthy($exercise); $mockFileStorage = Mockery::mock(FileStorageManager::class); - foreach ($exercise->getSupplementaryEvaluationFiles() as $file) { + foreach ($exercise->getExerciseFiles() as $file) { $mockFile = Mockery::mock(LocalImmutableFile::class); - $mockFileStorage->shouldReceive("getSupplementaryFileByHash")->with($file->getHashName())->andReturn($mockFile)->once(); + $mockFileStorage->shouldReceive("getExerciseFileByHash")->with($file->getHashName())->andReturn($mockFile)->once(); } $this->presenter->fileStorage = $mockFileStorage; $request = new Nette\Application\Request( "V1:ExerciseFiles", 'GET', - ['action' => 'downloadSupplementaryFilesArchive', 'id' => $exercise->getId()] + ['action' => 'downloadExerciseFilesArchive', 'id' => $exercise->getId()] ); $response = $this->presenter->run($request); Assert::type(App\Responses\ZipFilesResponse::class, $response); - Assert::equal("exercise-supplementary-" . $exercise->getId() . '.zip', $response->getName()); + Assert::equal("exercise-files-" . $exercise->getId() . '.zip', $response->getName()); } public function testGetAttachmentFiles() diff --git a/tests/Presenters/ExercisesPresenter.phpt b/tests/Presenters/ExercisesPresenter.phpt index 80a3cac14..44df884fd 100644 --- a/tests/Presenters/ExercisesPresenter.phpt +++ b/tests/Presenters/ExercisesPresenter.phpt @@ -42,8 +42,8 @@ class TestExercisesPresenter extends Tester\TestCase /** @var App\Model\Repository\HardwareGroups */ protected $hardwareGroups; - /** @var App\Model\Repository\SupplementaryExerciseFiles */ - protected $supplementaryFiles; + /** @var App\Model\Repository\ExerciseFiles */ + protected $exerciseFiles; /** @var App\Model\Repository\Logins */ protected $logins; @@ -80,7 +80,7 @@ class TestExercisesPresenter extends Tester\TestCase $this->user = $container->getByType(\Nette\Security\User::class); $this->runtimeEnvironments = $container->getByType(\App\Model\Repository\RuntimeEnvironments::class); $this->hardwareGroups = $container->getByType(\App\Model\Repository\HardwareGroups::class); - $this->supplementaryFiles = $container->getByType(\App\Model\Repository\SupplementaryExerciseFiles::class); + $this->exerciseFiles = $container->getByType(\App\Model\Repository\ExerciseFiles::class); $this->logins = $container->getByType(\App\Model\Repository\Logins::class); $this->exercises = $container->getByType(App\Model\Repository\Exercises::class); $this->assignments = $container->getByType(App\Model\Repository\Assignments::class); diff --git a/tests/Presenters/PipelinesPresenter.phpt b/tests/Presenters/PipelinesPresenter.phpt index 101161a7d..ffb374551 100644 --- a/tests/Presenters/PipelinesPresenter.phpt +++ b/tests/Presenters/PipelinesPresenter.phpt @@ -9,7 +9,7 @@ use App\Helpers\TmpFilesHelper; use App\Helpers\FileStorage\LocalFileStorage; use App\Helpers\FileStorage\LocalHashFileStorage; use App\Model\Entity\Pipeline; -use App\Model\Entity\SupplementaryExerciseFile; +use App\Model\Entity\ExerciseFile; use App\Model\Entity\UploadedFile; use App\V1Module\Presenters\PipelinesPresenter; use Doctrine\ORM\EntityManagerInterface; @@ -363,7 +363,7 @@ class TestPipelinesPresenter extends Tester\TestCase Assert::false($payload["versionIsUpToDate"]); } - public function testSupplementaryFilesUpload() + public function testExerciseFilesUpload() { // Mock file server setup $filename1 = "task1.txt"; @@ -386,8 +386,8 @@ class TestPipelinesPresenter extends Tester\TestCase $fileStorage = Mockery::mock(FileStorageManager::class); $fileStorage->makePartial(); - $fileStorage->shouldReceive("storeUploadedSupplementaryFile")->with($file1)->once(); - $fileStorage->shouldReceive("storeUploadedSupplementaryFile")->with($file2)->once(); + $fileStorage->shouldReceive("storeUploadedExerciseFile")->with($file1)->once(); + $fileStorage->shouldReceive("storeUploadedExerciseFile")->with($file2)->once(); $this->presenter->fileStorage = $fileStorage; // Finally, the test itself @@ -402,7 +402,7 @@ class TestPipelinesPresenter extends Tester\TestCase "V1:Pipelines", "POST", [ - "action" => 'uploadSupplementaryFiles', + "action" => 'uploadExerciseFiles', 'id' => $pipeline->getId() ], [ @@ -417,18 +417,18 @@ class TestPipelinesPresenter extends Tester\TestCase Assert::count(2, $payload); foreach ($payload as $item) { - Assert::type(App\Model\Entity\SupplementaryExerciseFile::class, $item); + Assert::type(App\Model\Entity\ExerciseFile::class, $item); } } - public function testGetSupplementaryFiles() + public function testGetExerciseFiles() { PresenterTestHelper::loginDefaultAdmin($this->container); // prepare files into exercise $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); $pipeline = current($this->presenter->pipelines->findAll()); - $expectedFile1 = new SupplementaryExerciseFile( + $expectedFile1 = new ExerciseFile( "name1", new DateTime(), 1, @@ -437,7 +437,7 @@ class TestPipelinesPresenter extends Tester\TestCase null, $pipeline ); - $expectedFile2 = new SupplementaryExerciseFile( + $expectedFile2 = new ExerciseFile( "name2", new DateTime(), 2, @@ -453,7 +453,7 @@ class TestPipelinesPresenter extends Tester\TestCase $request = new Nette\Application\Request( "V1:Pipelines", 'GET', - ['action' => 'getSupplementaryFiles', 'id' => $pipeline->getId()] + ['action' => 'getExerciseFiles', 'id' => $pipeline->getId()] ); $response = $this->presenter->run($request); Assert::type(Nette\Application\Responses\JsonResponse::class, $response); diff --git a/tests/Presenters/UploadedFilesPresenter.phpt b/tests/Presenters/UploadedFilesPresenter.phpt index 31d6cb806..2266e757d 100644 --- a/tests/Presenters/UploadedFilesPresenter.phpt +++ b/tests/Presenters/UploadedFilesPresenter.phpt @@ -402,8 +402,8 @@ class TestUploadedFilesPresenter extends Tester\TestCase $this->presenterPath, 'GET', [ - 'action' => 'download', - 'id' => $file->getId() + 'action' => 'download', + 'id' => $file->getId() ] ); $response = $this->presenter->run($request); @@ -428,8 +428,8 @@ class TestUploadedFilesPresenter extends Tester\TestCase $this->presenterPath, 'GET', [ - 'action' => 'download', - 'id' => $file->getId() + 'action' => 'download', + 'id' => $file->getId() ] ); $response = $this->presenter->run($request); @@ -453,13 +453,13 @@ class TestUploadedFilesPresenter extends Tester\TestCase $this->presenterPath, 'GET', [ - 'action' => 'download', - 'id' => $file->getId() + 'action' => 'download', + 'id' => $file->getId() ] ); Assert::exception(function () use ($request) { - $this->presenter->run($request); + $this->presenter->run($request); }, NotFoundException::class, "Not Found - File not found in the storage"); } @@ -467,20 +467,20 @@ class TestUploadedFilesPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); - $file = new \App\Model\Entity\SupplementaryExerciseFile("hefty name", new DateTime(), 1, "hash", $user); - $this->presenter->supplementaryFiles->persist($file); - $this->presenter->supplementaryFiles->flush(); + $file = new \App\Model\Entity\ExerciseFile("hefty name", new DateTime(), 1, "hash", $user); + $this->presenter->exerciseFiles->persist($file); + $this->presenter->exerciseFiles->flush(); // mock everything you can $fileMock = Mockery::mock(LocalImmutableFile::class); $mockStorage = Mockery::mock(FileStorageManager::class); - $mockStorage->shouldReceive("getSupplementaryFileByHash")->withArgs([ "hash" ])->andReturn($fileMock)->once(); + $mockStorage->shouldReceive("getExerciseFileByHash")->withArgs(["hash"])->andReturn($fileMock)->once(); $this->presenter->fileStorage = $mockStorage; $request = new Nette\Application\Request( $this->presenterPath, 'GET', - ['action' => 'downloadSupplementaryFile', 'id' => $file->getId()] + ['action' => 'downloadExerciseFile', 'id' => $file->getId()] ); $response = $this->presenter->run($request); Assert::type(App\Responses\StorageFileResponse::class, $response); @@ -726,8 +726,11 @@ class TestUploadedFilesPresenter extends Tester\TestCase $request = new Nette\Application\Request( $this->presenterPath, 'GET', - ['action' => 'download', 'id' => $file->getId(), - 'similarSolutionId' => $similarity->getTestedSolution()->getId()] + [ + 'action' => 'download', + 'id' => $file->getId(), + 'similarSolutionId' => $similarity->getTestedSolution()->getId() + ] ); $response = $this->presenter->run($request); Assert::type(App\Responses\StorageFileResponse::class, $response); diff --git a/tests/Presenters/WorkerFilesPresenter.phpt b/tests/Presenters/WorkerFilesPresenter.phpt index 5915071ee..1c0c664a4 100644 --- a/tests/Presenters/WorkerFilesPresenter.phpt +++ b/tests/Presenters/WorkerFilesPresenter.phpt @@ -113,9 +113,9 @@ class TestWorkerFilesPresenter extends Tester\TestCase $request = new \Nette\Application\Request( $this->presenterPath, 'GET', - ['action' => 'downloadSupplementaryFile', 'hash' => 'a123'] + ['action' => 'downloadExerciseFile', 'hash' => 'a123'] ); - $response = $this->presenter->run($request); + $response = $this->presenter->run($request); }, WrongCredentialsException::class, ''); } @@ -133,35 +133,35 @@ class TestWorkerFilesPresenter extends Tester\TestCase $request = new \Nette\Application\Request( $this->presenterPath, 'GET', - ['action' => 'downloadSupplementaryFile', 'hash' => 'a123'] + ['action' => 'downloadExerciseFile', 'hash' => 'a123'] ); - $response = $this->presenter->run($request); + $response = $this->presenter->run($request); }, ForbiddenRequestException::class, 'Worker files interface is disabled in the configuration.'); } - public function testDownloadSupplementaryFile() + public function testDownloadExerciseFile() { // mock file and storage $mockFileStorage = Mockery::mock(FileStorageManager::class); - $mockFileStorage->shouldReceive("getSupplementaryFileByHash") + $mockFileStorage->shouldReceive("getExerciseFileByHash") ->withArgs(['a123'])->andReturn(Mockery::mock(LocalImmutableFile::class))->once(); $this->presenter->fileStorage = $mockFileStorage; $request = new \Nette\Application\Request( $this->presenterPath, 'GET', - ['action' => 'downloadSupplementaryFile', 'hash' => 'a123'] + ['action' => 'downloadExerciseFile', 'hash' => 'a123'] ); $response = $this->presenter->run($request); Assert::type(StorageFileResponse::class, $response); } - public function testDownloadSupplementaryFileNonexist() + public function testDownloadExerciseFileNonExist() { // mock file and storage $mockFileStorage = Mockery::mock(FileStorageManager::class); - $mockFileStorage->shouldReceive("getSupplementaryFileByHash") + $mockFileStorage->shouldReceive("getExerciseFileByHash") ->withArgs(['a123'])->andReturn(null)->once(); $this->presenter->fileStorage = $mockFileStorage; @@ -169,10 +169,10 @@ class TestWorkerFilesPresenter extends Tester\TestCase $request = new \Nette\Application\Request( $this->presenterPath, 'GET', - ['action' => 'downloadSupplementaryFile', 'hash' => 'a123'] + ['action' => 'downloadExerciseFile', 'hash' => 'a123'] ); - $response = $this->presenter->run($request); - }, NotFoundException::class, 'Not Found - Supplementary file not found in the storage'); + $response = $this->presenter->run($request); + }, NotFoundException::class); } public function testDownloadSubmissionArchive() From bd9be31d71950ed2bdce1188f2b6fc7632e674e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 13 Nov 2025 19:31:04 +0100 Subject: [PATCH 02/25] Adding optional random-generated IDs for anonymous access to uploaded files. --- app/model/entity/ExerciseFile.php | 4 ++-- app/model/entity/UploadedFile.php | 35 ++++++++++++++++++++++++++++ migrations/Version20251113182809.php | 33 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 migrations/Version20251113182809.php diff --git a/app/model/entity/ExerciseFile.php b/app/model/entity/ExerciseFile.php index bc3350817..6b5ecded1 100644 --- a/app/model/entity/ExerciseFile.php +++ b/app/model/entity/ExerciseFile.php @@ -76,8 +76,8 @@ public function __construct( int $fileSize, string $hashName, ?User $user, - Exercise $exercise = null, - Pipeline $pipeline = null + ?Exercise $exercise = null, + ?Pipeline $pipeline = null ) { parent::__construct($name, $uploadedAt, $fileSize, $user); $this->hashName = $hashName; diff --git a/app/model/entity/UploadedFile.php b/app/model/entity/UploadedFile.php index de420c2be..171597e7e 100644 --- a/app/model/entity/UploadedFile.php +++ b/app/model/entity/UploadedFile.php @@ -47,9 +47,19 @@ public function getFileExtension(): string /** * @ORM\Column(type="boolean") + * If true, the file is accessible to all logged-in users. + * This is useful for files associated with entries like exercises. */ protected $isPublic; + /** + * External identification (used in URL) that allows for accessing the file without authentication. + * If empty, the file is not accessible this way. + * The identifier should be a generated random string. + * @ORM\Column(type="string", length=16, nullable=true, unique=true) + */ + protected $external = null; + /** * @ORM\Column(type="integer") */ @@ -113,6 +123,31 @@ public function isPublic() return $this->isPublic; } + public function setPublic(bool $isPublic): void + { + $this->isPublic = $isPublic; + } + + public function getExternal(): ?string + { + return $this->external; + } + + public function setExternal(?string $external): void + { + $this->external = $external; + } + + /** + * Generate a random external ID for this file which uses only URL-safe characters. + */ + public function generateRandomExternalId(): void + { + // the + and / characters are replaced to make the string URL-safe without the need for encoding + // (base64 encoding uses A-Z, a-z, 0-9, +, /) + $this->external = str_replace(['+', '/'], ['-', '$'], base64_encode(random_bytes(12))); + } + /** * Retrieve a corresponding file object from file storage. * @param FileStorageManager $manager the storage that retrieves the file diff --git a/migrations/Version20251113182809.php b/migrations/Version20251113182809.php new file mode 100644 index 000000000..aea033056 --- /dev/null +++ b/migrations/Version20251113182809.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE uploaded_file ADD external VARCHAR(16) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_B40DF75D5852A1B8 ON uploaded_file (external)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_B40DF75D5852A1B8 ON `uploaded_file`'); + $this->addSql('ALTER TABLE `uploaded_file` DROP external'); + } +} From 175e2e285c3a3baa3e2d101545851b805c7b7998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 14 Nov 2025 00:41:56 +0100 Subject: [PATCH 03/25] Removing previous modification of uploaded file (external links need a separate entity). Marking deprecated entry points. --- .../presenters/ExerciseFilesPresenter.php | 4 +++ .../presenters/UploadedFilesPresenter.php | 7 ++-- app/V1Module/router/RouterFactory.php | 7 ++-- .../Policies/UploadedFilePermissionPolicy.php | 8 +++-- app/config/permissions.neon | 18 +++++----- app/model/entity/UploadedFile.php | 28 ---------------- migrations/Version20251113182809.php | 33 ------------------- 7 files changed, 29 insertions(+), 76 deletions(-) delete mode 100644 migrations/Version20251113182809.php diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index 19211dfc1..76f328fc2 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -263,6 +263,7 @@ public function checkUploadAttachmentFiles(string $id) /** * Associate attachment exercise files with an exercise * @POST + * @DEPRECATED attachment files were unified with exercise files * @throws ForbiddenRequestException */ #[Post("files", new VMixed(), "Identifiers of attachment files", nullable: true)] @@ -319,6 +320,7 @@ public function checkGetAttachmentFiles(string $id) /** * Get a list of all attachment files for an exercise * @GET + * @DEPRECATED attachment files were unified with exercise files * @throws ForbiddenRequestException */ #[Path("id", new VUuid(), "identification of exercise", required: true)] @@ -345,6 +347,7 @@ public function checkDeleteAttachmentFile(string $id, string $fileId) /** * Delete attachment exercise file with given id * @DELETE + * @DEPRECATED attachment files were unified with exercise files * @throws ForbiddenRequestException * @throws NotFoundException */ @@ -408,6 +411,7 @@ public function checkDownloadAttachmentFilesArchive(string $id) /** * Download archive containing all attachment files for exercise. * @GET + * @DEPRECATED attachment files were unified with exercise files * @throws NotFoundException * @throws \Nette\Application\BadRequestException * @throws \Nette\Application\AbortException diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index 80715c57d..b63d082c4 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -353,7 +353,7 @@ public function actionUpload() ); } - // In theory, this may create race condition (DB record is commited before file is moved). + // In theory, this may create race condition (DB record is committed before file is moved). // But we need the ID from the database so we can save the file. $uploadedFile = new UploadedFile($file->getName(), new DateTime(), $file->getSize(), $user); $this->uploadedFiles->persist($uploadedFile); @@ -528,8 +528,8 @@ public function checkCompletePartial(string $id) } /** - * Finalize partial upload and convert the partial file into UploadFile. - * All data chunks are extracted from the store, assembled into one file, and is moved back into the store. + * Finalize partial upload and convert the partial file into UploadedFile entity. + * All data chunks are extracted from the store, assembled into one file, and that is moved back into the store. * @POST */ #[Path("id", new VUuid(), "Identifier of the partial file", required: true)] @@ -594,6 +594,7 @@ public function checkDownloadExerciseFile(string $id) /** * Download exercise file * @GET + * @DEPRECATED use generic uploaded-file download endpoint instead * @throws ForbiddenRequestException * @throws NotFoundException * @throws \Nette\Application\AbortException diff --git a/app/V1Module/router/RouterFactory.php b/app/V1Module/router/RouterFactory.php index b3c5a13aa..79489fb73 100644 --- a/app/V1Module/router/RouterFactory.php +++ b/app/V1Module/router/RouterFactory.php @@ -173,7 +173,7 @@ private static function createExercisesRoutes(string $prefix): RouteList "ExerciseFiles:downloadExerciseFilesArchive" ); - // deprecated routes for supplementary files + // deprecated routes for supplementary files (replaced with exercise-files) $router[] = new GetRoute("$prefix//supplementary-files", "ExerciseFiles:getExerciseFiles"); $router[] = new PostRoute("$prefix//supplementary-files", "ExerciseFiles:uploadExerciseFiles"); $router[] = new DeleteRoute( @@ -185,6 +185,7 @@ private static function createExercisesRoutes(string $prefix): RouteList "ExerciseFiles:downloadExerciseFilesArchive" ); + // deprecated (will be removed with AttachmentFile entity, unified with exercise-files) $router[] = new GetRoute("$prefix//attachment-files", "ExerciseFiles:getAttachmentFiles"); $router[] = new PostRoute("$prefix//attachment-files", "ExerciseFiles:uploadAttachmentFiles"); $router[] = new DeleteRoute("$prefix//attachment-files/", "ExerciseFiles:deleteAttachmentFile"); @@ -470,11 +471,13 @@ private static function createUploadedFilesRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix/partial/", "UploadedFiles:completePartial"); $router[] = new PostRoute("$prefix", "UploadedFiles:upload"); - $router[] = new GetRoute("$prefix/supplementary-file//download", "UploadedFiles:downloadExerciseFile"); $router[] = new GetRoute("$prefix/", "UploadedFiles:detail"); $router[] = new GetRoute("$prefix//download", "UploadedFiles:download"); $router[] = new GetRoute("$prefix//content", "UploadedFiles:content"); $router[] = new GetRoute("$prefix//digest", "UploadedFiles:digest"); + + // deprecated (should be handled by generic download) + $router[] = new GetRoute("$prefix/supplementary-file//download", "UploadedFiles:downloadExerciseFile"); return $router; } diff --git a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php index 4c4466bff..2e39d99fa 100644 --- a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php +++ b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php @@ -2,10 +2,10 @@ namespace App\Security\Policies; -use App\Model\Entity\Assignment; use App\Model\Entity\AttachmentFile; use App\Model\Entity\Exercise; use App\Model\Entity\ExerciseFile; +use App\Model\Entity\Group; use App\Model\Entity\UploadedFile; use App\Model\Repository\Assignments; use App\Model\Repository\UploadedFiles; @@ -61,6 +61,7 @@ public function isAuthorOfFileExercises(Identity $identity, UploadedFile $file) } foreach ($file->getExercises() as $exercise) { + /** @var Exercise $exercise */ if ($exercise->isAuthor($user)) { return true; } @@ -82,7 +83,9 @@ public function isExerciseFileInGroupUserSupervises(Identity $identity, Uploaded } foreach ($file->getExercises() as $exercise) { + /** @var Exercise $exercise */ foreach ($exercise->getGroups() as $group) { + /** @var Group $group */ if ($group->isAdminOrSupervisorOfSubgroup($user)) { return true; // The user can assign one of the corresponding exercises in hir group. } @@ -103,7 +106,7 @@ public function isOwner(Identity $identity, UploadedFile $file) return $file->getUser() && $file->getUser()->getId() === $user->getId(); } - public function isReferenceSolutionInSupervisedOrObserverdSubGroup(Identity $identity, UploadedFile $file) + public function isReferenceSolutionInSupervisedOrObservedSubGroup(Identity $identity, UploadedFile $file) { $user = $identity->getUserData(); if ($user === null) { @@ -140,6 +143,7 @@ public function isRelatedToAssignment(Identity $identity, UploadedFile $file) if ($file instanceof AttachmentFile) { foreach ($file->getExercises() as $exercise) { + /** @var Exercise $exercise */ foreach ($user->getGroups() as $group) { if ($this->assignments->isAssignedToGroup($exercise, $group)) { return true; diff --git a/app/config/permissions.neon b/app/config/permissions.neon index 87ee4c418..b5a899f5e 100644 --- a/app/config/permissions.neon +++ b/app/config/permissions.neon @@ -43,7 +43,7 @@ permissions: actions: - takeOver - - allow: false # A safety mechanism that prevents us from accidentaly allowing account takeover to non-admins (e.g. by wildcard rules) + - allow: false # A safety mechanism that prevents us from accidentally allowing account takeover to non-admins (e.g. by wildcard rules) actions: - takeOver @@ -743,7 +743,7 @@ permissions: actions: - viewThread - addComment - conditions: # TODO - make sure only apropriate group members/authors can do this + conditions: # TODO - make sure only appropriate group members/authors can do this - userIsNotGroupLocked - allow: true @@ -1030,7 +1030,7 @@ permissions: actions: - download - viewDetail - - downloadExerciseFile + - downloadExerciseFile # deprecated (replaced with generic download) - allow: true role: scope-ref-solutions @@ -1038,7 +1038,7 @@ permissions: actions: - download - viewDetail - - downloadExerciseFile + - downloadExerciseFile # deprecated (replaced with generic download) - upload - allow: true @@ -1075,11 +1075,14 @@ permissions: conditions: or: - file.isSolutionInSupervisedOrObservedGroup - - file.isReferenceSolutionInSupervisedOrObserverdSubGroup + - file.isReferenceSolutionInSupervisedOrObservedSubGroup - file.isPublic - file.isOwner - - file.isRelatedToAssignment + - file.isRelatedToAssignment # deprecated (will be removed with Attachment files) + - file.isAuthorOfFileExercises + - file.isExerciseFileInGroupUserSupervises + # deprecated (will be removed with Attachment files) - allow: true role: supervisor-student resource: uploadedFile @@ -1094,8 +1097,7 @@ permissions: role: supervisor-student resource: uploadedFile actions: - - viewDetail - - downloadExerciseFile + - downloadExerciseFile # deprecated (replaced with generic download) conditions: or: - file.isAuthorOfFileExercises diff --git a/app/model/entity/UploadedFile.php b/app/model/entity/UploadedFile.php index 171597e7e..ed80f88c7 100644 --- a/app/model/entity/UploadedFile.php +++ b/app/model/entity/UploadedFile.php @@ -52,14 +52,6 @@ public function getFileExtension(): string */ protected $isPublic; - /** - * External identification (used in URL) that allows for accessing the file without authentication. - * If empty, the file is not accessible this way. - * The identifier should be a generated random string. - * @ORM\Column(type="string", length=16, nullable=true, unique=true) - */ - protected $external = null; - /** * @ORM\Column(type="integer") */ @@ -128,26 +120,6 @@ public function setPublic(bool $isPublic): void $this->isPublic = $isPublic; } - public function getExternal(): ?string - { - return $this->external; - } - - public function setExternal(?string $external): void - { - $this->external = $external; - } - - /** - * Generate a random external ID for this file which uses only URL-safe characters. - */ - public function generateRandomExternalId(): void - { - // the + and / characters are replaced to make the string URL-safe without the need for encoding - // (base64 encoding uses A-Z, a-z, 0-9, +, /) - $this->external = str_replace(['+', '/'], ['-', '$'], base64_encode(random_bytes(12))); - } - /** * Retrieve a corresponding file object from file storage. * @param FileStorageManager $manager the storage that retrieves the file diff --git a/migrations/Version20251113182809.php b/migrations/Version20251113182809.php deleted file mode 100644 index aea033056..000000000 --- a/migrations/Version20251113182809.php +++ /dev/null @@ -1,33 +0,0 @@ -addSql('ALTER TABLE uploaded_file ADD external VARCHAR(16) DEFAULT NULL'); - $this->addSql('CREATE UNIQUE INDEX UNIQ_B40DF75D5852A1B8 ON uploaded_file (external)'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('DROP INDEX UNIQ_B40DF75D5852A1B8 ON `uploaded_file`'); - $this->addSql('ALTER TABLE `uploaded_file` DROP external'); - } -} From 7a863d057f0ffb9b8a73374987ed181a2f094d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 14 Nov 2025 18:08:37 +0100 Subject: [PATCH 04/25] Adding exercise file link entity and repository in model. --- app/V1Module/presenters/CommentsPresenter.php | 2 +- .../presenters/ExerciseFilesPresenter.php | 8 +- .../presenters/ExercisesConfigPresenter.php | 6 +- app/V1Module/presenters/GroupsPresenter.php | 4 +- app/V1Module/presenters/SisPresenter.php | 1 + .../presenters/UploadedFilesPresenter.php | 2 +- .../SisBoundGroupPermissionPolicy.php | 4 +- .../Policies/SisCoursePermissionPolicy.php | 4 +- .../SisGroupContextPermissionPolicy.php | 3 + .../security/Policies/SisPermissionPolicy.php | 3 + .../Policies/UploadedFilePermissionPolicy.php | 2 - app/config/config.neon | 31 +++-- app/helpers/ExerciseConfig/Compiler.php | 1 - .../Pipeline/Box/Boxes/NodeRunBox.php | 2 +- .../Pipeline/Box/Boxes/PhpRunBox.php | 2 +- .../Pipeline/Box/Boxes/Python3RunBox.php | 2 +- .../Validation/EnvironmentConfigValidator.php | 1 - app/model/entity/Assignment.php | 1 + app/model/entity/AttachmentFile.php | 2 +- app/model/entity/Exercise.php | 4 + app/model/entity/ExerciseFile.php | 15 +- app/model/entity/ExerciseFileLink.php | 129 ++++++++++++++++++ app/model/entity/base/ExerciseData.php | 24 ++++ app/model/repository/ExerciseFileLinks.php | 17 +++ app/model/repository/SisGroupBindings.php | 1 + app/model/repository/SisValidTerms.php | 1 + 26 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 app/model/entity/ExerciseFileLink.php create mode 100644 app/model/repository/ExerciseFileLinks.php diff --git a/app/V1Module/presenters/CommentsPresenter.php b/app/V1Module/presenters/CommentsPresenter.php index 6d1f5b6d5..96cde3f0d 100644 --- a/app/V1Module/presenters/CommentsPresenter.php +++ b/app/V1Module/presenters/CommentsPresenter.php @@ -180,7 +180,7 @@ public function checkTogglePrivate(string $threadId, string $commentId) /** * Make a private comment public or vice versa - * @DEPRECATED + * @deprecated * @POST * @throws NotFoundException */ diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index 76f328fc2..b7515f88c 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -263,7 +263,7 @@ public function checkUploadAttachmentFiles(string $id) /** * Associate attachment exercise files with an exercise * @POST - * @DEPRECATED attachment files were unified with exercise files + * @deprecated attachment files were unified with exercise files * @throws ForbiddenRequestException */ #[Post("files", new VMixed(), "Identifiers of attachment files", nullable: true)] @@ -320,7 +320,7 @@ public function checkGetAttachmentFiles(string $id) /** * Get a list of all attachment files for an exercise * @GET - * @DEPRECATED attachment files were unified with exercise files + * @deprecated attachment files were unified with exercise files * @throws ForbiddenRequestException */ #[Path("id", new VUuid(), "identification of exercise", required: true)] @@ -347,7 +347,7 @@ public function checkDeleteAttachmentFile(string $id, string $fileId) /** * Delete attachment exercise file with given id * @DELETE - * @DEPRECATED attachment files were unified with exercise files + * @deprecated attachment files were unified with exercise files * @throws ForbiddenRequestException * @throws NotFoundException */ @@ -411,7 +411,7 @@ public function checkDownloadAttachmentFilesArchive(string $id) /** * Download archive containing all attachment files for exercise. * @GET - * @DEPRECATED attachment files were unified with exercise files + * @deprecated attachment files were unified with exercise files * @throws NotFoundException * @throws \Nette\Application\BadRequestException * @throws \Nette\Application\AbortException diff --git a/app/V1Module/presenters/ExercisesConfigPresenter.php b/app/V1Module/presenters/ExercisesConfigPresenter.php index 536ef851e..0012418da 100644 --- a/app/V1Module/presenters/ExercisesConfigPresenter.php +++ b/app/V1Module/presenters/ExercisesConfigPresenter.php @@ -409,7 +409,7 @@ public function checkGetHardwareGroupLimits(string $id, string $runtimeEnvironme /** * Get a description of resource limits for an exercise for given hwgroup. - * @DEPRECATED + * @deprecated * @GET * @throws ForbiddenRequestException * @throws NotFoundException @@ -454,7 +454,7 @@ public function checkSetHardwareGroupLimits(string $id, string $runtimeEnvironme /** * Set resource limits for an exercise for given hwgroup. - * @DEPRECATED + * @deprecated * @POST * @throws ApiException * @throws ExerciseConfigException @@ -524,7 +524,7 @@ public function checkRemoveHardwareGroupLimits(string $id, string $runtimeEnviro /** * Remove resource limits of given hwgroup from an exercise. - * @DEPRECATED + * @deprecated * @DELETE * @throws NotFoundException */ diff --git a/app/V1Module/presenters/GroupsPresenter.php b/app/V1Module/presenters/GroupsPresenter.php index 28881ee9b..53f7586e2 100644 --- a/app/V1Module/presenters/GroupsPresenter.php +++ b/app/V1Module/presenters/GroupsPresenter.php @@ -891,7 +891,7 @@ public function checkSubgroups(string $id) /** * Get a list of subgroups of a group * @GET - * @DEPRECATED Subgroup list is part of group view. + * @deprecated Subgroup list is part of group view. */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] public function actionSubgroups(string $id) @@ -922,7 +922,7 @@ public function checkMembers(string $id) /** * Get a list of members of a group * @GET - * @DEPRECATED Members are listed in group view. + * @deprecated Members are listed in group view. */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] public function actionMembers(string $id) diff --git a/app/V1Module/presenters/SisPresenter.php b/app/V1Module/presenters/SisPresenter.php index b183af385..f864d2b32 100644 --- a/app/V1Module/presenters/SisPresenter.php +++ b/app/V1Module/presenters/SisPresenter.php @@ -34,6 +34,7 @@ /** * @LoggedIn + * @deprecated Use the new SIS extension instead */ class SisPresenter extends BasePresenter { diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index b63d082c4..8afd92d84 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -594,7 +594,7 @@ public function checkDownloadExerciseFile(string $id) /** * Download exercise file * @GET - * @DEPRECATED use generic uploaded-file download endpoint instead + * @deprecated use generic uploaded-file download endpoint instead * @throws ForbiddenRequestException * @throws NotFoundException * @throws \Nette\Application\AbortException diff --git a/app/V1Module/security/Policies/SisBoundGroupPermissionPolicy.php b/app/V1Module/security/Policies/SisBoundGroupPermissionPolicy.php index d1c1f7f58..4aac238ae 100644 --- a/app/V1Module/security/Policies/SisBoundGroupPermissionPolicy.php +++ b/app/V1Module/security/Policies/SisBoundGroupPermissionPolicy.php @@ -6,10 +6,12 @@ use App\Model\Entity\ExternalLogin; use App\Model\Entity\Group; use App\Model\Repository\ExternalLogins; -use App\Model\Repository\Groups; use App\Model\Repository\SisGroupBindings; use App\Security\Identity; +/** + * @deprecated Use the new SIS extension instead + */ class SisBoundGroupPermissionPolicy implements IPermissionPolicy { /** @var SisGroupBindings */ diff --git a/app/V1Module/security/Policies/SisCoursePermissionPolicy.php b/app/V1Module/security/Policies/SisCoursePermissionPolicy.php index acbd033ef..7aa1dd35c 100644 --- a/app/V1Module/security/Policies/SisCoursePermissionPolicy.php +++ b/app/V1Module/security/Policies/SisCoursePermissionPolicy.php @@ -6,9 +6,11 @@ use App\Model\Repository\ExternalLogins; use App\Security\Identity; +/** + * @deprecated Use the new SIS extension instead + */ class SisCoursePermissionPolicy implements IPermissionPolicy { - function getAssociatedClass() { return SisCourseRecord::class; diff --git a/app/V1Module/security/Policies/SisGroupContextPermissionPolicy.php b/app/V1Module/security/Policies/SisGroupContextPermissionPolicy.php index bac34f6e2..e1b5b0c97 100644 --- a/app/V1Module/security/Policies/SisGroupContextPermissionPolicy.php +++ b/app/V1Module/security/Policies/SisGroupContextPermissionPolicy.php @@ -12,6 +12,9 @@ use App\Security\ACL\SisGroupContext; use App\Security\Identity; +/** + * @deprecated Use the new SIS extension instead + */ class SisGroupContextPermissionPolicy implements IPermissionPolicy { public function getAssociatedClass() diff --git a/app/V1Module/security/Policies/SisPermissionPolicy.php b/app/V1Module/security/Policies/SisPermissionPolicy.php index 414cfe995..7fb159076 100644 --- a/app/V1Module/security/Policies/SisPermissionPolicy.php +++ b/app/V1Module/security/Policies/SisPermissionPolicy.php @@ -6,6 +6,9 @@ use App\Security\ACL\SisIdWrapper; use App\Security\Identity; +/** + * @deprecated Use the new SIS extension instead + */ class SisPermissionPolicy implements IPermissionPolicy { private $externalLogins; diff --git a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php index 2e39d99fa..6c0f3edcc 100644 --- a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php +++ b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php @@ -83,7 +83,6 @@ public function isExerciseFileInGroupUserSupervises(Identity $identity, Uploaded } foreach ($file->getExercises() as $exercise) { - /** @var Exercise $exercise */ foreach ($exercise->getGroups() as $group) { /** @var Group $group */ if ($group->isAdminOrSupervisorOfSubgroup($user)) { @@ -143,7 +142,6 @@ public function isRelatedToAssignment(Identity $identity, UploadedFile $file) if ($file instanceof AttachmentFile) { foreach ($file->getExercises() as $exercise) { - /** @var Exercise $exercise */ foreach ($user->getGroups() as $group) { if ($this->assignments->isAssignedToGroup($exercise, $group)) { return true; diff --git a/app/config/config.neon b/app/config/config.neon index 472ccae4d..acd8e9434 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -423,50 +423,51 @@ services: - App\Helpers\Notifications\SolutionFlagChangedEmailSender(%solutionFlagChangedNotifications%) # models - repositories + - App\Model\Repository\Assignments + - App\Model\Repository\AssignmentSolutions + - App\Model\Repository\AssignmentSolutionSubmissions + - App\Model\Repository\AssignmentSolvers - App\Model\Repository\AsyncJobs + - App\Model\Repository\AttachmentFiles - App\Model\Repository\Comments - App\Model\Repository\Exercises - App\Model\Repository\ExerciseConfigs + - App\Model\Repository\ExerciseFileLinks + - App\Model\Repository\ExerciseFiles - App\Model\Repository\ExerciseTags - App\Model\Repository\ExerciseTests - - App\Model\Repository\Assignments - App\Model\Repository\ExternalLogins - App\Model\Repository\Groups - App\Model\Repository\GroupExternalAttributes - - App\Model\Repository\GroupInvitations - App\Model\Repository\GroupExams - App\Model\Repository\GroupExamLocks + - App\Model\Repository\GroupInvitations + - App\Model\Repository\GroupMemberships + - App\Model\Repository\HardwareGroups - App\Model\Repository\Instances - App\Model\Repository\Licences - App\Model\Repository\Logins + - App\Model\Repository\Notifications + - App\Model\Repository\PlagiarismDetectionBatches + - App\Model\Repository\PlagiarismDetectedSimilarities + - App\Model\Repository\PlagiarismDetectedSimilarFiles - App\Model\Repository\ReferenceExerciseSolutions - App\Model\Repository\ReferenceSolutionSubmissions - - App\Model\Repository\AssignmentSolutions - - App\Model\Repository\AssignmentSolutionSubmissions - - App\Model\Repository\AssignmentSolvers - App\Model\Repository\ReviewComments + - App\Model\Repository\RuntimeEnvironments - App\Model\Repository\SubmissionFailures - App\Model\Repository\SolutionEvaluations + - App\Model\Repository\Solutions - App\Model\Repository\UploadedFiles - App\Model\Repository\UploadedPartialFiles - App\Model\Repository\Users - App\Model\Repository\UserCalendars - - App\Model\Repository\RuntimeEnvironments - - App\Model\Repository\Solutions - - App\Model\Repository\GroupMemberships - - App\Model\Repository\HardwareGroups - - App\Model\Repository\ExerciseFiles - - App\Model\Repository\AttachmentFiles - App\Model\Repository\Pipelines - App\Model\Repository\SecurityEvents - App\Model\Repository\SisGroupBindings - App\Model\Repository\SisValidTerms - App\Model\Repository\ShadowAssignments - App\Model\Repository\ShadowAssignmentPointsRepository - - App\Model\Repository\Notifications - - App\Model\Repository\PlagiarismDetectionBatches - - App\Model\Repository\PlagiarismDetectedSimilarities - - App\Model\Repository\PlagiarismDetectedSimilarFiles # views factories - App\Model\View\ExerciseViewFactory diff --git a/app/helpers/ExerciseConfig/Compiler.php b/app/helpers/ExerciseConfig/Compiler.php index 49732fd43..8d31e6755 100644 --- a/app/helpers/ExerciseConfig/Compiler.php +++ b/app/helpers/ExerciseConfig/Compiler.php @@ -21,7 +21,6 @@ */ class Compiler { - public const EXERCISE_CONFIG_TYPES = ["simpleExerciseConfig", "advancedExerciseConfig"]; /** diff --git a/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/NodeRunBox.php b/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/NodeRunBox.php index f9b0db8b9..400e135d9 100644 --- a/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/NodeRunBox.php +++ b/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/NodeRunBox.php @@ -11,7 +11,7 @@ /** * Box which represents execution of given javascript. - * @DEPRECATED - use ScriptExecutionBox instead + * @deprecated - use ScriptExecutionBox instead */ class NodeRunBox extends ExecutionBox { diff --git a/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/PhpRunBox.php b/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/PhpRunBox.php index 9e9eb28bd..375f4ed53 100644 --- a/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/PhpRunBox.php +++ b/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/PhpRunBox.php @@ -11,7 +11,7 @@ /** * Box which represents execution of given php script. - * @DEPRECATED - use ScriptExecutionBox instead + * @deprecated - use ScriptExecutionBox instead */ class PhpRunBox extends ExecutionBox { diff --git a/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/Python3RunBox.php b/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/Python3RunBox.php index 44dab3a06..e07a901e0 100644 --- a/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/Python3RunBox.php +++ b/app/helpers/ExerciseConfig/Pipeline/Box/Boxes/Python3RunBox.php @@ -10,7 +10,7 @@ /** * Box which represents execution python script. - * @DEPRECATED - use ScriptExecutionBox instead + * @deprecated - use ScriptExecutionBox instead */ class Python3RunBox extends ExecutionBox { diff --git a/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php b/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php index 0c91a041d..20230a976 100644 --- a/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php +++ b/app/helpers/ExerciseConfig/Validation/EnvironmentConfigValidator.php @@ -11,7 +11,6 @@ */ class EnvironmentConfigValidator { - /** * Validate exercise environment configuration. * For more detailed description look at @ref App\Helpers\ExerciseConfig\Validator diff --git a/app/model/entity/Assignment.php b/app/model/entity/Assignment.php index d2f4ccc47..c879ad8d2 100644 --- a/app/model/entity/Assignment.php +++ b/app/model/entity/Assignment.php @@ -76,6 +76,7 @@ private function __construct( $this->configurationType = $exercise->getConfigurationType(); $this->exerciseFiles = $exercise->getExerciseFiles(); $this->attachmentFiles = $exercise->getAttachmentFiles(); + $this->fileLinks = new ArrayCollection(); // TODO properly copy from exercise $this->solutionFilesLimit = $exercise->getSolutionFilesLimit(); $this->solutionSizeLimit = $exercise->getSolutionSizeLimit(); } diff --git a/app/model/entity/AttachmentFile.php b/app/model/entity/AttachmentFile.php index 05be5348a..89d99f63a 100644 --- a/app/model/entity/AttachmentFile.php +++ b/app/model/entity/AttachmentFile.php @@ -21,7 +21,7 @@ class AttachmentFile extends UploadedFile implements JsonSerializable protected $exercises; /** - * @return Collection + * @return Collection */ public function getExercises() { diff --git a/app/model/entity/Exercise.php b/app/model/entity/Exercise.php index 162bc2f40..9e005718a 100644 --- a/app/model/entity/Exercise.php +++ b/app/model/entity/Exercise.php @@ -171,6 +171,7 @@ private function __construct( $this->groups = $groups; $this->assignments = new ArrayCollection(); $this->attachmentFiles = $attachmentFiles; + $this->fileLinks = new ArrayCollection(); $this->exerciseLimits = $exerciseLimits; $this->exerciseConfig = $exerciseConfig; $this->hardwareGroups = $hardwareGroups; @@ -240,6 +241,8 @@ public static function forkFrom(Exercise $exercise, User $user, Group $group): E $exercise->solutionFilesLimit, $exercise->solutionSizeLimit ); + + // TODO handle file link duplication } public function setRuntimeEnvironments(Collection $runtimeEnvironments): void @@ -338,6 +341,7 @@ public function clearExerciseLimits() public function hasNonemptyLocalizedTexts(): bool { foreach ($this->getLocalizedTexts() as $localizedText) { + /** @var LocalizedExercise $localizedText */ if (!$localizedText->isEmpty()) { return true; } diff --git a/app/model/entity/ExerciseFile.php b/app/model/entity/ExerciseFile.php index 6b5ecded1..f59805e27 100644 --- a/app/model/entity/ExerciseFile.php +++ b/app/model/entity/ExerciseFile.php @@ -16,19 +16,21 @@ class ExerciseFile extends UploadedFile implements JsonSerializable { /** + * Identifier used to store/retrieve the file in/from the storage. * @ORM\Column(type="string") */ protected $hashName; /** * @ORM\ManyToMany(targetEntity="Exercise", mappedBy="exerciseFiles") + * @var Collection */ protected $exercises; /** - * @return Collection + * @return Collection */ - public function getExercises() + public function getExercises(): Collection { return $this->exercises->filter( function (Exercise $exercise) { @@ -39,6 +41,7 @@ function (Exercise $exercise) { /** * @ORM\ManyToMany(targetEntity="Assignment", mappedBy="exerciseFiles") + * @var Collection */ protected $assignments; @@ -56,9 +59,16 @@ function (Assignment $assignment) { /** * @ORM\ManyToMany(targetEntity="Pipeline", mappedBy="exerciseFiles") + * @var Collection */ protected $pipelines; + /** + * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exerciseFile") + * @var Collection + */ + protected $links; + /** * ExerciseFile constructor. @@ -85,6 +95,7 @@ public function __construct( $this->exercises = new ArrayCollection(); $this->assignments = new ArrayCollection(); $this->pipelines = new ArrayCollection(); + $this->links = new ArrayCollection(); if ($exercise) { $this->exercises->add($exercise); diff --git a/app/model/entity/ExerciseFileLink.php b/app/model/entity/ExerciseFileLink.php new file mode 100644 index 000000000..931ee2a9f --- /dev/null +++ b/app/model/entity/ExerciseFileLink.php @@ -0,0 +1,129 @@ +key = $key; + $this->requiredRole = $requiredRole; + $this->exerciseFile = $exerciseFile; + $this->exercise = $exercise; + $this->assignment = $assignment; + $this->createdAt = new DateTime(); + } + + public static function createForExercise( + string $key, + ExerciseFile $exerciseFile, + ?string $requiredRole, + Exercise $exercise + ): self { + return new self($key, $exerciseFile, $requiredRole, $exercise, null); + } + + public static function copyForAssignment( + ExerciseFileLink $link, + Assignment $assignment + ): self { + if ($link->getExercise()?->getId() !== $assignment->getExercise()?->getId()) { + throw new \InvalidArgumentException( + 'Can only copy links associated with an exercise of selected assignment.' + ); + } + return new self($link->key, $link->exerciseFile, $link->requiredRole, null, $assignment); + } + + /* + * Accessors + */ + + public function getKey(): string + { + return $this->key; + } + + public function getRequiredRole(): ?string + { + return $this->requiredRole; + } + + public function getExerciseFile(): ExerciseFile + { + return $this->exerciseFile; + } + + public function getExercise(): ?Exercise + { + return $this->exercise; + } + + public function getAssignment(): ?Assignment + { + return $this->assignment; + } +} diff --git a/app/model/entity/base/ExerciseData.php b/app/model/entity/base/ExerciseData.php index 4b1a5d609..67ed8ea8d 100644 --- a/app/model/entity/base/ExerciseData.php +++ b/app/model/entity/base/ExerciseData.php @@ -364,6 +364,30 @@ function (AttachmentFile $file) { )->getValues(); } + /** + * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exerciseFile", cascade={"persist", "remove"}) + * @var Collection + */ + protected $fileLinks; + + /** + * @return Collection + */ + public function getFileLinks(): Collection + { + return $this->fileLinks; + } + + public function addFileLink(ExerciseFileLink $fileLink): void + { + $this->fileLinks->add($fileLink); + } + + public function removeFileLink(ExerciseFileLink $fileLink): bool + { + return $this->fileLinks->removeElement($fileLink); + } + /** * @ORM\Column(type="integer", nullable=true) * How many files may one submit in a solution. diff --git a/app/model/repository/ExerciseFileLinks.php b/app/model/repository/ExerciseFileLinks.php new file mode 100644 index 000000000..3e4bae984 --- /dev/null +++ b/app/model/repository/ExerciseFileLinks.php @@ -0,0 +1,17 @@ + + */ +class ExerciseFileLinks extends BaseRepository +{ + public function __construct(EntityManagerInterface $em) + { + parent::__construct($em, ExerciseFileLink::class); + } +} diff --git a/app/model/repository/SisGroupBindings.php b/app/model/repository/SisGroupBindings.php index b7773fb37..40be68b4a 100644 --- a/app/model/repository/SisGroupBindings.php +++ b/app/model/repository/SisGroupBindings.php @@ -9,6 +9,7 @@ /** * @extends BaseRepository + * @deprecated */ class SisGroupBindings extends BaseRepository implements IGroupBindingProvider { diff --git a/app/model/repository/SisValidTerms.php b/app/model/repository/SisValidTerms.php index 669d5484a..74b058823 100644 --- a/app/model/repository/SisValidTerms.php +++ b/app/model/repository/SisValidTerms.php @@ -7,6 +7,7 @@ /** * @extends BaseRepository + * @deprecated Use the new SIS extension instead */ class SisValidTerms extends BaseRepository { From f493455b0a6f0a4970dafd7828743ef92c7aa781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 14 Nov 2025 18:27:41 +0100 Subject: [PATCH 05/25] Removing isPublic flag from uploaded file entity. Access to files will be ensured by file links. --- .../Policies/UploadedFilePermissionPolicy.php | 5 -- app/config/permissions.neon | 1 - app/model/entity/AttachmentFile.php | 2 +- app/model/entity/UploadedFile.php | 23 +------ tests/Presenters/UploadedFilesPresenter.phpt | 33 +--------- tests/helpers/FileStorage.phpt | 66 +++++++++---------- 6 files changed, 38 insertions(+), 92 deletions(-) diff --git a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php index 6c0f3edcc..fb6787ea3 100644 --- a/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php +++ b/app/V1Module/security/Policies/UploadedFilePermissionPolicy.php @@ -30,11 +30,6 @@ public function getAssociatedClass() return UploadedFile::class; } - public function isPublic(Identity $identity, UploadedFile $file) - { - return $file->isPublic(); - } - public function isAttachmentFile(Identity $identity, UploadedFile $file) { return $file instanceof AttachmentFile; diff --git a/app/config/permissions.neon b/app/config/permissions.neon index b5a899f5e..1ee23a9c5 100644 --- a/app/config/permissions.neon +++ b/app/config/permissions.neon @@ -1076,7 +1076,6 @@ permissions: or: - file.isSolutionInSupervisedOrObservedGroup - file.isReferenceSolutionInSupervisedOrObservedSubGroup - - file.isPublic - file.isOwner - file.isRelatedToAssignment # deprecated (will be removed with Attachment files) - file.isAuthorOfFileExercises diff --git a/app/model/entity/AttachmentFile.php b/app/model/entity/AttachmentFile.php index 89d99f63a..69f33e7b6 100644 --- a/app/model/entity/AttachmentFile.php +++ b/app/model/entity/AttachmentFile.php @@ -75,7 +75,7 @@ public function getAssignmentsAndIReallyMeanAllOkay() */ public function __construct($name, DateTime $uploadedAt, $fileSize, ?User $user, Exercise $exercise) { - parent::__construct($name, $uploadedAt, $fileSize, $user, true); + parent::__construct($name, $uploadedAt, $fileSize, $user); $this->exercises = new ArrayCollection(); $this->assignments = new ArrayCollection(); diff --git a/app/model/entity/UploadedFile.php b/app/model/entity/UploadedFile.php index ed80f88c7..7f4e9e6e7 100644 --- a/app/model/entity/UploadedFile.php +++ b/app/model/entity/UploadedFile.php @@ -45,13 +45,6 @@ public function getFileExtension(): string */ protected $uploadedAt; - /** - * @ORM\Column(type="boolean") - * If true, the file is accessible to all logged-in users. - * This is useful for files associated with entries like exercises. - */ - protected $isPublic; - /** * @ORM\Column(type="integer") */ @@ -82,20 +75,17 @@ public function getUserIdEvenIfDeleted(): ?string * @param DateTime $uploadedAt Time of the upload * @param int $fileSize Size of the file * @param User|null $user The user who uploaded the file - * @param bool $isPublic */ public function __construct( string $name, DateTime $uploadedAt, int $fileSize, - ?User $user, - $isPublic = false + ?User $user ) { $this->name = $name; $this->uploadedAt = $uploadedAt; $this->fileSize = $fileSize; $this->user = $user; - $this->isPublic = $isPublic; } public function jsonSerialize(): mixed @@ -106,20 +96,9 @@ public function jsonSerialize(): mixed "size" => $this->fileSize, "uploadedAt" => $this->uploadedAt->getTimestamp(), "userId" => $this->getUserId(), - "isPublic" => $this->isPublic ]; } - public function isPublic() - { - return $this->isPublic; - } - - public function setPublic(bool $isPublic): void - { - $this->isPublic = $isPublic; - } - /** * Retrieve a corresponding file object from file storage. * @param FileStorageManager $manager the storage that retrieves the file diff --git a/tests/Presenters/UploadedFilesPresenter.phpt b/tests/Presenters/UploadedFilesPresenter.phpt index 2266e757d..4c777286a 100644 --- a/tests/Presenters/UploadedFilesPresenter.phpt +++ b/tests/Presenters/UploadedFilesPresenter.phpt @@ -95,8 +95,8 @@ class TestUploadedFilesPresenter extends Tester\TestCase public function testUserCannotAccessDetail() { - $token = PresenterTestHelper::login($this->container, $this->otherUserLogin); - $file = current($this->presenter->uploadedFiles->findBy(["isPublic" => false])); + PresenterTestHelper::login($this->container, $this->otherUserLogin); + $file = current($this->presenter->uploadedFiles->findAll()); $request = new Nette\Application\Request( $this->presenterPath, 'GET', @@ -112,8 +112,7 @@ class TestUploadedFilesPresenter extends Tester\TestCase public function testDetail() { - $token = PresenterTestHelper::loginDefaultAdmin($this->container); - + PresenterTestHelper::loginDefaultAdmin($this->container); $file = current($this->presenter->uploadedFiles->findAll()); $request = new Nette\Application\Request( @@ -437,32 +436,6 @@ class TestUploadedFilesPresenter extends Tester\TestCase Assert::equal($file->getName(), $response->getName()); } - public function testOutsiderCannotAccessAttachmentFiles() - { - $token = PresenterTestHelper::login($this->container, $this->otherUserLogin); - - /** @var EntityManagerInterface $em */ - $em = $this->container->getByType(EntityManagerInterface::class); - $file = current($em->getRepository(AttachmentFile::class)->findAll()); - - $mockStorage = Mockery::mock(FileStorageManager::class); - $mockStorage->shouldReceive("getAttachmentFile")->withArgs([$file])->andReturn(null)->once(); - $this->presenter->fileStorage = $mockStorage; - - $request = new Nette\Application\Request( - $this->presenterPath, - 'GET', - [ - 'action' => 'download', - 'id' => $file->getId() - ] - ); - - Assert::exception(function () use ($request) { - $this->presenter->run($request); - }, NotFoundException::class, "Not Found - File not found in the storage"); - } - public function testDownloadResultArchive() { PresenterTestHelper::loginDefaultAdmin($this->container); diff --git a/tests/helpers/FileStorage.phpt b/tests/helpers/FileStorage.phpt index 751698e3c..31a986cdd 100644 --- a/tests/helpers/FileStorage.phpt +++ b/tests/helpers/FileStorage.phpt @@ -125,7 +125,7 @@ class TestFileStorage extends Tester\TestCase self::rmdirRecursive($hashDir); } @mkdir($hashDir); - $storage = new LocalHashFileStorage([ 'root' => $hashDir ]); + $storage = new LocalHashFileStorage(['root' => $hashDir]); foreach ($files as $file) { $hash = sha1($file); @@ -147,7 +147,7 @@ class TestFileStorage extends Tester\TestCase self::rmdirRecursive($rootDir); } @mkdir($rootDir); - $storage = new LocalFileStorage(new TmpFilesHelper($this->tmpDir), [ 'root' => $rootDir ]); + $storage = new LocalFileStorage(new TmpFilesHelper($this->tmpDir), ['root' => $rootDir]); foreach ($files as $file => $contents) { if (str_contains($file, '/')) { @@ -190,7 +190,7 @@ class TestFileStorage extends Tester\TestCase { $contents = "Lorem ipsum et sepsum!"; $hash = sha1($contents); - $hashStorage = $this->prepareHashStorage([ $contents ]); + $hashStorage = $this->prepareHashStorage([$contents]); $file = $hashStorage->fetchOrThrow($hash); Assert::type(IImmutableFile::class, $file); Assert::equal($hash, $file->getStoragePath()); @@ -204,7 +204,7 @@ class TestFileStorage extends Tester\TestCase { $contents = "Lorem ipsum et sepsum!"; $hash = sha1($contents); - $hashStorage = $this->prepareHashStorage([ $contents ]); + $hashStorage = $this->prepareHashStorage([$contents]); $file = $hashStorage->fetch(sha1('no aint here')); Assert::null($file); Assert::exception(function () use ($hashStorage) { @@ -263,7 +263,7 @@ class TestFileStorage extends Tester\TestCase { $contents = "Lorem ipsum et sepsum!"; $hash = sha1($contents); - $hashStorage = $this->prepareHashStorage([ $contents ]); + $hashStorage = $this->prepareHashStorage([$contents]); Assert::type(IImmutableFile::class, $hashStorage->fetch($hash)); Assert::true($hashStorage->delete($hash)); Assert::null($hashStorage->fetch($hash)); @@ -275,7 +275,7 @@ class TestFileStorage extends Tester\TestCase { $entry = 'foo/bar'; $data = 'abcde'; - $zip = $this->createZipFile([ $entry => $data ]); + $zip = $this->createZipFile([$entry => $data]); $file = new ArchivedImmutableFile($zip, $entry); Assert::equal("$zip#$entry", $file->getStoragePath()); Assert::equal(strlen($data), $file->getSize()); @@ -287,7 +287,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageFetch() { - $zip = $this->createZipFile([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]); + $zip = $this->createZipFile(['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); $fileA = $storage->fetch('a.txt'); Assert::equal('AAAAA', $fileA->getContents()); @@ -300,7 +300,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageFetchNonexist() { - $zip = $this->createZipFile([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]); + $zip = $this->createZipFile(['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); $fileC = $storage->fetch('c.txt'); Assert::null($fileC); @@ -312,7 +312,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageStoreFile() { - $zip = $this->createZipFile([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]); + $zip = $this->createZipFile(['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); $tmpX = $this->createTmpFile('XXX'); @@ -337,7 +337,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageStoreContents() { - $zip = $this->createZipFile([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]); + $zip = $this->createZipFile(['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); $storage->storeContents('XXX', 'x.txt', false); @@ -356,7 +356,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageStoreStream() { - $zip = $this->createZipFile([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]); + $zip = $this->createZipFile(['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); $tmpX = $this->createTmpFile('XXX'); @@ -385,7 +385,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageStoreCopy() { - $zip = $this->createZipFile([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]); + $zip = $this->createZipFile(['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); $storage->copy('a.txt', 'c.txt'); @@ -403,7 +403,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageStoreMove() { - $zip = $this->createZipFile([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]); + $zip = $this->createZipFile(['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); $storage->move('a.txt', 'c.txt'); @@ -427,7 +427,7 @@ class TestFileStorage extends Tester\TestCase public function testZipFileStorageStoreDelete() { - $files = [ 'a.txt' => 'AAAAA', 'b.txt' => 'BBBB' ]; + $files = ['a.txt' => 'AAAAA', 'b.txt' => 'BBBB']; $zip = $this->createZipFile($files); $storage = new ZipFileStorage(new TmpFilesHelper($this->tmpDir), $zip); @@ -444,7 +444,7 @@ class TestFileStorage extends Tester\TestCase $storage = $this->prepareLocalStorage([ 'a.txt' => 'AAAAA', 'b.txt' => 'BBB', - 'z/z.zip' => [ 'foo.md' => 'FOO', 'bar/bar' => 'BAR'] + 'z/z.zip' => ['foo.md' => 'FOO', 'bar/bar' => 'BAR'] ]); $fileA = $storage->fetch('a.txt'); @@ -481,7 +481,7 @@ class TestFileStorage extends Tester\TestCase { $storage = $this->prepareLocalStorage([ 'a.txt' => 'AAA', - 'z/z.zip' => [ 'foo.md' => 'FOO', 'bar/bar' => 'BAR'] + 'z/z.zip' => ['foo.md' => 'FOO', 'bar/bar' => 'BAR'] ]); // regular files @@ -518,7 +518,7 @@ class TestFileStorage extends Tester\TestCase { $storage = $this->prepareLocalStorage([ 'a.txt' => 'AAA', - 'z/z.zip' => [ 'foo.md' => 'FOO', 'bar/bar' => 'BAR'] + 'z/z.zip' => ['foo.md' => 'FOO', 'bar/bar' => 'BAR'] ]); // regular files @@ -548,7 +548,7 @@ class TestFileStorage extends Tester\TestCase { $storage = $this->prepareLocalStorage([ 'a.txt' => 'AAA', - 'z/z.zip' => [ 'foo.md' => 'FOO', 'bar/bar' => 'BAR'] + 'z/z.zip' => ['foo.md' => 'FOO', 'bar/bar' => 'BAR'] ]); // regular files @@ -589,8 +589,8 @@ class TestFileStorage extends Tester\TestCase $storage = $this->prepareLocalStorage([ 'a.txt' => 'AAA', 'b.txt' => 'BBB', - 'z/z.zip' => [ 'foo.md' => 'FOO', 'bar/bar' => 'BAR'], - 'z2.zip' => [ 'job.log' => 'failed' ] + 'z/z.zip' => ['foo.md' => 'FOO', 'bar/bar' => 'BAR'], + 'z2.zip' => ['job.log' => 'failed'] ]); // regular files @@ -662,8 +662,8 @@ class TestFileStorage extends Tester\TestCase 'b.txt' => 'BBB', 'c.txt' => 'CCC', 'd.txt' => 'DDD', - 'z/z.zip' => [ 'foo.md' => 'FOO', 'boo' => 'BOO', 'loo' => 'LOO', 'zoo' => 'ZOO', 'bar/bar' => 'BAR'], - 'z2.zip' => [ 'job.log' => 'failed', 'config.yaml' => 'YAML' ] + 'z/z.zip' => ['foo.md' => 'FOO', 'boo' => 'BOO', 'loo' => 'LOO', 'zoo' => 'ZOO', 'bar/bar' => 'BAR'], + 'z2.zip' => ['job.log' => 'failed', 'config.yaml' => 'YAML'] ]); // regular files @@ -733,7 +733,7 @@ class TestFileStorage extends Tester\TestCase $storage = $this->prepareLocalStorage([ 'foo/bar/a.txt' => 'AAA', 'foo/bar/b.txt' => 'BBB', - 'zip' => [ 'foo' => 'FOO', 'bar' => 'BAR', 'keeper' => 'placeholder' ], + 'zip' => ['foo' => 'FOO', 'bar' => 'BAR', 'keeper' => 'placeholder'], ]); $root = $storage->getRootDirectory(); @@ -782,8 +782,8 @@ class TestFileStorage extends Tester\TestCase 'foo/bar/a.txt' => 'AAA', 'foo/bar/b.txt' => 'BBB', 'c.txt' => 'CCC', - 'zip' => [ 'foo' => 'FOO' ], - 'zip2' => [ 'job.log' => 'failed', 'config.yaml' => 'YAML' ] + 'zip' => ['foo' => 'FOO'], + 'zip2' => ['job.log' => 'failed', 'config.yaml' => 'YAML'] ]); $root = $storage->getRootDirectory(); @@ -866,7 +866,7 @@ class TestFileStorage extends Tester\TestCase PresenterTestHelper::loginDefaultAdmin($this->container); $user = $this->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); $partialFile = new UploadedPartialFile("foo", 9, $user); - $uploadedFile = new UploadedFile("foo", new DateTime(), 9, $user, true); + $uploadedFile = new UploadedFile("foo", new DateTime(), 9, $user); self::setPropertyOfObject($partialFile, 'id', '123'); self::setPropertyOfObject($uploadedFile, 'id', '123'); @@ -902,7 +902,7 @@ class TestFileStorage extends Tester\TestCase $contents2 = '0123456789'; $storage = $this->prepareLocalStorage([ 'foo.txt' => $contents1, - 'bar.zip' => [ 'foo.txt' => $contents2 ], + 'bar.zip' => ['foo.txt' => $contents2], ]); $file1 = $storage->fetch('foo.txt'); @@ -917,8 +917,8 @@ class TestFileStorage extends Tester\TestCase $storage = $this->prepareLocalStorage([ 'foo.txt' => 'abc', 'foo.zip' => 'abc', - 'bar.zip' => [ 'foo.txt' => 'abcde' ], - 'inner.zip' => [ 'foo.txt' => 'abcdef' ], + 'bar.zip' => ['foo.txt' => 'abcde'], + 'inner.zip' => ['foo.txt' => 'abcdef'], ]); $storage->move('inner.zip', 'bar.zip#inner.zip'); @@ -933,14 +933,14 @@ class TestFileStorage extends Tester\TestCase { $storage = $this->prepareLocalStorage([ 'bad.zip' => 'abc', - 'foo.zip' => [ 'foo.txt' => 'abcde', 'bar.txt' => 'xyz' ], - 'inner.zip' => [ 'foo.txt' => 'abcde', 'bar.txt' => 'xyz' ], + 'foo.zip' => ['foo.txt' => 'abcde', 'bar.txt' => 'xyz'], + 'inner.zip' => ['foo.txt' => 'abcde', 'bar.txt' => 'xyz'], ]); $storage->move('inner.zip', 'bar.zip#inner.zip'); Assert::equal([ - [ 'name' => 'foo.txt', 'size' => 5 ], - [ 'name' => 'bar.txt', 'size' => 3 ], + ['name' => 'foo.txt', 'size' => 5], + ['name' => 'bar.txt', 'size' => 3], ], $storage->fetch('foo.zip')->getZipEntries()); Assert::exception( From 871e6a813000ee8eb6ae6fe2660fb84f6ff95d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 14 Nov 2025 18:48:01 +0100 Subject: [PATCH 06/25] Improving exercise links and generating migration. --- app/model/entity/ExerciseFileLink.php | 59 +++++++++++++++++++++++---- migrations/Version20251114174621.php | 39 ++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 migrations/Version20251114174621.php diff --git a/app/model/entity/ExerciseFileLink.php b/app/model/entity/ExerciseFileLink.php index 931ee2a9f..9ec12c180 100644 --- a/app/model/entity/ExerciseFileLink.php +++ b/app/model/entity/ExerciseFileLink.php @@ -28,10 +28,16 @@ class ExerciseFileLink /** * The key (fixed ID) used to identify the file in exercise specification (for simple replacement). - * @ORM\Column(type="string") + * @ORM\Column(type="string", length=16) */ protected $key; + /** + * New name under which the file is downloaded (null means the original name). + * @ORM\Column(type="string", nullable=true) + */ + protected $saveName; + /** * Minimal required user role to access the file (null means even non-logged-in users). * @ORM\Column(type="string", nullable=true) @@ -56,36 +62,53 @@ class ExerciseFileLink /** * Link constructor - * @param string $key + * @param string $key used to identify the file in exercise specification (for simple replacement) * @param ExerciseFile $exerciseFile - * @param string|null $requiredRole * @param Exercise|null $exercise * @param Assignment|null $assignment + * @param string|null $requiredRole minimal required user role to access the file (null = non-logged-in users) + * @param string|null $saveName new name under which the file is downloaded (null means the original name) */ private function __construct( string $key, ExerciseFile $exerciseFile, - ?string $requiredRole = null, ?Exercise $exercise = null, ?Assignment $assignment = null, + ?string $requiredRole = null, + ?string $saveName = null ) { $this->key = $key; $this->requiredRole = $requiredRole; + $this->saveName = $saveName; $this->exerciseFile = $exerciseFile; $this->exercise = $exercise; $this->assignment = $assignment; $this->createdAt = new DateTime(); } + /** + * Create a link for exercise + * @param string $key + * @param ExerciseFile $exerciseFile + * @param Exercise $exercise + * @param string|null $requiredRole + * @param string|null $saveName + */ public static function createForExercise( string $key, ExerciseFile $exerciseFile, - ?string $requiredRole, - Exercise $exercise + Exercise $exercise, + ?string $requiredRole = null, + ?string $saveName = null ): self { - return new self($key, $exerciseFile, $requiredRole, $exercise, null); + return new self($key, $exerciseFile, $exercise, null, $requiredRole, $saveName); } + /** + * Create a link for assignment by copying an existing link + * @param ExerciseFileLink $link to be copied when assignment is being created + * @param Assignment $assignment the assignment for which the link is being created + */ public static function copyForAssignment( ExerciseFileLink $link, Assignment $assignment @@ -95,7 +118,7 @@ public static function copyForAssignment( 'Can only copy links associated with an exercise of selected assignment.' ); } - return new self($link->key, $link->exerciseFile, $link->requiredRole, null, $assignment); + return new self($link->key, $link->exerciseFile, null, $assignment, $link->requiredRole, $link->saveName); } /* @@ -107,11 +130,31 @@ public function getKey(): string return $this->key; } + public function setKey(string $key): void + { + $this->key = $key; + } + public function getRequiredRole(): ?string { return $this->requiredRole; } + public function setRequiredRole(?string $requiredRole): void + { + $this->requiredRole = $requiredRole; + } + + public function getSaveName(): ?string + { + return $this->saveName; + } + + public function setSaveName(?string $saveName): void + { + $this->saveName = $saveName; + } + public function getExerciseFile(): ExerciseFile { return $this->exerciseFile; diff --git a/migrations/Version20251114174621.php b/migrations/Version20251114174621.php new file mode 100644 index 000000000..cc5eb931c --- /dev/null +++ b/migrations/Version20251114174621.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE exercise_file_link (id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\', exercise_file_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', exercise_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', assignment_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', `key` VARCHAR(16) NOT NULL, save_name VARCHAR(255) DEFAULT NULL, required_role VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, INDEX IDX_1187F77549DE8E29 (exercise_file_id), INDEX IDX_1187F775E934951A (exercise_id), INDEX IDX_1187F775D19302F8 (assignment_id), UNIQUE INDEX UNIQ_1187F7758A90ABA9E934951A (`key`, exercise_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F77549DE8E29 FOREIGN KEY (exercise_file_id) REFERENCES `uploaded_file` (id)'); + $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775E934951A FOREIGN KEY (exercise_id) REFERENCES exercise (id)'); + $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)'); + $this->addSql('ALTER TABLE uploaded_file DROP is_public'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE exercise_file_link DROP FOREIGN KEY FK_1187F77549DE8E29'); + $this->addSql('ALTER TABLE exercise_file_link DROP FOREIGN KEY FK_1187F775E934951A'); + $this->addSql('ALTER TABLE exercise_file_link DROP FOREIGN KEY FK_1187F775D19302F8'); + $this->addSql('DROP TABLE exercise_file_link'); + $this->addSql('ALTER TABLE `uploaded_file` ADD is_public TINYINT(1) NOT NULL'); + } +} From 2615ce5b5b94088106c86329a7253f2f45963def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 14 Nov 2025 19:10:35 +0100 Subject: [PATCH 07/25] Properly copying exercise file links when assignment is created or file is cloned. --- app/model/entity/Assignment.php | 47 ++++++++++++++++++++++++++++++--- app/model/entity/Exercise.php | 16 +++++++++-- tests/helpers/FileStorage.phpt | 2 +- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/app/model/entity/Assignment.php b/app/model/entity/Assignment.php index c879ad8d2..783601605 100644 --- a/app/model/entity/Assignment.php +++ b/app/model/entity/Assignment.php @@ -76,9 +76,15 @@ private function __construct( $this->configurationType = $exercise->getConfigurationType(); $this->exerciseFiles = $exercise->getExerciseFiles(); $this->attachmentFiles = $exercise->getAttachmentFiles(); - $this->fileLinks = new ArrayCollection(); // TODO properly copy from exercise + $this->fileLinks = new ArrayCollection(); $this->solutionFilesLimit = $exercise->getSolutionFilesLimit(); $this->solutionSizeLimit = $exercise->getSolutionSizeLimit(); + + // copy file links from exercise + foreach ($exercise->getFileLinks() as $link) { + $newLink = ExerciseFileLink::copyForAssignment($link, $this); + $this->fileLinks->add($newLink); + } } public static function assignToGroup( @@ -454,9 +460,7 @@ function ($key, ExerciseTest $test) use ($exercise) { public function areExerciseFilesInSync(): bool { $exercise = $this->getExercise(); - return $exercise - && $this->getExerciseFiles()->count() - === $exercise->getExerciseFiles()->count() + return $exercise && $this->getExerciseFiles()->count() === $exercise->getExerciseFiles()->count() && $this->getExerciseFiles()->forAll( function ($key, ExerciseFile $file) use ($exercise) { return $exercise->getExerciseFiles()->contains($file); @@ -464,6 +468,35 @@ function ($key, ExerciseFile $file) use ($exercise) { ); } + public function areExerciseFileLinksInSync(): bool + { + $exercise = $this->getExercise(); + if (!$exercise || $this->getFileLinks()->count() !== $exercise->getFileLinks()->count()) { + return false; + } + + // build index where keys are exercise file IDs + $fileIndex = []; + foreach ($this->getFileLinks() as $link) { + $fileIndex[$link->getExerciseFile()->getId()] = $link; + } + + // verify that all exercise links are present and has the same data + foreach ($exercise->getFileLinks() as $link) { + $ourLink = $fileIndex[$link->getExerciseFile()->getId()] ?? null; + if ( + $ourLink === null + || $ourLink->getKey() !== $link->getKey() + || $ourLink->getRequiredRole() !== $link->getRequiredRole() + || $ourLink->getSaveName() !== $link->getSaveName() + ) { + return false; + } + } + + return true; + } + public function areAttachmentFilesInSync(): bool { $exercise = $this->getExercise(); @@ -537,6 +570,12 @@ public function syncWithExercise() $this->attachmentFiles->add($file); } + $this->fileLinks->clear(); + foreach ($exercise->getFileLinks() as $link) { + $newLink = ExerciseFileLink::copyForAssignment($link, $this); + $this->fileLinks->add($newLink); + } + $this->runtimeEnvironments->clear(); foreach ($exercise->getRuntimeEnvironments() as $env) { $this->runtimeEnvironments->add($env); diff --git a/app/model/entity/Exercise.php b/app/model/entity/Exercise.php index 9e005718a..ad48580e8 100644 --- a/app/model/entity/Exercise.php +++ b/app/model/entity/Exercise.php @@ -219,7 +219,7 @@ public static function create( public static function forkFrom(Exercise $exercise, User $user, Group $group): Exercise { - return new self( + $newExercise = new self( 1, $exercise->difficulty, $exercise->localizedTexts, @@ -242,7 +242,19 @@ public static function forkFrom(Exercise $exercise, User $user, Group $group): E $exercise->solutionSizeLimit ); - // TODO handle file link duplication + // handle file link duplication + foreach ($exercise->fileLinks as $link) { + $newLink = ExerciseFileLink::createForExercise( + $link->getKey(), + $link->getExerciseFile(), + $newExercise, + $link->getRequiredRole(), + $link->getSaveName() + ); + $newExercise->fileLinks->add($newLink); + } + + return $newExercise; } public function setRuntimeEnvironments(Collection $runtimeEnvironments): void diff --git a/tests/helpers/FileStorage.phpt b/tests/helpers/FileStorage.phpt index 31a986cdd..9ea98ac83 100644 --- a/tests/helpers/FileStorage.phpt +++ b/tests/helpers/FileStorage.phpt @@ -866,7 +866,7 @@ class TestFileStorage extends Tester\TestCase PresenterTestHelper::loginDefaultAdmin($this->container); $user = $this->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); $partialFile = new UploadedPartialFile("foo", 9, $user); - $uploadedFile = new UploadedFile("foo", new DateTime(), 9, $user); + $uploadedFile = new UploadedFile("foo", new DateTime(), 9, $user, true); self::setPropertyOfObject($partialFile, 'id', '123'); self::setPropertyOfObject($uploadedFile, 'id', '123'); From ac935193a13fa88520a73ca25c2aa8f50f6ff3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 15 Nov 2025 14:07:06 +0100 Subject: [PATCH 08/25] Implementing endpoints for exercise file links manipulation. --- .../presenters/ExerciseFilesPresenter.php | 199 +++++++++++++++++- .../presenters/UploadedFilesPresenter.php | 152 ++++++++++++- app/V1Module/router/RouterFactory.php | 29 +-- app/V1Module/security/Roles.php | 4 +- app/model/entity/ExerciseFileLink.php | 19 +- 5 files changed, 374 insertions(+), 29 deletions(-) diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index b7515f88c..0b6c1049f 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -16,15 +16,18 @@ use App\Helpers\ExercisesConfig; use App\Helpers\FileStorageManager; use App\Model\Entity\Assignment; +use App\Model\Entity\AttachmentFile; +use App\Model\Entity\Exercise; use App\Model\Entity\ExerciseFile; +use App\Model\Entity\ExerciseFileLink; use App\Model\Entity\UploadedFile; -use App\Model\Entity\AttachmentFile; use App\Model\Repository\Assignments; use App\Model\Repository\AttachmentFiles; use App\Model\Repository\Exercises; -use App\Model\Entity\Exercise; +use App\Model\Repository\ExerciseFileLinks; use App\Model\Repository\ExerciseFiles; use App\Model\Repository\UploadedFiles; +use App\Security\Roles; use App\Security\ACL\IExercisePermissions; use Exception; @@ -58,6 +61,12 @@ class ExerciseFilesPresenter extends BasePresenter */ public $exerciseFiles; + /** + * @var ExerciseFileLinks + * @inject + */ + public $fileLinks; + /** * @var AttachmentFiles * @inject @@ -88,6 +97,12 @@ class ExerciseFilesPresenter extends BasePresenter */ public $configChecker; + /** + * @var Roles + * @inject + */ + public $roles; + public function checkUploadExerciseFiles(string $id) { $exercise = $this->exercises->findOrThrow($id); @@ -352,7 +367,7 @@ public function checkDeleteAttachmentFile(string $id, string $fileId) * @throws NotFoundException */ #[Path("id", new VUuid(), "identification of exercise", required: true)] - #[Path("fileId", new VString(), "identification of file", required: true)] + #[Path("fileId", new VUuid(), "identification of file", required: true)] public function actionDeleteAttachmentFile(string $id, string $fileId) { $exercise = $this->exercises->findOrThrow($id); @@ -428,4 +443,182 @@ public function actionDownloadAttachmentFilesArchive(string $id) } $this->sendZipFilesResponse($files, "exercise-attachment-{$id}.zip"); } + + /* + * Exercise file links management + */ + + public function checkGetFileLinks(string $id) + { + $exercise = $this->exercises->findOrThrow($id); + if (!$this->exerciseAcl->canUpdate($exercise)) { + throw new ForbiddenRequestException("You cannot view exercise file links for this exercise."); + } + } + + /** + * Retrieve a list of all exercise-file links for given exercise. + * @GET + */ + #[Path("id", new VUuid(), "of exercise", required: true)] + public function actionGetFileLinks(string $id) + { + $exercise = $this->exercises->findOrThrow($id); + $this->sendSuccessResponse($exercise->getFileLinks()->getValues()); + } + + public function checkCreateFileLink(string $id) + { + $exercise = $this->exercises->findOrThrow($id); + if (!$this->exerciseAcl->canUpdate($exercise)) { + throw new ForbiddenRequestException("You cannot create exercise file links for this exercise."); + } + } + /** + * Create a new exercise-file link for given exercise. + * @POST + */ + #[Path("id", new VUuid(), "of exercise", required: true)] + #[Post( + "exerciseFileId", + new VUuid(), + "Target file the link will point to", + required: true + )] + #[Post( + "key", + new VString(1, 16), + "Internal user-selected identifier of the exercise file link within the exercise", + required: true + )] + #[Post( + "requiredRole", + new VString(1, 255), + "Minimal required user role to access the file (null = non-logged-in users)", + nullable: true, + required: false + )] + #[Post( + "saveName", + new VString(1, 255), + "File name override (the file will be downloaded under this name instead of the original name)", + nullable: true, + required: false + )] + public function actionCreateFileLink(string $id) + { + $exercise = $this->exercises->findOrThrow($id); + $req = $this->getRequest(); + $exerciseFile = $this->exerciseFiles->findOrThrow($req->getPost("exerciseFileId")); + $key = $req->getPost("key"); + $requiredRole = $req->getPost("requiredRole"); + $saveName = $req->getPost("saveName"); + + if (!$this->roles->validateRole($requiredRole)) { + throw new InvalidApiArgumentException('requiredRole', "Unknown user role '$requiredRole'"); + } + + $link = ExerciseFileLink::createForExercise( + $key, + $exerciseFile, + $exercise, + $requiredRole, + $saveName + ); + + $this->fileLinks->persist($link); + $this->sendSuccessResponse($link); + } + + public function checkUpdateFileLink(string $id, string $linkId) + { + $exercise = $this->exercises->findOrThrow($id); + $link = $this->fileLinks->findOrThrow($linkId); + + if ($link->getExercise()?->getId() !== $id) { + throw new BadRequestException("The exercise file link is not associated with the given exercise."); + } + + if (!$this->exerciseAcl->canUpdate($exercise)) { + throw new ForbiddenRequestException("You cannot update exercise file links for this exercise."); + } + } + /** + * Update a specific exercise-file link. + * @POST + */ + #[Path("id", new VUuid(), "of exercise", required: true)] + #[Path("linkId", new VUuid(), "of the exercise file link", required: true)] + #[Post( + "key", + new VString(1, 16), + "Internal user-selected identifier of the exercise file link within the exercise", + required: true + )] + #[Post( + "requiredRole", + new VString(1, 255), + "Minimal required user role to access the file (null = non-logged-in users)", + nullable: true, + required: false + )] + #[Post( + "saveName", + new VString(1, 255), + "File name override (the file will be downloaded under this name instead of the original name)", + nullable: true, + required: false + )] + public function actionUpdateFileLink(string $id, string $linkId) + { + $link = $this->fileLinks->findOrThrow($linkId); + $req = $this->getRequest(); + $post = $req->getPost(); + + if (array_key_exists("requiredRole", $post)) { + // array_key_exists checks whether the key is present (even if null) + $requiredRole = $post["requiredRole"]; + if (!$this->roles->validateRole($requiredRole)) { + throw new InvalidApiArgumentException('requiredRole', "Unknown user role '$requiredRole'"); + } + $link->setRequiredRole($requiredRole); + } + + if (array_key_exists("saveName", $post)) { + // array_key_exists checks whether the key is present (even if null) + $link->setSaveName($post["saveName"]); + } + + $link->setKey($req->getPost("key")); + + $this->fileLinks->persist($link); + $this->sendSuccessResponse($link); + } + + public function checkDeleteFileLink(string $id, string $linkId) + { + $exercise = $this->exercises->findOrThrow($id); + $link = $this->fileLinks->findOrThrow($linkId); + + if ($link->getExercise()?->getId() !== $id) { + throw new BadRequestException("The exercise file link is not associated with the given exercise."); + } + + if (!$this->exerciseAcl->canUpdate($exercise)) { + throw new ForbiddenRequestException("You cannot delete exercise file links for this exercise."); + } + } + + /** + * Delete a specific exercise-file link. + * @DELETE + */ + #[Path("id", new VUuid(), "of exercise", required: true)] + #[Path("linkId", new VUuid(), "of the exercise file link", required: true)] + public function actionDeleteFileLink(string $id, string $linkId) + { + $link = $this->fileLinks->findOrThrow($linkId); + $this->fileLinks->remove($link); + $this->sendSuccessResponse("OK"); + } } diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index 8afd92d84..969abc7cc 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -22,17 +22,22 @@ use App\Helpers\UploadsConfig; use App\Model\Repository\Assignments; use App\Model\Repository\AssignmentSolutions; +use App\Model\Repository\Exercises; +use App\Model\Repository\ExerciseFileLinks; use App\Model\Repository\ExerciseFiles; use App\Model\Repository\UploadedFiles; use App\Model\Repository\UploadedPartialFiles; use App\Model\Repository\PlagiarismDetectedSimilarFiles; -use App\Model\Entity\UploadedFile; -use App\Model\Entity\UploadedPartialFile; +use App\Model\Entity\ExerciseFileLink; use App\Model\Entity\SolutionFile; use App\Model\Entity\SolutionZipFile; +use App\Model\Entity\UploadedFile; +use App\Model\Entity\UploadedPartialFile; +use App\Security\Roles; +use App\Security\ACL\IAssignmentSolutionPermissions; +use App\Security\ACL\IExercisePermissions; use App\Security\ACL\IUploadedFilePermissions; use App\Security\ACL\IUploadedPartialFilePermissions; -use App\Security\ACL\IAssignmentSolutionPermissions; use Nette\Utils\Strings; use Nette\Http\IResponse; use Tracy\ILogger; @@ -77,16 +82,28 @@ class UploadedFilesPresenter extends BasePresenter public $assignmentSolutions; /** - * @var IUploadedFilePermissions + * @var Exercises * @inject */ - public $uploadedFileAcl; + public $exercises; /** - * @var IUploadedPartialFilePermissions + * @var ExerciseFiles * @inject */ - public $uploadedPartialFileAcl; + public $exerciseFiles; + + /** + * @var ExerciseFileLinks + * @inject + */ + public $fileLinks; + + /** + * @var PlagiarismDetectedSimilarFiles + * @inject + */ + public $detectedSimilarFiles; /** * @var IAssignmentSolutionPermissions @@ -95,16 +112,28 @@ class UploadedFilesPresenter extends BasePresenter public $assignmentSolutionAcl; /** - * @var ExerciseFiles + * @var IExercisePermissions * @inject */ - public $exerciseFiles; + public $exerciseAcl; /** - * @var PlagiarismDetectedSimilarFiles + * @var IUploadedFilePermissions * @inject */ - public $detectedSimilarFiles; + public $uploadedFileAcl; + + /** + * @var IUploadedPartialFilePermissions + * @inject + */ + public $uploadedPartialFileAcl; + + /** + * @var Roles + * @inject + */ + public $roles; /** * @var UploadsConfig @@ -609,4 +638,105 @@ public function actionDownloadExerciseFile(string $id) } $this->sendStorageFileResponse($file, $fileEntity->getName()); } + + /** + * Perform verifications whether the user can download file via given link. + * @param ExerciseFileLink $link + * @throws ForbiddenRequestException + */ + private function checkExerciseFileLink(ExerciseFileLink $link): void + { + if ($link->getRequiredRole() === null) { + return; // public link, no further checks needed + } + + $user = $this->getCurrentUserOrNull(); + if (!$user) { + throw new ForbiddenRequestException("You must be logged in to download selected exercise file"); + } + + // for logged-in users, check exercise access (this is additional check on top of role requirement) + if (!$this->exerciseAcl->canViewDetail($link->getExercise())) { + throw new ForbiddenRequestException("You cannot download exercise file for this exercise."); + } + + $reqRole = $link->getRequiredRole(); + if (!$this->roles->isInRole($user->getRole(), $reqRole)) { + throw new ForbiddenRequestException("Minimal role '$reqRole' is required to download this exercise file."); + } + } + + /** + * Find the file associated with the link and send it to the user. + * @param ExerciseFileLink $link + * @throws NotFoundException + */ + private function downloadFileByLink(ExerciseFileLink $link): void + { + $fileEntity = $this->exerciseFiles->findOrThrow($link->getExerciseFile()->getId()); + $file = $fileEntity->getFile($this->fileStorage); + if (!$file) { + throw new NotFoundException("Exercise file not found in the storage"); + } + $this->sendStorageFileResponse($file, $link->getSaveName() ?? $fileEntity->getName()); + } + + public function checkDownloadExerciseFileByLink(string $id, string $linkId) + { + $link = $this->fileLinks->findOrThrow($linkId); + + if ($link->getExercise()?->getId() !== $id) { + throw new BadRequestException("The exercise file link is not associated with the given exercise."); + } + + $this->checkExerciseFileLink($link); + } + + /** + * Download a specific exercise-file via its link. + * This endpoint is deliberately placed in UploadedFilesPresenter so it works for non-logged-in users as well. + * @GET + */ + #[Path("id", new VUuid(), "of exercise", required: true)] + #[Path("linkId", new VUuid(), "of the exercise file link entity", required: true)] + public function actionDownloadExerciseFileByLink(string $id, string $linkId) + { + $link = $this->fileLinks->findOrThrow($linkId); + $this->downloadFileByLink($link); + } + + public function checkDownloadExerciseFileLinkByKey(string $id, string $linkKey) + { + $links = $this->fileLinks->findBy(['exercise' => $id, 'key' => $linkKey]); + if (count($links) === 0) { + throw new NotFoundException("Exercise file link with given key not found for the exercise."); + } + if (count($links) > 1) { + // this should never happen + throw new InternalServerException("Multiple exercise file links with given key found for the exercise."); + } + $link = $links[0]; + + $this->checkExerciseFileLink($link); + } + + /** + * Download a specific exercise-file via its link key. + * Unlike `downloadFileLink`, the key is selected by the user and does not have to change + * (when the link or the file is updated). + * On the other hand, it always retrieves the latest version of the file. + * @GET + */ + #[Path("id", new VUuid(), "of exercise", required: true)] + #[Path( + "linkKey", + new VString(1, 16), + "Internal user-selected identifier of the exercise file link within the exercise", + required: true + )] + public function actionDownloadExerciseFileLinkByKey(string $id, string $linkKey) + { + $links = $this->fileLinks->findBy(['exercise' => $id, 'key' => $linkKey]); + $this->downloadFileByLink($links[0]); + } } diff --git a/app/V1Module/router/RouterFactory.php b/app/V1Module/router/RouterFactory.php index 79489fb73..17673fa97 100644 --- a/app/V1Module/router/RouterFactory.php +++ b/app/V1Module/router/RouterFactory.php @@ -162,18 +162,23 @@ private static function createExercisesRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix//admins", "Exercises:setAdmins"); $router[] = new PostRoute("$prefix//notification", "Exercises:sendNotification"); - $router[] = new GetRoute("$prefix//exercise-files", "ExerciseFiles:getExerciseFiles"); - $router[] = new PostRoute("$prefix//exercise-files", "ExerciseFiles:uploadExerciseFiles"); - $router[] = new DeleteRoute( - "$prefix//exercise-files/", - "ExerciseFiles:deleteExerciseFile" - ); - $router[] = new GetRoute( - "$prefix//exercise-files/download-archive", - "ExerciseFiles:downloadExerciseFilesArchive" - ); - - // deprecated routes for supplementary files (replaced with exercise-files) + // exercise files + $router[] = new GetRoute("$prefix//files", "ExerciseFiles:getExerciseFiles"); + $router[] = new PostRoute("$prefix//files", "ExerciseFiles:uploadExerciseFiles"); + $router[] = new DeleteRoute("$prefix//files/", "ExerciseFiles:deleteExerciseFile"); + $router[] = new GetRoute("$prefix//files/download-archive", "ExerciseFiles:downloadExerciseFilesArchive"); + + // file links + $router[] = new GetRoute("$prefix//file-links", "ExerciseFiles:getFileLinks"); + $router[] = new PostRoute("$prefix//file-links", "ExerciseFiles:createFileLink"); + $router[] = new PostRoute("$prefix//file-links/", "ExerciseFiles:updateFileLink"); + $router[] = new DeleteRoute("$prefix//file-links/", "ExerciseFiles:deleteFileLink"); + $router[] = new GetRoute("$prefix//file-links/", "UploadedFiles:downloadExerciseFileByLink"); + + // special download route for file link by its key + $router[] = new GetRoute("$prefix//file-download/", "UploadedFiles:downloadExerciseFileLinkByKey"); + + // deprecated routes for supplementary-files (replaced with `files`) $router[] = new GetRoute("$prefix//supplementary-files", "ExerciseFiles:getExerciseFiles"); $router[] = new PostRoute("$prefix//supplementary-files", "ExerciseFiles:uploadExerciseFiles"); $router[] = new DeleteRoute( diff --git a/app/V1Module/security/Roles.php b/app/V1Module/security/Roles.php index c6dc374bc..46158f7ad 100644 --- a/app/V1Module/security/Roles.php +++ b/app/V1Module/security/Roles.php @@ -30,8 +30,8 @@ protected function addRole(string $role, array $parents) /** * Verify whether given actual role has at least the permissions of minimal requested role. - * In other words, this function is basicaly a check that $actualTestedRole >= $minimalRequestedRole - * in the terms of role strenghth (more permissive is bigger). + * In other words, this function is basically a check that $actualTestedRole >= $minimalRequestedRole + * in the terms of role strength (more permissive is bigger). * @param string $actualTestedRole * @param string $minimalRequestedRole */ diff --git a/app/model/entity/ExerciseFileLink.php b/app/model/entity/ExerciseFileLink.php index 9ec12c180..2c3f9fd26 100644 --- a/app/model/entity/ExerciseFileLink.php +++ b/app/model/entity/ExerciseFileLink.php @@ -3,6 +3,7 @@ namespace App\Model\Entity; use Doctrine\ORM\Mapping as ORM; +use JsonSerializable; use DateTime; /** @@ -13,7 +14,7 @@ * @ORM\Entity * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(columns={"key", "exercise_id"})}) */ -class ExerciseFileLink +class ExerciseFileLink implements JsonSerializable { use CreatableEntity; @@ -121,10 +122,26 @@ public static function copyForAssignment( return new self($link->key, $link->exerciseFile, null, $assignment, $link->requiredRole, $link->saveName); } + public function jsonSerialize(): mixed + { + return [ + 'id' => $this->getId(), + 'key' => $this->getKey(), + 'requiredRole' => $this->getRequiredRole(), + 'saveName' => $this->getSaveName(), + 'exerciseFileId' => $this->exerciseFile->getId(), + 'createdAt' => $this->createdAt->getTimestamp(), + ]; + } + /* * Accessors */ + public function getId(): ?string + { + return $this->id === null ? null : (string)$this->id; + } public function getKey(): string { return $this->key; From 8364d97c4a96b34ba2789f57081263113508fa38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 15 Nov 2025 14:36:25 +0100 Subject: [PATCH 09/25] Adding key-fileId mapping (from exercise file links) to exercise and assignment views, so the texts can be properly augmented at client side. Deprecating old supplementary and attachment file items in the view entities. --- app/model/repository/ExerciseFileLinks.php | 32 ++++++++++++++++++++++ app/model/view/AssignmentViewFactory.php | 20 +++++++++++--- app/model/view/ExerciseViewFactory.php | 19 +++++++++---- app/model/view/PipelineViewFactory.php | 6 ++-- 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/app/model/repository/ExerciseFileLinks.php b/app/model/repository/ExerciseFileLinks.php index 3e4bae984..25419ff35 100644 --- a/app/model/repository/ExerciseFileLinks.php +++ b/app/model/repository/ExerciseFileLinks.php @@ -14,4 +14,36 @@ public function __construct(EntityManagerInterface $em) { parent::__construct($em, ExerciseFileLink::class); } + + /** + * Load an associative array [ key => external-file-ID ] for all file links of the given exercise. + * @param string $exerciseId + * @return array + */ + public function getLinksMapForExercise(string $exerciseId): array + { + $links = $this->findBy(['exercise' => $exerciseId]); + $result = []; + foreach ($links as $link) { + /** @var ExerciseFileLink $link */ + $result[$link->getKey()] = $link->getExerciseFile()->getId(); + } + return $result; + } + + /** + * Load an associative array [ key => external-file-ID ] for all file links of the given assignment. + * @param string $assignmentId + * @return array + */ + public function getLinksMapForAssignment(string $assignmentId): array + { + $links = $this->findBy(['assignment' => $assignmentId]); + $result = []; + foreach ($links as $link) { + /** @var ExerciseFileLink $link */ + $result[$link->getKey()] = $link->getExerciseFile()->getId(); + } + return $result; + } } diff --git a/app/model/view/AssignmentViewFactory.php b/app/model/view/AssignmentViewFactory.php index 5ea2f3a63..82684447d 100644 --- a/app/model/view/AssignmentViewFactory.php +++ b/app/model/view/AssignmentViewFactory.php @@ -5,16 +5,19 @@ use App\Helpers\PermissionHints; use App\Model\Entity\Assignment; use App\Model\Entity\LocalizedExercise; +use App\Model\Repository\ExerciseFileLinks; use App\Security\ACL\IAssignmentPermissions; class AssignmentViewFactory { /** @var IAssignmentPermissions */ private $assignmentAcl; + private $fileLinks; - public function __construct(IAssignmentPermissions $assignmentAcl) + public function __construct(IAssignmentPermissions $assignmentAcl, ExerciseFileLinks $fileLinks) { $this->assignmentAcl = $assignmentAcl; + $this->fileLinks = $fileLinks; } public function getAssignments(array $assignments): array @@ -49,6 +52,7 @@ function (LocalizedExercise $text) use ($assignment) { return $data; } )->getValues(), + "localizedTextsLinks" => $this->fileLinks->getLinksMapForAssignment($assignment->getId()), "exerciseId" => $exercise ? $exercise->getId() : null, "groupId" => $assignment->getGroup() ? $assignment->getGroup()->getId() : null, "firstDeadline" => $assignment->getFirstDeadline()->getTimestamp(), @@ -101,11 +105,11 @@ function (LocalizedExercise $text) use ($assignment) { "exerciseTests" => [ "upToDate" => $assignment->areExerciseTestsInSync() ], - "supplementaryFiles" => [ + "files" => [ "upToDate" => $assignment->areExerciseFilesInSync() ], - "attachmentFiles" => [ - "upToDate" => $assignment->areAttachmentFilesInSync() + "fileLinks" => [ + "upToDate" => $assignment->areExerciseFileLinksInSync() ], "runtimeEnvironments" => [ "upToDate" => $assignment->areRuntimeEnvironmentsInSync() @@ -113,6 +117,14 @@ function (LocalizedExercise $text) use ($assignment) { "mergeJudgeLogs" => [ "upToDate" => $exercise && $assignment->getMergeJudgeLogs() === $exercise->getMergeJudgeLogs(), ], + + // DEPRECATED fields (will be removed in future) + "supplementaryFiles" => [ + "upToDate" => $assignment->areExerciseFilesInSync() + ], + "attachmentFiles" => [ + "upToDate" => $assignment->areAttachmentFilesInSync() + ], ], "solutionFilesLimit" => $assignment->getSolutionFilesLimit(), "solutionSizeLimit" => $assignment->getSolutionSizeLimit(), diff --git a/app/model/view/ExerciseViewFactory.php b/app/model/view/ExerciseViewFactory.php index d46dd57ec..f7744aca0 100644 --- a/app/model/view/ExerciseViewFactory.php +++ b/app/model/view/ExerciseViewFactory.php @@ -8,15 +8,18 @@ use App\Model\Entity\ExerciseTag; use App\Model\Entity\LocalizedExercise; use App\Model\Entity\ReferenceExerciseSolution; +use App\Model\Repository\ExerciseFileLinks; use App\Security\ACL\IExercisePermissions; class ExerciseViewFactory { private $exercisePermissions; + private $fileLinks; - public function __construct(IExercisePermissions $exercisePermissions) + public function __construct(IExercisePermissions $exercisePermissions, ExerciseFileLinks $fileLinks) { $this->exercisePermissions = $exercisePermissions; + $this->fileLinks = $fileLinks; } /** @@ -42,12 +45,12 @@ public function getExercise(Exercise $exercise) return [ "id" => $exercise->getId(), - "name" => $primaryLocalization ? $primaryLocalization->getName() : "", // BC "version" => $exercise->getVersion(), "createdAt" => $exercise->getCreatedAt()->getTimestamp(), "updatedAt" => $exercise->getUpdatedAt()->getTimestamp(), "archivedAt" => $exercise->isArchived() ? $exercise->getArchivedAt()->getTimestamp() : null, "localizedTexts" => $exercise->getLocalizedTexts()->getValues(), + "localizedTextsLinks" => $this->fileLinks->getLinksMapForExercise($exercise->getId()), "difficulty" => $exercise->getDifficulty(), "runtimeEnvironments" => $exercise->getRuntimeEnvironments()->getValues(), "hardwareGroups" => $exercise->getHardwareGroups()->getValues(), @@ -56,9 +59,7 @@ public function getExercise(Exercise $exercise) "adminsIds" => $exercise->getAdminsIds(), "groupsIds" => $exercise->getGroupsIds(), "mergeJudgeLogs" => $exercise->getMergeJudgeLogs(), - "description" => $primaryLocalization ? $primaryLocalization->getDescription() : "", // BC - "supplementaryFilesIds" => $exercise->getExerciseFilesIds(), - "attachmentFilesIds" => $exercise->getAttachmentFilesIds(), + "filesIds" => $exercise->getExerciseFilesIds(), "configurationType" => $exercise->getConfigurationType(), "isPublic" => $exercise->isPublic(), "isLocked" => $exercise->isLocked(), @@ -75,7 +76,13 @@ function (ExerciseTag $tag) { ), "solutionFilesLimit" => $exercise->getSolutionFilesLimit(), "solutionSizeLimit" => $exercise->getSolutionSizeLimit(), - "permissionHints" => PermissionHints::get($this->exercisePermissions, $exercise) + "permissionHints" => PermissionHints::get($this->exercisePermissions, $exercise), + + // DEPRECATED fields (will be removed in future) + "name" => $primaryLocalization ? $primaryLocalization->getName() : "", + "description" => $primaryLocalization ? $primaryLocalization->getDescription() : "", + "supplementaryFilesIds" => $exercise->getExerciseFilesIds(), + "attachmentFilesIds" => $exercise->getAttachmentFilesIds(), ]; } diff --git a/app/model/view/PipelineViewFactory.php b/app/model/view/PipelineViewFactory.php index 67e28242d..27b6255fd 100644 --- a/app/model/view/PipelineViewFactory.php +++ b/app/model/view/PipelineViewFactory.php @@ -43,7 +43,7 @@ public function getPipeline(Pipeline $pipeline) "description" => $pipeline->getDescription(), "author" => $pipeline->getAuthor() ? $pipeline->getAuthor()->getId() : null, "forkedFrom" => $pipeline->getCreatedFrom() ? $pipeline->getCreatedFrom()->getId() : null, - "supplementaryFilesIds" => $pipeline->getExerciseFilesIds(), + "filesIds" => $pipeline->getExerciseFilesIds(), "pipeline" => $pipeline->getPipelineConfig()->getParsedPipeline(), "parameters" => array_merge(Pipeline::DEFAULT_PARAMETERS, $pipeline->getParameters()->toArray()), "runtimeEnvironmentIds" => $pipeline->getRuntimeEnvironments()->map( @@ -51,7 +51,9 @@ function (RuntimeEnvironment $env) { return $env->getId(); } )->getValues(), - "permissionHints" => PermissionHints::get($this->pipelineAcl, $pipeline) + "permissionHints" => PermissionHints::get($this->pipelineAcl, $pipeline), + // DEPRECATED fields (will be removed in future) + "supplementaryFilesIds" => $pipeline->getExerciseFilesIds(), ]; } } From f620013fa3c111cdd8d4b5f4534613e0a21b3579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 15 Nov 2025 16:28:37 +0100 Subject: [PATCH 10/25] Updating exercise file link interactions with other entities and updating exercise file replacements (to correctly preserve links). --- .../presenters/ExerciseFilesPresenter.php | 17 +++++++++++++- app/model/entity/ExerciseFile.php | 8 ------- app/model/entity/ExerciseFileLink.php | 23 +++++++++++++++---- app/model/entity/base/ExerciseData.php | 2 +- migrations/Version20251114174621.php | 6 ++--- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index 0b6c1049f..c7b65217a 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -126,6 +126,7 @@ public function actionUploadExerciseFiles(string $id) $files = $this->uploadedFiles->findAllById($this->getRequest()->getPost("files")); $currentFiles = []; + $filesToRemove = []; $totalFileSize = 0; /** @var ExerciseFile $file */ @@ -145,7 +146,7 @@ public function actionUploadExerciseFiles(string $id) if (array_key_exists($file->getName(), $currentFiles)) { /** @var ExerciseFile $currentFile */ $currentFile = $currentFiles[$file->getName()]; - $exercise->getExerciseFiles()->removeElement($currentFile); + $filesToRemove[$file->getName()] = $currentFile; $totalFileSize -= $currentFile->getFileSize(); } else { $totalFileCount += 1; @@ -174,6 +175,20 @@ public function actionUploadExerciseFiles(string $id) foreach ($files as $file) { $hash = $this->fileStorage->storeUploadedExerciseFile($file); $exerciseFile = ExerciseFile::fromUploadedFileAndExercise($file, $exercise, $hash); + if (array_key_exists($file->getName(), $filesToRemove)) { + /** @var ExerciseFile $currentFile */ + $fileToRemove = $filesToRemove[$file->getName()]; + + // move links from old file to the new one + $links = $this->fileLinks->findBy(["exerciseFile" => $fileToRemove, "exercise" => $exercise]); + foreach ($links as $link) { + $link->setExerciseFile($exerciseFile); + $this->fileLinks->persist($link, false); + } + + $exercise->getExerciseFiles()->removeElement($fileToRemove); + } + $this->uploadedFiles->persist($exerciseFile, false); $this->uploadedFiles->remove($file, false); } diff --git a/app/model/entity/ExerciseFile.php b/app/model/entity/ExerciseFile.php index f59805e27..4c998e0ff 100644 --- a/app/model/entity/ExerciseFile.php +++ b/app/model/entity/ExerciseFile.php @@ -63,13 +63,6 @@ function (Assignment $assignment) { */ protected $pipelines; - /** - * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exerciseFile") - * @var Collection - */ - protected $links; - - /** * ExerciseFile constructor. * @param string $name @@ -95,7 +88,6 @@ public function __construct( $this->exercises = new ArrayCollection(); $this->assignments = new ArrayCollection(); $this->pipelines = new ArrayCollection(); - $this->links = new ArrayCollection(); if ($exercise) { $this->exercises->add($exercise); diff --git a/app/model/entity/ExerciseFileLink.php b/app/model/entity/ExerciseFileLink.php index 2c3f9fd26..74253057f 100644 --- a/app/model/entity/ExerciseFileLink.php +++ b/app/model/entity/ExerciseFileLink.php @@ -8,9 +8,10 @@ /** * Additional identification for files that are accessible to users via stable URLs. - * The entity always points to a single ExerciseFile, but also follows the CoW principle used for all - * exercise-assignment records. I.e., when Assignment is created from Exercise, the links are copied - * as well, but they still point to the same ExerciseFile entities. + * The entity always points to a single ExerciseFile, but also follows the principles used for all + * exercise-assignment records. However, links are small, so they are copied eagerly (not by CoW). + * I.e., when Assignment is created from Exercise, the links are copied (immediately) as well. + * The link of an exercise may be updated, but the link of an assignment is immutable. * @ORM\Entity * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(columns={"key", "exercise_id"})}) */ @@ -45,19 +46,21 @@ class ExerciseFileLink implements JsonSerializable */ protected $requiredRole; - /** - * @ORM\ManyToOne(targetEntity="ExerciseFile", inversedBy="links", cascade={"persist", "remove"}) + * @ORM\ManyToOne(targetEntity="ExerciseFile") + * @ORM\JoinColumn(onDelete="CASCADE") */ protected $exerciseFile; /** * @ORM\ManyToOne(targetEntity="Exercise", inversedBy="fileLinks") + * @ORM\JoinColumn(onDelete="CASCADE") */ protected $exercise; /** * @ORM\ManyToOne(targetEntity="Assignment", inversedBy="fileLinks") + * @ORM\JoinColumn(onDelete="CASCADE") */ protected $assignment; @@ -177,6 +180,16 @@ public function getExerciseFile(): ExerciseFile return $this->exerciseFile; } + /** + * This is a rare case where we allow changing the linked ExerciseFile. + * It is used only when the immutable ExerciseFile is being replaced. + * Changing the link to point to a different ExerciseFile should not be allowed otherwise. + */ + public function setExerciseFile(ExerciseFile $exerciseFile): void + { + $this->exerciseFile = $exerciseFile; + } + public function getExercise(): ?Exercise { return $this->exercise; diff --git a/app/model/entity/base/ExerciseData.php b/app/model/entity/base/ExerciseData.php index 67ed8ea8d..1e4f8cea0 100644 --- a/app/model/entity/base/ExerciseData.php +++ b/app/model/entity/base/ExerciseData.php @@ -365,7 +365,7 @@ function (AttachmentFile $file) { } /** - * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exerciseFile", cascade={"persist", "remove"}) + * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exerciseFile", cascade={"persist"}) * @var Collection */ protected $fileLinks; diff --git a/migrations/Version20251114174621.php b/migrations/Version20251114174621.php index cc5eb931c..dc44e2229 100644 --- a/migrations/Version20251114174621.php +++ b/migrations/Version20251114174621.php @@ -21,9 +21,9 @@ public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs $this->addSql('CREATE TABLE exercise_file_link (id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\', exercise_file_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', exercise_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', assignment_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', `key` VARCHAR(16) NOT NULL, save_name VARCHAR(255) DEFAULT NULL, required_role VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, INDEX IDX_1187F77549DE8E29 (exercise_file_id), INDEX IDX_1187F775E934951A (exercise_id), INDEX IDX_1187F775D19302F8 (assignment_id), UNIQUE INDEX UNIQ_1187F7758A90ABA9E934951A (`key`, exercise_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); - $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F77549DE8E29 FOREIGN KEY (exercise_file_id) REFERENCES `uploaded_file` (id)'); - $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775E934951A FOREIGN KEY (exercise_id) REFERENCES exercise (id)'); - $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id)'); + $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775E934951A FOREIGN KEY (exercise_id) REFERENCES exercise (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F77549DE8E29 FOREIGN KEY (exercise_file_id) REFERENCES `uploaded_file` (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE uploaded_file DROP is_public'); } From 816da6e0d01491e242ee4fbf2d0746e83f97b6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sun, 16 Nov 2025 19:19:50 +0100 Subject: [PATCH 11/25] Updating tests to cover latest changes in views, fixing bugs. --- app/model/entity/Assignment.php | 31 ++++++++++++---- app/model/entity/AssignmentSolution.php | 3 ++ app/model/entity/Exercise.php | 17 +++++++++ app/model/entity/ExerciseFileLink.php | 2 +- app/model/entity/Group.php | 12 +++++-- app/model/entity/Solution.php | 7 ++-- app/model/entity/base/ExerciseData.php | 24 ------------- fixtures/demo/25-exercises.neon | 25 +++++++++++++ tests/Presenters/AssignmentsPresenter.phpt | 32 ++++++++++------- tests/Presenters/ExerciseFilesPresenter.phpt | 2 +- tests/Presenters/ExercisesPresenter.phpt | 37 ++++++++++++++------ 11 files changed, 130 insertions(+), 62 deletions(-) diff --git a/app/model/entity/Assignment.php b/app/model/entity/Assignment.php index 783601605..215980d38 100644 --- a/app/model/entity/Assignment.php +++ b/app/model/entity/Assignment.php @@ -23,6 +23,20 @@ class Assignment extends AssignmentBase implements IExercise { use ExerciseData; + /** + * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="assignment", cascade={"persist"}) + * @var Collection + */ + protected $fileLinks; + + /** + * @return Collection + */ + public function getFileLinks(): Collection + { + return $this->fileLinks; + } + private function __construct( DateTime $firstDeadline, int $maxPointsBeforeFirstDeadline, @@ -79,12 +93,6 @@ private function __construct( $this->fileLinks = new ArrayCollection(); $this->solutionFilesLimit = $exercise->getSolutionFilesLimit(); $this->solutionSizeLimit = $exercise->getSolutionSizeLimit(); - - // copy file links from exercise - foreach ($exercise->getFileLinks() as $link) { - $newLink = ExerciseFileLink::copyForAssignment($link, $this); - $this->fileLinks->add($newLink); - } } public static function assignToGroup( @@ -113,6 +121,12 @@ public static function assignToGroup( $group->addAssignment($assignment); + // copy file links from exercise + foreach ($exercise->getFileLinks() as $link) { + $newLink = ExerciseFileLink::copyForAssignment($link, $assignment); + $assignment->fileLinks->add($newLink); + } + return $assignment; } @@ -620,6 +634,9 @@ public function setSubmissionsCountLimit(int $submissionsCountLimit): void $this->submissionsCountLimit = $submissionsCountLimit; } + /** + * @return Collection + */ public function getAssignmentSolutions(): Collection { return $this->assignmentSolutions; @@ -755,7 +772,7 @@ public function getPlagiarismBatch(): ?PlagiarismDetectionBatch return $this->plagiarismBatch; } - public function setPlagiarismBatch(?PlagiarismDetectionBatch $batch = null) + public function setPlagiarismBatch(?PlagiarismDetectionBatch $batch = null): void { $this->plagiarismBatch = $batch; } diff --git a/app/model/entity/AssignmentSolution.php b/app/model/entity/AssignmentSolution.php index 4045b0967..e01c58224 100644 --- a/app/model/entity/AssignmentSolution.php +++ b/app/model/entity/AssignmentSolution.php @@ -343,6 +343,9 @@ public function setOverriddenPoints(?int $overriddenPoints): void $this->overriddenPoints = $overriddenPoints; } + /** + * @return Collection + */ public function getSubmissions(): Collection { return $this->submissions; diff --git a/app/model/entity/Exercise.php b/app/model/entity/Exercise.php index ad48580e8..8baded50c 100644 --- a/app/model/entity/Exercise.php +++ b/app/model/entity/Exercise.php @@ -111,6 +111,20 @@ class Exercise implements IExercise */ protected $archivedAt = null; + /** + * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exercise", cascade={"persist"}) + * @var Collection + */ + protected $fileLinks; + + /** + * @return Collection + */ + public function getFileLinks(): Collection + { + return $this->fileLinks; + } + /** * Constructor * @param int $version @@ -390,6 +404,9 @@ public function setAuthor(User $author): void $this->author = $author; } + /** + * @return ReadableCollection + */ public function getAdmins(): ReadableCollection { return $this->admins->filter( diff --git a/app/model/entity/ExerciseFileLink.php b/app/model/entity/ExerciseFileLink.php index 74253057f..974db1782 100644 --- a/app/model/entity/ExerciseFileLink.php +++ b/app/model/entity/ExerciseFileLink.php @@ -59,7 +59,7 @@ class ExerciseFileLink implements JsonSerializable protected $exercise; /** - * @ORM\ManyToOne(targetEntity="Assignment", inversedBy="fileLinks") + * @ORM\ManyToOne(targetEntity="Assignment") * @ORM\JoinColumn(onDelete="CASCADE") */ protected $assignment; diff --git a/app/model/entity/Group.php b/app/model/entity/Group.php index af8ce3d90..7e4bd537a 100644 --- a/app/model/entity/Group.php +++ b/app/model/entity/Group.php @@ -474,7 +474,7 @@ public function removeMembership(GroupMembership $membership): bool /** * Get all members of the group of given type * @param string[] ...$types - * @return ReadableCollection + * @return ReadableCollection */ public function getMembers(...$types): ReadableCollection { @@ -505,12 +505,18 @@ function (GroupMembership $membership) { )->getValues(); } - public function getStudents() + /** + * @return ReadableCollection + */ + public function getStudents(): ReadableCollection { return $this->getMembers(GroupMembership::TYPE_STUDENT); } - public function getStudentsIds() + /** + * @return string[] ids + */ + public function getStudentsIds(): array { return $this->getMembersIds(GroupMembership::TYPE_STUDENT); } diff --git a/app/model/entity/Solution.php b/app/model/entity/Solution.php index f47af735d..412f73370 100644 --- a/app/model/entity/Solution.php +++ b/app/model/entity/Solution.php @@ -74,7 +74,7 @@ public function __construct(User $author, RuntimeEnvironment $runtimeEnvironment $this->subdir = $this->createdAt->format('Y-m'); } - public function addFile(SolutionFile $file) + public function addFile(SolutionFile $file): void { $this->files->add($file); } @@ -97,7 +97,7 @@ public function getSolutionParams(): SolutionParams return new SolutionParams(Yaml::parse($this->solutionParams)); } - public function setSolutionParams(SolutionParams $params) + public function setSolutionParams(SolutionParams $params): void { $dumped = Yaml::dump($params->toArray()); $this->solutionParams = $dumped ?: ""; @@ -137,6 +137,9 @@ public function getAuthorId(): ?string return $this->author->isDeleted() ? null : $this->author->getId(); } + /** + * @return Collection + */ public function getFiles(): Collection { return $this->files; diff --git a/app/model/entity/base/ExerciseData.php b/app/model/entity/base/ExerciseData.php index 1e4f8cea0..4b1a5d609 100644 --- a/app/model/entity/base/ExerciseData.php +++ b/app/model/entity/base/ExerciseData.php @@ -364,30 +364,6 @@ function (AttachmentFile $file) { )->getValues(); } - /** - * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exerciseFile", cascade={"persist"}) - * @var Collection - */ - protected $fileLinks; - - /** - * @return Collection - */ - public function getFileLinks(): Collection - { - return $this->fileLinks; - } - - public function addFileLink(ExerciseFileLink $fileLink): void - { - $this->fileLinks->add($fileLink); - } - - public function removeFileLink(ExerciseFileLink $fileLink): bool - { - return $this->fileLinks->removeElement($fileLink); - } - /** * @ORM\Column(type="integer", nullable=true) * How many files may one submit in a solution. diff --git a/fixtures/demo/25-exercises.neon b/fixtures/demo/25-exercises.neon index 4bd5c3af6..78bcb551e 100644 --- a/fixtures/demo/25-exercises.neon +++ b/fixtures/demo/25-exercises.neon @@ -216,6 +216,31 @@ App\Model\Entity\ExerciseFile: - "shahash" - @demoAdmin - @demoExercise + linkedExerciseFile: + __construct: + - "lib.py" + - "" + - 42 + - "hashmash" + - @demoAdmin + - @demoExercise + +App\Model\Entity\ExerciseFileLink: + demoExerciseFileLink1: + __construct: + createForExercise: + - "LIB" + - @linkedExerciseFile + - @demoExercise + - "student" + - "thelib.py" + + demoExerciseFileLink2: + __construct: + createForExercise: + - "ORIG" + - @linkedExerciseFile + - @demoExercise App\Model\Entity\ExerciseTag: demoTag1: diff --git a/tests/Presenters/AssignmentsPresenter.phpt b/tests/Presenters/AssignmentsPresenter.phpt index a8a005296..893f33193 100644 --- a/tests/Presenters/AssignmentsPresenter.phpt +++ b/tests/Presenters/AssignmentsPresenter.phpt @@ -76,7 +76,7 @@ class TestAssignmentsPresenter extends Tester\TestCase $this->hardwareGroups = $container->getByType(HardwareGroups::class); $this->assignmentSolutionViewFactory = $container->getByType(AssignmentSolutionViewFactory::class); - // patch container, since we cannot create actual file storage manarer + // patch container, since we cannot create actual file storage manager $fsName = current($this->container->findByType(FileStorageManager::class)); $this->container->removeService($fsName); $this->container->addService($fsName, new FileStorageManager( @@ -234,8 +234,7 @@ class TestAssignmentsPresenter extends Tester\TestCase /** @var Mockery\Mock | AssignmentEmailsSender $mockAssignmentEmailsSender */ $mockAssignmentEmailsSender = Mockery::mock(JobConfig\JobConfig::class); - $mockAssignmentEmailsSender->shouldReceive()->never( - ); // this is the main assertion of this test (no mail is sent) + $mockAssignmentEmailsSender->shouldReceive()->never(); // this is the main assertion of this test (no mail is sent) $this->presenter->assignmentEmailsSender = $mockAssignmentEmailsSender; $mockEvaluations = Mockery::mock(SolutionEvaluations::class); @@ -619,32 +618,39 @@ class TestAssignmentsPresenter extends Tester\TestCase { PresenterTestHelper::loginDefaultAdmin($this->container); + $exercises = array_filter( + $this->presenter->exercises->findAll(), + function (Exercise $e) { + return !$e->getFileLinks()->isEmpty(); // select the exercise with file links + } + ); + Assert::count(1, $exercises); /** @var Exercise $exercise */ - $exercise = $this->presenter->exercises->findAll()[0]; + $exercise = array_pop($exercises); + /** @var Group $group */ $group = $this->presenter->groups->findAll()[0]; - $request = new Nette\Application\Request( + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, 'V1:Assignments', 'POST', ['action' => 'create'], ['exerciseId' => $exercise->getId(), 'groupId' => $group->getId()] ); - $response = $this->presenter->run($request); - Assert::type(Nette\Application\Responses\JsonResponse::class, $response); - - $result = $response->getPayload(); - Assert::equal(200, $result['code']); - /** @var AssignmentViewFactory $viewFactory */ $viewFactory = $this->container->getByType(AssignmentViewFactory::class); // Make sure the assignment was persisted Assert::same( - $viewFactory->getAssignment($this->presenter->assignments->findOneBy(['id' => $result['payload']["id"]])), - $result['payload'] + $viewFactory->getAssignment($this->presenter->assignments->findOneBy(['id' => $payload["id"]])), + $payload ); + Assert::count(2, $payload['localizedTextsLinks']); + $keys = array_keys($payload['localizedTextsLinks']); + sort($keys); + Assert::same(['LIB', 'ORIG'], $keys); } public function testCreateAssignmentFromLockedExercise() diff --git a/tests/Presenters/ExerciseFilesPresenter.phpt b/tests/Presenters/ExerciseFilesPresenter.phpt index 4d5cdacda..0a8c06c1b 100644 --- a/tests/Presenters/ExerciseFilesPresenter.phpt +++ b/tests/Presenters/ExerciseFilesPresenter.phpt @@ -57,7 +57,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase $this->exercises = $container->getByType(App\Model\Repository\Exercises::class); $this->attachmentFiles = $container->getByType(\App\Model\Repository\AttachmentFiles::class); - // patch container, since we cannot create actual file storage manarer + // patch container, since we cannot create actual file storage manager $fsName = current($this->container->findByType(FileStorageManager::class)); $this->container->removeService($fsName); $this->container->addService($fsName, new FileStorageManager( diff --git a/tests/Presenters/ExercisesPresenter.phpt b/tests/Presenters/ExercisesPresenter.phpt index 44df884fd..02067f275 100644 --- a/tests/Presenters/ExercisesPresenter.phpt +++ b/tests/Presenters/ExercisesPresenter.phpt @@ -54,6 +54,9 @@ class TestExercisesPresenter extends Tester\TestCase /** @var App\Model\Repository\Exercises */ protected $exercises; + /** @var App\Model\Repository\ExerciseFileLinks */ + protected $exerciseFileLinks; + /** @var App\Model\Repository\Assignments */ protected $assignments; @@ -83,6 +86,7 @@ class TestExercisesPresenter extends Tester\TestCase $this->exerciseFiles = $container->getByType(\App\Model\Repository\ExerciseFiles::class); $this->logins = $container->getByType(\App\Model\Repository\Logins::class); $this->exercises = $container->getByType(App\Model\Repository\Exercises::class); + $this->exerciseFileLinks = $container->getByType(App\Model\Repository\ExerciseFileLinks::class); $this->assignments = $container->getByType(App\Model\Repository\Assignments::class); $this->pipelines = $container->getByType(App\Model\Repository\Pipelines::class); $this->attachmentFiles = $container->getByType(\App\Model\Repository\AttachmentFiles::class); @@ -396,7 +400,7 @@ class TestExercisesPresenter extends Tester\TestCase $groups = $this->groups->findByName("en", "Private group", $instance); $group = reset($groups); - $token = PresenterTestHelper::loginDefaultAdmin($this->container); + PresenterTestHelper::loginDefaultAdmin($this->container); $payload = PresenterTestHelper::performPresenterRequest( $this->presenter, 'V1:Exercises', @@ -409,7 +413,7 @@ class TestExercisesPresenter extends Tester\TestCase public function testListExercisesByIds() { - $token = PresenterTestHelper::loginDefaultAdmin($this->container); + PresenterTestHelper::loginDefaultAdmin($this->container); $exercises = $this->exercises->findAll(); $first = $exercises[0]; $second = $exercises[1]; @@ -430,18 +434,29 @@ class TestExercisesPresenter extends Tester\TestCase public function testDetail() { - $token = PresenterTestHelper::loginDefaultAdmin($this->container); + PresenterTestHelper::loginDefaultAdmin($this->container); - $allExercises = $this->presenter->exercises->findAll(); - $exercise = array_pop($allExercises); + $exercises = array_filter( + $this->exercises->findAll(), + function (Exercise $e) { + return !$e->getFileLinks()->isEmpty(); // select the exercise with file links + } + ); + Assert::count(1, $exercises); + $exercise = array_pop($exercises); - $request = new Nette\Application\Request('V1:Exercises', 'GET', ['action' => 'detail', 'id' => $exercise->getId()]); - $response = $this->presenter->run($request); - Assert::type(Nette\Application\Responses\JsonResponse::class, $response); + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'V1:Exercises', + 'GET', + ['action' => 'detail', 'id' => $exercise->getId()] + ); - $result = $response->getPayload(); - Assert::equal(200, $result['code']); - Assert::same($exercise->getId(), $result['payload']['id']); + Assert::same($exercise->getId(), $payload['id']); + Assert::count(2, $payload['localizedTextsLinks']); + $keys = array_keys($payload['localizedTextsLinks']); + sort($keys); + Assert::same(['LIB', 'ORIG'], $keys); } public function testUpdateDetail() From 931a54bfa5954f632291672a5fde733fa1fb68f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 17 Nov 2025 11:33:35 +0100 Subject: [PATCH 12/25] Adding tests for basic CRUD operations on exercise file links. --- .../presenters/ExerciseFilesPresenter.php | 19 +- tests/Presenters/ExerciseFilesPresenter.phpt | 211 ++++++++++++++++++ 2 files changed, 222 insertions(+), 8 deletions(-) diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index c7b65217a..8b7e65d8d 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -529,7 +529,7 @@ public function actionCreateFileLink(string $id) $requiredRole = $req->getPost("requiredRole"); $saveName = $req->getPost("saveName"); - if (!$this->roles->validateRole($requiredRole)) { + if ($requiredRole !== null && !$this->roles->validateRole($requiredRole)) { throw new InvalidApiArgumentException('requiredRole', "Unknown user role '$requiredRole'"); } @@ -559,7 +559,7 @@ public function checkUpdateFileLink(string $id, string $linkId) } } /** - * Update a specific exercise-file link. + * Update a specific exercise-file link. Missing arguments are not changed. * @POST */ #[Path("id", new VUuid(), "of exercise", required: true)] @@ -568,7 +568,7 @@ public function checkUpdateFileLink(string $id, string $linkId) "key", new VString(1, 16), "Internal user-selected identifier of the exercise file link within the exercise", - required: true + required: false )] #[Post( "requiredRole", @@ -590,22 +590,25 @@ public function actionUpdateFileLink(string $id, string $linkId) $req = $this->getRequest(); $post = $req->getPost(); + $key = $req->getPost("key"); + if ($key) { + $link->setKey($key); // if key is null, it is not changed + } + if (array_key_exists("requiredRole", $post)) { - // array_key_exists checks whether the key is present (even if null) + // array_key_exists checks whether the requiredRole is present (even if null) $requiredRole = $post["requiredRole"]; - if (!$this->roles->validateRole($requiredRole)) { + if ($requiredRole !== null && !$this->roles->validateRole($requiredRole)) { throw new InvalidApiArgumentException('requiredRole', "Unknown user role '$requiredRole'"); } $link->setRequiredRole($requiredRole); } if (array_key_exists("saveName", $post)) { - // array_key_exists checks whether the key is present (even if null) + // array_key_exists checks whether the saveName is present (even if null) $link->setSaveName($post["saveName"]); } - $link->setKey($req->getPost("key")); - $this->fileLinks->persist($link); $this->sendSuccessResponse($link); } diff --git a/tests/Presenters/ExerciseFilesPresenter.phpt b/tests/Presenters/ExerciseFilesPresenter.phpt index 0a8c06c1b..bcdb1ec75 100644 --- a/tests/Presenters/ExerciseFilesPresenter.phpt +++ b/tests/Presenters/ExerciseFilesPresenter.phpt @@ -9,6 +9,8 @@ use App\Helpers\FileStorage\LocalImmutableFile; use App\Helpers\ExercisesConfig; use App\Helpers\TmpFilesHelper; use App\Model\Entity\AttachmentFile; +use App\Model\Entity\Exercise; +use App\Model\Entity\ExerciseFileLink; use App\Model\Entity\UploadedFile; use App\V1Module\Presenters\ExerciseFilesPresenter; use App\Model\Entity\ExerciseFile; @@ -514,6 +516,215 @@ class TestExerciseFilesPresenter extends Tester\TestCase Assert::type(App\Model\Entity\AttachmentFile::class, $item); } } + + private function getExerciseWithLinks(): Exercise + { + $exercises = array_filter( + $this->exercises->findAll(), + function (Exercise $e) { + return !$e->getFileLinks()->isEmpty(); // select the exercise with file links + } + ); + Assert::count(1, $exercises); + return array_pop($exercises); + } + + public function testGetExerciseFileLinks() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $exercise = $this->getExerciseWithLinks(); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + "V1:ExerciseFiles", + 'GET', + ['action' => 'getFileLinks', 'id' => $exercise->getId()] + ); + + $expectedLinks = []; + foreach ($exercise->getFileLinks() as $link) { + $expectedLinks[$link->getId()] = $link; + } + + foreach ($payload as $link) { + Assert::true(array_key_exists($link->getId(), $expectedLinks)); + $expectedLink = $expectedLinks[$link->getId()]; + Assert::equal($expectedLink->getId(), $link->getId()); + Assert::equal($expectedLink->getExerciseFile()->getId(), $link->getExerciseFile()->getId()); + Assert::equal($expectedLink->getExercise()?->getId(), $link->getExercise()?->getId()); + Assert::equal($expectedLink->getKey(), $link->getKey()); + Assert::equal($expectedLink->getSaveName(), $link->getSaveName()); + Assert::equal($expectedLink->getRequiredRole(), $link->getRequiredRole()); + Assert::null($link->getAssignment()); + } + } + + public function testCreateExerciseFileLink() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $exercise = $this->getExerciseWithLinks(); + $exerciseFile = $exercise->getExerciseFiles()->filter(function (ExerciseFile $ef) { + return $ef->getName() === 'input.txt'; + })->first(); + Assert::truthy($exerciseFile); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + "V1:ExerciseFiles", + 'POST', + [ + 'action' => 'createFileLink', + 'id' => $exercise->getId(), + ], + [ + 'exerciseFileId' => $exerciseFile->getId(), + 'key' => 'test-key', + 'requiredRole' => 'supervisor', + 'saveName' => 'rename.txt' + ] + ); + + Assert::equal($exerciseFile->getId(), $payload->getExerciseFile()->getId()); + Assert::equal('test-key', $payload->getKey()); + Assert::equal('supervisor', $payload->getRequiredRole()); + Assert::equal('rename.txt', $payload->getSaveName()); + + Assert::count(3, $this->presenter->fileLinks->findAll()); + } + + public function testCreateExerciseFileLinkWithoutOptionalFields() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $exercise = $this->getExerciseWithLinks(); + $exerciseFile = $exercise->getExerciseFiles()->filter(function (ExerciseFile $ef) { + return $ef->getName() === 'input.txt'; + })->first(); + Assert::truthy($exerciseFile); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + "V1:ExerciseFiles", + 'POST', + [ + 'action' => 'createFileLink', + 'id' => $exercise->getId(), + ], + [ + 'exerciseFileId' => $exerciseFile->getId(), + 'key' => 'test-key', + ] + ); + + Assert::equal($exerciseFile->getId(), $payload->getExerciseFile()->getId()); + Assert::equal('test-key', $payload->getKey()); + Assert::null($payload->getRequiredRole()); + Assert::null($payload->getSaveName()); + + Assert::count(3, $this->presenter->fileLinks->findAll()); + } + + public function testUpdateExerciseFileLink() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $exercise = $this->getExerciseWithLinks(); + $link = $exercise->getFileLinks()->filter(function (ExerciseFileLink $l) { + return $l->getKey() === 'LIB'; + })->first(); + Assert::truthy($link); + $exerciseFile = $link->getExerciseFile(); + $saveName = $link->getSaveName(); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + "V1:ExerciseFiles", + 'POST', + [ + 'action' => 'updateFileLink', + 'id' => $exercise->getId(), + 'linkId' => $link->getId(), + ], + [ + 'key' => 'SPAM', + 'requiredRole' => 'supervisor', + ] + ); + + $this->presenter->fileLinks->refresh($link); + + Assert::equal($link->getId(), $payload->getId()); + Assert::count(2, $this->presenter->fileLinks->findAll()); + + Assert::equal($exerciseFile->getId(), $payload->getExerciseFile()->getId()); + Assert::equal('SPAM', $payload->getKey()); + Assert::equal('supervisor', $payload->getRequiredRole()); + Assert::equal($saveName, $payload->getSaveName()); // wasn't changed + + Assert::equal($exerciseFile->getId(), $link->getExerciseFile()->getId()); + Assert::equal('SPAM', $link->getKey()); + Assert::equal('supervisor', $link->getRequiredRole()); + Assert::equal($saveName, $link->getSaveName()); // wasn't changed + } + + public function testUpdateExerciseFileLinkSetNulls() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $exercise = $this->getExerciseWithLinks(); + $link = $exercise->getFileLinks()->filter(function (ExerciseFileLink $l) { + return $l->getKey() === 'LIB'; + })->first(); + Assert::truthy($link); + $exerciseFile = $link->getExerciseFile(); + $key = $link->getKey(); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + "V1:ExerciseFiles", + 'POST', + [ + 'action' => 'updateFileLink', + 'id' => $exercise->getId(), + 'linkId' => $link->getId(), + ], + [ + 'requiredRole' => null, + 'saveName' => null, + ] + ); + + $this->presenter->fileLinks->refresh($link); + + Assert::equal($link->getId(), $payload->getId()); + Assert::count(2, $this->presenter->fileLinks->findAll()); + + Assert::equal($exerciseFile->getId(), $payload->getExerciseFile()->getId()); + Assert::equal($key, $payload->getKey()); // wasn't changed + Assert::null($payload->getRequiredRole()); + Assert::null($payload->getSaveName()); + + Assert::equal($exerciseFile->getId(), $link->getExerciseFile()->getId()); + Assert::equal($key, $link->getKey()); // wasn't changed + Assert::null($link->getRequiredRole()); + Assert::null($link->getSaveName()); + } + + public function testDeleteExerciseFileLink() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $exercise = $this->getExerciseWithLinks(); + $link = $exercise->getFileLinks()->first(); + Assert::truthy($link); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + "V1:ExerciseFiles", + 'DELETE', + ['action' => 'deleteFileLink', 'id' => $exercise->getId(), 'linkId' => $link->getId()] + ); + + Assert::count(1, $this->presenter->fileLinks->findAll()); + $remaining = current($this->presenter->fileLinks->findAll()); + Assert::notEqual($link->getId(), $remaining->getId()); + } } (new TestExerciseFilesPresenter())->run(); From dda336b35e415bebe6bed115bcf5e62d241e40bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 17 Nov 2025 16:18:25 +0100 Subject: [PATCH 13/25] Adding tests for new download (via link) endpoints and fixing related bugs in the process. --- .../presenters/ExerciseFilesPresenter.php | 1 + .../presenters/UploadedFilesPresenter.php | 28 +- app/V1Module/router/RouterFactory.php | 3 +- fixtures/demo/20-groups.neon | 5 + tests/Presenters/ExerciseFilesPresenter.phpt | 2 +- tests/Presenters/GroupsPresenter.phpt | 50 +-- tests/Presenters/UploadedFilesPresenter.phpt | 340 +++++++++++++++++- tests/base/PresenterTestHelper.php | 1 + 8 files changed, 374 insertions(+), 56 deletions(-) diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index 8b7e65d8d..4b2c2eec1 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -558,6 +558,7 @@ public function checkUpdateFileLink(string $id, string $linkId) throw new ForbiddenRequestException("You cannot update exercise file links for this exercise."); } } + /** * Update a specific exercise-file link. Missing arguments are not changed. * @POST diff --git a/app/V1Module/presenters/UploadedFilesPresenter.php b/app/V1Module/presenters/UploadedFilesPresenter.php index 969abc7cc..301877d6f 100644 --- a/app/V1Module/presenters/UploadedFilesPresenter.php +++ b/app/V1Module/presenters/UploadedFilesPresenter.php @@ -34,6 +34,7 @@ use App\Model\Entity\UploadedFile; use App\Model\Entity\UploadedPartialFile; use App\Security\Roles; +use App\Security\ACL\IAssignmentPermissions; use App\Security\ACL\IAssignmentSolutionPermissions; use App\Security\ACL\IExercisePermissions; use App\Security\ACL\IUploadedFilePermissions; @@ -111,6 +112,12 @@ class UploadedFilesPresenter extends BasePresenter */ public $assignmentSolutionAcl; + /** + * @var IAssignmentPermissions + * @inject + */ + public $assignmentAcl; + /** * @var IExercisePermissions * @inject @@ -656,7 +663,10 @@ private function checkExerciseFileLink(ExerciseFileLink $link): void } // for logged-in users, check exercise access (this is additional check on top of role requirement) - if (!$this->exerciseAcl->canViewDetail($link->getExercise())) { + if ($link->getExercise() !== null && !$this->exerciseAcl->canViewDetail($link->getExercise())) { + throw new ForbiddenRequestException("You cannot download exercise file for this exercise."); + } + if ($link->getAssignment() !== null && !$this->assignmentAcl->canViewDetail($link->getAssignment())) { throw new ForbiddenRequestException("You cannot download exercise file for this exercise."); } @@ -681,14 +691,9 @@ private function downloadFileByLink(ExerciseFileLink $link): void $this->sendStorageFileResponse($file, $link->getSaveName() ?? $fileEntity->getName()); } - public function checkDownloadExerciseFileByLink(string $id, string $linkId) + public function checkDownloadExerciseFileByLink(string $id) { - $link = $this->fileLinks->findOrThrow($linkId); - - if ($link->getExercise()?->getId() !== $id) { - throw new BadRequestException("The exercise file link is not associated with the given exercise."); - } - + $link = $this->fileLinks->findOrThrow($id); $this->checkExerciseFileLink($link); } @@ -697,11 +702,10 @@ public function checkDownloadExerciseFileByLink(string $id, string $linkId) * This endpoint is deliberately placed in UploadedFilesPresenter so it works for non-logged-in users as well. * @GET */ - #[Path("id", new VUuid(), "of exercise", required: true)] - #[Path("linkId", new VUuid(), "of the exercise file link entity", required: true)] - public function actionDownloadExerciseFileByLink(string $id, string $linkId) + #[Path("id", new VUuid(), "of the exercise file link entity", required: true)] + public function actionDownloadExerciseFileByLink(string $id) { - $link = $this->fileLinks->findOrThrow($linkId); + $link = $this->fileLinks->findOrThrow($id); $this->downloadFileByLink($link); } diff --git a/app/V1Module/router/RouterFactory.php b/app/V1Module/router/RouterFactory.php index 17673fa97..8414ca211 100644 --- a/app/V1Module/router/RouterFactory.php +++ b/app/V1Module/router/RouterFactory.php @@ -173,7 +173,6 @@ private static function createExercisesRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix//file-links", "ExerciseFiles:createFileLink"); $router[] = new PostRoute("$prefix//file-links/", "ExerciseFiles:updateFileLink"); $router[] = new DeleteRoute("$prefix//file-links/", "ExerciseFiles:deleteFileLink"); - $router[] = new GetRoute("$prefix//file-links/", "UploadedFiles:downloadExerciseFileByLink"); // special download route for file link by its key $router[] = new GetRoute("$prefix//file-download/", "UploadedFiles:downloadExerciseFileLinkByKey"); @@ -475,6 +474,8 @@ private static function createUploadedFilesRoutes(string $prefix): RouteList $router[] = new DeleteRoute("$prefix/partial/", "UploadedFiles:cancelPartial"); $router[] = new PostRoute("$prefix/partial/", "UploadedFiles:completePartial"); + $router[] = new GetRoute("$prefix/link/", "UploadedFiles:downloadExerciseFileByLink"); + $router[] = new PostRoute("$prefix", "UploadedFiles:upload"); $router[] = new GetRoute("$prefix/", "UploadedFiles:detail"); $router[] = new GetRoute("$prefix//download", "UploadedFiles:download"); diff --git a/fixtures/demo/20-groups.neon b/fixtures/demo/20-groups.neon index 1eb0d5b6f..27e31fd4b 100644 --- a/fixtures/demo/20-groups.neon +++ b/fixtures/demo/20-groups.neon @@ -117,6 +117,11 @@ App\Model\Entity\User: __construct: ["submitUser1@example.com", "", "", "", "", student, @demoInstance] makeStudentOf: @demoGroup setVerified: true + + nonmemberStudent: + __construct: ["nonmemberStudent@example.com", "", "", "", "", student, @demoInstance] + setVerified: true + App\Model\Entity\Login: submitUser1Login: diff --git a/tests/Presenters/ExerciseFilesPresenter.phpt b/tests/Presenters/ExerciseFilesPresenter.phpt index bcdb1ec75..83dde8e6d 100644 --- a/tests/Presenters/ExerciseFilesPresenter.phpt +++ b/tests/Presenters/ExerciseFilesPresenter.phpt @@ -714,7 +714,7 @@ class TestExerciseFilesPresenter extends Tester\TestCase $link = $exercise->getFileLinks()->first(); Assert::truthy($link); - $payload = PresenterTestHelper::performPresenterRequest( + PresenterTestHelper::performPresenterRequest( $this->presenter, "V1:ExerciseFiles", 'DELETE', diff --git a/tests/Presenters/GroupsPresenter.phpt b/tests/Presenters/GroupsPresenter.phpt index aaf7996b8..02d1aefe4 100644 --- a/tests/Presenters/GroupsPresenter.phpt +++ b/tests/Presenters/GroupsPresenter.phpt @@ -26,16 +26,7 @@ $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; class TestGroupsPresenter extends Tester\TestCase { private $userLogin = "user2@example.com"; - private $userPassword = "password2"; - private $adminLogin = "admin@admin.com"; - private $adminPassword = "admin"; - - private $groupSupervisorLogin = "demoGroupSupervisor@example.com"; - private $groupSupervisorPassword = "password"; - - private $groupSupervisor2Login = "demoGroupSupervisor2@example.com"; - private $groupSupervisor2Password = "password"; /** @var GroupsPresenter */ protected $presenter; @@ -152,10 +143,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testListAllGroupsBySupervisor() { - $token = PresenterTestHelper::login( - $this->container, - $this->groupSupervisorLogin, - ); + PresenterTestHelper::login($this->container, PresenterTestHelper::GROUP_SUPERVISOR_LOGIN); $payload = PresenterTestHelper::performPresenterRequest( $this->presenter, 'V1:Groups', @@ -167,7 +155,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testListAllGroupsByAdmin() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $payload = PresenterTestHelper::performPresenterRequest( $this->presenter, 'V1:Groups', @@ -179,7 +167,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testSearchGroupByName() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $payload = PresenterTestHelper::performPresenterRequest( $this->presenter, 'V1:Groups', @@ -191,7 +179,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testSearchGroupByNameIncludingAncestors() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $payload = PresenterTestHelper::performPresenterRequest( $this->presenter, 'V1:Groups', @@ -203,7 +191,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testListGroupIncludingArchived() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $payload = PresenterTestHelper::performPresenterRequest( $this->presenter, 'V1:Groups', @@ -215,7 +203,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testListGroupOnlyArchived() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $payload = PresenterTestHelper::performPresenterRequest( $this->presenter, 'V1:Groups', @@ -390,7 +378,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testAddGroup() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $admin = $this->container->getByType(Users::class)->getByEmail($this->adminLogin); /** @var Instance $instance */ @@ -445,7 +433,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testAddOrganizationalGroup() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $admin = $this->container->getByType(Users::class)->getByEmail($this->adminLogin); /** @var Instance $instance */ @@ -501,7 +489,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testAddExamGroup() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $admin = $this->container->getByType(Users::class)->getByEmail($this->adminLogin); /** @var Instance $instance */ @@ -558,7 +546,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testAddGroupNoAdmin() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $admin = $this->container->getByType(Users::class)->getByEmail($this->adminLogin); /** @var Instance $instance */ @@ -613,7 +601,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testValidateAddGroupData() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $instance = $this->presenter->instances->findAll()[0]; @@ -755,7 +743,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testRemoveGroup() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $instance = $this->presenter->instances->findAll()[0]; $groups = $this->presenter->groups->findAll(); @@ -779,7 +767,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testDetail() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $groups = $this->presenter->groups->findAll(); $group = array_pop($groups); @@ -800,7 +788,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testSubgroups() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $groups = $this->presenter->groups->findAll(); $group = array_pop($groups); @@ -821,7 +809,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testMembers() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $groups = $this->presenter->groups->findAll(); $group = array_pop($groups); @@ -851,7 +839,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testAssignments() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $groups = $this->presenter->groups->findAll(); foreach ($groups as $group) { @@ -871,7 +859,7 @@ class TestGroupsPresenter extends Tester\TestCase public function testShadowAssignments() { - $token = PresenterTestHelper::login($this->container, $this->adminLogin); + PresenterTestHelper::loginDefaultAdmin($this->container); $groups = $this->presenter->groups->findAll(); foreach ($groups as $group) { @@ -1450,8 +1438,8 @@ class TestGroupsPresenter extends Tester\TestCase private function prepExamGroup(): Group { - PresenterTestHelper::login($this->container, $this->groupSupervisor2Login); - $admin = $this->presenter->users->getByEmail($this->groupSupervisor2Login); + PresenterTestHelper::login($this->container, PresenterTestHelper::GROUP_SUPERVISOR2_LOGIN); + $admin = $this->presenter->users->getByEmail(PresenterTestHelper::GROUP_SUPERVISOR2_LOGIN); $groups = $this->getAllGroupsInDepth( 2, function (Group $g) { diff --git a/tests/Presenters/UploadedFilesPresenter.phpt b/tests/Presenters/UploadedFilesPresenter.phpt index 4c777286a..5aacd22f3 100644 --- a/tests/Presenters/UploadedFilesPresenter.phpt +++ b/tests/Presenters/UploadedFilesPresenter.phpt @@ -4,10 +4,18 @@ $container = require_once __DIR__ . "/../bootstrap.php"; use App\Model\Entity\AttachmentFile; use App\V1Module\Presenters\UploadedFilesPresenter; +use App\Model\Entity\Assignment; +use App\Model\Entity\Exercise; +use App\Model\Entity\ExerciseFileLink; +use App\Model\Entity\Group; use App\Model\Entity\UploadedFile; use App\Model\Entity\UploadedPartialFile; -use App\Model\Repository\Logins; +use App\Model\Repository\Assignments; use App\Model\Repository\AssignmentSolutions; +use App\Model\Repository\Exercises; +use App\Model\Repository\Groups; +use App\Model\Repository\Logins; +use App\Model\Repository\Users; use App\Exceptions\ForbiddenRequestException; use App\Exceptions\NotFoundException; use App\Exceptions\CannotReceiveUploadedFileException; @@ -16,6 +24,7 @@ use App\Helpers\FileStorage\LocalImmutableFile; use App\Helpers\TmpFilesHelper; use App\Helpers\FileStorage\LocalFileStorage; use App\Helpers\FileStorage\LocalHashFileStorage; +use App\Security\Roles; use Doctrine\ORM\EntityManagerInterface; use Nette\Utils\Json; use Tester\Assert; @@ -32,10 +41,7 @@ class TestUploadedFilesPresenter extends Tester\TestCase private $userPassword = "password"; private $otherUserLogin = "user1@example.com"; - private $otherUserPassword = "password1"; - private $supervisorLogin = "demoGroupSupervisor@example.com"; - private $supervisorPassword = "password"; /** @var UploadedFilesPresenter */ protected $presenter; @@ -46,11 +52,20 @@ class TestUploadedFilesPresenter extends Tester\TestCase /** @var Nette\DI\Container */ protected $container; + /** @var Assignments */ + protected $assignments; + + /** @var Exercises */ + protected $exercises; + + /** @var Groups */ + protected $groups; + /** @var Logins */ protected $logins; /** @var AssignmentSolutions */ - protected $asignmentSolutions; + protected $assignmentSolutions; /** @var Nette\Security\User */ private $user; @@ -64,10 +79,13 @@ class TestUploadedFilesPresenter extends Tester\TestCase $this->container = $container; $this->em = PresenterTestHelper::getEntityManager($container); $this->user = $container->getByType(\Nette\Security\User::class); + $this->assignments = $container->getByType(Assignments::class); + $this->exercises = $container->getByType(Exercises::class); + $this->groups = $container->getByType(Groups::class); $this->logins = $container->getByType(Logins::class); - $this->asignmentSolutions = $container->getByType(AssignmentSolutions::class); + $this->assignmentSolutions = $container->getByType(AssignmentSolutions::class); - // patch container, since we cannot create actual file storage manarer + // patch container, since we cannot create actual file storage manager $fsName = current($this->container->findByType(FileStorageManager::class)); $this->container->removeService($fsName); $this->container->addService($fsName, new FileStorageManager( @@ -131,7 +149,7 @@ class TestUploadedFilesPresenter extends Tester\TestCase public function testNotFoundDownload() { - $token = PresenterTestHelper::loginDefaultAdmin($this->container); + PresenterTestHelper::loginDefaultAdmin($this->container); $user = $this->logins->getUser($this->userLogin, $this->userPassword, new Nette\Security\Passwords()); $uploadedFile = new UploadedFile("nonexistfile", new DateTime(), 1, $user); @@ -384,7 +402,7 @@ class TestUploadedFilesPresenter extends Tester\TestCase { $token = PresenterTestHelper::login($this->container, $this->supervisorLogin); - $solution = current(array_filter($this->asignmentSolutions->findAll(), function ($solution) { + $solution = current(array_filter($this->assignmentSolutions->findAll(), function ($solution) { return $solution->getSolution()->getFiles()->count() > 0; })); Assert::truthy($solution); @@ -436,7 +454,7 @@ class TestUploadedFilesPresenter extends Tester\TestCase Assert::equal($file->getName(), $response->getName()); } - public function testDownloadResultArchive() + public function testDownloadExerciseFile() { PresenterTestHelper::loginDefaultAdmin($this->container); $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); @@ -671,7 +689,7 @@ class TestUploadedFilesPresenter extends Tester\TestCase public function testDownloadDetectedPlagiarismSource() { - $token = PresenterTestHelper::login($this->container, 'demoGroupSupervisor@example.com'); + PresenterTestHelper::login($this->container, 'demoGroupSupervisor@example.com'); $similarFileEnt = current($this->presenter->detectedSimilarFiles->findAll()); $file = $similarFileEnt->getSolutionFile(); @@ -709,6 +727,306 @@ class TestUploadedFilesPresenter extends Tester\TestCase Assert::type(App\Responses\StorageFileResponse::class, $response); Assert::equal($file->getName(), $response->getName()); } + + private function getExerciseWithLinks(): Exercise + { + $exercises = array_filter( + $this->exercises->findAll(), + function (Exercise $e) { + return !$e->getFileLinks()->isEmpty(); // select the exercise with file links + } + ); + Assert::count(1, $exercises); + return array_pop($exercises); + } + + private function makeAssignmentWithLinks() + { + $exercise = $this->getExerciseWithLinks(); + $supervisor = $this->container->getByType(Users::class)->getByEmail(PresenterTestHelper::GROUP_SUPERVISOR_LOGIN); + + $group = current(array_filter( + $this->groups->findAll(), + function (Group $g) use ($supervisor) { + return $g->isSupervisorOf($supervisor) && !$g->isOrganizational(); + } + )); + Assert::truthy($group); + + $assignment = Assignment::assignToGroup($exercise, $group, true, null); + $this->assignments->persist($assignment); + return $assignment; + } + + public function testDownloadExerciseFileByLink() + { + // no login (should be visible for everyone) + + $exercise = $this->getExerciseWithLinks(); + $fileLink = $exercise->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getRequiredRole() === null; + } + )->first(); + Assert::truthy($fileLink); + $file = $fileLink->getExerciseFile(); + + // mock everything you can + $fileMock = Mockery::mock(LocalImmutableFile::class); + $mockStorage = Mockery::mock(FileStorageManager::class); + $mockStorage->shouldReceive("getExerciseFileByHash") + ->withArgs([$file->getHashName()]) + ->andReturn($fileMock)->once(); + $this->presenter->fileStorage = $mockStorage; + + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + + $response = $this->presenter->run($request); + Assert::type(App\Responses\StorageFileResponse::class, $response); + Assert::equal($file->getName(), $response->getName()); + } + + public function testDownloadAssignmentFileByLink() + { + // no login (should be visible for everyone) + + $assignment = $this->makeAssignmentWithLinks(); + + Assert::count(2, $assignment->getFileLinks()); + $fileLink = $assignment->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getRequiredRole() === null; + } + )->first(); + Assert::truthy($fileLink); + $file = $fileLink->getExerciseFile(); + + // mock everything you can + $fileMock = Mockery::mock(LocalImmutableFile::class); + $mockStorage = Mockery::mock(FileStorageManager::class); + $mockStorage->shouldReceive("getExerciseFileByHash") + ->withArgs([$file->getHashName()]) + ->andReturn($fileMock)->once(); + $this->presenter->fileStorage = $mockStorage; + + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + + $response = $this->presenter->run($request); + Assert::type(App\Responses\StorageFileResponse::class, $response); + Assert::equal($file->getName(), $response->getName()); + } + + public function testDownloadExerciseFileByLinkWithRenaming() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::GROUP_SUPERVISOR_LOGIN); + + $exercise = $this->getExerciseWithLinks(); + $fileLink = $exercise->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getSaveName() !== null; + } + )->first(); + Assert::truthy($fileLink); + $file = $fileLink->getExerciseFile(); + + // mock everything you can + $fileMock = Mockery::mock(LocalImmutableFile::class); + $mockStorage = Mockery::mock(FileStorageManager::class); + $mockStorage->shouldReceive("getExerciseFileByHash") + ->withArgs([$file->getHashName()]) + ->andReturn($fileMock)->once(); + $this->presenter->fileStorage = $mockStorage; + + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + + $response = $this->presenter->run($request); + Assert::type(App\Responses\StorageFileResponse::class, $response); + Assert::equal($fileLink->getSaveName(), $response->getName()); + } + + public function testDownloadAssignmentFileByLinkWithRenaming() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::GROUP_SUPERVISOR_LOGIN); + + $assignment = $this->makeAssignmentWithLinks(); + $fileLink = $assignment->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getSaveName() !== null; + } + )->first(); + Assert::truthy($fileLink); + $file = $fileLink->getExerciseFile(); + + // mock everything you can + $fileMock = Mockery::mock(LocalImmutableFile::class); + $mockStorage = Mockery::mock(FileStorageManager::class); + $mockStorage->shouldReceive("getExerciseFileByHash") + ->withArgs([$file->getHashName()]) + ->andReturn($fileMock)->once(); + $this->presenter->fileStorage = $mockStorage; + + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + + $response = $this->presenter->run($request); + Assert::type(App\Responses\StorageFileResponse::class, $response); + Assert::equal($fileLink->getSaveName(), $response->getName()); + } + + public function testDownloadExerciseFileByLinkDeniedByRole() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::GROUP_SUPERVISOR_LOGIN); + + $exercise = $this->getExerciseWithLinks(); + $fileLink = $exercise->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getRequiredRole() !== null; + } + )->first(); + Assert::truthy($fileLink); + $fileLink->setRequiredRole(Roles::EMPOWERED_SUPERVISOR_ROLE); // set role that current user does not have + $this->presenter->fileLinks->persist($fileLink); + + Assert::exception( + function () use ($fileLink) { + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + $this->presenter->run($request); + }, + ForbiddenRequestException::class + ); + } + + public function testDownloadAssignmentFileByLinkDeniedByRole() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT_GROUP_MEMBER_LOGIN); + + $assignment = $this->makeAssignmentWithLinks(); + $fileLink = $assignment->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getRequiredRole() !== null; + } + )->first(); + Assert::truthy($fileLink); + $fileLink->setRequiredRole(Roles::SUPERVISOR_ROLE); // set role that current user does not have + $this->presenter->fileLinks->persist($fileLink); + + Assert::exception( + function () use ($fileLink) { + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + $this->presenter->run($request); + }, + ForbiddenRequestException::class + ); + } + + public function testDownloadExerciseFileByLinkDeniedByExerciseAcl() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::ANOTHER_SUPERVISOR_LOGIN); + + $exercise = $this->getExerciseWithLinks(); + $fileLink = $exercise->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getRequiredRole() !== null; + } + )->first(); + Assert::truthy($fileLink); + + Assert::exception( + function () use ($fileLink) { + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + $this->presenter->run($request); + }, + ForbiddenRequestException::class + ); + } + + public function testDownloadAssignmentFileByLinkDeniedByAssignmentAcl() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::NONMEMBER_STUDENT_LOGIN); + + $assignment = $this->makeAssignmentWithLinks(); + $fileLink = $assignment->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getRequiredRole() !== null; + } + )->first(); + Assert::truthy($fileLink); + + Assert::exception( + function () use ($fileLink) { + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + ['action' => 'downloadExerciseFileByLink', 'id' => $fileLink->getId()] + ); + $this->presenter->run($request); + }, + ForbiddenRequestException::class + ); + } + + public function testDownloadExerciseFileByKey() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::GROUP_SUPERVISOR_LOGIN); + + $exercise = $this->getExerciseWithLinks(); + $fileLink = $exercise->getFileLinks()->filter( + function (ExerciseFileLink $link) { + return $link->getSaveName() !== null; + } + )->first(); + Assert::truthy($fileLink); + $file = $fileLink->getExerciseFile(); + + // mock everything you can + $fileMock = Mockery::mock(LocalImmutableFile::class); + $mockStorage = Mockery::mock(FileStorageManager::class); + $mockStorage->shouldReceive("getExerciseFileByHash") + ->withArgs([$file->getHashName()]) + ->andReturn($fileMock)->once(); + $this->presenter->fileStorage = $mockStorage; + + $request = new Nette\Application\Request( + $this->presenterPath, + 'GET', + [ + 'action' => 'downloadExerciseFileLinkByKey', + 'id' => $exercise->getId(), + 'linkKey' => $fileLink->getKey() + ] + ); + + $response = $this->presenter->run($request); + Assert::type(App\Responses\StorageFileResponse::class, $response); + Assert::equal($fileLink->getSaveName(), $response->getName()); + } } $testCase = new TestUploadedFilesPresenter(); diff --git a/tests/base/PresenterTestHelper.php b/tests/base/PresenterTestHelper.php index 636b70d4c..25dfcd48f 100644 --- a/tests/base/PresenterTestHelper.php +++ b/tests/base/PresenterTestHelper.php @@ -25,6 +25,7 @@ class PresenterTestHelper public const ADMIN_PASSWORD = "admin"; public const STUDENT_GROUP_MEMBER_LOGIN = "demoGroupMember1@example.com"; + public const NONMEMBER_STUDENT_LOGIN = "nonmemberStudent@example.com"; public const GROUP_SUPERVISOR_LOGIN = "demoGroupSupervisor@example.com"; public const GROUP_SUPERVISOR2_LOGIN = "demoGroupSupervisor2@example.com"; public const ANOTHER_SUPERVISOR_LOGIN = "anotherSupervisor@example.com"; From fafc13481bd2e97a59ce0a4ea2fbcdb7da04b933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Mon, 17 Nov 2025 17:50:45 +0100 Subject: [PATCH 14/25] Updating tests to check for proper file link duplication/updates, when triggered by exercise/assignment updates. --- app/model/entity/base/ExerciseData.php | 2 +- app/model/repository/ExerciseFileLinks.php | 8 +- tests/Presenters/AssignmentsPresenter.phpt | 54 ++++++- tests/Presenters/ExerciseFilesPresenter.phpt | 139 +++++++++++++++++-- tests/Presenters/ExercisesPresenter.phpt | 33 ++++- 5 files changed, 215 insertions(+), 21 deletions(-) diff --git a/app/model/entity/base/ExerciseData.php b/app/model/entity/base/ExerciseData.php index 4b1a5d609..717e25311 100644 --- a/app/model/entity/base/ExerciseData.php +++ b/app/model/entity/base/ExerciseData.php @@ -280,7 +280,7 @@ function (ExerciseTest $test) { /** * @ORM\ManyToMany(targetEntity="ExerciseFile") - * @var Collection + * @var Collection */ protected $exerciseFiles; diff --git a/app/model/repository/ExerciseFileLinks.php b/app/model/repository/ExerciseFileLinks.php index 25419ff35..663677e59 100644 --- a/app/model/repository/ExerciseFileLinks.php +++ b/app/model/repository/ExerciseFileLinks.php @@ -16,7 +16,7 @@ public function __construct(EntityManagerInterface $em) } /** - * Load an associative array [ key => external-file-ID ] for all file links of the given exercise. + * Load an associative array [ key => link-ID ] for all file links of the given exercise. * @param string $exerciseId * @return array */ @@ -26,13 +26,13 @@ public function getLinksMapForExercise(string $exerciseId): array $result = []; foreach ($links as $link) { /** @var ExerciseFileLink $link */ - $result[$link->getKey()] = $link->getExerciseFile()->getId(); + $result[$link->getKey()] = $link->getId(); } return $result; } /** - * Load an associative array [ key => external-file-ID ] for all file links of the given assignment. + * Load an associative array [ key => link-ID ] for all file links of the given assignment. * @param string $assignmentId * @return array */ @@ -42,7 +42,7 @@ public function getLinksMapForAssignment(string $assignmentId): array $result = []; foreach ($links as $link) { /** @var ExerciseFileLink $link */ - $result[$link->getKey()] = $link->getExerciseFile()->getId(); + $result[$link->getKey()] = $link->getId(); } return $result; } diff --git a/tests/Presenters/AssignmentsPresenter.phpt b/tests/Presenters/AssignmentsPresenter.phpt index 893f33193..b0c57fa0c 100644 --- a/tests/Presenters/AssignmentsPresenter.phpt +++ b/tests/Presenters/AssignmentsPresenter.phpt @@ -21,8 +21,8 @@ use App\Helpers\TmpFilesHelper; use App\Helpers\FileStorage\LocalFileStorage; use App\Helpers\FileStorage\LocalHashFileStorage; use App\V1Module\Presenters\AssignmentsPresenter; +use App\Security\Roles; use Doctrine\ORM\EntityManagerInterface; -use Nette\Utils\Json; use Tester\Assert; use App\Helpers\JobConfig; use App\Exceptions\NotFoundException; @@ -628,6 +628,12 @@ class TestAssignmentsPresenter extends Tester\TestCase /** @var Exercise $exercise */ $exercise = array_pop($exercises); + // original links of the exercise indexed by keys + $exerciseLinks = []; + foreach ($exercise->getFileLinks() as $link) { + $exerciseLinks[$link->getKey()] = $link; + } + /** @var Group $group */ $group = $this->presenter->groups->findAll()[0]; @@ -647,10 +653,24 @@ class TestAssignmentsPresenter extends Tester\TestCase $viewFactory->getAssignment($this->presenter->assignments->findOneBy(['id' => $payload["id"]])), $payload ); - Assert::count(2, $payload['localizedTextsLinks']); - $keys = array_keys($payload['localizedTextsLinks']); - sort($keys); - Assert::same(['LIB', 'ORIG'], $keys); + Assert::count(count($exerciseLinks), $payload['localizedTextsLinks']); + foreach ($payload['localizedTextsLinks'] as $key => $linkId) { + Assert::true(array_key_exists($key, $exerciseLinks)); + Assert::notEqual($exerciseLinks[$key]->getId(), $linkId); // new link should be created + } + + // verify the newly created file links in the assignment + $assignment = $this->presenter->assignments->get($payload["id"]); + Assert::count(count($exerciseLinks), $assignment->getFileLinks()); + foreach ($assignment->getFileLinks() as $link) { + Assert::true(array_key_exists($link->getKey(), $exerciseLinks)); + $origLink = $exerciseLinks[$link->getKey()]; + Assert::notSame($origLink->getId(), $link->getId()); + Assert::null($link->getExercise()); + Assert::equal($origLink->getExerciseFile()->getId(), $link->getExerciseFile()->getId()); + Assert::equal($origLink->getSaveName(), $link->getSaveName()); + Assert::equal($origLink->getRequiredRole(), $link->getRequiredRole()); + } } public function testCreateAssignmentFromLockedExercise() @@ -755,15 +775,22 @@ class TestAssignmentsPresenter extends Tester\TestCase wall-time: 44 "; + $exercises = array_filter( + $this->presenter->exercises->findAll(), + function (Exercise $e) { + return !$e->getFileLinks()->isEmpty(); // select the exercise with file links + } + ); + Assert::count(1, $exercises); /** @var Exercise $exercise */ - $exercise = $this->presenter->exercises->findAll()[0]; + $exercise = array_pop($exercises); + $exerciseLimits = new ExerciseLimits($environment, $hwGroup, $limits, $user); $this->em->persist($exerciseLimits); $exercise->addExerciseLimits($exerciseLimits); $assignment = Assignment::assignToGroup($exercise, $group); $this->em->persist($assignment); - $this->em->flush(); $newExerciseLimits = new ExerciseLimits($environment, $hwGroup, $newLimits, $user); @@ -771,6 +798,13 @@ class TestAssignmentsPresenter extends Tester\TestCase $exercise->clearExerciseLimits(); $exercise->addExerciseLimits($newExerciseLimits); + $exercise->getFileLinks()->removeElement($exercise->getFileLinks()->first()); + Assert::count(1, $exercise->getFileLinks()); + $link = $exercise->getFileLinks()->first(); + $link->setKey("NEW"); + $link->setRequiredRole(Roles::SUPERVISOR_ROLE); + $this->em->persist($link); + $this->em->persist($exercise); $this->em->flush(); $request = new Nette\Application\Request( @@ -785,6 +819,12 @@ class TestAssignmentsPresenter extends Tester\TestCase Assert::same($assignment->getId(), $data["id"]); Assert::same($newExerciseLimits, $assignment->getLimitsByEnvironmentAndHwGroup($environment, $hwGroup)); + Assert::count(1, $assignment->getFileLinks()); + $newLink = $assignment->getFileLinks()->first(); + Assert::equal("NEW", $newLink->getKey()); + Assert::equal(Roles::SUPERVISOR_ROLE, $newLink->getRequiredRole()); + Assert::equal($link->getExerciseFile()->getId(), $newLink->getExerciseFile()->getId()); + Assert::null($newLink->getExercise()); } public function testRemove() diff --git a/tests/Presenters/ExerciseFilesPresenter.phpt b/tests/Presenters/ExerciseFilesPresenter.phpt index 83dde8e6d..657a6dc8d 100644 --- a/tests/Presenters/ExerciseFilesPresenter.phpt +++ b/tests/Presenters/ExerciseFilesPresenter.phpt @@ -8,10 +8,17 @@ use App\Helpers\FileStorage\LocalHashFileStorage; use App\Helpers\FileStorage\LocalImmutableFile; use App\Helpers\ExercisesConfig; use App\Helpers\TmpFilesHelper; +use App\Model\Entity\Assignment; use App\Model\Entity\AttachmentFile; use App\Model\Entity\Exercise; use App\Model\Entity\ExerciseFileLink; use App\Model\Entity\UploadedFile; +use App\Model\Repository\Assignments; +use App\Model\Repository\AttachmentFiles; +use App\Model\Repository\Exercises; +use App\Model\Repository\ExerciseFiles; +use App\Model\Repository\Groups; +use App\Model\Repository\Logins; use App\V1Module\Presenters\ExerciseFilesPresenter; use App\Model\Entity\ExerciseFile; use Doctrine\ORM\EntityManagerInterface; @@ -33,19 +40,24 @@ class TestExerciseFilesPresenter extends Tester\TestCase /** @var Nette\DI\Container */ protected $container; - /** @var App\Model\Repository\ExerciseFiles */ + /** @var ExerciseFiles */ protected $exerciseFiles; - /** @var App\Model\Repository\Logins */ + /** @var Logins */ protected $logins; /** @var Nette\Security\User */ private $user; - /** @var App\Model\Repository\Exercises */ + /** @var Assignments */ + protected $assignments; + /** @var Groups */ + protected $groups; + + /** @var Exercises */ protected $exercises; - /** @var App\Model\Repository\AttachmentFiles */ + /** @var AttachmentFiles */ protected $attachmentFiles; public function __construct() @@ -54,10 +66,12 @@ class TestExerciseFilesPresenter extends Tester\TestCase $this->container = $container; $this->em = PresenterTestHelper::getEntityManager($container); $this->user = $container->getByType(\Nette\Security\User::class); - $this->exerciseFiles = $container->getByType(\App\Model\Repository\ExerciseFiles::class); - $this->logins = $container->getByType(\App\Model\Repository\Logins::class); - $this->exercises = $container->getByType(App\Model\Repository\Exercises::class); - $this->attachmentFiles = $container->getByType(\App\Model\Repository\AttachmentFiles::class); + $this->assignments = $container->getByType(Assignments::class); + $this->attachmentFiles = $container->getByType(AttachmentFiles::class); + $this->exercises = $container->getByType(Exercises::class); + $this->exerciseFiles = $container->getByType(ExerciseFiles::class); + $this->groups = $container->getByType(Groups::class); + $this->logins = $container->getByType(Logins::class); // patch container, since we cannot create actual file storage manager $fsName = current($this->container->findByType(FileStorageManager::class)); @@ -142,6 +156,115 @@ class TestExerciseFilesPresenter extends Tester\TestCase } } + public function testExerciseFilesUploadOverload() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); + $exercise = current(array_filter( + $this->presenter->exercises->findAll(), + function ($exercise) { + return !$exercise->getFileLinks()->isEmpty(); + } + )); + Assert::truthy($exercise); + + $oldFiles = []; + foreach ($exercise->getExerciseFiles() as $file) { + /** @var ExerciseFile $file */ + $oldFiles[$file->getName()] = $file->getId(); + } + $oldFile = $exercise->getFileLinks()->first()->getExerciseFile(); + $oldLinks = []; + foreach ($exercise->getFileLinks() as $link) { + if ($link->getExerciseFile()->getId() === $oldFile->getId()) { + $oldLinks[$link->getKey()] = $link; + } + } + + // make an assignment (so we can check it is left unchanged) + $group = current($this->groups->findAll()); + Assert::truthy($group); + + $assignment = Assignment::assignToGroup($exercise, $group, true, null); + $this->assignments->persist($assignment); + $assignmentFiles = []; + foreach ($assignment->getExerciseFiles() as $file) { + /** @var ExerciseFile $file */ + $assignmentFiles[$file->getName()] = $file->getId(); + } + $assignmentLinks = $this->presenter->fileLinks->getLinksMapForAssignment($assignment->getId()); + + // prepare a new file + $filename = $oldFile->getName(); + $file = new UploadedFile($filename, new \DateTime(), 42, $user); + $this->presenter->uploadedFiles->persist($file); + $this->presenter->uploadedFiles->flush(); + + // Mock file server setup + $fileStorage = Mockery::mock(FileStorageManager::class); + $fileStorage->shouldReceive("storeUploadedExerciseFile")->with($file)->once(); + $this->presenter->fileStorage = $fileStorage; + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + "V1:ExerciseFiles", + "POST", + [ + "action" => 'uploadExerciseFiles', + 'id' => $exercise->getId() + ], + [ + 'files' => [$file->getId()] + ] + ); + + // number of files hasn't changed + Assert::count(count($oldFiles), $payload); + foreach ($payload as $item) { + Assert::type(App\Model\Entity\ExerciseFile::class, $item); + } + + // all files (except the overloaded one) are unchanged, the overloaded one is new + $newFileId = null; + $this->presenter->exercises->refresh($exercise); + Assert::count(count($oldFiles), $exercise->getExerciseFiles()); + foreach ($exercise->getExerciseFiles() as $file) { + /** @var ExerciseFile $file */ + Assert::true(array_key_exists($file->getName(), $oldFiles)); + if ($file->getName() !== $filename) { + Assert::equal($oldFiles[$file->getName()], $file->getId()); + } else { + Assert::notEqual($oldFiles[$file->getName()], $file->getId()); + Assert::equal(42, $file->getFileSize()); + $newFileId = $file->getId(); + } + } + Assert::truthy($newFileId); + + // links has been properly updated + foreach ($exercise->getFileLinks() as $link) { + /** @var ExerciseFileLink $link */ + Assert::true(array_key_exists($link->getKey(), $oldLinks)); + $oldLink = $oldLinks[$link->getKey()]; + Assert::equal($oldLink->getSaveName(), $link->getSaveName()); + Assert::equal($oldLink->getRequiredRole(), $link->getRequiredRole()); + Assert::equal($newFileId, $link->getExerciseFile()->getId()); + } + + // assignment is unchanged + $this->presenter->assignments->refresh($assignment); + Assert::count(count($assignmentFiles), $assignment->getExerciseFiles()); + foreach ($assignment->getExerciseFiles() as $file) { + /** @var ExerciseFile $file */ + Assert::true(array_key_exists($file->getName(), $assignmentFiles)); + Assert::equal($assignmentFiles[$file->getName()], $file->getId()); + } + + $newAssignmentLinks = $this->presenter->fileLinks->getLinksMapForAssignment($assignment->getId()); + Assert::same($assignmentLinks, $newAssignmentLinks); + } + public function testUploadTooManyExerciseFiles() { $user = $this->presenter->users->getByEmail(PresenterTestHelper::ADMIN_LOGIN); diff --git a/tests/Presenters/ExercisesPresenter.phpt b/tests/Presenters/ExercisesPresenter.phpt index 02067f275..a277e0b06 100644 --- a/tests/Presenters/ExercisesPresenter.phpt +++ b/tests/Presenters/ExercisesPresenter.phpt @@ -690,9 +690,20 @@ class TestExercisesPresenter extends Tester\TestCase PresenterTestHelper::loginDefaultAdmin($this->container); $user = $this->logins->getUser(PresenterTestHelper::ADMIN_LOGIN, PresenterTestHelper::ADMIN_PASSWORD, new Nette\Security\Passwords()); - $exercise = current($this->presenter->exercises->findAll()); $group = current($this->presenter->groups->findAll()); + $exercises = array_filter($this->presenter->exercises->findAll(), function ($e) { + return !$e->isArchived() && !$e->getFileLinks()->isEmpty(); + }); + $exercise = current($exercises); + Assert::truthy($exercise); + + // original links of the exercise indexed by keys + $exerciseLinks = []; + foreach ($exercise->getFileLinks() as $link) { + $exerciseLinks[$link->getKey()] = $link; + } + $request = new Nette\Application\Request( 'V1:Exercises', 'POST', @@ -715,6 +726,26 @@ class TestExercisesPresenter extends Tester\TestCase Assert::equal($user->getId(), $forked["authorId"]); Assert::equal(1, count($forked["groupsIds"])); Assert::equal($group->getId(), $forked["groupsIds"][0]); + + Assert::count(count($exerciseLinks), $forked["localizedTextsLinks"]); + foreach ($forked["localizedTextsLinks"] as $key => $link) { + Assert::true(array_key_exists($key, $exerciseLinks)); + Assert::notEqual($exerciseLinks[$key]->getId(), $link); // different link + } + + $forked = $this->presenter->exercises->get($forked["id"]); + Assert::truthy($forked); + + Assert::count(count($exerciseLinks), $forked->getFileLinks()); + foreach ($forked->getFileLinks() as $link) { + Assert::true(array_key_exists($link->getKey(), $exerciseLinks)); + $origLink = $exerciseLinks[$link->getKey()]; + Assert::notSame($origLink->getId(), $link->getId()); + Assert::null($link->getAssignment()); + Assert::equal($origLink->getExerciseFile()->getId(), $link->getExerciseFile()->getId()); + Assert::equal($origLink->getSaveName(), $link->getSaveName()); + Assert::equal($origLink->getRequiredRole(), $link->getRequiredRole()); + } } public function testHardwareGroups() From 10b169cdf13acac0f59d579947d7a9bd1664cfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 21 Nov 2025 15:34:59 +0100 Subject: [PATCH 15/25] Creating a command for attachment files conversion. --- app/commands/BaseCommand.php | 35 + app/commands/ConvertExerciseAttachments.php | 887 ++++++++++++++++++ app/config/config.neon | 2 +- .../FileStorage/File/LocalImmutableFile.php | 10 + app/helpers/FileStorage/IHashFileStorage.php | 7 + .../LocalStorage/LocalHashFileStorage.php | 10 +- app/helpers/FileStorageManager.php | 30 + app/model/entity/Exercise.php | 3 + app/model/entity/ExerciseFileLink.php | 2 +- app/model/entity/Group.php | 16 +- app/model/entity/LocalizedExercise.php | 20 + app/model/entity/base/ExerciseData.php | 10 +- 12 files changed, 1017 insertions(+), 15 deletions(-) create mode 100644 app/commands/ConvertExerciseAttachments.php diff --git a/app/commands/BaseCommand.php b/app/commands/BaseCommand.php index 2183743fd..afcf80907 100644 --- a/app/commands/BaseCommand.php +++ b/app/commands/BaseCommand.php @@ -145,4 +145,39 @@ protected function prompt(string $text, string $default = '', bool $hidden = fal } return $helper->ask($this->input, $this->output, $question); } + + protected function writeError(string $message): void + { + if ($this->output) { + $this->output->writeln("ERROR: $message", OutputInterface::VERBOSITY_QUIET); + } + } + + protected function writeWarning(string $message): void + { + if ($this->output) { + $this->output->writeln("WARNING: $message", OutputInterface::OUTPUT_NORMAL); + } + } + + protected function writeVerbose(string $message): void + { + if ($this->output) { + $this->output->writeln($message, OutputInterface::VERBOSITY_VERBOSE); + } + } + + protected function writeComment(string $message): void + { + if ($this->output) { + $this->output->writeln("$message", OutputInterface::VERBOSITY_VERY_VERBOSE); + } + } + + protected function writeDebug(string $message): void + { + if ($this->output) { + $this->output->writeln("DEBUG: $message", OutputInterface::VERBOSITY_DEBUG); + } + } } diff --git a/app/commands/ConvertExerciseAttachments.php b/app/commands/ConvertExerciseAttachments.php new file mode 100644 index 000000000..14f9bff73 --- /dev/null +++ b/app/commands/ConvertExerciseAttachments.php @@ -0,0 +1,887 @@ + [ attachmentFile, ... ], ... ] + */ + private array $missingFiles = []; + + /** + * Log of invalid links detected in exercises. + * [ exerciseId => [ link => error-description, ... ] + */ + private array $invalidLinks = []; + + /** + * Log of assignments that cannot be updated because they are not in sync with their exercises. + * (only localized texts and attachment files are considered for sync check) + */ + private array $notSyncedAssignments = []; + + /** + * Number of skipped exercises (because they already have file links). + */ + private int $skipped = 0; + + /** + * Total number of files copied from attachment files to exercise files. + */ + private int $filesCopied = 0; + + /** + * Total number of files renamed due to name collisions (when being copied). + */ + private int $filesRenamed = 0; + + /** + * Total number of files associated with existing exercise files (deduplicated without copying). + */ + private int $filesDeduplicated = 0; + + /** + * Total number of exercise and assignment file links created. + */ + private int $linksCreated = 0; + + public function __construct( + string $apuBase, + Exercises $exercises, + ExerciseFiles $exerciseFiles, + ExerciseFileLinks $exerciseFileLinks, + FileStorageManager $fileStorageManager + ) { + parent::__construct(); + $this->apuBase = rtrim($apuBase, '/'); + $this->exercises = $exercises; + $this->exerciseFiles = $exerciseFiles; + $this->exerciseFileLinks = $exerciseFileLinks; + $this->fileStorageManager = $fileStorageManager; + } + + protected function configure() + { + $this->addOption( + 'apiBase', + null, + InputOption::VALUE_REQUIRED, + 'Location of the ReCodEx API (URL prefix) used for detection of the attachment file links.', + null + ); + $this->addOption( + 'forReal', + null, + InputOption::VALUE_NONE, + 'If set, the changes are performed for real; otherwise just a simulation is done.' + ); + $this->addOption( + 'exercise', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'If set, only the exercises with the given IDs are processed (multiple IDs can be specified).', + null + ); + $this->addOption( + 'missingLog', + null, + InputOption::VALUE_REQUIRED, + 'CSV file where missing attachment files are logged (only those that are used in texts).', + null + ); + $this->addOption( + 'invalidLog', + null, + InputOption::VALUE_REQUIRED, + 'CSV file where invalid attachment file links are logged.', + null + ); + $this->addOption( + 'notSyncedLog', + null, + InputOption::VALUE_REQUIRED, + 'CSV file where assignments that are not in sync with their exercises are logged.', + null + ); + } + + /** + * Returns all attachment files of the given exercise indexed by their ID. + * @param Exercise $exercise + * @return array [ attachment-file-id => AttachmentFile, ... ] + */ + private function getAttachmentFiles(Exercise $exercise): array + { + $files = []; + foreach ($exercise->getAttachmentFiles() as $file) { + /** @var AttachmentFile $file */ + $files[$file->getId()] = $file; + } + return $files; + } + + /** + * Returns all exercise files of the given exercise indexed by their ID. + * @param Exercise $exercise + * @return array [ exercise-file-id => ExerciseFile, ... ] + */ + private function getExerciseFilesByName(Exercise $exercise): array + { + $files = []; + foreach ($exercise->getExerciseFiles() as $file) { + /** @var ExerciseFile $file */ + $files[$file->getName()] = $file; + } + return $files; + } + + /** + * Return physical file wrappers (IImmutableFile) for all attachment files. + * @param array $attachmentFiles [ attachment-file-id => IImmutableFile, ... ] + */ + private function getAttachmentFilesImmutableFiles(array $attachmentFiles): array + { + $immutableFiles = []; + foreach ($attachmentFiles as $id => $file) { + /** @var AttachmentFile $file */ + $immutableFiles[$id] = $this->fileStorageManager->getAttachmentFile($file); + } + return $immutableFiles; + } + + /** + * Extracts all attachment file links from the given exercise texts. + * Bad links are logged in global $invalidLinks. + * @param Exercise $exercise + * @param array|null $attachmentFiles attachment files indexed by their ID + * @return array [ link-url => attachment-file-id, ... ] + */ + private function extractAllLinksFromText(Exercise $exercise, ?array $attachmentFiles = null): array + { + $links = []; + if ($attachmentFiles === null) { + $attachmentFiles = $this->getAttachmentFiles($exercise); + } + + $reg = '#https://(?[-_a-zA-Z0-9./:]+)/v1/uploaded-files/(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/download#'; + foreach ($exercise->getLocalizedTexts() as $localizedText) { + /** @var LocalizedExercise $localizedText */ + $text = $localizedText->getAssignmentText() . "\n" . $localizedText->getDescription(); + if (!preg_match_all($reg, $text, $matches, PREG_SET_ORDER)) { + continue; + } + + foreach ($matches as $match) { + $base = "https://" . $match['base']; + if ($base !== $this->apuBase) { + // report bad link + $this->invalidLinks[$exercise->getId()][$match[0]] = "invalid base URL"; + continue; + } + + if (!array_key_exists($match['id'], $attachmentFiles)) { + // report bad link + $this->invalidLinks[$exercise->getId()][$match[0]] = "invalid attachment file ID"; + continue; + } + + $links[$match[0]] = $match['id']; + } + } + + $this->writeDebug( + "Extracted " . count($links) . " attachment links from exercise '{$exercise->getId()}'." + ); + return $links; + } + + /** + * Detects which attachment files are used in the given links. + * @param array $attachmentFiles [ attachment-file-id => AttachmentFile, ... ] + * @param array $textLinks [ link-url => attachment-file-id, ... ] + * @return array [ attachment-file-id => bool (is-used), ... ] + */ + private function detectUsedAttachmentFiles(array $attachmentFiles, array $textLinks): array + { + $used = []; + foreach (array_keys($attachmentFiles) as $id) { + $used[$id] = false; + } + foreach ($textLinks as $attachmentFileId) { + $used[$attachmentFileId] = true; + } + return $used; + } + + /** + * Finds an exercise file in the given list that matches the given attachment file by content hash. + * If multiple files match, the one with the most similar name is returned. + * @param AttachmentFile $attachmentFile + * @param array $exerciseFiles [ name => ExerciseFile, ... ] + * @return ExerciseFile|null (null if no matching file is found) + */ + private function findExerciseFileDuplicate( + AttachmentFile $attachmentFile, + array $exerciseFiles, + ): ?ExerciseFile { + $hash = $this->fileStorageManager->getAttachmentFileHash($attachmentFile); + $name = $attachmentFile->getName(); + $candidates = []; + foreach ($exerciseFiles as $name => $exerciseFile) { + /** @var ExerciseFile $exerciseFile */ + if ($exerciseFile instanceof ExerciseFile && $exerciseFile->getHashName() === $hash) { + $candidates[$name] = $exerciseFile; + } + } + + if (!$candidates) { + return null; + } + + if (array_key_exists($name, $candidates)) { + return $candidates[$name]; + } + + $best = reset($candidates); + if (count($candidates) > 1) { + $cost = PHP_INT_MAX; + foreach ($candidates as $candidateName => $candidate) { + $currentCost = levenshtein($name, $candidateName); + if ($currentCost < $cost) { + $cost = $currentCost; + $best = $candidate; + } + } + } + + $this->writeVerbose( + "Attachment file '{$attachmentFile->getId()}' named '$name' matches existing exercise file " + . "'{$best->getId()}' named '{$best->getName()}' [deduplicated]." + ); + $this->filesDeduplicated++; + return $best; + } + + /** + * Creates a new exercise file from the given attachment file for the given exercise. + * The created exercise file is added to the $exerciseFiles array (indexed by name). + * In case of name collision, the name is modified by adding suffix before the extension. + * @param AttachmentFile $attachmentFile + * @param Exercise $exercise + * @param array $exerciseFiles [ name => ExerciseFile, ... ] modified in place + * @return ExerciseFile|string the created exercise file (or its name in simulation mode) + */ + private function createExerciseFileFromAttachment( + AttachmentFile $attachmentFile, + Exercise $exercise, + array &$exerciseFiles + ): mixed { + $name = $attachmentFile->getName(); + if (array_key_exists($name, $exerciseFiles)) { + // rename the file in case of a collision + $base = pathinfo($name, PATHINFO_FILENAME) . '_attach'; + $ext = pathinfo($name, PATHINFO_EXTENSION); + $counter = ''; + do { + $name = "$base$counter.$ext"; + $counter = (int)$counter + 1; + } while (array_key_exists($name, $exerciseFiles)); + } + + $this->writeComment( + "Copying attachment file '{$attachmentFile->getId()}' named '{$attachmentFile->getName()}' " + . "as new exercise file named '$name' ..." + ); + if ($this->forReal) { + $hash = $this->fileStorageManager->copyAttachmentFileToHashStorage($attachmentFile); + + $exerciseFile = new ExerciseFile( + $name, + $attachmentFile->getUploadedAt(), + $attachmentFile->getFileSize(), + $hash, + $attachmentFile->getUser(), + $exercise + ); + $this->exerciseFiles->persist($exerciseFile); + } else { + $exerciseFile = $name; // dummy (we need to remember the name only) + } + + $exerciseFiles[$name] = $exerciseFile; + $this->filesCopied++; + return $exerciseFile; + } + + /** + * Creates exercise file links for the given attachment files mapped to exercise files. + * Only attachment files present in the mapping are processed. + * @param array $attachmentFiles [ attachment-file-id => AttachmentFile, ... ] + * @param array $mapping [ attachment-file-id => ExerciseFile, ... ] + * @param Exercise $exercise + * @return array [ attachment-id => ExerciseFileLink, ... ] + * a link key (instead of ExerciseFileLink) is used as value in the simulation mode + */ + private function createExerciseLinks(array $attachmentFiles, array $mapping, Exercise $exercise): array + { + $links = []; + $usedKeys = []; + foreach ($mapping as $id => $exerciseFile) { + /** @var AttachmentFile $attachmentFile */ + $attachmentFile = $attachmentFiles[$id]; + $attachmentName = $attachmentFile->getName(); + $key = strtoupper(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($attachmentName, PATHINFO_FILENAME))); + $key = $key ? $key : 'FILE'; + $key = substr($key, 0, 14); + + // make sure the key is unique + $baseKey = $key; + $counter = 0; + while (array_key_exists($key, $usedKeys)) { + ++$counter; + while (strlen("{$baseKey}_{$counter}") > 16) { + $baseKey = substr($baseKey, 0, -1); + } + $key = "{$baseKey}_{$counter}"; + } + $usedKeys[$key] = true; + + if ($this->forReal) { + $saveAs = $exerciseFile->getName() !== $attachmentName ? $attachmentName : null; + $this->filesRenamed += $saveAs ? 1 : 0; + + $saveAsStr = $saveAs ? "(to be saved as '$saveAs') " : ''; + $this->writeComment( + "Creating exercise ('{$exercise->getId()}') link for file '{$exerciseFile->getId()}' " + . "named '{$exerciseFile->getName()}' with key '$key' $saveAsStr..." + ); + + $links[$id] = ExerciseFileLink::createForExercise( + $key, + $exerciseFile, + $exercise, + Roles::STUDENT_ROLE, + $saveAs + ); + $this->exerciseFileLinks->persist($links[$id], false); + } else { + if ($exerciseFile instanceof ExerciseFile) { + $exerciseFile = $exerciseFile->getName(); + } + $saveAsStr = ($exerciseFile !== $attachmentName) ? "(to be saved as '$attachmentName') " : ''; + $this->writeComment( + "Creating exercise ('{$exercise->getId()}') link for file named '$exerciseFile' " + . "with key '$key' $saveAsStr..." + ); + + // just a simulation, make sure the counters are correct + $this->filesRenamed += ($exerciseFile !== $attachmentName) ? 1 : 0; + $links[$id] = $key; + } + + $this->linksCreated++; + } + + if ($this->forReal) { + $this->exerciseFileLinks->flush(); + } + + return $links; + } + + /** + * Copy exercise file links for the given assignment. + * @param Assignment $assignment (that needs to be in sync with its exercise) + * @param ExerciseFileLink[] $links + */ + private function createAssignmentsLinks(Assignment $assignment, array $links): void + { + $this->writeComment("Creating links for assignment '{$assignment->getId()}' ..."); + $this->linksCreated += count($links); + + if ($this->forReal) { + $assignment->getFileLinks()->clear(); // remove old links + foreach ($links as $link) { + $assignmentLink = ExerciseFileLink::copyForAssignment( + $link, + $assignment, + ); + $this->exerciseFileLinks->persist($assignmentLink, false); + } + $this->exerciseFileLinks->flush(); + } + + $this->writeDebug("Created " . count($links) . " links for the assignment."); + } + + /** + * Updates the texts of the given exercise to use the given links instead of direct URLs. + * Each link is replaced by %%key%% where key is the key of the corresponding ExerciseFileLink. + * @param Exercise $exercise + * @param array $textLinks [ link-url => attachment-file-id, ... ] + * @param array $links [ attachment-file-id => ExerciseFileLink, ... ] + */ + private function updateExerciseTexts( + Exercise $exercise, + array $textLinks, + array $links, + ): void { + + // prepare search and replace arrays for collective replacement + $search = []; + $replace = []; + foreach ($textLinks as $url => $id) { + $search[] = $url; + $key = ($links[$id] instanceof ExerciseFileLink) ? $links[$id]->getKey() : (string)$links[$id]; + $replace[] = "%%$key%%"; + $this->writeDebug("Replacing '$url' with '%%$key%%' in exercise '{$exercise->getId()}'"); + } + + $this->writeComment("Updating texts for exercise '{$exercise->getId()}' (" . count($textLinks) + . " replacements) ..."); + + if ($this->forReal) { + foreach ($exercise->getLocalizedTexts() as $localizedText) { + /** @var LocalizedExercise $localizedText */ + $newAssignmentText = str_replace($search, $replace, $localizedText->getAssignmentText()); + $localizedText->setAssignmentTextDangerous($newAssignmentText); + $newDescription = str_replace($search, $replace, $localizedText->getDescription()); + $localizedText->setDescriptionDangerous($newDescription); + $this->exercises->persist($localizedText); + } + } + } + + /** + * Returns assignments of the exercise which are in sync with it (localized texts + attachment files). + * Assignments in archived groups are ignored. Assignments not in sync are logged in $notSyncedAssignments. + * @param Exercise $exercise + * @return array [ assignmentId => Assignment, ... ] + */ + private function getSyncedAssignments(Exercise $exercise): array + { + $assignments = []; + foreach ($exercise->getAssignments() as $assignment) { + if ($assignment->getGroup() === null || $assignment->getGroup()->isArchived()) { + $this->writeDebug( + "Skipping out-of-sync assignment '{$assignment->getId()}' in archived or deleted group." + ); + continue; + } + + if (!$assignment->areLocalizedTextsInSync() || !$assignment->areAttachmentFilesInSync()) { + $this->writeWarning("Skipping assignment '{$assignment->getId()}' since it is not in sync."); + $this->notSyncedAssignments[] = $assignment; + continue; + } + + $assignments[$assignment->getId()] = $assignment; + } + return $assignments; + } + + /** + * Processes a single exercise. + * All existing attachment files are copied to exercise files and corresponding links are created. + * The exercise texts are updated to use the links (by keys) instead of direct URLs. + * The links are then copied to all assignments which are in sync with the exercise. + * Notes: + * - missing attachment files used in the texts are reported in $missingFiles (not used files are ignored) + * - files are deduplicated based on content hash (using a match with the most similar name) + * - attachment names conflicting with existing exercise files are renamed + * (original names are preserved in the links using save-as feature for renaming) + * @param Exercise $exercise + */ + protected function processExercise(Exercise $exercise): void + { + // get info about the exercise files + $attachmentFiles = $this->getAttachmentFiles($exercise); + $exerciseFiles = $origExerciseFiles = $this->getExerciseFilesByName($exercise); + $textLinks = $this->extractAllLinksFromText($exercise, $attachmentFiles); + $used = $this->detectUsedAttachmentFiles($attachmentFiles, $textLinks); + $physicalFiles = $this->getAttachmentFilesImmutableFiles($attachmentFiles); + + // process all attachment files, create/associate corresponding exercise files + $attachmentExerciseFileMap = []; // attachment-id => ExerciseFile + foreach ($attachmentFiles as $id => $attachmentFile) { + if (!$physicalFiles[$id]) { // file is missing + if ($used[$id]) { + $this->writeWarning( + "Attachment file '{$id}' is missing but used in exercise '{$exercise->getId()}'." + ); + $this->missingFiles[$exercise->getId()][] = $attachmentFile; + } else { + $this->writeComment( + "Attachment file '{$id}' is missing but not used in exercise '{$exercise->getId()}'; skipping." + ); + } + continue; + } + + $this->writeDebug( + "Processing attachment file '{$id}' named '{$attachmentFile->getName()}' ..." + ); + $exerciseFile = $this->findExerciseFileDuplicate($attachmentFile, $origExerciseFiles); + if ($exerciseFile === null) { + $exerciseFile = $this->createExerciseFileFromAttachment($attachmentFile, $exercise, $exerciseFiles); + } + $attachmentExerciseFileMap[$id] = $exerciseFile; + } + + $links = $this->createExerciseLinks($attachmentFiles, $attachmentExerciseFileMap, $exercise); + + // this updates the exercise texts in-place, so the assignments in sync also see the update + $this->updateExerciseTexts($exercise, $textLinks, $links); + + // create a copy of links for all synced assignments + foreach ($this->getSyncedAssignments($exercise) as $assignment) { + /** @var Assignment $assignment */ + $this->createAssignmentsLinks($assignment, $links); + } + } + + /** + * Prints the final conversion statistics. + */ + private function printStatistics(int $totalExercises): void + { + $this->output->writeln("=== Conversion statistics ==="); + $this->output->writeln("Exercises: {$totalExercises} (skipped: {$this->skipped})"); + $this->output->writeln("Files copied: {$this->filesCopied}"); + $this->output->writeln( + "Files renamed: {$this->filesRenamed}", + $this->filesRenamed ? OutputInterface::VERBOSITY_NORMAL : OutputInterface::VERBOSITY_VERBOSE + ); + $this->output->writeln( + "Files deduplicated: {$this->filesDeduplicated}", + $this->filesDeduplicated ? OutputInterface::VERBOSITY_NORMAL : OutputInterface::VERBOSITY_VERBOSE + ); + $this->output->writeln("Links created: {$this->linksCreated}"); + + $missingFilesExercises = count($this->missingFiles); + if ($missingFilesExercises > 0) { + $this->output->writeln("Exercises missing files: {$missingFilesExercises}"); + } + + $invalidLinkExercises = count($this->invalidLinks); + if ($invalidLinkExercises > 0) { + $this->output->writeln("Exercises with invalid links: {$invalidLinkExercises}"); + } + + $notSyncedAssignments = count($this->notSyncedAssignments); + if ($notSyncedAssignments > 0) { + $this->output->writeln("Not-synced assignments: {$notSyncedAssignments}"); + } + + $this->output->writeln("============================="); + if (!$this->forReal) { + $this->output->writeln( + "NOTE: This was a simulation only; no changes were made. Use --forReal to apply changes." + ); + } + } + + /** + * Saves a CSV log file. + * @param string $filePath + * @param string[] $header (column names) + * @param array[] $rows (each row is an associative array with column-name => value) + */ + private function saveCsv(string $filePath, array $header, array $rows): void + { + $fp = fopen($filePath, 'w'); + fputcsv($fp, $header); + foreach ($rows as $row) { + $orderedRow = []; + foreach ($header as $col) { + $orderedRow[] = $row[$col] ?? ''; + } + fputcsv($fp, $orderedRow); + } + fclose($fp); + } + + /** + * Returns the name of the given exercise (preferring English localization). + * @param Exercise $exercise + * @return string + */ + private function getExerciseName(?Exercise $exercise): string + { + $localizedTexts = $exercise?->getLocalizedTexts(); + if ($localizedTexts === null || $localizedTexts->isEmpty()) { + return '???'; + } + /** @var LocalizedExercise $localizedText */ + $localizedText = $localizedTexts->first(); + foreach ($localizedTexts as $lt) { + /** @var LocalizedExercise $lt */ + if ($lt->getLocale() === 'en') { + $localizedText = $lt; + break; + } + } + return $localizedText->getName(); + } + + private function getGroupName(?Group $group): string + { + $localizedGroups = $group?->getLocalizedTexts(); + if ($localizedGroups === null || $localizedGroups->isEmpty()) { + return '???'; + } + + $localizedGroup = $localizedGroups->first(); + foreach ($localizedGroups as $lg) { + /** @var LocalizedGroup $lg */ + if ($lg->getLocale() === 'en') { + $localizedGroup = $lg; + break; + } + } + + return $localizedGroup->getName(); + } + + /** + * Exports a log of missing files to a CSV file. + * @param string $filePath + */ + private function exportMissingFilesLog(string $filePath): void + { + $this->writeVerbose("Exporting missing attachment files log to '$filePath' ..."); + $rows = []; + foreach ($this->missingFiles as $exerciseId => $files) { + $exercise = $this->exercises->findOrThrow($exerciseId); + /** @var AttachmentFile $file */ + foreach ($files as $file) { + $rows[] = [ + 'exerciseId' => $exerciseId, + 'exerciseName' => $this->getExerciseName($exercise), + 'authorId' => $exercise->getAuthor()?->getId(), + 'authorName' => $exercise->getAuthor()?->getFirstName() . ' ' + . $exercise->getAuthor()?->getLastName(), + 'authorMail' => $exercise->getAuthor()?->getEmail(), + 'attachmentFileId' => $file->getId(), + 'fileName' => $file->getName(), + ]; + } + } + $this->saveCsv( + $filePath, + ['exerciseId', 'exerciseName', 'authorId', 'authorName', 'authorMail', 'attachmentFileId', 'fileName'], + $rows + ); + } + + /** + * Exports a log of invalid links to a CSV file. + * @param string $filePath + */ + private function exportInvalidLinksLog(string $filePath): void + { + $this->writeVerbose("Exporting invalid attachment file links log to '$filePath' ..."); + $rows = []; + foreach ($this->invalidLinks as $exerciseId => $links) { + $exercise = $this->exercises->findOrThrow($exerciseId); + foreach ($links as $link => $error) { + $rows[] = [ + 'exerciseId' => $exerciseId, + 'exerciseName' => $this->getExerciseName($exercise), + 'authorId' => $exercise->getAuthor()?->getId(), + 'authorName' => $exercise->getAuthor()?->getFirstName() . ' ' + . $exercise->getAuthor()?->getLastName(), + 'authorMail' => $exercise->getAuthor()?->getEmail(), + 'link' => $link, + 'error' => $error, + ]; + } + } + $this->saveCsv( + $filePath, + ['exerciseId', 'exerciseName', 'authorId', 'authorName', 'authorMail', 'link', 'error'], + $rows + ); + } + + private function exportNotSyncedAssignmentsLog(string $filePath): void + { + $this->writeVerbose("Exporting not-synced assignments log to '$filePath' ..."); + $rows = []; + foreach ($this->notSyncedAssignments as $assignment) { + /** @var Assignment $assignment */ + $exercise = $assignment->getExercise(); + $admins = []; + foreach ($assignment->getGroup()?->getMemberships(GroupMembership::TYPE_ADMIN) ?? [] as $membership) { + /** @var GroupMembership $membership */ + $admins[] = $membership->getUser()->getFirstName() . ' ' . $membership->getUser()->getLastName() + . " <" . $membership->getUser()->getEmail() . ">"; + } + $rows[] = [ + 'assignmentId' => $assignment->getId(), + 'groupId' => $assignment->getGroup()?->getId(), + 'groupName' => $this->getGroupName($assignment->getGroup()), + 'groupAdmins' => implode('|', $admins), + 'exerciseId' => $exercise?->getId(), + 'exerciseName' => $this->getExerciseName($exercise), + 'authorId' => $exercise->getAuthor()?->getId(), + 'authorName' => $exercise->getAuthor()?->getFirstName() . ' ' + . $exercise->getAuthor()?->getLastName(), + 'authorMail' => $exercise->getAuthor()?->getEmail(), + ]; + } + $this->saveCsv( + $filePath, + [ + 'assignmentId', + 'groupId', + 'groupName', + 'groupAdmins', + 'exerciseId', + 'exerciseName', + 'authorId', + 'authorName', + 'authorMail' + ], + $rows + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->input = $input; + $this->output = $output; + + // get options + $apuBase = $this->input->getOption('apiBase'); + if ($apuBase) { + $this->apuBase = rtrim($apuBase, '/'); + } + $this->forReal = (bool)$this->input->getOption('forReal'); + + $exerciseIds = $this->input->getOption('exercise'); + if ($exerciseIds !== null && !is_array($exerciseIds)) { + $exerciseIds = [$exerciseIds]; + } + if ($exerciseIds) { + $this->writeVerbose('Processing only exercises: ' . implode(', ', $exerciseIds)); + $exercises = array_map(function ($id) { + return $this->exercises->findOrThrow($id); + }, $exerciseIds); + } else { + $exercises = $this->exercises->findAll(); + } + + // process exercises + foreach ($exercises as $exercise) { + if (!$exercise->getFileLinks()->isEmpty()) { + ++$this->skipped; + $this->writeComment( + "Skipping exercise '{$exercise->getId()}' since it already has file links (already processed)." + ); + continue; + } + + if ($exercise->getAttachmentFiles()->isEmpty()) { + // just check whether there are any invalid links in the specification + $this->writeDebug("Processing exercise '{$exercise->getId()}' (just checking links) ..."); + $this->extractAllLinksFromText($exercise); + } else { + // complete exercise processing + $this->writeDebug("Processing exercise '{$exercise->getId()}' (with attachments) ..."); + $this->processExercise($exercise); + } + } + + // print final statistics and dump logs + $this->printStatistics(count($exercises)); + + if ($this->input->getOption('missingLog')) { + $this->exportMissingFilesLog($this->input->getOption('missingLog')); + } + + if ($this->input->getOption('invalidLog')) { + $this->exportInvalidLinksLog($this->input->getOption('invalidLog')); + } + + if ($this->input->getOption('notSyncedLog')) { + $this->exportNotSyncedAssignmentsLog($this->input->getOption('notSyncedLog')); + } + + return Command::SUCCESS; + } +} diff --git a/app/config/config.neon b/app/config/config.neon index acd8e9434..22d5ee40e 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -317,7 +317,7 @@ services: - App\Console\CleanupWorkerTmpFiles - App\Console\SendAssignmentDeadlineNotification(%assignmentNotifications.deadlineThresholdFrom%, %assignmentNotifications.deadlineThresholdTo%) - App\Console\SendPendingReviewsNotification(%reviewsNotifications.pendingThreshold%) - - App\Console\AsyncJobsUpkeep(%async.upkeep%) + - App\Console\ConvertExerciseAttachments(%api.address%) - App\Console\GeneralStatsNotification - App\Console\ExportDatabase - App\Console\MetaConverter diff --git a/app/helpers/FileStorage/File/LocalImmutableFile.php b/app/helpers/FileStorage/File/LocalImmutableFile.php index 9ef3fb389..b2e9438d0 100644 --- a/app/helpers/FileStorage/File/LocalImmutableFile.php +++ b/app/helpers/FileStorage/File/LocalImmutableFile.php @@ -179,4 +179,14 @@ public function passthru(): void @readfile($this->realPath); } + + /** + * Returns the actual real path on local file system. + * This method is not part of IImmutableFile interface, it is for specific local operations only. + * @return string + */ + public function getRealPath(): string + { + return $this->realPath; + } } diff --git a/app/helpers/FileStorage/IHashFileStorage.php b/app/helpers/FileStorage/IHashFileStorage.php index 335c1f34f..4c6a68c75 100644 --- a/app/helpers/FileStorage/IHashFileStorage.php +++ b/app/helpers/FileStorage/IHashFileStorage.php @@ -24,6 +24,13 @@ public function fetch(string $hash): ?IImmutableFile; */ public function fetchOrThrow(string $hash): IImmutableFile; + /** + * Returns the hash of the given file from local file system. + * @param string $path Valid local path to an existing file. + * @return string Hash of the file. + */ + public function getFileHash(string $path): string; + /** * Stores a regular file from local file system into the storage. * @param string $path Valid local path to an existing file. diff --git a/app/helpers/FileStorage/LocalStorage/LocalHashFileStorage.php b/app/helpers/FileStorage/LocalStorage/LocalHashFileStorage.php index c54b9e3ec..eb631d082 100644 --- a/app/helpers/FileStorage/LocalStorage/LocalHashFileStorage.php +++ b/app/helpers/FileStorage/LocalStorage/LocalHashFileStorage.php @@ -81,7 +81,7 @@ public function fetchOrThrow(string $hash): IImmutableFile return $file; } - public function storeFile(string $path, bool $move = true): string + public function getFileHash(string $path): string { if (!file_exists($path) || !is_file($path)) { throw new FileStorageException("Given local file not found.", $path); @@ -91,7 +91,12 @@ public function storeFile(string $path, bool $move = true): string throw new FileStorageException("Given file is not accessible for reading.", $path); } - $hash = sha1_file($path); + return sha1_file($path); + } + + public function storeFile(string $path, bool $move = true): string + { + $hash = $this->getFileHash($path); $newPath = $this->getRealPath($hash); $dirPath = dirname($newPath); if (!@mkdir($dirPath, 0775, true) && !is_dir($dirPath)) { // true = recursive @@ -106,7 +111,6 @@ public function storeFile(string $path, bool $move = true): string } } - // @phpstan-ignore booleanAnd.rightAlwaysTrue if ($move && file_exists($path)) { @unlink($path); // the file was copied or already exists, lets simulate move } diff --git a/app/helpers/FileStorageManager.php b/app/helpers/FileStorageManager.php index d0f4e597f..54fb80ec7 100644 --- a/app/helpers/FileStorageManager.php +++ b/app/helpers/FileStorageManager.php @@ -6,6 +6,7 @@ use App\Helpers\FileStorage\IHashFileStorage; use App\Helpers\FileStorage\IImmutableFile; use App\Helpers\FileStorage\FileStorageException; +use App\Helpers\FileStorage\LocalImmutableFile; use App\Model\Entity\Submission; use App\Model\Entity\Solution; use App\Model\Entity\AssignmentSolutionSubmission; @@ -359,6 +360,35 @@ private function getAttachmentFilePath(AttachmentFile $file): string return "$dir/$userDir/{$id}_{$name}"; } + /** + * Helper function that computes what hash would an attachment file have in the hash storage. + * The file must exist or an exception is thrown. + * @param AttachmentFile $file + * @return string hash of the file + */ + public function getAttachmentFileHash(AttachmentFile $file): string + { + $imFile = $this->getAttachmentFile($file); + if (!$imFile || !($imFile instanceof LocalImmutableFile)) { + throw new FileStorageException("Attachment file not found in storage.", $this->getAttachmentFilePath($file)); + } + return $this->hashStorage->getFileHash($imFile->getRealPath()); + } + + /** + * Copy attachment file to persistent hash storage for exercise files. + * @param AttachmentFile $file + * @return string hash identifying the file in the hash storage + */ + public function copyAttachmentFileToHashStorage(AttachmentFile $file): string + { + $imFile = $this->getAttachmentFile($file); + if (!$imFile || !($imFile instanceof LocalImmutableFile)) { + throw new FileStorageException("Attachment file not found in storage.", $this->getAttachmentFilePath($file)); + } + return $this->hashStorage->storeFile($imFile->getRealPath(), false); + } + /** * Move uploaded file to persistent hash storage for attachment files. * @param UploadedFile $uploadedFile previously uploaded file diff --git a/app/model/entity/Exercise.php b/app/model/entity/Exercise.php index 8baded50c..0540010f6 100644 --- a/app/model/entity/Exercise.php +++ b/app/model/entity/Exercise.php @@ -480,6 +480,9 @@ function (Group $group) { ); } + /** + * @return Collection + */ public function getAssignments(): Collection { return $this->assignments->filter( diff --git a/app/model/entity/ExerciseFileLink.php b/app/model/entity/ExerciseFileLink.php index 974db1782..a1d5573e2 100644 --- a/app/model/entity/ExerciseFileLink.php +++ b/app/model/entity/ExerciseFileLink.php @@ -30,7 +30,7 @@ class ExerciseFileLink implements JsonSerializable /** * The key (fixed ID) used to identify the file in exercise specification (for simple replacement). - * @ORM\Column(type="string", length=16) + * @ORM\Column(name="`key`", type="string", length=16) */ protected $key; diff --git a/app/model/entity/Group.php b/app/model/entity/Group.php index 7e4bd537a..1400700b7 100644 --- a/app/model/entity/Group.php +++ b/app/model/entity/Group.php @@ -397,9 +397,9 @@ function (GroupMembership $membership) use ($user) { * @param string[] $types allowed membership types (empty array = all) * @param bool|null $inherited flag indicating how to filter inherited memberships * (null = no filter, true = only inherited, false = only direct) - * @return Collection + * @return Collection */ - private function getMembershipsInternal(array $types, ?bool $inherited = null) + private function getMembershipsInternal(array $types, ?bool $inherited = null): Collection { $memberships = $this->memberships->filter( function (GroupMembership $membership) { @@ -432,7 +432,7 @@ function (GroupMembership $membership) { /** * Return all direct members depending on specified type * @param string[] ...$types - * @return Collection + * @return Collection */ public function getMemberships(...$types) { @@ -442,7 +442,7 @@ public function getMemberships(...$types) /** * Return all inherited members depending on specified type * @param string[] ...$types - * @return Collection + * @return Collection */ public function getInheritedMemberships(...$types) { @@ -812,6 +812,9 @@ public function addLocalizedText(LocalizedGroup $group) $group->setGroup($this); } + /** + * @return Collection + */ public function getLocalizedTexts(): Collection { return $this->localizedTexts; @@ -819,7 +822,7 @@ public function getLocalizedTexts(): Collection /** * Return all localized texts as an array indexed by locales. - * @return array + * @return LocalizedGroup[] */ public function getLocalizedTextsAssocArray(): array { @@ -885,6 +888,9 @@ function (Group $group) { ); } + /** + * @return Collection + */ public function getChildGroups(): Collection { return $this->childGroups->filter( diff --git a/app/model/entity/LocalizedExercise.php b/app/model/entity/LocalizedExercise.php index 2b910d8e5..5719b9cd1 100644 --- a/app/model/entity/LocalizedExercise.php +++ b/app/model/entity/LocalizedExercise.php @@ -113,8 +113,28 @@ public function getDescription(): string return $this->description; } + /** + * This setter is dangerous since the entity may be shared by multiple exercises/assignments. + * You better know what you are doing when using it!!! + * @param string $description + */ + public function setDescriptionDangerous(string $description): void + { + $this->description = $description; + } + public function getAssignmentText(): string { return $this->assignmentText; } + + /** + * This setter is dangerous since the entity may be shared by multiple exercises/assignments. + * You better know what you are doing when using it!!! + * @param string $assignmentText + */ + public function setAssignmentTextDangerous(string $assignmentText): void + { + $this->assignmentText = $assignmentText; + } } diff --git a/app/model/entity/base/ExerciseData.php b/app/model/entity/base/ExerciseData.php index 717e25311..cfce4433a 100644 --- a/app/model/entity/base/ExerciseData.php +++ b/app/model/entity/base/ExerciseData.php @@ -22,7 +22,7 @@ public function getConfigurationType(): string /** * @ORM\ManyToMany(targetEntity="LocalizedExercise", indexBy="locale") - * @var Collection|Selectable + * @var Collection|Selectable */ protected $localizedTexts; @@ -328,7 +328,7 @@ public function getHashedExerciseFiles(): array /** * @ORM\ManyToMany(targetEntity="AttachmentFile") - * @var Collection + * @var Collection */ protected $attachmentFiles; @@ -346,16 +346,16 @@ public function addAttachmentFile(AttachmentFile $exerciseFile) * @param AttachmentFile $file * @return bool */ - public function removeAttachmentFile(AttachmentFile $file) + public function removeAttachmentFile(AttachmentFile $file): bool { return $this->attachmentFiles->removeElement($file); } /** * Get identifications of additional exercise files. - * @return array + * @return string[] */ - public function getAttachmentFilesIds() + public function getAttachmentFilesIds(): array { return $this->attachmentFiles->map( function (AttachmentFile $file) { From de61d313a7940916a95379ae902f9f05c8af0541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 27 Nov 2025 17:26:56 +0100 Subject: [PATCH 16/25] Adding cookie-based authentication (as configurable option) so the files can be properly downloaded via links even when authentication is required. --- app/V1Module/security/AccessManager.php | 35 ++++++++++++++++--------- app/config/config.local.neon.example | 13 ++++----- app/config/config.neon | 11 ++++---- app/model/view/InstanceViewFactory.php | 6 +++-- tests/AccessToken/AccessManager.phpt | 25 +++++++++++++----- 5 files changed, 58 insertions(+), 32 deletions(-) diff --git a/app/V1Module/security/AccessManager.php b/app/V1Module/security/AccessManager.php index 7fcf77ed6..70b5d44af 100644 --- a/app/V1Module/security/AccessManager.php +++ b/app/V1Module/security/AccessManager.php @@ -39,6 +39,9 @@ class AccessManager /** @var int Expiration time of newly issued invitation tokens (in seconds) */ private $invitationExpiration; + /** @var string|null Name of the cookie where to look for the token */ + private $tokenCookieName; + public function __construct(array $parameters, Users $users) { $this->users = $users; @@ -48,6 +51,7 @@ public function __construct(array $parameters, Users $users) $this->issuer = Arrays::get($parameters, "issuer", "https://recodex.mff.cuni.cz"); $this->audience = Arrays::get($parameters, "audience", "https://recodex.mff.cuni.cz"); $this->usedAlgorithm = Arrays::get($parameters, "usedAlgorithm", "HS256"); + $this->tokenCookieName = Arrays::get($parameters, "tokenCookieName", null); JWT::$leeway = Arrays::get($parameters, "leeway", 10); // 10 seconds } @@ -140,9 +144,9 @@ public function getUser(AccessToken $token): User */ public function issueToken( User $user, - string $effectiveRole = null, + ?string $effectiveRole = null, array $scopes = [], - int $exp = null, + ?int $exp = null, array $payload = [] ) { if (!$user->isAllowed()) { @@ -208,7 +212,7 @@ public function issueInvitationToken( string $titlesBefore = "", string $titlesAfter = "", array $groupsIds = [], - int $invitationExpiration = null, + ?int $invitationExpiration = null, ): string { $token = InvitationToken::create( $invitationExpiration ?? $this->invitationExpiration, @@ -227,25 +231,30 @@ public function issueInvitationToken( * Extract the access token from the request. * @return string|null The access token parsed from the HTTP request, or null if there is no access token. */ - public static function getGivenAccessToken(IRequest $request) + public function getGivenAccessToken(IRequest $request) { $accessToken = $request->getQuery("access_token"); if ($accessToken !== null && Strings::length($accessToken) > 0) { - return $accessToken; // the token specified in the URL is prefered + return $accessToken; // the token specified in the URL is preferred } // if the token is not in the URL, try to find the "Authorization" header with the bearer token $authorizationHeader = $request->getHeader("Authorization"); - - if ($authorizationHeader === null) { - return null; + if ($authorizationHeader !== null) { + $parts = Strings::split($authorizationHeader, "/ /"); + if (count($parts) === 2) { + list($bearer, $accessToken) = $parts; + if ($bearer === "Bearer" && !str_contains($accessToken, " ") && Strings::length($accessToken) > 0) { + return $accessToken; + } + } } - $parts = Strings::split($authorizationHeader, "/ /"); - if (count($parts) === 2) { - list($bearer, $accessToken) = $parts; - if ($bearer === "Bearer" && !str_contains($accessToken, " ") && Strings::length($accessToken) > 0) { - return $accessToken; + // finally, try fallback to cookie if configured + if ($this->tokenCookieName !== null) { + $cookieToken = $request->getCookie($this->tokenCookieName); + if ($cookieToken !== null && Strings::length($cookieToken) > 0) { + return $cookieToken; // token found in the cookie } } diff --git a/app/config/config.local.neon.example b/app/config/config.local.neon.example index 68bdd41ca..3ec828ae4 100644 --- a/app/config/config.local.neon.example +++ b/app/config/config.local.neon.example @@ -6,8 +6,8 @@ parameters: address: "https://your.recodex.domain" async: - pollingInterval: 10 # seconds (you may set this to larger values if inotify wakeups are allowed) - # inotify can wake the async worker (immediately once an async opertion is issued) + pollingInterval: 10 # seconds (you may set this to larger values if inotify wake-ups are allowed) + # inotify can wake the async worker (immediately once an async operation is issued) inotify: false # set to true only if your system (and PHP) supports inotify (not available on Windows, extension required on Linux) fileStorage: # where the local files are being stored @@ -17,7 +17,7 @@ parameters: root: %appDir%/../storage/hash # this should be replaced with path to existing directory submissions: - locked: false # if set to true, the API will not be accepting submissions (and it will be incidated in can-submit/permission hints) + locked: false # if set to true, the API will not be accepting submissions (and it will be indicated in can-submit/permission hints) lockedReason: # Localized message with reason displayed in UI, why the submissions are locked (ignored if locked == false) cs: "Odevzdávání řešení bylo zablokováno v konfiguraci aplikace." en: "Submitting new solutions is currently locked out in the application configuration." @@ -28,12 +28,13 @@ parameters: expiration: 604800 # 7 days in seconds invitationExpiration: 604800 # of an invitation token (7 days in seconds) verificationKey: "recodex-123" # this should be a really secret string + tokenCookieName: 'recodex_accessToken' # web-app config value 'PERSISTENT_TOKENS_KEY_PREFIX' + '_accessToken', null if only Authorization header is used broker: address: "tcp://127.0.0.1:9658" auth: username: "user" # these credentials must match credentials - password: "pass" # in broker configration file + password: "pass" # in broker configuration file monitor: address: "wss://your.recodex.domain:443/ws" @@ -59,7 +60,7 @@ parameters: footerUrl: "%webapp.address%" from: "ReCodEx " defaultAdminTo: "Administrator " - #debugMode: true # in debug mode, no messages are sent via SMPT (you should also active archiving) + #debugMode: true # in debug mode, no messages are sent via SMTP (you should also active archiving) #archivingDir: "%appDir%/../log/email-debug" # a directory where copies of all emails sent are stored (in text files) exercises: @@ -72,7 +73,7 @@ parameters: solutionSizeLimitDefault: 262144 # 256 KiB, max. size for all submitted files (default, configurable per assignment) removeInactiveUsers: - # How long the user has to be inactive to warant the removal (null = never remove students, 1 month is minimum). + # How long the user has to be inactive to warrant the removal (null = never remove students, 1 month is minimum). # Please note that the length of the auth. token expiration should be considered (readonly tokens may expire after 1 year). threshold: "2 years" diff --git a/app/config/config.neon b/app/config/config.neon index 22d5ee40e..7470ef04b 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -23,9 +23,9 @@ parameters: versionFormat: "{tag}" async: - pollingInterval: 60 # seconds (you may set this to larger values if inotify wakeups are allowed) + pollingInterval: 60 # seconds (you may set this to larger values if inotify wake-ups are allowed) retries: 3 # how many times each async job is retried when failing - # inotify can wake the async worker (immediately once an async opertion is issued) + # inotify can wake the async worker (immediately once an async operation is issued) inotify: false # set to true only if your system (and PHP) supports inotify (not available on Windows, extension required on Linux) inotifyFile: %tempDir%/async-inotify-file # file used as inotify rod for triggering async worker on dispatch restartWorkerAfter: # memory leak precaution - worker is restarted once in a while @@ -44,7 +44,7 @@ parameters: address: "https://your.recodex.domain" submissions: - locked: false # if set to true, the API will not be accepting submissions (and it will be incidated in can-submit/permission hints) + locked: false # if set to true, the API will not be accepting submissions (and it will be indicated in can-submit/permission hints) lockedReason: # Localized message with reason displayed in UI, why the submissions are locked (ignored if locked == false) cs: "Odevzdávání řešení bylo zablokováno v konfiguraci aplikace." en: "Submitting new solutions is currently locked out in the application configuration." @@ -57,6 +57,7 @@ parameters: invitationExpiration: 86400 # of an invitation token (seconds) usedAlgorithm: HS256 verificationKey: "recodex-123" + tokenCookieName: 'recodex_accessToken' # web-app config value 'PERSISTENT_TOKENS_KEY_PREFIX' + '_accessToken', null if only Authorization header is used broker: # connection to broker address: "tcp://127.0.0.1:9658" @@ -198,7 +199,7 @@ parameters: deletedEmailSuffix: "@deleted.recodex" # Suffix string appended to an email address of a user, when account is deleted removeInactiveUsers: - # How long the user has to be inactive to warant the removal (null = never remove students, 1 month is minimum). + # How long the user has to be inactive to warrant the removal (null = never remove students, 1 month is minimum). # Please note that the length of the auth. token expiration should be considered (readonly tokens may expire after 1 year). disableAfter: "2 years" deleteAfter: null # null = never, if not null, better be > disableAfter @@ -225,7 +226,7 @@ mail: # configuration of sending mails password: "" # password to the server secure: "tls" # security, values are empty for no security, "ssl" or "tls" context: # additional parameters, depending on used mail engine - ssl: # examle self-signed certificates can be allowed as verify_peer and verify_peer_name to false and allow_self_signed to true under ssl key (see example) + ssl: # example self-signed certificates can be allowed as verify_peer and verify_peer_name to false and allow_self_signed to true under ssl key (see example) verify_peer: false verify_peer_name: false allow_self_signed: true diff --git a/app/model/view/InstanceViewFactory.php b/app/model/view/InstanceViewFactory.php index e17a5859a..488b71141 100644 --- a/app/model/view/InstanceViewFactory.php +++ b/app/model/view/InstanceViewFactory.php @@ -44,8 +44,6 @@ public function getInstance(Instance $instance, ?User $loggedUser = null): array return [ "id" => $instance->getId(), - "name" => $localizedRootGroup ? $localizedRootGroup->getName() : "", // BC - "description" => $localizedRootGroup ? $localizedRootGroup->getDescription() : "", // BC "hasValidLicence" => $instance->hasValidLicense(), "isOpen" => $instance->isOpen(), "isAllowed" => $instance->isAllowed(), @@ -56,6 +54,10 @@ public function getInstance(Instance $instance, ?User $loggedUser = null): array "rootGroup" => $this->groupViewFactory->getGroup($instance->getRootGroup()), "rootGroupId" => $instance->getRootGroup()->getId(), "extensions" => $extensions, + + // deprecated + "name" => $localizedRootGroup ? $localizedRootGroup->getName() : "", // BC + "description" => $localizedRootGroup ? $localizedRootGroup->getDescription() : "", // BC ]; } diff --git a/tests/AccessToken/AccessManager.phpt b/tests/AccessToken/AccessManager.phpt index 0b85fb737..4adc144e6 100644 --- a/tests/AccessToken/AccessManager.phpt +++ b/tests/AccessToken/AccessManager.phpt @@ -227,7 +227,10 @@ class TestAccessManager extends Tester\TestCase $token = "abcdefg"; $url = new UrlScript("https://www.whatever.com/bla/bla/bla?x=y&access_token=$token"); $request = new Request($url); - Assert::equal($token, AccessManager::getGivenAccessToken($request)); + + $users = Mockery::mock(App\Model\Repository\Users::class); + $manager = new AccessManager(["verificationKey" => "abc"], $users); + Assert::equal($token, $manager->getGivenAccessToken($request)); } public function testExtractFromEmptyQuery() @@ -235,7 +238,9 @@ class TestAccessManager extends Tester\TestCase $token = ""; $url = new UrlScript("https://www.whatever.com/bla/bla/bla?x=y&access_token=$token"); $request = new Request($url); - Assert::null(AccessManager::getGivenAccessToken($request)); + $users = Mockery::mock(App\Model\Repository\Users::class); + $manager = new AccessManager(["verificationKey" => "abc"], $users); + Assert::null($manager->getGivenAccessToken($request)); } public function testExtractFromHeader() @@ -243,7 +248,9 @@ class TestAccessManager extends Tester\TestCase $token = "abcdefg"; $url = new UrlScript("https://www.whatever.com/bla/bla/bla?x=y"); $request = new Request($url, [], [], [], ["Authorization" => "Bearer $token"]); - Assert::equal($token, AccessManager::getGivenAccessToken($request)); + $users = Mockery::mock(App\Model\Repository\Users::class); + $manager = new AccessManager(["verificationKey" => "abc"], $users); + Assert::equal($token, $manager->getGivenAccessToken($request)); } public function testExtractFromHeaderWrongType() @@ -251,7 +258,9 @@ class TestAccessManager extends Tester\TestCase $token = "abcdefg"; $url = new UrlScript("https://www.whatever.com/bla/bla/bla?x=y"); $request = new Request($url, [], [], [], ["Authorization" => "Basic $token"]); - Assert::null(AccessManager::getGivenAccessToken($request)); + $users = Mockery::mock(App\Model\Repository\Users::class); + $manager = new AccessManager(["verificationKey" => "abc"], $users); + Assert::null($manager->getGivenAccessToken($request)); } public function testExtractFromHeaderEmpty() @@ -259,7 +268,9 @@ class TestAccessManager extends Tester\TestCase $token = ""; $url = new UrlScript("https://www.whatever.com/bla/bla/bla?x=y"); $request = new Request($url, [], [], [], ["Authorization" => "Basic $token"]); - Assert::null(AccessManager::getGivenAccessToken($request)); + $users = Mockery::mock(App\Model\Repository\Users::class); + $manager = new AccessManager(["verificationKey" => "abc"], $users); + Assert::null($manager->getGivenAccessToken($request)); } public function testExtractFromHeaderWithSpace() @@ -267,7 +278,9 @@ class TestAccessManager extends Tester\TestCase $token = ""; $url = new UrlScript("https://www.whatever.com/bla/bla/bla?x=y"); $request = new Request($url, [], [], [], ["Authorization" => "Bearer $token and more!"]); - Assert::null(AccessManager::getGivenAccessToken($request)); + $users = Mockery::mock(App\Model\Repository\Users::class); + $manager = new AccessManager(["verificationKey" => "abc"], $users); + Assert::null($manager->getGivenAccessToken($request)); } } From 4491a69de1a77c2fb40cd2a3c5c7f58c90097b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 28 Nov 2025 17:08:36 +0100 Subject: [PATCH 17/25] Fixing annotations for UUID values in endpoints. --- .../AssignmentSolutionReviewsPresenter.php | 4 +-- .../presenters/AssignmentsPresenter.php | 8 +++--- app/V1Module/presenters/CommentsPresenter.php | 12 ++++---- app/V1Module/presenters/EmailsPresenter.php | 3 +- .../presenters/ExerciseFilesPresenter.php | 2 +- .../presenters/ExercisesConfigPresenter.php | 12 ++++---- .../presenters/ExercisesPresenter.php | 16 +++++------ .../presenters/ExtensionsPresenter.php | 3 +- .../presenters/GroupInvitationsPresenter.php | 4 +-- app/V1Module/presenters/GroupsPresenter.php | 18 ++++++------ .../presenters/InstancesPresenter.php | 4 +-- app/V1Module/presenters/LoginPresenter.php | 3 +- .../presenters/PipelinesPresenter.php | 2 +- .../presenters/PlagiarismPresenter.php | 4 +-- .../ReferenceExerciseSolutionsPresenter.php | 28 +++++++++---------- .../presenters/RegistrationPresenter.php | 3 +- .../presenters/ShadowAssignmentsPresenter.php | 8 +++--- app/V1Module/presenters/SubmitPresenter.php | 10 +++---- 18 files changed, 74 insertions(+), 70 deletions(-) diff --git a/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php b/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php index 9e55c6389..292313a54 100644 --- a/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php +++ b/app/V1Module/presenters/AssignmentSolutionReviewsPresenter.php @@ -347,7 +347,7 @@ public function checkEditComment(string $id, string $commentId) required: false, )] #[Path("id", new VUuid(), "identifier of the solution", required: true)] - #[Path("commentId", new VString(), "identifier of the review comment", required: true)] + #[Path("commentId", new VUuid(), "identifier of the review comment", required: true)] public function actionEditComment(string $id, string $commentId) { $solution = $this->assignmentSolutions->findOrThrow($id); @@ -407,7 +407,7 @@ public function checkDeleteComment(string $id, string $commentId) * @DELETE */ #[Path("id", new VUuid(), "identifier of the solution", required: true)] - #[Path("commentId", new VString(), "identifier of the review comment", required: true)] + #[Path("commentId", new VUuid(), "identifier of the review comment", required: true)] public function actionDeleteComment(string $id, string $commentId) { $comment = $this->reviewComments->findOrThrow($commentId); diff --git a/app/V1Module/presenters/AssignmentsPresenter.php b/app/V1Module/presenters/AssignmentsPresenter.php index 20aef9420..b5998cd62 100644 --- a/app/V1Module/presenters/AssignmentsPresenter.php +++ b/app/V1Module/presenters/AssignmentsPresenter.php @@ -543,8 +543,8 @@ public function actionValidate($id) * @throws InvalidStateException * @throws NotFoundException */ - #[Post("exerciseId", new VMixed(), "Identifier of the exercise", nullable: true)] - #[Post("groupId", new VMixed(), "Identifier of the group", nullable: true)] + #[Post("exerciseId", new VUuid(), "Identifier of the exercise", nullable: true)] + #[Post("groupId", new VUuid(), "Identifier of the group", nullable: true)] public function actionCreate() { $req = $this->getRequest(); @@ -713,7 +713,7 @@ public function checkUserSolutions(string $id, string $userId) * @GET */ #[Path("id", new VUuid(), "Identifier of the assignment", required: true)] - #[Path("userId", new VString(), "Identifier of the user", required: true)] + #[Path("userId", new VUuid(), "Identifier of the user", required: true)] public function actionUserSolutions(string $id, string $userId) { $assignment = $this->assignments->findOrThrow($id); @@ -757,7 +757,7 @@ public function checkBestSolution(string $id, string $userId) * @throws ForbiddenRequestException */ #[Path("id", new VUuid(), "Identifier of the assignment", required: true)] - #[Path("userId", new VString(), "Identifier of the user", required: true)] + #[Path("userId", new VUuid(), "Identifier of the user", required: true)] public function actionBestSolution(string $id, string $userId) { $assignment = $this->assignments->findOrThrow($id); diff --git a/app/V1Module/presenters/CommentsPresenter.php b/app/V1Module/presenters/CommentsPresenter.php index 96cde3f0d..2fca6a0be 100644 --- a/app/V1Module/presenters/CommentsPresenter.php +++ b/app/V1Module/presenters/CommentsPresenter.php @@ -184,8 +184,8 @@ public function checkTogglePrivate(string $threadId, string $commentId) * @POST * @throws NotFoundException */ - #[Path("threadId", new VString(), "Identifier of the comment thread", required: true)] - #[Path("commentId", new VString(), "Identifier of the comment", required: true)] + #[Path("threadId", new VUuid(), "Identifier of the comment thread", required: true)] + #[Path("commentId", new VUuid(), "Identifier of the comment", required: true)] public function actionTogglePrivate(string $threadId, string $commentId) { /** @var Comment $comment */ @@ -218,8 +218,8 @@ public function checkSetPrivate(string $threadId, string $commentId) * @throws NotFoundException */ #[Post("isPrivate", new VBool(), "True if the comment is private")] - #[Path("threadId", new VString(), "Identifier of the comment thread", required: true)] - #[Path("commentId", new VString(), "Identifier of the comment", required: true)] + #[Path("threadId", new VUuid(), "Identifier of the comment thread", required: true)] + #[Path("commentId", new VUuid(), "Identifier of the comment", required: true)] public function actionSetPrivate(string $threadId, string $commentId) { /** @var Comment $comment */ @@ -255,8 +255,8 @@ public function checkDelete(string $threadId, string $commentId) * @throws ForbiddenRequestException * @throws NotFoundException */ - #[Path("threadId", new VString(), "Identifier of the comment thread", required: true)] - #[Path("commentId", new VString(), "Identifier of the comment", required: true)] + #[Path("threadId", new VUuid(), "Identifier of the comment thread", required: true)] + #[Path("commentId", new VUuid(), "Identifier of the comment", required: true)] public function actionDelete(string $threadId, string $commentId) { /** @var Comment $comment */ diff --git a/app/V1Module/presenters/EmailsPresenter.php b/app/V1Module/presenters/EmailsPresenter.php index 78c93995f..6f98b188f 100644 --- a/app/V1Module/presenters/EmailsPresenter.php +++ b/app/V1Module/presenters/EmailsPresenter.php @@ -10,6 +10,7 @@ use App\Exceptions\NotFoundException; use App\Helpers\EmailHelper; use App\Helpers\Emails\EmailLocalizationHelper; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Model\Entity\User; use App\Model\Repository\Groups; use App\Security\ACL\IEmailPermissions; @@ -166,7 +167,7 @@ public function checkSendToGroupMembers(string $groupId) #[Post("toMe", new VBool(), "User wants to also receive an email")] #[Post("subject", new VString(1), "Subject for the soon to be sent email")] #[Post("message", new VString(1), "Message which will be sent, can be html code")] - #[Path("groupId", new VString(), required: true)] + #[Path("groupId", new VUuid(), required: true)] public function actionSendToGroupMembers(string $groupId) { $user = $this->getCurrentUser(); diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index 4b2c2eec1..640067051 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -236,7 +236,7 @@ public function checkDeleteExerciseFile(string $id, string $fileId) * @throws ForbiddenRequestException */ #[Path("id", new VUuid(), "identification of exercise", required: true)] - #[Path("fileId", new VString(), "identification of file", required: true)] + #[Path("fileId", new VUuid(), "identification of file", required: true)] public function actionDeleteExerciseFile(string $id, string $fileId) { $exercise = $this->exercises->findOrThrow($id); diff --git a/app/V1Module/presenters/ExercisesConfigPresenter.php b/app/V1Module/presenters/ExercisesConfigPresenter.php index 0012418da..6e5aee270 100644 --- a/app/V1Module/presenters/ExercisesConfigPresenter.php +++ b/app/V1Module/presenters/ExercisesConfigPresenter.php @@ -416,8 +416,8 @@ public function checkGetHardwareGroupLimits(string $id, string $runtimeEnvironme * @throws ExerciseConfigException */ #[Path("id", new VUuid(), "Identifier of the exercise", required: true)] - #[Path("runtimeEnvironmentId", new VString(), required: true)] - #[Path("hwGroupId", new VString(), required: true)] + #[Path("runtimeEnvironmentId", new VString(1), required: true)] + #[Path("hwGroupId", new VString(1), required: true)] public function actionGetHardwareGroupLimits(string $id, string $runtimeEnvironmentId, string $hwGroupId) { /** @var Exercise $exercise */ @@ -465,8 +465,8 @@ public function checkSetHardwareGroupLimits(string $id, string $runtimeEnvironme */ #[Post("limits", new VArray(), "A list of resource limits for the given environment and hardware group")] #[Path("id", new VUuid(), "Identifier of the exercise", required: true)] - #[Path("runtimeEnvironmentId", new VString(), required: true)] - #[Path("hwGroupId", new VString(), required: true)] + #[Path("runtimeEnvironmentId", new VString(1), required: true)] + #[Path("hwGroupId", new VString(1), required: true)] public function actionSetHardwareGroupLimits(string $id, string $runtimeEnvironmentId, string $hwGroupId) { /** @var Exercise $exercise */ @@ -529,8 +529,8 @@ public function checkRemoveHardwareGroupLimits(string $id, string $runtimeEnviro * @throws NotFoundException */ #[Path("id", new VUuid(), "Identifier of the exercise", required: true)] - #[Path("runtimeEnvironmentId", new VString(), required: true)] - #[Path("hwGroupId", new VString(), required: true)] + #[Path("runtimeEnvironmentId", new VString(1), required: true)] + #[Path("hwGroupId", new VString(1), required: true)] public function actionRemoveHardwareGroupLimits(string $id, string $runtimeEnvironmentId, string $hwGroupId) { /** @var Exercise $exercise */ diff --git a/app/V1Module/presenters/ExercisesPresenter.php b/app/V1Module/presenters/ExercisesPresenter.php index 585629279..a5c90a55c 100644 --- a/app/V1Module/presenters/ExercisesPresenter.php +++ b/app/V1Module/presenters/ExercisesPresenter.php @@ -258,10 +258,10 @@ public function checkAuthors() * List authors of all exercises, possibly filtered by a group in which the exercises appear. * @GET */ - #[Query("instanceId", new VString(), "Id of an instance from which the authors are listed.", required: false)] + #[Query("instanceId", new VUuid(), "Id of an instance from which the authors are listed.", required: false)] #[Query( "groupId", - new VString(), + new VUuid(), "A group where the relevant exercises can be seen (assigned).", required: false, nullable: true, @@ -283,7 +283,7 @@ public function checkListByIds() * Get a list of exercises based on given ids. * @POST */ - #[Post("ids", new VArray(), "Identifications of exercises")] + #[Post("ids", new VArray(new VUuid()), "Identifications of exercises")] public function actionListByIds() { $exercises = $this->exercises->findByIds($this->getRequest()->getPost("ids")); @@ -537,7 +537,7 @@ function (Assignment $assignment) use ($archived) { * @throws ApiException * @throws ParseException */ - #[Post("groupId", new VMixed(), "Identifier of the group to which exercise belongs to", nullable: true)] + #[Post("groupId", new VUuid(), "Identifier of the group to which exercise belongs to", nullable: true)] public function actionCreate() { $user = $this->getCurrentUser(); @@ -652,7 +652,7 @@ public function actionRemove(string $id) * @throws NotFoundException * @throws ParseException */ - #[Post("groupId", new VMixed(), "Identifier of the group to which exercise will be forked", nullable: true)] + #[Post("groupId", new VUuid(), "Identifier of the group to which exercise will be forked", nullable: true)] #[Path("id", new VUuid(), "Identifier of the exercise", required: true)] public function actionForkFrom(string $id) { @@ -695,7 +695,7 @@ public function checkAttachGroup(string $id, string $groupId) * @throws InvalidApiArgumentException */ #[Path("id", new VUuid(), "Identifier of the exercise", required: true)] - #[Path("groupId", new VString(), "Identifier of the group to which exercise should be attached", required: true)] + #[Path("groupId", new VUuid(), "Identifier of the group to which exercise should be attached", required: true)] public function actionAttachGroup(string $id, string $groupId) { $exercise = $this->exercises->findOrThrow($id); @@ -729,7 +729,7 @@ public function checkDetachGroup(string $id, string $groupId) * @throws InvalidApiArgumentException */ #[Path("id", new VUuid(), "Identifier of the exercise", required: true)] - #[Path("groupId", new VString(), "Identifier of the group which should be detached from exercise", required: true)] + #[Path("groupId", new VUuid(), "Identifier of the group which should be detached from exercise", required: true)] public function actionDetachGroup(string $id, string $groupId) { $exercise = $this->exercises->findOrThrow($id); @@ -999,7 +999,7 @@ public function checkSetAdmins(string $id) * @POST * @throws NotFoundException */ - #[Post("admins", new VArray(), "List of user IDs.", required: true)] + #[Post("admins", new VArray(new VUuid()), "List of user IDs.", required: true)] #[Path("id", new VUuid(), "identifier of the exercise", required: true)] public function actionSetAdmins(string $id) { diff --git a/app/V1Module/presenters/ExtensionsPresenter.php b/app/V1Module/presenters/ExtensionsPresenter.php index 90eaf9254..eebaca6a3 100644 --- a/app/V1Module/presenters/ExtensionsPresenter.php +++ b/app/V1Module/presenters/ExtensionsPresenter.php @@ -12,6 +12,7 @@ use App\Model\Repository\Users; use App\Model\View\UserViewFactory; use App\Helpers\Extensions; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Security\AccessManager; use App\Security\TokenScope; @@ -67,7 +68,7 @@ public function checkUrl(string $extId, string $instanceId) #[Query("locale", new VString(2, 2), required: false)] #[Query("return", new VString(), required: false)] #[Path("extId", new VString(), required: true)] - #[Path("instanceId", new VString(), required: true)] + #[Path("instanceId", new VUuid(), required: true)] public function actionUrl(string $extId, string $instanceId, ?string $locale, ?string $return) { $user = $this->getCurrentUser(); diff --git a/app/V1Module/presenters/GroupInvitationsPresenter.php b/app/V1Module/presenters/GroupInvitationsPresenter.php index 37d986cfb..3980311ca 100644 --- a/app/V1Module/presenters/GroupInvitationsPresenter.php +++ b/app/V1Module/presenters/GroupInvitationsPresenter.php @@ -155,7 +155,7 @@ public function checkList($groupId) * List all invitations of a group. * @GET */ - #[Path("groupId", new VString(), required: true)] + #[Path("groupId", new VUuid(), required: true)] public function actionList($groupId) { $group = $this->groups->findOrThrow($groupId); @@ -176,7 +176,7 @@ public function checkCreate($groupId) */ #[Post("expireAt", new VTimestamp(), "When the invitation expires.", nullable: true)] #[Post("note", new VMixed(), "Note for the students who wish to use the invitation link.", nullable: true)] - #[Path("groupId", new VString(), required: true)] + #[Path("groupId", new VUuid(), required: true)] public function actionCreate($groupId) { $req = $this->getRequest(); diff --git a/app/V1Module/presenters/GroupsPresenter.php b/app/V1Module/presenters/GroupsPresenter.php index 53f7586e2..af39741d5 100644 --- a/app/V1Module/presenters/GroupsPresenter.php +++ b/app/V1Module/presenters/GroupsPresenter.php @@ -790,7 +790,7 @@ public function actionGetExamLocks(string $id, string $examId) * @throws BadRequestException */ #[Path("id", new VUuid(), "An identifier of the relocated group", required: true)] - #[Path("newParentId", new VString(), "An identifier of the new parent group", required: true)] + #[Path("newParentId", new VUuid(), "An identifier of the new parent group", required: true)] public function actionRelocate(string $id, string $newParentId) { $group = $this->groups->findOrThrow($id); @@ -959,7 +959,7 @@ public function checkAddMember(string $id, string $userId) */ #[Post("type", new VString(1), "Identifier of membership type (admin, supervisor, ...)", required: true)] #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the supervisor", required: true)] + #[Path("userId", new VUuid(), "Identifier of the supervisor", required: true)] #[ResponseFormat(GroupFormat::class)] public function actionAddMember(string $id, string $userId) { @@ -1006,7 +1006,7 @@ public function checkRemoveMember(string $id, string $userId) * @DELETE */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the supervisor", required: true)] + #[Path("userId", new VUuid(), "Identifier of the supervisor", required: true)] #[ResponseFormat(GroupFormat::class)] public function actionRemoveMember(string $id, string $userId) { @@ -1148,7 +1148,7 @@ public function checkStudentsStats(string $id, string $userId) * @throws BadRequestException */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the student", required: true)] + #[Path("userId", new VUuid(), "Identifier of the student", required: true)] public function actionStudentsStats(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1178,7 +1178,7 @@ public function checkStudentsSolutions(string $id, string $userId) * @throws BadRequestException */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the student", required: true)] + #[Path("userId", new VUuid(), "Identifier of the student", required: true)] public function actionStudentsSolutions(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1225,7 +1225,7 @@ public function checkAddStudent(string $id, string $userId) * @POST */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the student", required: true)] + #[Path("userId", new VUuid(), "Identifier of the student", required: true)] #[ResponseFormat(GroupFormat::class)] public function actionAddStudent(string $id, string $userId) { @@ -1257,7 +1257,7 @@ public function checkRemoveStudent(string $id, string $userId) * @DELETE */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the student", required: true)] + #[Path("userId", new VUuid(), "Identifier of the student", required: true)] #[ResponseFormat(GroupFormat::class)] public function actionRemoveStudent(string $id, string $userId) { @@ -1290,7 +1290,7 @@ public function checkLockStudent(string $id, string $userId) * @POST */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the student", required: true)] + #[Path("userId", new VUuid(), "Identifier of the student", required: true)] public function actionLockStudent(string $id, string $userId) { $user = $this->users->findOrThrow($userId); @@ -1330,7 +1330,7 @@ public function checkUnlockStudent(string $id, string $userId) * @DELETE */ #[Path("id", new VUuid(), "Identifier of the group", required: true)] - #[Path("userId", new VString(), "Identifier of the student", required: true)] + #[Path("userId", new VUuid(), "Identifier of the student", required: true)] public function actionUnlockStudent(string $id, string $userId) { $user = $this->users->findOrThrow($userId); diff --git a/app/V1Module/presenters/InstancesPresenter.php b/app/V1Module/presenters/InstancesPresenter.php index 35b0f453d..b00af608a 100644 --- a/app/V1Module/presenters/InstancesPresenter.php +++ b/app/V1Module/presenters/InstancesPresenter.php @@ -277,7 +277,7 @@ public function checkUpdateLicence(string $licenceId) #[Post("note", new VString(2, 255), "A note for users or administrators", required: false)] #[Post("validUntil", new VString(), "Expiration date of the license", required: false)] #[Post("isValid", new VBool(), "Administrator switch to toggle license validity", required: false)] - #[Path("licenceId", new VString(), "Identifier of the license", required: true)] + #[Path("licenceId", new VUuid(), "Identifier of the license", required: true)] public function actionUpdateLicence(string $licenceId) { $licence = $this->licences->findOrThrow($licenceId); @@ -311,7 +311,7 @@ public function checkDeleteLicence(string $licenceId) * @DELETE * @throws NotFoundException */ - #[Path("licenceId", new VString(), "Identifier of the license", required: true)] + #[Path("licenceId", new VUuid(), "Identifier of the license", required: true)] public function actionDeleteLicence(string $licenceId) { $licence = $this->licences->findOrThrow($licenceId); diff --git a/app/V1Module/presenters/LoginPresenter.php b/app/V1Module/presenters/LoginPresenter.php index 6a47c4215..be10de65f 100644 --- a/app/V1Module/presenters/LoginPresenter.php +++ b/app/V1Module/presenters/LoginPresenter.php @@ -15,6 +15,7 @@ use App\Exceptions\InvalidApiArgumentException; use App\Exceptions\WrongCredentialsException; use App\Helpers\ExternalLogin\ExternalServiceAuthenticator; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Model\Entity\SecurityEvent; use App\Model\Entity\User; use App\Model\Repository\Logins; @@ -178,7 +179,7 @@ public function checkTakeOver($userId) * @throws ForbiddenRequestException * @throws InvalidAccessTokenException */ - #[Path("userId", new VString(), required: true)] + #[Path("userId", new VUuid(), required: true)] public function actionTakeOver($userId) { $user = $this->users->findOrThrow($userId); diff --git a/app/V1Module/presenters/PipelinesPresenter.php b/app/V1Module/presenters/PipelinesPresenter.php index bae42172d..99fbe9109 100644 --- a/app/V1Module/presenters/PipelinesPresenter.php +++ b/app/V1Module/presenters/PipelinesPresenter.php @@ -543,7 +543,7 @@ public function checkDeleteExerciseFile(string $id, string $fileId) * @throws NotFoundException */ #[Path("id", new VUuid(), "identification of pipeline", required: true)] - #[Path("fileId", new VString(), "identification of file", required: true)] + #[Path("fileId", new VUuid(), "identification of file", required: true)] public function actionDeleteExerciseFile(string $id, string $fileId) { $pipeline = $this->pipelines->findOrThrow($id); diff --git a/app/V1Module/presenters/PlagiarismPresenter.php b/app/V1Module/presenters/PlagiarismPresenter.php index d1c647219..8fee4f37f 100644 --- a/app/V1Module/presenters/PlagiarismPresenter.php +++ b/app/V1Module/presenters/PlagiarismPresenter.php @@ -224,7 +224,7 @@ public function checkGetSimilarities(string $id, string $solutionId): void * @GET */ #[Path("id", new VUuid(), "Identification of the detection batch", required: true)] - #[Path("solutionId", new VString(), required: true)] + #[Path("solutionId", new VUuid(), required: true)] public function actionGetSimilarities(string $id, string $solutionId): void { $batch = $this->detectionBatches->findOrThrow($id); @@ -260,7 +260,7 @@ public function checkAddSimilarities(string $id, string $solutionId): void #[Post("similarity", new VDouble(), "Relative similarity of the records associated with selected author [0-1].")] #[Post("files", new VArray(), "List of similar files and their records.")] #[Path("id", new VUuid(), "Identification of the detection batch", required: true)] - #[Path("solutionId", new VString(), required: true)] + #[Path("solutionId", new VUuid(), required: true)] public function actionAddSimilarities(string $id, string $solutionId): void { $batch = $this->detectionBatches->findOrThrow($id); diff --git a/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php b/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php index cc8bdde90..ef5d6c0e0 100644 --- a/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php +++ b/app/V1Module/presenters/ReferenceExerciseSolutionsPresenter.php @@ -172,7 +172,7 @@ public function checkSolutions(string $exerciseId) * Get reference solutions for an exercise * @GET */ - #[Path("exerciseId", new VString(), "Identifier of the exercise", required: true)] + #[Path("exerciseId", new VUuid(), "Identifier of the exercise", required: true)] public function actionSolutions(string $exerciseId) { $exercise = $this->exercises->findOrThrow($exerciseId); @@ -203,7 +203,7 @@ public function checkDetail(string $solutionId) * @GET * @throws NotFoundException */ - #[Path("solutionId", new VString(), "An identifier of the solution", required: true)] + #[Path("solutionId", new VUuid(), "An identifier of the solution", required: true)] public function actionDetail(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -225,7 +225,7 @@ public function checkUpdate(string $solutionId) * @throws InternalServerException */ #[Post("note", new VString(0, 65535), "A description by the author of the solution")] - #[Path("solutionId", new VString(), "Identifier of the solution", required: true)] + #[Path("solutionId", new VUuid(), "Identifier of the solution", required: true)] public function actionUpdate(string $solutionId) { $req = $this->getRequest(); @@ -248,7 +248,7 @@ public function checkDeleteReferenceSolution(string $solutionId) * Delete reference solution with given identification. * @DELETE */ - #[Path("solutionId", new VString(), "identifier of reference solution", required: true)] + #[Path("solutionId", new VUuid(), "identifier of reference solution", required: true)] public function actionDeleteReferenceSolution(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -283,7 +283,7 @@ public function checkSubmissions(string $solutionId) * @GET * @throws InternalServerException */ - #[Path("solutionId", new VString(), "identifier of the reference exercise solution", required: true)] + #[Path("solutionId", new VUuid(), "identifier of the reference exercise solution", required: true)] public function actionSubmissions(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -311,7 +311,7 @@ public function checkSubmission(string $submissionId) * @throws NotFoundException * @throws InternalServerException */ - #[Path("submissionId", new VString(), "identifier of the reference exercise submission", required: true)] + #[Path("submissionId", new VUuid(), "identifier of the reference exercise submission", required: true)] public function actionSubmission(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -335,7 +335,7 @@ public function checkDeleteSubmission(string $submissionId) * Remove reference solution evaluation (submission) permanently. * @DELETE */ - #[Path("submissionId", new VString(), "Identifier of the reference solution submission", required: true)] + #[Path("submissionId", new VUuid(), "Identifier of the reference solution submission", required: true)] public function actionDeleteSubmission(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -367,7 +367,7 @@ public function checkPreSubmit(string $exerciseId) * @throws BadRequestException */ #[Post("files", new VArray())] - #[Path("exerciseId", new VString(), "identifier of exercise", required: true)] + #[Path("exerciseId", new VUuid(), "identifier of exercise", required: true)] public function actionPreSubmit(string $exerciseId) { $exercise = $this->exercises->findOrThrow($exerciseId); @@ -423,7 +423,7 @@ public function checkSubmit(string $exerciseId) #[Post("files", new VMixed(), "Files of the reference solution", nullable: true)] #[Post("runtimeEnvironmentId", new VMixed(), "ID of runtime for this solution", nullable: true)] #[Post("solutionParams", new VMixed(), "Solution parameters", required: false, nullable: true)] - #[Path("exerciseId", new VString(), "Identifier of the exercise", required: true)] + #[Path("exerciseId", new VUuid(), "Identifier of the exercise", required: true)] public function actionSubmit(string $exerciseId) { $exercise = $this->exercises->findOrThrow($exerciseId); @@ -515,7 +515,7 @@ function ($solution) { * @throws NotFoundException */ #[Post("debug", new VBool(), "Debugging evaluation with all logs and outputs", required: false)] - #[Path("exerciseId", new VString(), "Identifier of the exercise", required: true)] + #[Path("exerciseId", new VUuid(), "Identifier of the exercise", required: true)] public function actionResubmitAll($exerciseId) { $req = $this->getRequest(); @@ -611,7 +611,7 @@ public function checkDownloadSolutionArchive(string $solutionId) * @throws \Nette\Application\BadRequestException * @throws \Nette\Application\AbortException */ - #[Path("solutionId", new VString(), "of reference solution", required: true)] + #[Path("solutionId", new VUuid(), "of reference solution", required: true)] public function actionDownloadSolutionArchive(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); @@ -663,7 +663,7 @@ public function checkDownloadResultArchive(string $submissionId) * @throws InternalServerException * @throws \Nette\Application\AbortException */ - #[Path("submissionId", new VString(), required: true)] + #[Path("submissionId", new VUuid(), required: true)] public function actionDownloadResultArchive(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -695,7 +695,7 @@ public function checkEvaluationScoreConfig(string $submissionId) * @throws NotFoundException * @throws InternalServerException */ - #[Path("submissionId", new VString(), "identifier of the reference exercise submission", required: true)] + #[Path("submissionId", new VUuid(), "identifier of the reference exercise submission", required: true)] public function actionEvaluationScoreConfig(string $submissionId) { $submission = $this->referenceSubmissions->findOrThrow($submissionId); @@ -722,7 +722,7 @@ public function checkSetVisibility(string $solutionId) * @throws BadRequestException */ #[Post("visibility", new VInt(), "New visibility level.", required: true)] - #[Path("solutionId", new VString(), "of reference solution", required: true)] + #[Path("solutionId", new VUuid(), "of reference solution", required: true)] public function actionSetVisibility(string $solutionId) { $solution = $this->referenceSolutions->findOrThrow($solutionId); diff --git a/app/V1Module/presenters/RegistrationPresenter.php b/app/V1Module/presenters/RegistrationPresenter.php index 2fb44a964..87f71151f 100644 --- a/app/V1Module/presenters/RegistrationPresenter.php +++ b/app/V1Module/presenters/RegistrationPresenter.php @@ -28,6 +28,7 @@ use App\Helpers\InvitationHelper; use App\Helpers\MetaFormats\Attributes\Format; use App\Helpers\MetaFormats\FormatDefinitions\UserFormat; +use App\Helpers\MetaFormats\Validators\VUuid; use App\Security\Roles; use App\Security\ACL\IUserPermissions; use App\Security\ACL\IGroupPermissions; @@ -160,7 +161,7 @@ public function checkCreateAccount() #[Post("lastName", new VString(2), "Last name")] #[Post("password", new VString(1), "A password for authentication")] #[Post("passwordConfirm", new VString(1), "A password confirmation")] - #[Post("instanceId", new VString(1), "Identifier of the instance to register in")] + #[Post("instanceId", new VUuid(), "Identifier of the instance to register in")] #[Post("titlesBeforeName", new VString(1), "Titles which is placed before user name", required: false)] #[Post("titlesAfterName", new VString(1), "Titles which is placed after user name", required: false)] #[Post( diff --git a/app/V1Module/presenters/ShadowAssignmentsPresenter.php b/app/V1Module/presenters/ShadowAssignmentsPresenter.php index ede8bdebb..35a732b6d 100644 --- a/app/V1Module/presenters/ShadowAssignmentsPresenter.php +++ b/app/V1Module/presenters/ShadowAssignmentsPresenter.php @@ -261,7 +261,7 @@ public function actionUpdateDetail(string $id) * @throws BadRequestException * @throws NotFoundException */ - #[Post("groupId", new VMixed(), "Identifier of the group", nullable: true)] + #[Post("groupId", new VUuid(), "Identifier of the group")] public function actionCreate() { $req = $this->getRequest(); @@ -318,7 +318,7 @@ public function checkCreatePoints(string $id) * @throws BadRequestException * @throws InvalidStateException */ - #[Post("userId", new VString(), "Identifier of the user which is marked as awardee for points")] + #[Post("userId", new VUuid(), "Identifier of the user which is marked as awardee for points")] #[Post("points", new VInt(), "Number of points assigned to the user")] #[Post("note", new VString(), "Note about newly created points")] #[Post( @@ -391,7 +391,7 @@ public function checkUpdatePoints(string $pointsId) "Datetime when the points were awarded, whatever that means", required: false, )] - #[Path("pointsId", new VString(), "Identifier of the shadow assignment points", required: true)] + #[Path("pointsId", new VUuid(), "Identifier of the shadow assignment points", required: true)] public function actionUpdatePoints(string $pointsId) { $pointsEntity = $this->shadowAssignmentPointsRepository->findOrThrow($pointsId); @@ -432,7 +432,7 @@ public function checkRemovePoints(string $pointsId) * @DELETE * @throws NotFoundException */ - #[Path("pointsId", new VString(), "Identifier of the shadow assignment points", required: true)] + #[Path("pointsId", new VUuid(), "Identifier of the shadow assignment points", required: true)] public function actionRemovePoints(string $pointsId) { $points = $this->shadowAssignmentPointsRepository->findOrThrow($pointsId); diff --git a/app/V1Module/presenters/SubmitPresenter.php b/app/V1Module/presenters/SubmitPresenter.php index 2feda4be1..ecf901e1d 100644 --- a/app/V1Module/presenters/SubmitPresenter.php +++ b/app/V1Module/presenters/SubmitPresenter.php @@ -209,7 +209,7 @@ public function checkCanSubmit(string $id) * @throws NotFoundException */ #[Path("id", new VUuid(), "Identifier of the assignment", required: true)] - #[Query("userId", new VString(), "Identification of the user", required: false, nullable: true)] + #[Query("userId", new VUuid(), "Identification of the user", required: false, nullable: true)] public function actionCanSubmit(string $id, ?string $userId = null) { $assignment = $this->assignments->findOrThrow($id); @@ -235,8 +235,8 @@ public function actionCanSubmit(string $id, ?string $userId = null) * @throws ParseException */ #[Post("note", new VString(0, 1024), "A note by the author of the solution")] - #[Post("userId", new VMixed(), "Author of the submission", required: false, nullable: true)] - #[Post("files", new VMixed(), "Submitted files", nullable: true)] + #[Post("userId", new VUuid(), "Author of the submission", required: false, nullable: true)] + #[Post("files", new VArray(new VUuid()), "Submitted files", nullable: true)] #[Post( "runtimeEnvironmentId", new VMixed(), @@ -435,9 +435,9 @@ public function checkPreSubmit(string $id, ?string $userId = null) * @throws InvalidApiArgumentException * @throws NotFoundException */ - #[Post("files", new VArray())] + #[Post("files", new VArray(new VUuid()), "Submitted files", nullable: true)] #[Path("id", new VUuid(), "identifier of assignment", required: true)] - #[Query("userId", new VString(), "Identifier of the submission author", required: false, nullable: true)] + #[Query("userId", new VUuid(), "Identifier of the submission author", required: false, nullable: true)] public function actionPreSubmit(string $id, string $userId = null) { $assignment = $this->assignments->findOrThrow($id); From 07da7f3d37cc79eb2cad111529a63891e30acaec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 4 Dec 2025 00:45:58 +0100 Subject: [PATCH 18/25] Adding checks to key input for create/update exercise file link endpoints. --- app/V1Module/presenters/ExerciseFilesPresenter.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/V1Module/presenters/ExerciseFilesPresenter.php b/app/V1Module/presenters/ExerciseFilesPresenter.php index 640067051..9ecc1a162 100644 --- a/app/V1Module/presenters/ExerciseFilesPresenter.php +++ b/app/V1Module/presenters/ExerciseFilesPresenter.php @@ -502,7 +502,7 @@ public function checkCreateFileLink(string $id) )] #[Post( "key", - new VString(1, 16), + new VString(1, 16, '/^[-a-zA-Z0-9_]+$/'), "Internal user-selected identifier of the exercise file link within the exercise", required: true )] @@ -567,7 +567,7 @@ public function checkUpdateFileLink(string $id, string $linkId) #[Path("linkId", new VUuid(), "of the exercise file link", required: true)] #[Post( "key", - new VString(1, 16), + new VString(1, 16, '/^[-a-zA-Z0-9_]+$/'), "Internal user-selected identifier of the exercise file link within the exercise", required: false )] From 6de89bce0adefe1029e69fa1f87633bd2df59448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 6 Dec 2025 20:01:25 +0100 Subject: [PATCH 19/25] Enabling partial assignment sync from the exercise. --- .../presenters/AssignmentsPresenter.php | 9 +- app/model/entity/Assignment.php | 113 +++++++++++++----- app/model/view/AssignmentViewFactory.php | 8 -- 3 files changed, 88 insertions(+), 42 deletions(-) diff --git a/app/V1Module/presenters/AssignmentsPresenter.php b/app/V1Module/presenters/AssignmentsPresenter.php index b5998cd62..e93a50353 100644 --- a/app/V1Module/presenters/AssignmentsPresenter.php +++ b/app/V1Module/presenters/AssignmentsPresenter.php @@ -645,10 +645,17 @@ public function checkSyncWithExercise(string $id) * @throws NotFoundException */ #[Path("id", new VUuid(), "Identifier of the assignment that should be synchronized", required: true)] + #[Post("syncOptions", new VArray(new VString( + 1, + 32, + '/^(configurationType|exerciseConfig|exerciseEnvironmentConfigs|exerciseTests|files|fileLinks|hardwareGroups' + . '|limits|localizedTexts|mergeJudgeLogs|runtimeEnvironments|scoreConfig)$/' + )), "List of options what to synchronize (if missing, everything is synchronized)", required: false)] public function actionSyncWithExercise($id) { $assignment = $this->assignments->findOrThrow($id); $exercise = $assignment->getExercise(); + $syncOptions = $this->getRequest()->getPost("syncOptions") ?? []; if ($exercise === null) { throw new NotFoundException("Exercise for assignment '{$id}' was deleted"); @@ -661,7 +668,7 @@ public function actionSyncWithExercise($id) } $assignment->updatedNow(); - $assignment->syncWithExercise(); + $assignment->syncWithExercise($syncOptions); $this->assignments->flush(); $this->sendSuccessResponse($this->assignmentViewFactory->getAssignment($assignment)); } diff --git a/app/model/entity/Assignment.php b/app/model/entity/Assignment.php index 215980d38..aa8d650b1 100644 --- a/app/model/entity/Assignment.php +++ b/app/model/entity/Assignment.php @@ -12,6 +12,7 @@ use Doctrine\Common\Collections\Criteria; use Gedmo\Mapping\Annotation as Gedmo; use DateTime; +use InvalidArgumentException; /** * @ORM\Entity @@ -535,7 +536,7 @@ function ($key, RuntimeEnvironment $env) use ($exercise) { ); } - public function syncWithExercise() + public function syncWithExercise(array $options = []): void { $exercise = $this->getExercise(); if ($exercise === null) { @@ -543,56 +544,102 @@ public function syncWithExercise() return; } - $this->mergeJudgeLogs = $exercise->getMergeJudgeLogs(); + // figure out which parts to sync (all are synced if no options are given) + $syncOptions = [ + "configurationType" => !$options, + "exerciseConfig" => !$options, + "exerciseEnvironmentConfigs" => !$options, + "exerciseTests" => !$options, + "files" => !$options, + "fileLinks" => !$options, + "hardwareGroups" => !$options, + "limits" => !$options, + "localizedTexts" => !$options, + "mergeJudgeLogs" => !$options, + "runtimeEnvironments" => !$options, + "scoreConfig" => !$options, + ]; + + foreach ($options as $option) { + if (!array_key_exists($option, $syncOptions)) { + throw new InvalidArgumentException("Unknown sync option: $option"); + } + $syncOptions[$option] = true; + } - $this->hardwareGroups->clear(); - foreach ($exercise->getHardwareGroups() as $group) { - $this->hardwareGroups->add($group); + $syncOptions = (object)$syncOptions; + + if ($syncOptions->configurationType) { + $this->configurationType = $exercise->getConfigurationType(); } - $this->localizedTexts->clear(); - foreach ($exercise->getLocalizedTexts() as $text) { - $this->localizedTexts->add($text); + if ($syncOptions->exerciseConfig) { + $this->exerciseConfig = $exercise->getExerciseConfig(); } - $this->exerciseConfig = $exercise->getExerciseConfig(); - $this->configurationType = $exercise->getConfigurationType(); - $this->scoreConfig = $exercise->getScoreConfig(); + if ($syncOptions->exerciseEnvironmentConfigs) { + $this->exerciseEnvironmentConfigs->clear(); + foreach ($exercise->getExerciseEnvironmentConfigs() as $config) { + $this->exerciseEnvironmentConfigs->add($config); + } + } - $this->exerciseEnvironmentConfigs->clear(); - foreach ($exercise->getExerciseEnvironmentConfigs() as $config) { - $this->exerciseEnvironmentConfigs->add($config); + if ($syncOptions->exerciseTests) { + $this->exerciseTests->clear(); + foreach ($exercise->getExerciseTests() as $test) { + $this->exerciseTests->add($test); + } } - $this->exerciseLimits->clear(); - foreach ($exercise->getExerciseLimits() as $limits) { - $this->exerciseLimits->add($limits); + if ($syncOptions->files) { + $this->exerciseFiles->clear(); + foreach ($exercise->getExerciseFiles() as $file) { + $this->exerciseFiles->add($file); + } } - $this->exerciseTests->clear(); - foreach ($exercise->getExerciseTests() as $test) { - $this->exerciseTests->add($test); + if ($syncOptions->fileLinks) { + $this->fileLinks->clear(); + foreach ($exercise->getFileLinks() as $link) { + $newLink = ExerciseFileLink::copyForAssignment($link, $this); + $this->fileLinks->add($newLink); + } } - $this->exerciseFiles->clear(); - foreach ($exercise->getExerciseFiles() as $file) { - $this->exerciseFiles->add($file); + if ($syncOptions->hardwareGroups) { + $this->hardwareGroups->clear(); + foreach ($exercise->getHardwareGroups() as $group) { + $this->hardwareGroups->add($group); + } } - $this->attachmentFiles->clear(); - foreach ($exercise->getAttachmentFiles() as $file) { - $this->attachmentFiles->add($file); + if ($syncOptions->limits) { + $this->exerciseLimits->clear(); + foreach ($exercise->getExerciseLimits() as $limits) { + $this->exerciseLimits->add($limits); + } } - $this->fileLinks->clear(); - foreach ($exercise->getFileLinks() as $link) { - $newLink = ExerciseFileLink::copyForAssignment($link, $this); - $this->fileLinks->add($newLink); + if ($syncOptions->localizedTexts) { + $this->localizedTexts->clear(); + foreach ($exercise->getLocalizedTexts() as $text) { + $this->localizedTexts->add($text); + } + } + + if ($syncOptions->mergeJudgeLogs) { + $this->mergeJudgeLogs = $exercise->getMergeJudgeLogs(); + } + + if ($syncOptions->runtimeEnvironments) { + $this->runtimeEnvironments->clear(); + foreach ($exercise->getRuntimeEnvironments() as $env) { + $this->runtimeEnvironments->add($env); + } } - $this->runtimeEnvironments->clear(); - foreach ($exercise->getRuntimeEnvironments() as $env) { - $this->runtimeEnvironments->add($env); + if ($syncOptions->scoreConfig) { + $this->scoreConfig = $exercise->getScoreConfig(); } $this->syncedAt = new DateTime(); diff --git a/app/model/view/AssignmentViewFactory.php b/app/model/view/AssignmentViewFactory.php index 82684447d..ecacb61bc 100644 --- a/app/model/view/AssignmentViewFactory.php +++ b/app/model/view/AssignmentViewFactory.php @@ -117,14 +117,6 @@ function (LocalizedExercise $text) use ($assignment) { "mergeJudgeLogs" => [ "upToDate" => $exercise && $assignment->getMergeJudgeLogs() === $exercise->getMergeJudgeLogs(), ], - - // DEPRECATED fields (will be removed in future) - "supplementaryFiles" => [ - "upToDate" => $assignment->areExerciseFilesInSync() - ], - "attachmentFiles" => [ - "upToDate" => $assignment->areAttachmentFilesInSync() - ], ], "solutionFilesLimit" => $assignment->getSolutionFilesLimit(), "solutionSizeLimit" => $assignment->getSolutionSizeLimit(), From 0f873b8684090b67963cf5dcd2ee3257d8b66eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 6 Dec 2025 20:07:11 +0100 Subject: [PATCH 20/25] Adding test for partial assignment sync. --- tests/Presenters/AssignmentsPresenter.phpt | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/Presenters/AssignmentsPresenter.phpt b/tests/Presenters/AssignmentsPresenter.phpt index b0c57fa0c..c1a3e27cc 100644 --- a/tests/Presenters/AssignmentsPresenter.phpt +++ b/tests/Presenters/AssignmentsPresenter.phpt @@ -827,6 +827,76 @@ class TestAssignmentsPresenter extends Tester\TestCase Assert::null($newLink->getExercise()); } + public function testSyncWithExercisePartial() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $user = PresenterTestHelper::getUser($this->container); + + /** @var RuntimeEnvironment $environment */ + $environment = $this->runtimeEnvironments->findAll()[0]; + /** @var HardwareGroup $hwGroup */ + $hwGroup = $this->hardwareGroups->findAll()[0]; + /** @var Group $group */ + $group = $this->presenter->groups->findAll()[0]; + + $limits = " + memory: 42, + wall-time: 33 + "; + + $newLimits = " + memory: 33, + wall-time: 44 + "; + + $exercises = array_filter( + $this->presenter->exercises->findAll(), + function (Exercise $e) { + return !$e->getFileLinks()->isEmpty(); // select the exercise with file links + } + ); + Assert::count(1, $exercises); + /** @var Exercise $exercise */ + $exercise = array_pop($exercises); + + $exerciseLimits = new ExerciseLimits($environment, $hwGroup, $limits, $user); + $this->em->persist($exerciseLimits); + + $exercise->addExerciseLimits($exerciseLimits); + $assignment = Assignment::assignToGroup($exercise, $group); + $this->em->persist($assignment); + $this->em->flush(); + + $newExerciseLimits = new ExerciseLimits($environment, $hwGroup, $newLimits, $user); + $this->em->persist($newExerciseLimits); + $exercise->clearExerciseLimits(); + $exercise->addExerciseLimits($newExerciseLimits); + + $exercise->getFileLinks()->removeElement($exercise->getFileLinks()->first()); + Assert::count(1, $exercise->getFileLinks()); + $link = $exercise->getFileLinks()->first(); + $link->setKey("NEW"); + $link->setRequiredRole(Roles::SUPERVISOR_ROLE); + $this->em->persist($link); + $this->em->persist($exercise); + $this->em->flush(); + + $request = new Nette\Application\Request( + 'V1:Assignments', + 'POST', + ['action' => 'syncWithExercise', 'id' => $assignment->getId()], + ['syncOptions' => ['limits']] + ); + $response = $this->presenter->run($request); + Assert::type(Nette\Application\Responses\JsonResponse::class, $response); + $payload = $response->getPayload(); + $data = $payload["payload"]; + + Assert::same($assignment->getId(), $data["id"]); + Assert::same($newExerciseLimits, $assignment->getLimitsByEnvironmentAndHwGroup($environment, $hwGroup)); + Assert::count(2, $assignment->getFileLinks()); + } + public function testRemove() { $token = PresenterTestHelper::loginDefaultAdmin($this->container); From 427a01066e8bcd67f861f79a2952d4d136eaf0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 6 Dec 2025 23:22:25 +0100 Subject: [PATCH 21/25] Moving updates of localized texts of an assignment to a separate endpoint. Student hints remain in the update-detail. --- .../presenters/AssignmentsPresenter.php | 128 ++++++++++++------ app/V1Module/router/RouterFactory.php | 1 + tests/Presenters/AssignmentsPresenter.phpt | 86 +++++++----- 3 files changed, 140 insertions(+), 75 deletions(-) diff --git a/app/V1Module/presenters/AssignmentsPresenter.php b/app/V1Module/presenters/AssignmentsPresenter.php index e93a50353..6b4fad875 100644 --- a/app/V1Module/presenters/AssignmentsPresenter.php +++ b/app/V1Module/presenters/AssignmentsPresenter.php @@ -211,7 +211,11 @@ public function checkUpdateDetail(string $id) */ #[Post("version", new VInt(), "Version of the edited assignment")] #[Post("isPublic", new VBool(), "Is the assignment ready to be displayed to students?")] - #[Post("localizedTexts", new VArray(), "A description of the assignment")] + #[Post( + "localizedStudentHints", + new VArray(), + "Additional localized hint texts for students (locale => hint text)", + )] #[Post("firstDeadline", new VTimestamp(), "First deadline for submission of the assignment")] #[Post( "maxPointsBeforeFirstDeadline", @@ -307,10 +311,6 @@ public function actionUpdateDetail(string $id) ); } - // localized texts cannot be empty - if (count($req->getPost("localizedTexts")) == 0) { - throw new InvalidApiArgumentException('localizedTexts', "No entry for localized texts given."); - } if ($this->isRequestJson()) { $disabledRuntimeIds = $req->getPost("disabledRuntimeEnvironmentIds"); @@ -425,49 +425,17 @@ public function actionUpdateDetail(string $id) $this->solutionEvaluations->flush(); } - // go through localizedTexts and construct database entities - $localizedTexts = []; + // go through localized hints and construct database entities $localizedAssignments = []; - foreach ($req->getPost("localizedTexts") as $localization) { - $lang = $localization["locale"]; - - if (array_key_exists($lang, $localizedTexts)) { - throw new InvalidApiArgumentException('localizedTexts', "Duplicate entry for language '$lang'"); - } - - // create all new localized texts - $assignmentExercise = $assignment->getExercise(); - $localizedExercise = $assignmentExercise ? $assignmentExercise->getLocalizedTextByLocale($lang) : null; - $externalAssignmentLink = trim(Arrays::get($localization, "link", "")); - if ($externalAssignmentLink !== "" && !Validators::isUrl($externalAssignmentLink)) { - throw new InvalidApiArgumentException('link', "External assignment link is not a valid URL"); - } - - $localizedTexts[$lang] = new LocalizedExercise( + foreach ($req->getPost("localizedStudentHints") ?? [] as $lang => $hintText) { + $localizedAssignments[$lang] = new LocalizedAssignment( $lang, - trim(Arrays::get($localization, "name", "")), - trim(Arrays::get($localization, "text", "")), - $localizedExercise ? $localizedExercise->getDescription() : "", - $externalAssignmentLink ?: null + trim($hintText) ); - - if (array_key_exists("studentHint", $localization)) { - $localizedAssignments[$lang] = new LocalizedAssignment( - $lang, - trim(Arrays::get($localization, "studentHint", "")) - ); - } - } - - // make changes to database - Localizations::updateCollection($assignment->getLocalizedTexts(), $localizedTexts); - - foreach ($assignment->getLocalizedTexts() as $localizedText) { - $this->assignments->persist($localizedText, false); } + // save changes to database (if any) Localizations::updateCollection($assignment->getLocalizedAssignments(), $localizedAssignments); - foreach ($assignment->getLocalizedAssignments() as $localizedAssignment) { $this->assignments->persist($localizedAssignment, false); } @@ -501,7 +469,81 @@ public function actionUpdateDetail(string $id) } } - $this->assignments->flush(); + $this->assignments->persist($assignment); + $this->sendSuccessResponse($this->assignmentViewFactory->getAssignment($assignment)); + } + + public function checkUpdateLocalizedTexts(string $id) + { + $assignment = $this->assignments->findOrThrow($id); + if (!$this->assignmentAcl->canUpdate($assignment)) { + throw new ForbiddenRequestException("You cannot update this assignment."); + } + } + + /** + * Update (only) the localized texts of an assignment. + * This is a separate operations since the texts are taken over from the exercise. + * Updating them is an override of the exercise specification and needs to be handled carefully. + * @POST + */ + #[Post("version", new VInt(), "Version of the edited assignment")] + #[Post("localizedTexts", new VArray(), "Localized texts with exercise/assignment specification")] + #[Path("id", new VUuid(), "Identifier of the updated assignment", required: true)] + public function actionUpdateLocalizedTexts(string $id) + { + $assignment = $this->assignments->findOrThrow($id); + + $req = $this->getRequest(); + $version = (int)$req->getPost("version"); + if ($version !== $assignment->getVersion()) { + $newVer = $assignment->getVersion(); + throw new BadRequestException( + "The assignment was edited in the meantime and the version has changed. Current version is $newVer.", + FrontendErrorMappings::E400_010__ENTITY_VERSION_TOO_OLD, + [ + 'entity' => 'assignment', + 'id' => $id, + 'version' => $newVer + ] + ); + } + + // go through localizedTexts and construct database entities + $localizedTexts = []; + foreach ($req->getPost("localizedTexts") as $localization) { + $lang = $localization["locale"]; + + if (array_key_exists($lang, $localizedTexts)) { + throw new InvalidApiArgumentException('localizedTexts', "Duplicate entry for language '$lang'"); + } + + // create all new localized texts + $assignmentExercise = $assignment->getExercise(); + $localizedExercise = $assignmentExercise ? $assignmentExercise->getLocalizedTextByLocale($lang) : null; + $externalAssignmentLink = trim(Arrays::get($localization, "link", "")); + if ($externalAssignmentLink !== "" && !Validators::isUrl($externalAssignmentLink)) { + throw new InvalidApiArgumentException('link', "External assignment link is not a valid URL"); + } + + $localizedTexts[$lang] = new LocalizedExercise( + $lang, + trim(Arrays::get($localization, "name", "")), + trim(Arrays::get($localization, "text", "")), + $localizedExercise ? $localizedExercise->getDescription() : "", + $externalAssignmentLink ?: null + ); + } + + // make changes to database + Localizations::updateCollection($assignment->getLocalizedTexts(), $localizedTexts); + foreach ($assignment->getLocalizedTexts() as $localizedText) { + $this->assignments->persist($localizedText, false); + } + + $assignment->incrementVersion(); + $assignment->updatedNow(); + $this->assignments->persist($assignment); $this->sendSuccessResponse($this->assignmentViewFactory->getAssignment($assignment)); } diff --git a/app/V1Module/router/RouterFactory.php b/app/V1Module/router/RouterFactory.php index 8414ca211..aeeb3d3f0 100644 --- a/app/V1Module/router/RouterFactory.php +++ b/app/V1Module/router/RouterFactory.php @@ -236,6 +236,7 @@ private static function createAssignmentsRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix", "Assignments:create"); $router[] = new GetRoute("$prefix/", "Assignments:detail"); $router[] = new PostRoute("$prefix/", "Assignments:updateDetail"); + $router[] = new PostRoute("$prefix//localized-texts", "Assignments:updateLocalizedTexts"); $router[] = new DeleteRoute("$prefix/", "Assignments:remove"); $router[] = new GetRoute("$prefix//solutions", "Assignments:solutions"); $router[] = new GetRoute("$prefix//best-solutions", "Assignments:bestSolutions"); diff --git a/tests/Presenters/AssignmentsPresenter.phpt b/tests/Presenters/AssignmentsPresenter.phpt index c1a3e27cc..359b5af8b 100644 --- a/tests/Presenters/AssignmentsPresenter.phpt +++ b/tests/Presenters/AssignmentsPresenter.phpt @@ -145,8 +145,8 @@ class TestAssignmentsPresenter extends Tester\TestCase $this->presenter->solutionEvaluations = $mockEvaluations; $isPublic = true; - $localizedTexts = [ - ["locale" => "locA", "text" => "descA", "name" => "nameA"] + $localizedStudentHints = [ + "en" => "Use the force!", ]; $firstDeadline = (new DateTime())->getTimestamp(); $maxPointsBeforeFirstDeadline = 123; @@ -171,7 +171,7 @@ class TestAssignmentsPresenter extends Tester\TestCase [ 'isPublic' => $isPublic, 'version' => 1, - 'localizedTexts' => $localizedTexts, + 'localizedStudentHints' => $localizedStudentHints, 'firstDeadline' => $firstDeadline, 'maxPointsBeforeFirstDeadline' => $maxPointsBeforeFirstDeadline, 'submissionsCountLimit' => $submissionsCountLimit, @@ -218,10 +218,10 @@ class TestAssignmentsPresenter extends Tester\TestCase // check localized texts Assert::count(1, $updatedAssignment["localizedTexts"]); - $localized = current($localizedTexts); + $localized = current($localizedStudentHints); $updatedLocalized = $updatedAssignment["localizedTexts"][0]; - Assert::equal($updatedLocalized["locale"], $localized["locale"]); - Assert::equal($updatedLocalized["text"], $localized["text"]); + Assert::equal("en", $updatedLocalized["locale"]); + Assert::equal($localized, $updatedLocalized["studentHint"]); } public function testUpdateDetailWithoutNotifications() @@ -249,9 +249,7 @@ class TestAssignmentsPresenter extends Tester\TestCase 'isPublic' => true, 'version' => 1, 'sendNotification' => false, - 'localizedTexts' => [ - ["locale" => "locA", "text" => "descA", "name" => "nameA"] - ], + 'localizedStudentHints' => ["en" => "Use the force!"], 'firstDeadline' => (new DateTime())->getTimestamp(), 'maxPointsBeforeFirstDeadline' => 42, 'submissionsCountLimit' => 10, @@ -303,9 +301,7 @@ class TestAssignmentsPresenter extends Tester\TestCase $this->presenter->solutionEvaluations = $mockEvaluations; $isPublic = true; - $localizedTexts = [ - ["locale" => "locA", "text" => "descA", "name" => "nameA"] - ]; + $localizedStudentHints = ["en" => "Use the force!"]; $firstDeadline = (new DateTime())->getTimestamp() + 100; $maxPointsBeforeFirstDeadline = 123; $submissionsCountLimit = 32; @@ -330,7 +326,7 @@ class TestAssignmentsPresenter extends Tester\TestCase 'isPublic' => $isPublic, 'version' => 1, 'sendNotification' => true, - 'localizedTexts' => $localizedTexts, + 'localizedStudentHints' => $localizedStudentHints, 'firstDeadline' => $firstDeadline, 'maxPointsBeforeFirstDeadline' => $maxPointsBeforeFirstDeadline, 'submissionsCountLimit' => $submissionsCountLimit, @@ -377,10 +373,10 @@ class TestAssignmentsPresenter extends Tester\TestCase // check localized texts Assert::count(1, $updatedAssignment["localizedTexts"]); - $localized = current($localizedTexts); + $localized = current($localizedStudentHints); $updatedLocalized = $updatedAssignment["localizedTexts"][0]; - Assert::equal($updatedLocalized["locale"], $localized["locale"]); - Assert::equal($updatedLocalized["text"], $localized["text"]); + Assert::equal("en", $updatedLocalized["locale"]); + Assert::equal($localized, $updatedLocalized["studentHint"]); } public function testAddStudentHints() @@ -399,9 +395,7 @@ class TestAssignmentsPresenter extends Tester\TestCase [ 'isPublic' => true, 'version' => 1, - 'localizedTexts' => [ - ["locale" => "locA", "text" => "descA", "name" => "nameA", "studentHint" => "Try hard"] - ], + 'localizedStudentHints' => ["en" => "Try hard"], 'firstDeadline' => (new DateTime())->getTimestamp(), 'maxPointsBeforeFirstDeadline' => 123, 'submissionsCountLimit' => 32, @@ -424,7 +418,7 @@ class TestAssignmentsPresenter extends Tester\TestCase $response = $this->presenter->run($request); $updatedAssignment = PresenterTestHelper::extractPayload($response); Assert::count(1, $updatedAssignment["localizedTexts"]); - Assert::equal("locA", $updatedAssignment["localizedTexts"][0]["locale"]); + Assert::equal("en", $updatedAssignment["localizedTexts"][0]["locale"]); Assert::equal("Try hard", $updatedAssignment["localizedTexts"][0]["studentHint"]); Assert::true($updatedAssignment["maxPointsDeadlineInterpolation"]); } @@ -446,9 +440,7 @@ class TestAssignmentsPresenter extends Tester\TestCase [ 'isPublic' => true, 'version' => 1, - 'localizedTexts' => [ - ["locale" => "locA", "text" => "descA", "name" => "nameA"] - ], + 'localizedStudentHints' => ["en" => "Use the force!"], 'firstDeadline' => (new DateTime())->getTimestamp(), 'maxPointsBeforeFirstDeadline' => 123, 'submissionsCountLimit' => 32, @@ -491,9 +483,7 @@ class TestAssignmentsPresenter extends Tester\TestCase [ 'isPublic' => true, 'version' => 1, - 'localizedTexts' => [ - ["locale" => "locA", "text" => "descA", "name" => "nameA"] - ], + 'localizedStudentHints' => ["en" => "Use the force!"], 'firstDeadline' => (new DateTime())->getTimestamp(), 'maxPointsBeforeFirstDeadline' => 123, 'submissionsCountLimit' => 32, @@ -540,9 +530,7 @@ class TestAssignmentsPresenter extends Tester\TestCase [ 'isPublic' => true, 'version' => 1, - 'localizedTexts' => [ - ["locale" => "locA", "text" => "descA", "name" => "nameA"] - ], + 'localizedStudentHints' => ["en" => "Use the force!"], 'firstDeadline' => (new DateTime())->getTimestamp(), 'maxPointsBeforeFirstDeadline' => 123, 'submissionsCountLimit' => 32, @@ -587,9 +575,7 @@ class TestAssignmentsPresenter extends Tester\TestCase [ 'isPublic' => true, 'version' => 1, - 'localizedTexts' => [ - ["locale" => "locA", "text" => "descA", "name" => "nameA"] - ], + 'localizedStudentHints' => ["en" => "Use the force!"], 'firstDeadline' => (new DateTime())->getTimestamp(), 'maxPointsBeforeFirstDeadline' => 123, 'submissionsCountLimit' => 32, @@ -614,6 +600,42 @@ class TestAssignmentsPresenter extends Tester\TestCase Assert::false($assignment->isExam()); } + public function testUpdateLocalizedTexts() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $assignments = $this->assignments->findAll(); + /** @var Assignment $assignment */ + $assignment = array_pop($assignments); + + $request = new Nette\Application\Request( + 'V1:Assignments', + 'POST', + ['action' => 'updateLocalizedTexts', 'id' => $assignment->getId()], + [ + 'version' => 1, + 'localizedTexts' => [ + ["locale" => "en", "name" => "Overwritten name", "text" => "Overwritten text"], + ["locale" => "nw", "name" => "New name", "text" => "New text"], + ], + ] + ); + + $response = $this->presenter->run($request); + $updatedAssignment = PresenterTestHelper::extractPayload($response); + Assert::count(2, $updatedAssignment["localizedTexts"]); + $texts = $updatedAssignment["localizedTexts"]; + usort($texts, function ($a, $b) { + return strcmp($a["locale"], $b["locale"]); + }); + Assert::equal("en", $texts[0]["locale"]); + Assert::equal("Overwritten name", $texts[0]["name"]); + Assert::equal("Overwritten text", $texts[0]["text"]); + Assert::equal("nw", $texts[1]["locale"]); + Assert::equal("New name", $texts[1]["name"]); + Assert::equal("New text", $texts[1]["text"]); + } + public function testCreateAssignment() { PresenterTestHelper::loginDefaultAdmin($this->container); From 862985abf773b865291d34d7ab4bdc868720e144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Tue, 9 Dec 2025 19:40:17 +0100 Subject: [PATCH 22/25] Fixing detection in changes of exercise-assignment file links for sync indication. --- app/model/entity/Assignment.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/model/entity/Assignment.php b/app/model/entity/Assignment.php index aa8d650b1..3f85206b4 100644 --- a/app/model/entity/Assignment.php +++ b/app/model/entity/Assignment.php @@ -490,18 +490,18 @@ public function areExerciseFileLinksInSync(): bool return false; } - // build index where keys are exercise file IDs + // build an index where keys are keys (the only unique attribute) $fileIndex = []; foreach ($this->getFileLinks() as $link) { - $fileIndex[$link->getExerciseFile()->getId()] = $link; + $fileIndex[$link->getKey()] = $link; } // verify that all exercise links are present and has the same data foreach ($exercise->getFileLinks() as $link) { - $ourLink = $fileIndex[$link->getExerciseFile()->getId()] ?? null; + $ourLink = $fileIndex[$link->getKey()] ?? null; if ( $ourLink === null - || $ourLink->getKey() !== $link->getKey() + || $ourLink->getExerciseFile()->getId() !== $link->getExerciseFile()->getId() || $ourLink->getRequiredRole() !== $link->getRequiredRole() || $ourLink->getSaveName() !== $link->getSaveName() ) { From 3289c63b6a45ca994c3b9a66507a6661082e6951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Wed, 10 Dec 2025 12:48:50 +0100 Subject: [PATCH 23/25] Fixing problem with exercise file links removal during exercise-assignment sync. --- app/model/entity/Assignment.php | 15 ++++++++------- app/model/entity/Exercise.php | 3 ++- app/model/entity/ExerciseFileLink.php | 5 +++-- migrations/Version20251114174621.php | 2 +- tests/Presenters/AssignmentsPresenter.phpt | 1 + 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/model/entity/Assignment.php b/app/model/entity/Assignment.php index 3f85206b4..235c79375 100644 --- a/app/model/entity/Assignment.php +++ b/app/model/entity/Assignment.php @@ -25,17 +25,18 @@ class Assignment extends AssignmentBase implements IExercise use ExerciseData; /** - * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="assignment", cascade={"persist"}) + * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="assignment", cascade={"persist", "remove"}, + * orphanRemoval=true) * @var Collection */ - protected $fileLinks; + protected $exerciseFileLinks; /** * @return Collection */ public function getFileLinks(): Collection { - return $this->fileLinks; + return $this->exerciseFileLinks; } private function __construct( @@ -91,7 +92,7 @@ private function __construct( $this->configurationType = $exercise->getConfigurationType(); $this->exerciseFiles = $exercise->getExerciseFiles(); $this->attachmentFiles = $exercise->getAttachmentFiles(); - $this->fileLinks = new ArrayCollection(); + $this->exerciseFileLinks = new ArrayCollection(); $this->solutionFilesLimit = $exercise->getSolutionFilesLimit(); $this->solutionSizeLimit = $exercise->getSolutionSizeLimit(); } @@ -125,7 +126,7 @@ public static function assignToGroup( // copy file links from exercise foreach ($exercise->getFileLinks() as $link) { $newLink = ExerciseFileLink::copyForAssignment($link, $assignment); - $assignment->fileLinks->add($newLink); + $assignment->exerciseFileLinks->add($newLink); } return $assignment; @@ -599,10 +600,10 @@ public function syncWithExercise(array $options = []): void } if ($syncOptions->fileLinks) { - $this->fileLinks->clear(); + $this->exerciseFileLinks->clear(); foreach ($exercise->getFileLinks() as $link) { $newLink = ExerciseFileLink::copyForAssignment($link, $this); - $this->fileLinks->add($newLink); + $this->exerciseFileLinks->add($newLink); } } diff --git a/app/model/entity/Exercise.php b/app/model/entity/Exercise.php index 0540010f6..0e50c6b09 100644 --- a/app/model/entity/Exercise.php +++ b/app/model/entity/Exercise.php @@ -112,7 +112,8 @@ class Exercise implements IExercise protected $archivedAt = null; /** - * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exercise", cascade={"persist"}) + * @ORM\OneToMany(targetEntity="ExerciseFileLink", mappedBy="exercise", cascade={"persist", "remove"}, + * orphanRemoval=true) * @var Collection */ protected $fileLinks; diff --git a/app/model/entity/ExerciseFileLink.php b/app/model/entity/ExerciseFileLink.php index a1d5573e2..5646ca8a2 100644 --- a/app/model/entity/ExerciseFileLink.php +++ b/app/model/entity/ExerciseFileLink.php @@ -13,7 +13,8 @@ * I.e., when Assignment is created from Exercise, the links are copied (immediately) as well. * The link of an exercise may be updated, but the link of an assignment is immutable. * @ORM\Entity - * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(columns={"key", "exercise_id"})}) + * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(columns={"key", "exercise_id"}), + * @ORM\UniqueConstraint(columns={"key", "assignment_id"})}) */ class ExerciseFileLink implements JsonSerializable { @@ -59,7 +60,7 @@ class ExerciseFileLink implements JsonSerializable protected $exercise; /** - * @ORM\ManyToOne(targetEntity="Assignment") + * @ORM\ManyToOne(targetEntity="Assignment", inversedBy="exerciseFileLinks") * @ORM\JoinColumn(onDelete="CASCADE") */ protected $assignment; diff --git a/migrations/Version20251114174621.php b/migrations/Version20251114174621.php index dc44e2229..8d3dfba88 100644 --- a/migrations/Version20251114174621.php +++ b/migrations/Version20251114174621.php @@ -20,7 +20,7 @@ public function getDescription(): string public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE TABLE exercise_file_link (id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\', exercise_file_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', exercise_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', assignment_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', `key` VARCHAR(16) NOT NULL, save_name VARCHAR(255) DEFAULT NULL, required_role VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, INDEX IDX_1187F77549DE8E29 (exercise_file_id), INDEX IDX_1187F775E934951A (exercise_id), INDEX IDX_1187F775D19302F8 (assignment_id), UNIQUE INDEX UNIQ_1187F7758A90ABA9E934951A (`key`, exercise_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE exercise_file_link (id CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\', exercise_file_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', exercise_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', assignment_id CHAR(36) DEFAULT NULL COMMENT \'(DC2Type:uuid)\', `key` VARCHAR(16) NOT NULL, save_name VARCHAR(255) DEFAULT NULL, required_role VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, INDEX IDX_1187F77549DE8E29 (exercise_file_id), INDEX IDX_1187F775E934951A (exercise_id), INDEX IDX_1187F775D19302F8 (assignment_id), UNIQUE INDEX UNIQ_1187F7758A90ABA9E934951A (`key`, exercise_id), UNIQUE INDEX UNIQ_1187F7758A90ABA9D19302F8 (`key`, assignment_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775D19302F8 FOREIGN KEY (assignment_id) REFERENCES assignment (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F775E934951A FOREIGN KEY (exercise_id) REFERENCES exercise (id) ON DELETE CASCADE'); $this->addSql('ALTER TABLE exercise_file_link ADD CONSTRAINT FK_1187F77549DE8E29 FOREIGN KEY (exercise_file_id) REFERENCES `uploaded_file` (id) ON DELETE CASCADE'); diff --git a/tests/Presenters/AssignmentsPresenter.phpt b/tests/Presenters/AssignmentsPresenter.phpt index 359b5af8b..31e58033e 100644 --- a/tests/Presenters/AssignmentsPresenter.phpt +++ b/tests/Presenters/AssignmentsPresenter.phpt @@ -841,6 +841,7 @@ class TestAssignmentsPresenter extends Tester\TestCase Assert::same($assignment->getId(), $data["id"]); Assert::same($newExerciseLimits, $assignment->getLimitsByEnvironmentAndHwGroup($environment, $hwGroup)); + $this->presenter->assignments->refresh($assignment); Assert::count(1, $assignment->getFileLinks()); $newLink = $assignment->getFileLinks()->first(); Assert::equal("NEW", $newLink->getKey()); From 7f9644bed4d73dc2c4baa49b051593d1d507ef4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Wed, 10 Dec 2025 15:38:36 +0100 Subject: [PATCH 24/25] Fixing exercise files cleanup command so that linked files are not deleted. --- app/model/repository/ExerciseFiles.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/model/repository/ExerciseFiles.php b/app/model/repository/ExerciseFiles.php index 218b09123..c70c55d74 100644 --- a/app/model/repository/ExerciseFiles.php +++ b/app/model/repository/ExerciseFiles.php @@ -29,6 +29,10 @@ public function findUnused(): array (SELECT a FROM App\Model\Entity\Assignment a WHERE a MEMBER OF f.assignments AND a.deletedAt IS NULL) AND NOT EXISTS (SELECT p FROM App\Model\Entity\Pipeline p WHERE p MEMBER OF f.pipelines AND p.deletedAt IS NULL) + AND NOT EXISTS + (SELECT l FROM App\Model\Entity\ExerciseFileLink l + LEFT JOIN l.exercise le LEFT JOIN l.assignment la + WHERE l.exerciseFile = f AND le.deletedAt IS NULL AND la.deletedAt IS NULL) "); return $query->getResult(); From cadf70cf63a68ccb7468069db60c56af28312522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Wed, 10 Dec 2025 18:59:52 +0100 Subject: [PATCH 25/25] Improving swagger OAPI generator to mark deprecated endpoint and generating new swagger specification. --- ...nerateSwagger.php => SwaggerGenerator.php} | 7 +- app/config/config.neon | 3 +- app/helpers/Swagger/AnnotationData.php | 37 +- app/helpers/Swagger/AnnotationHelper.php | 28 +- docs/swagger.yaml | 441 +++++++++++++++--- 5 files changed, 450 insertions(+), 66 deletions(-) rename app/commands/{GenerateSwagger.php => SwaggerGenerator.php} (82%) diff --git a/app/commands/GenerateSwagger.php b/app/commands/SwaggerGenerator.php similarity index 82% rename from app/commands/GenerateSwagger.php rename to app/commands/SwaggerGenerator.php index 257134e41..403c8804e 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/SwaggerGenerator.php @@ -16,7 +16,7 @@ description: 'Generate an OpenAPI documentation from the temporary file created by the swagger:annotate command. ' . 'The temporary file is deleted afterwards.' )] -class GenerateSwagger extends Command +class SwaggerGenerator extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { @@ -24,11 +24,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int // check if file exists if (!file_exists($path)) { - $output->writeln("Error in GenerateSwagger: Temp annotation file not found."); + $output->writeln("Error in SwaggerGenerator: Temp annotation file not found."); return Command::FAILURE; } - $openapi = Generator::scan([$path]); + $generator = new Generator(); + $openapi = $generator->generate([$path]); $output->writeln($openapi->toYaml()); diff --git a/app/config/config.neon b/app/config/config.neon index 7470ef04b..3520160c9 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -312,7 +312,6 @@ services: # commands - App\Console\DoctrineFixtures - #- App\Console\GenerateSwagger(@router) - App\Console\CleanupUploads - App\Console\CleanupExercisesFiles - App\Console\CleanupWorkerTmpFiles @@ -322,7 +321,7 @@ services: - App\Console\GeneralStatsNotification - App\Console\ExportDatabase - App\Console\MetaConverter - - App\Console\GenerateSwagger + - App\Console\SwaggerGenerator - App\Console\SwaggerAnnotator - App\Console\CleanupLocalizedTexts - App\Console\CleanupExerciseConfigs diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php index ddd547657..0738d15dd 100644 --- a/app/helpers/Swagger/AnnotationData.php +++ b/app/helpers/Swagger/AnnotationData.php @@ -37,6 +37,12 @@ class AnnotationData public array $responseDataList; public ?string $endpointDescription; + /** + * @var string|null A message indicating why the endpoint is deprecated, or null if the endpoint is not deprecated. + * Empty string is valid and indicates deprecation without a message. + */ + public ?string $deprecated = null; + public function __construct( string $className, string $methodName, @@ -47,6 +53,7 @@ public function __construct( array $fileParams, array $responseDataList, ?string $endpointDescription = null, + ?string $deprecated = null, ) { $this->className = $className; $this->methodName = $methodName; @@ -57,6 +64,28 @@ public function __construct( $this->fileParams = $fileParams; $this->responseDataList = $responseDataList; $this->endpointDescription = $endpointDescription; + $this->deprecated = $deprecated; + } + + private function getSummary(): ?string + { + $summary = $this->endpointDescription; + if ($this->deprecated !== null) { + $summary = $summary ?? ''; + $summary .= ($summary ? " " : "") . "[DEPRECATED]"; + } + return $summary; + } + + private function getDescription(): ?string + { + $description = $this->endpointDescription; + if ($this->deprecated !== null) { + $description = $description ?? ''; + $description .= ($description ? "\n" : "") . "[DEPRECATED]" . ($this->deprecated ? ": " : "") + . $this->deprecated; + } + return $description; } public function getAllParams(): array @@ -219,8 +248,8 @@ public function toSwaggerAnnotations(string $route) // add the endpoint description when provided if ($this->endpointDescription !== null) { - $body->addKeyValue("summary", $this->endpointDescription); - $body->addKeyValue("description", $this->endpointDescription); + $body->addKeyValue("summary", $this->getSummary()); + $body->addKeyValue("description", $this->getDescription()); } foreach ($this->pathParams as $pathParam) { @@ -255,6 +284,10 @@ public function toSwaggerAnnotations(string $route) $body->addValue('@OA\Response(response="200",description="Placeholder response")'); } + if ($this->deprecated) { + $body->addKeyValue("deprecated", true); + } + return $httpMethodAnnotation . $body->toString(); } } diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php index f3c8066b7..ed20bce1f 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -285,6 +285,23 @@ private static function extractAnnotationDescription(array $annotations): ?strin return null; } + /** + * Extracts the deprecated message from the annotations. + * @param array $annotations The array of annotations. + * @return string|null Returns the concatenated deprecated message (may be '' @deprecated without message is set) + * or null if the endpoint is not deprecated. + */ + private static function extractAnnotationDeprecatedMessage(array $annotations): ?string + { + $deprecated = []; + foreach ($annotations as $annotation) { + if (str_starts_with($annotation, "@deprecated")) { + $deprecated[] = trim(preg_replace('/^@deprecated\s*/', '', $annotation)); + } + } + return $deprecated ? implode("\n", array_filter($deprecated)) : null; + } + private static function annotationParameterDataToAnnotationData( string $className, string $methodName, @@ -292,6 +309,7 @@ private static function annotationParameterDataToAnnotationData( array $params, array $responseDataList, ?string $description, + ?string $deprecated = null, ): AnnotationData { $pathParams = []; $queryParams = []; @@ -322,6 +340,7 @@ private static function annotationParameterDataToAnnotationData( $fileParams, $responseDataList, $description, + $deprecated ); } @@ -345,14 +364,16 @@ public static function extractStandardAnnotationData( $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); $params = self::extractStandardAnnotationParams($methodAnnotations, $route); $description = self::extractAnnotationDescription($methodAnnotations); + $deprecated = self::extractAnnotationDeprecatedMessage($methodAnnotations); return self::annotationParameterDataToAnnotationData( $className, $methodName, $httpMethod, $params, - [], // there are no reponse params defined in the old annotations - $description + [], // there are no response params defined in the old annotations + $description, + $deprecated, ); } @@ -413,6 +434,7 @@ public static function extractAttributeData(string $className, string $methodNam return $data->toAnnotationParameterData(); }, $attributeData); $description = self::extractAnnotationDescription($methodAnnotations); + $deprecated = self::extractAnnotationDeprecatedMessage($methodAnnotations); return self::annotationParameterDataToAnnotationData( $className, @@ -421,6 +443,7 @@ public static function extractAttributeData(string $className, string $methodNam $params, $responseDataList, $description, + $deprecated, ); } @@ -511,7 +534,6 @@ public static function getPropertyValue(mixed $object, string $propertyName): mi } } while ($property === null && $class !== null); - $property->setAccessible(true); return $property->getValue($object); } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f9d93d76a..d3a6c55b0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -109,6 +109,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -278,8 +279,8 @@ paths: description: 'Placeholder response' '/v1/comments/{threadId}/comment/{commentId}/toggle': post: - summary: 'Make a private comment public or vice versa' - description: 'Make a private comment public or vice versa' + summary: 'Make a private comment public or vice versa [DEPRECATED]' + description: "Make a private comment public or vice versa\n[DEPRECATED]" operationId: commentsPresenterActionTogglePrivate parameters: - @@ -289,6 +290,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: commentId @@ -297,6 +299,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -314,6 +317,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: commentId @@ -322,6 +326,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -352,6 +357,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: commentId @@ -360,6 +366,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -428,6 +435,8 @@ paths: groupId: description: 'Identifier of the group to which exercise belongs to' type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 nullable: true type: object responses: @@ -448,7 +457,7 @@ paths: ids: description: 'Identifications of exercises' type: array - items: { } + items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 } nullable: false type: object responses: @@ -467,6 +476,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: groupId @@ -475,6 +485,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: true responses: '200': @@ -695,6 +706,8 @@ paths: groupId: description: 'Identifier of the group to which exercise will be forked' type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 nullable: true type: object responses: @@ -779,6 +792,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -804,6 +818,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -949,7 +964,7 @@ paths: admins: description: 'List of user IDs.' type: array - items: { } + items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 } nullable: false type: object responses: @@ -986,7 +1001,7 @@ paths: responses: '200': description: 'Placeholder response' - '/v1/exercises/{id}/exercise-files': + '/v1/exercises/{id}/files': get: summary: 'Get list of all exercise files for an exercise' description: 'Get list of all exercise files for an exercise' @@ -1033,7 +1048,7 @@ paths: responses: '200': description: 'Placeholder response' - '/v1/exercises/{id}/exercise-files/{fileId}': + '/v1/exercises/{id}/files/{fileId}': delete: summary: 'Delete exercise file with given id' description: 'Delete exercise file with given id' @@ -1055,11 +1070,12 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': description: 'Placeholder response' - '/v1/exercises/{id}/exercise-files/download-archive': + '/v1/exercises/{id}/files/download-archive': get: summary: 'Download archive containing all files for exercise.' description: 'Download archive containing all files for exercise.' @@ -1077,6 +1093,187 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/exercises/{id}/file-links': + get: + summary: 'Retrieve a list of all exercise-file links for given exercise.' + description: 'Retrieve a list of all exercise-file links for given exercise.' + operationId: exerciseFilesPresenterActionGetFileLinks + parameters: + - + name: id + in: path + description: 'of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + responses: + '200': + description: 'Placeholder response' + post: + summary: 'Create a new exercise-file link for given exercise.' + description: 'Create a new exercise-file link for given exercise.' + operationId: exerciseFilesPresenterActionCreateFileLink + parameters: + - + name: id + in: path + description: 'of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + requestBody: + content: + application/json: + schema: + required: + - exerciseFileId + - key + properties: + exerciseFileId: + description: 'Target file the link will point to' + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 + nullable: false + key: + description: 'Internal user-selected identifier of the exercise file link within the exercise' + type: string + maxLength: 16 + minLength: 1 + pattern: '^[-a-zA-Z0-9_]+$' + example: text + nullable: false + requiredRole: + description: 'Minimal required user role to access the file (null = non-logged-in users)' + type: string + maxLength: 255 + minLength: 1 + example: text + nullable: true + saveName: + description: 'File name override (the file will be downloaded under this name instead of the original name)' + type: string + maxLength: 255 + minLength: 1 + example: text + nullable: true + type: object + responses: + '200': + description: 'Placeholder response' + '/v1/exercises/{id}/file-links/{linkId}': + post: + summary: 'Update a specific exercise-file link. Missing arguments are not changed.' + description: 'Update a specific exercise-file link. Missing arguments are not changed.' + operationId: exerciseFilesPresenterActionUpdateFileLink + parameters: + - + name: id + in: path + description: 'of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + - + name: linkId + in: path + description: 'of the exercise file link' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + requestBody: + content: + application/json: + schema: + properties: + key: + description: 'Internal user-selected identifier of the exercise file link within the exercise' + type: string + maxLength: 16 + minLength: 1 + pattern: '^[-a-zA-Z0-9_]+$' + example: text + nullable: false + requiredRole: + description: 'Minimal required user role to access the file (null = non-logged-in users)' + type: string + maxLength: 255 + minLength: 1 + example: text + nullable: true + saveName: + description: 'File name override (the file will be downloaded under this name instead of the original name)' + type: string + maxLength: 255 + minLength: 1 + example: text + nullable: true + type: object + responses: + '200': + description: 'Placeholder response' + delete: + summary: 'Delete a specific exercise-file link.' + description: 'Delete a specific exercise-file link.' + operationId: exerciseFilesPresenterActionDeleteFileLink + parameters: + - + name: id + in: path + description: 'of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + - + name: linkId + in: path + description: 'of the exercise file link' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + responses: + '200': + description: 'Placeholder response' + '/v1/exercises/{id}/file-download/{linkKey}': + get: + summary: 'Download a specific exercise-file via its link key. Unlike `downloadFileLink`, the key is selected by the user and does not have to change (when the link or the file is updated). On the other hand, it always retrieves the latest version of the file.' + description: 'Download a specific exercise-file via its link key. Unlike `downloadFileLink`, the key is selected by the user and does not have to change (when the link or the file is updated). On the other hand, it always retrieves the latest version of the file.' + operationId: uploadedFilesPresenterActionDownloadExerciseFileLinkByKey + parameters: + - + name: id + in: path + description: 'of exercise' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + - + name: linkKey + in: path + description: 'Internal user-selected identifier of the exercise file link within the exercise' + required: true + schema: + type: string + maxLength: 16 + minLength: 1 + nullable: false + responses: + '200': + description: 'Placeholder response' '/v1/exercises/{id}/supplementary-files': get: summary: 'Get list of all exercise files for an exercise' @@ -1146,6 +1343,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1170,8 +1368,8 @@ paths: description: 'Placeholder response' '/v1/exercises/{id}/attachment-files': get: - summary: 'Get a list of all attachment files for an exercise' - description: 'Get a list of all attachment files for an exercise' + summary: 'Get a list of all attachment files for an exercise [DEPRECATED]' + description: "Get a list of all attachment files for an exercise\n[DEPRECATED]: attachment files were unified with exercise files" operationId: exerciseFilesPresenterActionGetAttachmentFiles parameters: - @@ -1186,9 +1384,10 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true post: - summary: 'Associate attachment exercise files with an exercise' - description: 'Associate attachment exercise files with an exercise' + summary: 'Associate attachment exercise files with an exercise [DEPRECATED]' + description: "Associate attachment exercise files with an exercise\n[DEPRECATED]: attachment files were unified with exercise files" operationId: exerciseFilesPresenterActionUploadAttachmentFiles parameters: - @@ -1215,10 +1414,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/exercises/{id}/attachment-files/{fileId}': delete: - summary: 'Delete attachment exercise file with given id' - description: 'Delete attachment exercise file with given id' + summary: 'Delete attachment exercise file with given id [DEPRECATED]' + description: "Delete attachment exercise file with given id\n[DEPRECATED]: attachment files were unified with exercise files" operationId: exerciseFilesPresenterActionDeleteAttachmentFile parameters: - @@ -1237,14 +1437,16 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': description: 'Placeholder response' + deprecated: true '/v1/exercises/{id}/attachment-files/download-archive': get: - summary: 'Download archive containing all attachment files for exercise.' - description: 'Download archive containing all attachment files for exercise.' + summary: 'Download archive containing all attachment files for exercise. [DEPRECATED]' + description: "Download archive containing all attachment files for exercise.\n[DEPRECATED]: attachment files were unified with exercise files" operationId: exerciseFilesPresenterActionDownloadAttachmentFilesArchive parameters: - @@ -1259,6 +1461,7 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/exercises/{id}/tests': get: summary: 'Get tests for exercise based on given identification.' @@ -1442,8 +1645,8 @@ paths: description: 'Placeholder response' '/v1/exercises/{id}/environment/{runtimeEnvironmentId}/hwgroup/{hwGroupId}/limits': get: - summary: 'Get a description of resource limits for an exercise for given hwgroup.' - description: 'Get a description of resource limits for an exercise for given hwgroup.' + summary: 'Get a description of resource limits for an exercise for given hwgroup. [DEPRECATED]' + description: "Get a description of resource limits for an exercise for given hwgroup.\n[DEPRECATED]" operationId: exercisesConfigPresenterActionGetHardwareGroupLimits parameters: - @@ -1462,6 +1665,7 @@ paths: required: true schema: type: string + minLength: 1 nullable: false - name: hwGroupId @@ -1470,13 +1674,14 @@ paths: required: true schema: type: string + minLength: 1 nullable: false responses: '200': description: 'Placeholder response' post: - summary: 'Set resource limits for an exercise for given hwgroup.' - description: 'Set resource limits for an exercise for given hwgroup.' + summary: 'Set resource limits for an exercise for given hwgroup. [DEPRECATED]' + description: "Set resource limits for an exercise for given hwgroup.\n[DEPRECATED]" operationId: exercisesConfigPresenterActionSetHardwareGroupLimits parameters: - @@ -1495,6 +1700,7 @@ paths: required: true schema: type: string + minLength: 1 nullable: false - name: hwGroupId @@ -1503,6 +1709,7 @@ paths: required: true schema: type: string + minLength: 1 nullable: false requestBody: content: @@ -1521,8 +1728,8 @@ paths: '200': description: 'Placeholder response' delete: - summary: 'Remove resource limits of given hwgroup from an exercise.' - description: 'Remove resource limits of given hwgroup from an exercise.' + summary: 'Remove resource limits of given hwgroup from an exercise. [DEPRECATED]' + description: "Remove resource limits of given hwgroup from an exercise.\n[DEPRECATED]" operationId: exercisesConfigPresenterActionRemoveHardwareGroupLimits parameters: - @@ -1541,6 +1748,7 @@ paths: required: true schema: type: string + minLength: 1 nullable: false - name: hwGroupId @@ -1549,6 +1757,7 @@ paths: required: true schema: type: string + minLength: 1 nullable: false responses: '200': @@ -1670,10 +1879,14 @@ paths: exerciseId: description: 'Identifier of the exercise' type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 nullable: true groupId: description: 'Identifier of the group' type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 nullable: true type: object responses: @@ -1718,7 +1931,7 @@ paths: required: - version - isPublic - - localizedTexts + - localizedStudentHints - firstDeadline - maxPointsBeforeFirstDeadline - submissionsCountLimit @@ -1742,8 +1955,8 @@ paths: type: boolean example: 'true' nullable: false - localizedTexts: - description: 'A description of the assignment' + localizedStudentHints: + description: 'Additional localized hint texts for students (locale => hint text)' type: array items: { } nullable: false @@ -1863,6 +2076,43 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/exercise-assignments/{id}/localized-texts': + post: + summary: 'Update (only) the localized texts of an assignment. This is a separate operations since the texts are taken over from the exercise. Updating them is an override of the exercise specification and needs to be handled carefully.' + description: 'Update (only) the localized texts of an assignment. This is a separate operations since the texts are taken over from the exercise. Updating them is an override of the exercise specification and needs to be handled carefully.' + operationId: assignmentsPresenterActionUpdateLocalizedTexts + parameters: + - + name: id + in: path + description: 'Identifier of the updated assignment' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + requestBody: + content: + application/json: + schema: + required: + - version + - localizedTexts + properties: + version: + description: 'Version of the edited assignment' + type: integer + example: '0' + nullable: false + localizedTexts: + description: 'Localized texts with exercise/assignment specification' + type: array + items: { } + nullable: false + type: object + responses: + '200': + description: 'Placeholder response' '/v1/exercise-assignments/{id}/solutions': get: summary: 'Get a list of solutions of all users for the assignment' @@ -1939,6 +2189,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -1965,6 +2216,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2015,6 +2267,17 @@ paths: type: string pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false + requestBody: + content: + application/json: + schema: + properties: + syncOptions: + description: 'List of options what to synchronize (if missing, everything is synchronized)' + type: array + items: { type: string, maxLength: 32, minLength: 1, pattern: ^(configurationType|exerciseConfig|exerciseEnvironmentConfigs|exerciseTests|files|fileLinks|hardwareGroups|limits|localizedTexts|mergeJudgeLogs|runtimeEnvironments|scoreConfig)$, example: text } + nullable: false + type: object responses: '200': description: 'Placeholder response' @@ -2040,6 +2303,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: true responses: '200': @@ -2077,10 +2341,13 @@ paths: userId: description: 'Author of the submission' type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 nullable: true files: description: 'Submitted files' - type: string + type: array + items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 } nullable: true runtimeEnvironmentId: description: 'Identifier of the runtime environment used for evaluation' @@ -2151,6 +2418,7 @@ paths: required: false schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: true requestBody: content: @@ -2160,10 +2428,10 @@ paths: - files properties: files: - description: '' + description: 'Submitted files' type: array - items: { } - nullable: false + items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 } + nullable: true type: object responses: '200': @@ -2479,8 +2747,8 @@ paths: description: 'Placeholder response' '/v1/groups/{id}/subgroups': get: - summary: 'Get a list of subgroups of a group' - description: 'Get a list of subgroups of a group' + summary: 'Get a list of subgroups of a group [DEPRECATED]' + description: "Get a list of subgroups of a group\n[DEPRECATED]: Subgroup list is part of group view." operationId: groupsPresenterActionSubgroups parameters: - @@ -2495,6 +2763,7 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/groups/{id}/organizational': post: summary: "Set the 'isOrganizational' flag for a group" @@ -2711,6 +2980,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2755,6 +3025,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2780,6 +3051,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2817,6 +3089,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2855,6 +3128,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2881,6 +3155,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -2906,14 +3181,15 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': description: 'Placeholder response' '/v1/groups/{id}/members': get: - summary: 'Get a list of members of a group' - description: 'Get a list of members of a group' + summary: 'Get a list of members of a group [DEPRECATED]' + description: "Get a list of members of a group\n[DEPRECATED]: Members are listed in group view." operationId: groupsPresenterActionMembers parameters: - @@ -2928,6 +3204,7 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/groups/{id}/members/{userId}': post: summary: 'Add/update a membership (other than student) for given user' @@ -2950,6 +3227,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3001,6 +3279,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3066,6 +3345,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3082,6 +3362,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3507,6 +3788,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3546,6 +3828,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3563,6 +3846,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3580,6 +3864,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3610,6 +3895,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3654,6 +3940,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3682,6 +3969,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3698,6 +3986,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3728,6 +4017,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3774,6 +4064,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3809,6 +4100,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3826,6 +4118,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -3856,6 +4149,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3872,6 +4166,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3889,6 +4184,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -3906,6 +4202,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4347,6 +4644,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -4397,6 +4695,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -4570,8 +4869,8 @@ paths: '200': description: 'Placeholder response' post: - summary: 'Finalize partial upload and convert the partial file into UploadFile. All data chunks are extracted from the store, assembled into one file, and is moved back into the store.' - description: 'Finalize partial upload and convert the partial file into UploadFile. All data chunks are extracted from the store, assembled into one file, and is moved back into the store.' + summary: 'Finalize partial upload and convert the partial file into UploadedFile entity. All data chunks are extracted from the store, assembled into one file, and that is moved back into the store.' + description: 'Finalize partial upload and convert the partial file into UploadedFile entity. All data chunks are extracted from the store, assembled into one file, and that is moved back into the store.' operationId: uploadedFilesPresenterActionCompletePartial parameters: - @@ -4603,6 +4902,24 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/uploaded-files/link/{id}': + get: + summary: 'Download a specific exercise-file via its link. This endpoint is deliberately placed in UploadedFilesPresenter so it works for non-logged-in users as well.' + description: 'Download a specific exercise-file via its link. This endpoint is deliberately placed in UploadedFilesPresenter so it works for non-logged-in users as well.' + operationId: uploadedFilesPresenterActionDownloadExerciseFileByLink + parameters: + - + name: id + in: path + description: 'of the exercise file link entity' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + responses: + '200': + description: 'Placeholder response' /v1/uploaded-files: post: summary: 'Upload a file' @@ -4624,24 +4941,6 @@ paths: responses: '200': description: 'Placeholder response' - '/v1/uploaded-files/supplementary-file/{id}/download': - get: - summary: 'Download exercise file' - description: 'Download exercise file' - operationId: uploadedFilesPresenterActionDownloadExerciseFile - parameters: - - - name: id - in: path - description: 'Identifier of the file' - required: true - schema: - type: string - pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - nullable: false - responses: - '200': - description: 'Placeholder response' '/v1/uploaded-files/{id}': get: summary: 'Get details of a file' @@ -4750,6 +5049,25 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/uploaded-files/supplementary-file/{id}/download': + get: + summary: 'Download exercise file [DEPRECATED]' + description: "Download exercise file\n[DEPRECATED]: use generic uploaded-file download endpoint instead" + operationId: uploadedFilesPresenterActionDownloadExerciseFile + parameters: + - + name: id + in: path + description: 'Identifier of the file' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + responses: + '200': + description: 'Placeholder response' + deprecated: true /v1/users: get: summary: 'Get a list of all users matching given filters in given pagination rage. The result conforms to pagination protocol.' @@ -4868,8 +5186,8 @@ paths: instanceId: description: 'Identifier of the instance to register in' type: string - minLength: 1 - example: text + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 nullable: false titlesBeforeName: description: 'Titles which is placed before user name' @@ -5974,6 +6292,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6065,6 +6384,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -6432,6 +6752,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -6592,7 +6913,9 @@ paths: groupId: description: 'Identifier of the group' type: string - nullable: true + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 + nullable: false type: object responses: '200': @@ -6655,7 +6978,8 @@ paths: userId: description: 'Identifier of the user which is marked as awardee for points' type: string - example: text + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + example: 10000000-2000-4000-8000-160000000000 nullable: false points: description: 'Number of points assigned to the user' @@ -6689,6 +7013,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -6730,6 +7055,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -7122,6 +7448,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false responses: '200': @@ -7147,6 +7474,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false requestBody: content: @@ -7211,6 +7539,7 @@ paths: required: true schema: type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' nullable: false - name: locale