diff --git a/app/Http/Controllers/Admin/ExportController.php b/app/Http/Controllers/Admin/ExportController.php new file mode 100644 index 000000000..bd051c5b6 --- /dev/null +++ b/app/Http/Controllers/Admin/ExportController.php @@ -0,0 +1,33 @@ +validate([ + 'type' => 'required|string', + ]); + + $filePath = $this->exporter->export($request->type); + + return response()->download($filePath)->deleteFileAfterSend(); + } +} diff --git a/app/Models/Chapter.php b/app/Models/Chapter.php index 336d2201f..cd5066b6b 100644 --- a/app/Models/Chapter.php +++ b/app/Models/Chapter.php @@ -4,9 +4,9 @@ use App\Presenters\ChapterPresenter; use Hemp\Presenter\Presentable; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; @@ -16,6 +16,7 @@ class Chapter extends Model { use Presentable; + use HasFactory; public string $defaultPresenter = ChapterPresenter::class; diff --git a/app/Models/Exercise.php b/app/Models/Exercise.php index 548bb79a5..5f8e8e7f2 100644 --- a/app/Models/Exercise.php +++ b/app/Models/Exercise.php @@ -4,6 +4,7 @@ use App\Presenters\ExercisePresenter; use Hemp\Presenter\Presentable; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -17,6 +18,7 @@ class Exercise extends Model { use Presentable; + use HasFactory; public string $defaultPresenter = ExercisePresenter::class; diff --git a/app/Services/AnalyticsExporter.php b/app/Services/AnalyticsExporter.php new file mode 100644 index 000000000..855ef31dd --- /dev/null +++ b/app/Services/AnalyticsExporter.php @@ -0,0 +1,105 @@ + $this->exportUsers("export/users.csv"), + 'chapters' => $this->exportChapters("export/chapters.csv"), + 'exercises' => $this->exportExercises("export/exercises.csv"), + 'solutions' => $this->exportSolutions("export/solutions.csv"), + 'comments' => $this->exportComments("export/comments.csv"), + 'activity' => $this->exportActivityLog("export/activity.csv"), + default => throw new \InvalidArgumentException("Unknown export type: {$type}"), + }; + } + + private function exportUsers(string $path): string + { + $data = User::select(['id', 'name', 'email', 'email_verified_at', 'github_name', 'points', 'created_at', 'is_admin']) + ->get(); + + return $this->writeCsv($data, $path); + } + + private function exportChapters(string $path): string + { + $data = Chapter::select(['id', 'path', 'parent_id', 'created_at']) + ->get(); + + return $this->writeCsv($data, $path); + } + + private function exportExercises(string $path): string + { + $data = Exercise::select(['id', 'chapter_id', 'path', 'created_at']) + ->get(); + + return $this->writeCsv($data, $path); + } + + private function exportSolutions(string $path): string + { + $data = Solution::select(['id', 'exercise_id', 'user_id', 'created_at']) + ->get(); + + return $this->writeCsv($data, $path); + } + + private function exportComments(string $path): string + { + $data = Comment::select(['id', 'user_id', 'commentable_type', 'commentable_id', 'parent_id', 'created_at']) + ->get(); + + return $this->writeCsv($data, $path); + } + + private function exportActivityLog(string $path): string + { + $data = Activity::select(['id', 'log_name', 'subject_id', 'subject_type', 'causer_id', 'event', 'created_at']) + ->get(); + + return $this->writeCsv($data, $path); + } + + private function writeCsv(Collection $collection, string $path): string + { + $disk = Storage::disk(); + $directory = dirname($path); + + if (!$disk->exists($directory)) { + $disk->makeDirectory($directory); + } + + $csv = Writer::createFromPath( + $disk->path($path), + 'w+' + ); + + $csv->setDelimiter(','); + $csv->setNewline("\n"); + + if ($collection->isNotEmpty()) { + $csv->insertOne(array_keys($collection->first()->getAttributes())); + + foreach ($collection as $item) { + $csv->insertOne(array_values($item->getAttributes())); + } + } + + return $disk->path($path); + } +} diff --git a/composer.json b/composer.json index a5b805b8a..eb9d5a8e4 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "laravel/socialite": "^5.11", "laravel/tinker": "^2.8", "laravel/ui": "^v4.2.3", + "league/csv": "^9.27", "mcamara/laravel-localization": "^2.0", "mikehaertl/php-shellcommand": "^1.7", "rollbar/rollbar-laravel": "^8.1.3", diff --git a/composer.lock b/composer.lock index 1decdc586..f3218de45 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "76ba7132a044b1e7ba7877e41dbe55f0", + "content-hash": "a1d58db17b678781ed2a56b354a4e3bf", "packages": [ { "name": "brick/math", @@ -3135,6 +3135,97 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/csv", + "version": "9.27.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/26de738b8fccf785397d05ee2fc07b6cd8749797", + "reference": "26de738b8fccf785397d05ee2fc07b6cd8749797", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-xdebug": "*", + "friendsofphp/php-cs-fixer": "^3.75.0", + "phpbench/phpbench": "^1.4.1", + "phpstan/phpstan": "^1.12.27", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-strict-rules": "^1.6.2", + "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.3.6", + "symfony/var-dumper": "^6.4.8 || ^7.3.0" + }, + "suggest": { + "ext-dom": "Required to use the XMLConverter and the HTMLConverter classes", + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters", + "ext-mbstring": "Needed to ease transcoding CSV using mb stream filters", + "ext-mysqli": "Requiered to use the package with the MySQLi extension", + "ext-pdo": "Required to use the package with the PDO extension", + "ext-pgsql": "Requiered to use the package with the PgSQL extension", + "ext-sqlite3": "Required to use the package with the SQLite3 extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "League\\Csv\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "CSV data manipulation made easy in PHP", + "homepage": "https://csv.thephpleague.com", + "keywords": [ + "convert", + "csv", + "export", + "filter", + "import", + "read", + "transform", + "write" + ], + "support": { + "docs": "https://csv.thephpleague.com", + "issues": "https://github.com/thephpleague/csv/issues", + "rss": "https://github.com/thephpleague/csv/releases.atom", + "source": "https://github.com/thephpleague/csv" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-10-25T08:35:20+00:00" + }, { "name": "league/flysystem", "version": "3.30.2", @@ -6097,20 +6188,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -6169,9 +6260,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "rollbar/rollbar", @@ -12812,12 +12903,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "415fded4fc00cd3df267d36e384e3e7454b39c40" + "reference": "1553067758ae7f3df13df7c7e232c62d928e1d23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/415fded4fc00cd3df267d36e384e3e7454b39c40", - "reference": "415fded4fc00cd3df267d36e384e3e7454b39c40", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/1553067758ae7f3df13df7c7e232c62d928e1d23", + "reference": "1553067758ae7f3df13df7c7e232c62d928e1d23", "shasum": "" }, "conflict": { @@ -13088,6 +13179,7 @@ "floriangaerber/magnesium": "<0.3.1", "fluidtypo3/vhs": "<5.1.1", "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", + "fof/pretty-mail": "<=1.1.2", "fof/upload": "<1.2.3", "foodcoopshop/foodcoopshop": ">=3.2,<3.6.1", "fooman/tcpdf": "<6.2.22", @@ -13288,6 +13380,7 @@ "microsoft/microsoft-graph-core": "<2.0.2", "microweber/microweber": "<=2.0.19", "mikehaertl/php-shellcommand": "<1.6.1", + "mineadmin/mineadmin": "<=3.0.9", "miniorange/miniorange-saml": "<1.4.3", "mittwald/typo3_forum": "<1.2.1", "mobiledetect/mobiledetectlib": "<2.8.32", @@ -13805,7 +13898,7 @@ "type": "tidelift" } ], - "time": "2025-12-11T17:09:09+00:00" + "time": "2025-12-12T23:06:01+00:00" }, { "name": "sebastian/cli-parser", @@ -15356,5 +15449,5 @@ "ext-zip": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/database/factories/ChapterFactory.php b/database/factories/ChapterFactory.php new file mode 100644 index 000000000..b540b3fd7 --- /dev/null +++ b/database/factories/ChapterFactory.php @@ -0,0 +1,21 @@ + $this->faker->word, + 'parent_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } +} diff --git a/database/factories/ExerciseFactory.php b/database/factories/ExerciseFactory.php new file mode 100644 index 000000000..7dfb3a6e2 --- /dev/null +++ b/database/factories/ExerciseFactory.php @@ -0,0 +1,22 @@ + Chapter::factory(), + 'path' => $this->faker->word, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 8801f3fbe..5f70710ef 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -17,9 +17,10 @@ public function definition(): array 'email' => $this->faker->safeEmail, 'email_verified_at' => now(), 'github_name' => $this->faker->userName, - // password + 'points' => $this->faker->numberBetween(0, 1000), 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', - 'remember_token' => str_random(10), + 'remember_token' => \Str::random(10), + 'is_admin' => false, ]; } } diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index e6729cf88..59f6e77fa 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -83,4 +83,19 @@ 'empty' => 'No solutions found', 'empty_user' => 'No solutions found for this user', ], + 'export' => [ + 'title' => 'Data Export', + 'select' => 'Select data to export', + 'button' => 'Export', + 'all' => 'All data', + + 'types' => [ + 'users' => 'Users', + 'chapters' => 'Chapters', + 'exercises' => 'Exercises', + 'solutions' => 'Solutions', + 'comments' => 'Comments', + 'activity' => 'Activity log', + ], + ], ]; diff --git a/resources/lang/ru/admin.php b/resources/lang/ru/admin.php index bd95d0f5a..daff1eaa9 100644 --- a/resources/lang/ru/admin.php +++ b/resources/lang/ru/admin.php @@ -82,4 +82,19 @@ 'empty' => 'Решения не найдены', 'empty_user' => 'Решения этого пользователя не найдены', ], + 'export' => [ + 'title' => 'Экспорт данных', + 'select' => 'Выберите данные для экспорта', + 'button' => 'Выгрузить', + 'all' => 'Все данные', + + 'types' => [ + 'users' => 'Пользователи', + 'chapters' => 'Главы', + 'exercises' => 'Упражнения', + 'solutions' => 'Решения', + 'comments' => 'Комментарии', + 'activity' => 'Журнал активности', + ], + ], ]; diff --git a/resources/views/admin/export.blade.php b/resources/views/admin/export.blade.php new file mode 100644 index 000000000..4cab64d5f --- /dev/null +++ b/resources/views/admin/export.blade.php @@ -0,0 +1,45 @@ +@extends('layouts.app') + +@section('title', __('admin.export.title')) + +@section('content') +
+
+ @include('admin.partials.navigation') +
+ +
+
+
+

+ {{ __('admin.export.title') }} +

+ +
+ @csrf + +
+ + + +
+ + +
+
+
+
+
+@endsection diff --git a/resources/views/admin/partials/navigation.blade.php b/resources/views/admin/partials/navigation.blade.php index 1f2e579c5..1b40d5eaa 100644 --- a/resources/views/admin/partials/navigation.blade.php +++ b/resources/views/admin/partials/navigation.blade.php @@ -11,4 +11,8 @@ href="{{ route('admin.solutions.index', request()->only(['filter'])) }}"> {{ __('admin.solutions.title') }} + + {{ __('admin.export.title') }} + diff --git a/resources/views/layouts/_nav.blade.php b/resources/views/layouts/_nav.blade.php index acbc47828..c23b2074e 100644 --- a/resources/views/layouts/_nav.blade.php +++ b/resources/views/layouts/_nav.blade.php @@ -56,6 +56,9 @@ class="nav-link link-info p-2">{{ __('layout.nav.sicp_book') }} {{ __('admin.solutions.title') }} + + {{ __('admin.export.title') }} + diff --git a/routes/web.php b/routes/web.php index 67afccb9c..bc9dad545 100644 --- a/routes/web.php +++ b/routes/web.php @@ -56,6 +56,7 @@ Route::resource('users', 'UserController')->only('index'); Route::resource('comments', 'CommentController')->only('index'); Route::resource('solutions', 'SolutionController')->only('index'); + Route::resource('export', 'ExportController')->only('index', 'store'); }); Route::fallback(function () { diff --git a/tests/Feature/Http/Controllers/Admin/ExportControllerTest.php b/tests/Feature/Http/Controllers/Admin/ExportControllerTest.php new file mode 100644 index 000000000..e433614eb --- /dev/null +++ b/tests/Feature/Http/Controllers/Admin/ExportControllerTest.php @@ -0,0 +1,48 @@ +deleteDirectory('export'); + + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $response = $this->post(route('admin.export.store'), [ + 'type' => 'users', + ]); + + $response->assertStatus(200); + $response->assertHeader('content-disposition'); + + $filePath = storage_path('app/export/users.csv'); + + $this->assertFileExists($filePath); + + $content = file_get_contents($filePath); + $this->assertStringContainsString((string) $user->id, $content); + $this->assertStringContainsString('John Doe', $content); + $this->assertStringContainsString('john@example.com', $content); + } + + public function testExportInvalidTypeThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->post(route('admin.export.store'), [ + 'type' => 'invalid_type', + ]); + } +} diff --git a/tests/Feature/Services/AnalyticsExporterTest.php b/tests/Feature/Services/AnalyticsExporterTest.php new file mode 100644 index 000000000..e3aee6093 --- /dev/null +++ b/tests/Feature/Services/AnalyticsExporterTest.php @@ -0,0 +1,84 @@ +seed(); + + $service = new AnalyticsExporter(); + + $types = [ + 'users' => 'export/users.csv', + 'chapters' => 'export/chapters.csv', + 'exercises' => 'export/exercises.csv', + 'solutions' => 'export/solutions.csv', + 'comments' => 'export/comments.csv', + 'activity' => 'export/activity.csv', + ]; + + foreach ($types as $type => $path) { + $service->export($type); + Storage::assertExists($path); + } +} + + public function testUsersCsvContainsCorrectHeadersAndRow(): void + { + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $service = new AnalyticsExporter(); + + $service->export('users'); + + $content = Storage::get('export/users.csv'); + $lines = explode("\n", trim($content)); + + $expectedHeader = [ + 'id', + 'name', + 'email', + 'email_verified_at', + 'github_name', + 'points', + 'created_at', + 'is_admin', + ]; + + $this->assertEquals($expectedHeader, str_getcsv($lines[0])); + $this->assertStringContainsString((string) $user->id, $lines[1]); + $this->assertStringContainsString('John Doe', $lines[1]); + $this->assertStringContainsString('john@example.com', $lines[1]); + } + + public function testEmptyTablesProduceEmptyCsv(): void + { + $service = new AnalyticsExporter(); + + $service->export('chapters'); + + $content = Storage::get('export/chapters.csv'); + $this->assertSame('', $content); + } +}