diff --git a/composer.json b/composer.json index bddc939..a62f395 100644 --- a/composer.json +++ b/composer.json @@ -4,13 +4,13 @@ "type": "package", "require": { "php": "^7.4|^8.0|^8.1", - "darkaonline/l5-swagger": "^8.5", "escolalms/auth": "^0", "escolalms/categories": "^0", "escolalms/core": "^1", "escolalms/settings": "^0", "escolalms/topic-types": "^0", - "laravel/framework": "^8|^9" + "laravel/framework": "^8|^9", + "maatwebsite/excel": "^3.1" }, "require-dev": { "phpunit/phpunit": "^9.0", diff --git a/src/Dtos/Criteria/ExportQuestionsCriteriaDto.php b/src/Dtos/Criteria/ExportQuestionsCriteriaDto.php new file mode 100644 index 0000000..0fe258b --- /dev/null +++ b/src/Dtos/Criteria/ExportQuestionsCriteriaDto.php @@ -0,0 +1,33 @@ +has('topic_gift_quiz_id')) { + $criteria->push(new EqualCriterion('topic_gift_quiz_id', $request->get('topic_gift_quiz_id'))); + } + + if ($request->has('category_ids')) { + $criteria->push(new InCriterion('category_id', $request->get('category_ids'))); + } + + if ($request->has('ids')) { + $criteria->push(new InCriterion('id', $request->get('ids'))); + } + + return new static($criteria); + } +} diff --git a/src/Enum/TopicTypeGiftPermissionEnum.php b/src/Enum/TopicTypeGiftPermissionEnum.php index 213ba4e..d139147 100644 --- a/src/Enum/TopicTypeGiftPermissionEnum.php +++ b/src/Enum/TopicTypeGiftPermissionEnum.php @@ -20,6 +20,8 @@ class TopicTypeGiftPermissionEnum extends BasicEnum public const CREATE_GIFT_QUIZ_QUESTION = 'gift-quiz-question_create'; public const UPDATE_GIFT_QUIZ_QUESTION = 'gift-quiz-question_update'; public const DELETE_GIFT_QUIZ_QUESTION = 'gift-quiz-question_delete'; + public const EXPORT_GIFT_QUIZ_QUESTION = 'gift-quiz-question_export'; + public const IMPORT_GIFT_QUIZ_QUESTION = 'gift-quiz-question_import'; public static function studentPermissions():array { diff --git a/src/Export/QuestionExport.php b/src/Export/QuestionExport.php new file mode 100644 index 0000000..0294dc5 --- /dev/null +++ b/src/Export/QuestionExport.php @@ -0,0 +1,47 @@ +criteria = $criteriaDto; + } + + public function collection(): Collection + { + return app(GiftQuestionRepositoryContract::class) + ->searchByCriteria($this->criteria->toArray()) + ->map(function (GiftQuestion $question) { + return [ + 'value' => $question->value, + 'type' => $question->type, + 'score' => $question->score, + ]; + }); + } + + public function headings(): array + { + return [ + __('Question'), + __('Type'), + __('Score'), + ]; + } +} diff --git a/src/Http/Controllers/GiftQuestionApiAdminController.php b/src/Http/Controllers/GiftQuestionApiAdminController.php index 9adb42c..64d69b6 100644 --- a/src/Http/Controllers/GiftQuestionApiAdminController.php +++ b/src/Http/Controllers/GiftQuestionApiAdminController.php @@ -2,15 +2,22 @@ namespace EscolaLms\TopicTypeGift\Http\Controllers; +use Carbon\Carbon; use EscolaLms\Core\Http\Controllers\EscolaLmsBaseController; +use EscolaLms\TopicTypeGift\Export\QuestionExport; use EscolaLms\TopicTypeGift\Http\Controllers\Swagger\GiftQuestionApiAdminSwagger; use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminCreateGiftQuestionRequest; use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminDeleteGiftQuestionRequest; +use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminExportGiftQuestionsRequest; +use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminImportGiftQuestionsRequest; use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminSortGiftQuestionRequest; use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminUpdateGiftQuestionRequest; use EscolaLms\TopicTypeGift\Http\Resources\AdminGiftQuestionResource; +use EscolaLms\TopicTypeGift\Import\QuestionImport; use EscolaLms\TopicTypeGift\Services\Contracts\GiftQuestionServiceContract; use Illuminate\Http\JsonResponse; +use Maatwebsite\Excel\Facades\Excel; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class GiftQuestionApiAdminController extends EscolaLmsBaseController implements GiftQuestionApiAdminSwagger { @@ -48,4 +55,20 @@ public function sort(AdminSortGiftQuestionRequest $request): JsonResponse return $this->sendSuccess(__('Gift questions sorted successfully.')); } + + public function export(AdminExportGiftQuestionsRequest $request): BinaryFileResponse + { + return Excel::download( + new QuestionExport($request->toDto()), + 'questions.xlsx', + \Maatwebsite\Excel\Excel::XLSX + ); + } + + public function import(AdminImportGiftQuestionsRequest $request): JsonResponse + { + Excel::import(new QuestionImport($request->getQuizId()), $request->getFile()); + + return $this->sendSuccess(__('Gift questions imported successfully.')); + } } diff --git a/src/Http/Controllers/Swagger/GiftQuestionApiAdminSwagger.php b/src/Http/Controllers/Swagger/GiftQuestionApiAdminSwagger.php index 39323e0..69c3ec7 100644 --- a/src/Http/Controllers/Swagger/GiftQuestionApiAdminSwagger.php +++ b/src/Http/Controllers/Swagger/GiftQuestionApiAdminSwagger.php @@ -4,9 +4,12 @@ use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminCreateGiftQuestionRequest; use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminDeleteGiftQuestionRequest; +use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminExportGiftQuestionsRequest; +use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminImportGiftQuestionsRequest; use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminSortGiftQuestionRequest; use EscolaLms\TopicTypeGift\Http\Requests\Admin\AdminUpdateGiftQuestionRequest; use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\BinaryFileResponse; interface GiftQuestionApiAdminSwagger { @@ -184,4 +187,96 @@ public function delete(AdminDeleteGiftQuestionRequest $request): JsonResponse; * ) */ public function sort(AdminSortGiftQuestionRequest $request): JsonResponse; + + /** + * @OA\Get( + * path="/api/admin/gift-questions/export", + * summary="Export Gift Questions", + * tags={"Admin Gift Question"}, + * description="Export Gift Questions", + * security={ + * {"passport": {}}, + * }, + * @OA\Parameter( + * name="topic_gift_quiz_id", + * required=false, + * in="path", + * @OA\Schema( + * type="integer", + * ), + * ), + * @OA\Parameter( + * name="category_ids[]", + * required=false, + * in="query", + * @OA\Schema( + * type="array", + * @OA\Items( + * @OA\Schema( + * type="integer" + * ), + * ), + * ), + * ), + * @OA\Parameter( + * name="ids[]", + * required=false, + * in="query", + * @OA\Schema( + * type="array", + * @OA\Items( + * @OA\Schema( + * type="integer" + * ), + * ), + * ), + * ), + * @OA\Response( + * response=200, + * description="successful operation", + * @OA\MediaType( + * mediaType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + * ), + * ), + * @OA\Response( + * response=422, + * description="Bad request", + * @OA\MediaType( + * mediaType="application/json" + * ) + * ) + * ) + */ + public function export(AdminExportGiftQuestionsRequest $request): BinaryFileResponse; + + /** + * @OA\Post( + * path="/api/admin/gift-questions/import", + * summary="Import Gift Questions", + * tags={"Admin Gift Question"}, + * description="Import Gift Questions", + * security={ + * {"passport": {}}, + * }, + * @OA\RequestBody( + * required=true, + * @OA\MediaType( + * mediaType="multipart/form-data", + * @OA\Schema(ref="#/components/schemas/AdminImportGiftQuestionsRequest") + * ), + * ), + * @OA\Response( + * response=200, + * description="successful operation", + * ), + * @OA\Response( + * response=422, + * description="Bad request", + * @OA\MediaType( + * mediaType="application/json" + * ) + * ) + * ) + */ + public function import(AdminImportGiftQuestionsRequest $request): JsonResponse; } diff --git a/src/Http/Requests/Admin/AdminExportGiftQuestionsRequest.php b/src/Http/Requests/Admin/AdminExportGiftQuestionsRequest.php new file mode 100644 index 0000000..165bebc --- /dev/null +++ b/src/Http/Requests/Admin/AdminExportGiftQuestionsRequest.php @@ -0,0 +1,31 @@ +user()->can(TopicTypeGiftPermissionEnum::EXPORT_GIFT_QUIZ_QUESTION); + } + + public function rules(): array + { + return [ + 'topic_gift_quiz_id' => ['sometimes', 'integer'], + 'category_ids' => ['sometimes', 'array'], + 'category_ids.*' => ['sometimes', 'integer'], + 'ids' => ['sometimes', 'array'], + 'ids.*' => ['sometimes', 'integer'], + ]; + } + + public function toDto(): ExportQuestionsCriteriaDto + { + return ExportQuestionsCriteriaDto::instantiateFromRequest($this); + } +} diff --git a/src/Http/Requests/Admin/AdminImportGiftQuestionsRequest.php b/src/Http/Requests/Admin/AdminImportGiftQuestionsRequest.php new file mode 100644 index 0000000..da3835b --- /dev/null +++ b/src/Http/Requests/Admin/AdminImportGiftQuestionsRequest.php @@ -0,0 +1,50 @@ +user()->can(TopicTypeGiftPermissionEnum::IMPORT_GIFT_QUIZ_QUESTION); + } + + public function rules(): array + { + return [ + 'topic_gift_quiz_id' => ['required', 'integer', 'exists:topic_gift_quizzes,id'], + 'file' => ['required', 'file'], + ]; + } + + public function getQuizId(): int + { + return $this->get('topic_gift_quiz_id'); + } + + public function getFile(): UploadedFile + { + return $this->file('file'); + } +} diff --git a/src/Import/QuestionImport.php b/src/Import/QuestionImport.php new file mode 100644 index 0000000..95f5505 --- /dev/null +++ b/src/Import/QuestionImport.php @@ -0,0 +1,42 @@ +quizId = $quizId; + } + + public function collection(Collection $collection): void + { + DB::transaction(function () use ($collection) { + /** @var GiftQuestionServiceContract $service */ + $service = app(GiftQuestionServiceContract::class); + + foreach ($collection as $item) { + $dto = new GiftQuestionDto($this->quizId, $item->get('question'), $item->get('score'), null, null); + $service->create($dto); + } + }); + } + + public function rules(): array + { + return [ + '*.question' => ['required', 'string'], + '*.score' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/src/routes.php b/src/routes.php index c385072..29afc58 100644 --- a/src/routes.php +++ b/src/routes.php @@ -15,6 +15,8 @@ Route::delete('{id}', [GiftQuestionApiAdminController::class, 'delete']); Route::put('{id}', [GiftQuestionApiAdminController::class, 'update']); Route::post('sort', [GiftQuestionApiAdminController::class, 'sort']); + Route::get('export', [GiftQuestionApiAdminController::class, 'export']); + Route::post('import', [GiftQuestionApiAdminController::class, 'import']); }); Route::prefix('quiz-attempts')->group(function () { diff --git a/tests/Api/Admin/AdminExportGiftQuestionTest.php b/tests/Api/Admin/AdminExportGiftQuestionTest.php new file mode 100644 index 0000000..afdd047 --- /dev/null +++ b/tests/Api/Admin/AdminExportGiftQuestionTest.php @@ -0,0 +1,88 @@ +seed(TopicTypeGiftPermissionSeeder::class); + } + + public function testAdminExportGiftQuestionsUnauthorized(): void + { + $this->getJson('api/admin/gift-questions/export') + ->assertUnauthorized(); + } + + public function testAdminExportGifQuestionsByCategory(): void + { + Excel::fake(); + GiftQuestion::factory()->count(5); + + $category = Category::factory()->create(); + GiftQuestion::factory() + ->count(3) + ->state(['category_id' => $category->getKey()]) + ->create(); + + $this->actingAs($this->makeAdmin(), 'api') + ->getJson('api/admin/gift-questions/export?category_ids[]=' . $category->getKey()) + ->assertOk(); + + Excel::assertDownloaded('questions.xlsx', function (QuestionExport $export) { + $this->assertCount(3, $export->collection()); + $this->assertEquals([__('Question'), __('Type'), __('Score')], $export->headings()); + return true; + }); + } + + public function testAdminExportGifQuestionsByQuiz(): void + { + Excel::fake(); + GiftQuestion::factory()->count(5); + + $quiz = GiftQuiz::factory() + ->has(GiftQuestion::factory()->count(3), 'questions') + ->create(); + + $this->actingAs($this->makeAdmin(), 'api') + ->getJson('api/admin/gift-questions/export?topic_gift_quiz_id=' . $quiz->getKey()) + ->assertOk(); + + Excel::assertDownloaded('questions.xlsx', function (QuestionExport $export) { + $this->assertCount(3, $export->collection()); + return true; + }); + } + + public function testAdminExportGifQuestionsByIds(): void + { + Excel::fake(); + GiftQuestion::factory()->count(5); + + $question1 = GiftQuestion::factory()->create(); + $question2 = GiftQuestion::factory()->create(); + + $this->actingAs($this->makeAdmin(), 'api') + ->getJson('api/admin/gift-questions/export?ids[]=' . $question1->getKey() . '&ids[]=' . $question2->getKey()) + ->assertOk(); + + Excel::assertDownloaded('questions.xlsx', function (QuestionExport $export) { + $this->assertCount(2, $export->collection()); + return true; + }); + } +} diff --git a/tests/Api/Admin/AdminImportGiftQuestionTest.php b/tests/Api/Admin/AdminImportGiftQuestionTest.php new file mode 100644 index 0000000..aa10778 --- /dev/null +++ b/tests/Api/Admin/AdminImportGiftQuestionTest.php @@ -0,0 +1,89 @@ +seed(TopicTypeGiftPermissionSeeder::class); + } + + public function testAdminImportGiftQuestionsUnauthorized(): void + { + $this + ->postJson('api/admin/gift-questions/import') + ->assertUnauthorized(); + } + + public function testAdminImportGiftQuestionsRequiredValidation(): void + { + $this + ->actingAs($this->makeAdmin(), 'api') + ->postJson('api/admin/gift-questions/import') + ->assertJsonValidationErrors(['topic_gift_quiz_id', 'file']); + } + + public function testAdminImportGiftQuestions(): void + { + Excel::fake(); + + /** @var GiftQuiz $quiz */ + $quiz = GiftQuiz::factory()->create(); + + $this + ->actingAs($this->makeAdmin(), 'api') + ->postJson('api/admin/gift-questions/import', [ + 'topic_gift_quiz_id' => $quiz->getKey(), + 'file' => UploadedFile::fake()->create('questions.xlsx'), + ]); + + $data = $this->prepareImportData(); + + Excel::assertImported('questions.xlsx', function (QuestionImport $import) use ($data) { + $import->collection($data); + $this->assertEquals([ + '*.question' => ['required', 'string'], + '*.score' => ['required', 'integer', 'min:1'], + ], $import->rules()); + + return true; + }); + + $quiz->refresh(); + $this->assertCount(2, $quiz->questions); + } + + private function prepareImportData(): Collection + { + $collection = collect(); + + $collection->push(collect([ + 'question' => 'Who\'s buried in Grant\'s tomb?{=Grant ~no one ~Napoleon ~Churchill ~Mother Teresa }', + 'type' => QuestionTypeEnum::MULTIPLE_CHOICE, + 'score' => $this->faker->numberBetween(1, 10), + ])); + + $collection->push(collect([ + 'question' => '::TrueStatement about Grant::Grant was buried in a tomb in New York City.{T}', + 'type' => QuestionTypeEnum::TRUE_FALSE, + 'score' => $this->faker->numberBetween(1, 10), + ])); + + + return $collection; + } +}