diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index 7846873..5c11917 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -19,7 +19,9 @@ jobs: - name: Copy .env run: php -r "file_exists('.env') || copy('.env.example', '.env');" - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + run: | + composer update --lock + composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - name: Generate key run: php artisan key:generate - name: Directory Permissions diff --git a/app/Http/Controllers/Gui/SquidUserController.php b/app/Http/Controllers/Gui/SquidUserController.php index f220406..ea34292 100644 --- a/app/Http/Controllers/Gui/SquidUserController.php +++ b/app/Http/Controllers/Gui/SquidUserController.php @@ -3,12 +3,16 @@ namespace App\Http\Controllers\Gui; use App\Http\Controllers\Controller; +use App\Http\Requests\SquidUser\BulkImportRequest; use App\Http\Requests\SquidUser\CreateRequest; use App\Http\Requests\SquidUser\DestroyRequest; use App\Http\Requests\SquidUser\ModifyRequest; use App\Http\Requests\SquidUser\ReadRequest; use App\Http\Requests\SquidUser\SearchRequest; use App\Services\SquidUserService; +use App\UseCases\SquidUser\BulkCreateAction; +use App\UseCases\SquidUser\BulkDeleteAction; +use App\UseCases\SquidUser\BulkUpdateAction; use App\UseCases\SquidUser\CreateAction; use App\UseCases\SquidUser\DestroyAction; use App\UseCases\SquidUser\ModifyAction; @@ -19,11 +23,9 @@ class SquidUserController extends Controller { - private $squidUserService; - - public function __construct(SquidUserService $squidUserService) - { - $this->squidUserService = $squidUserService; + public function __construct( + private readonly SquidUserService $squidUserService + ) { } public function search(SearchRequest $request, SearchAction $action): View @@ -72,4 +74,33 @@ public function destroy(DestroyRequest $request, DestroyAction $action): Redirec return redirect()->route('squiduser.search', $request->user()->id); } + + public function bulkImporter(): View + { + return view('squidusers.bulk_import'); + } + + public function bulkImport(BulkImportRequest $request): RedirectResponse + { + $rows = $request->parseCsv(); + $operation = $request->input('operation'); + $userId = $request->user()->id; + + $results = match ($operation) { + 'create' => (new BulkCreateAction())($rows, $userId), + 'update' => (new BulkUpdateAction())($rows, $userId), + 'delete' => (new BulkDeleteAction())($rows, $userId), + default => ['success' => 0, 'failed' => 0, 'errors' => ['Invalid operation']], + }; + + $message = "Success: {$results['success']}, Failed: {$results['failed']}"; + if (!empty($results['errors'])) { + $message .= "\nErrors: " . implode("\n", $results['errors']); + } + + return redirect() + ->route('squiduser.bulk.importer') + ->with('message', $message) + ->with('results', $results); + } } diff --git a/app/Http/Controllers/Gui/UserController.php b/app/Http/Controllers/Gui/UserController.php index 4a696ed..e7c9ec2 100644 --- a/app/Http/Controllers/Gui/UserController.php +++ b/app/Http/Controllers/Gui/UserController.php @@ -19,11 +19,9 @@ class UserController extends Controller { - private $user; - - public function __construct(UserService $user) - { - $this->user = $user; + public function __construct( + private readonly UserService $user + ) { } public function create(CreateRequest $request, CreateAction $action): RedirectResponse diff --git a/app/Http/Requests/SquidUser/BulkImportRequest.php b/app/Http/Requests/SquidUser/BulkImportRequest.php new file mode 100644 index 0000000..3380a23 --- /dev/null +++ b/app/Http/Requests/SquidUser/BulkImportRequest.php @@ -0,0 +1,78 @@ +allows('create-squid-user', $this->user()->id); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'csv_file' => 'required|file|mimes:csv,txt|max:10240', + 'operation' => 'required|in:create,update,delete', + ]; + } + + public function parseCsv(): array + { + $file = $this->file('csv_file'); + $handle = fopen($file->getRealPath(), 'r'); + + if ($handle === false) { + throw new \RuntimeException('Failed to open CSV file'); + } + + $rows = []; + $header = null; + $lineNumber = 0; + + try { + while (($data = fgetcsv($handle, 0, ',')) !== false) { + $lineNumber++; + + if ($header === null) { + $header = array_map('trim', $data); + continue; + } + + // Skip empty lines + if (empty(array_filter($data))) { + continue; + } + + // Ensure column count matches header + if (count($data) !== count($header)) { + throw new \RuntimeException( + "Line {$lineNumber}: Column count mismatch. Expected " . count($header) . " columns, got " . count($data) + ); + } + + $row = array_combine($header, array_map('trim', $data)); + if ($row !== false) { + $rows[] = $row; + } + } + } finally { + fclose($handle); + } + + if (empty($rows)) { + throw new \RuntimeException('CSV file is empty or contains no valid data rows'); + } + + return $rows; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index b4f4123..823847a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -43,7 +43,7 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', ]; - public function setPasswordAttribute($value) + public function setPasswordAttribute(string $value): void { $this->attributes['password'] = Hash::needsRehash($value) ? Hash::make($value) : $value; } diff --git a/app/Services/SquidAllowedIpService.php b/app/Services/SquidAllowedIpService.php index d9b6a64..e8f9933 100644 --- a/app/Services/SquidAllowedIpService.php +++ b/app/Services/SquidAllowedIpService.php @@ -6,10 +6,8 @@ class SquidAllowedIpService { - public function getById($id) : SquidAllowedIp + public function getById(int|string $id): SquidAllowedIp { - $ip = SquidAllowedIp::query()->where('id', '=', $id)->first(); - - return $ip ?? new SquidAllowedIp(); + return SquidAllowedIp::query()->findOr($id, fn() => new SquidAllowedIp()); } } diff --git a/app/Services/SquidUserService.php b/app/Services/SquidUserService.php index 191b950..a822582 100644 --- a/app/Services/SquidUserService.php +++ b/app/Services/SquidUserService.php @@ -6,10 +6,8 @@ class SquidUserService { - public function getById($id) : SquidUser + public function getById(int|string $id): SquidUser { - $su = SquidUser::query()->where('id', '=', $id)->first(); - - return $su ?? new SquidUser(); + return SquidUser::query()->findOr($id, fn() => new SquidUser()); } } diff --git a/app/Services/UserService.php b/app/Services/UserService.php index b5553db..7d54f22 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -6,10 +6,8 @@ class UserService { - public function getById($id) : User + public function getById(int|string $id): User { - $user = User::query()->where('id', '=', $id)->first(); - - return $user ?? new User(); + return User::query()->findOr($id, fn() => new User()); } } diff --git a/app/UseCases/SquidUser/BulkCreateAction.php b/app/UseCases/SquidUser/BulkCreateAction.php new file mode 100644 index 0000000..6147392 --- /dev/null +++ b/app/UseCases/SquidUser/BulkCreateAction.php @@ -0,0 +1,45 @@ + 0, + 'failed' => 0, + 'errors' => [], + ]; + + DB::beginTransaction(); + + try { + foreach ($rows as $index => $row) { + $squidUser = new SquidUser([ + 'user' => $row['user'] ?? '', + 'password' => $row['password'] ?? '', + 'enabled' => $row['enabled'] ?? 1, + 'fullname' => $row['fullname'] ?? '', + 'comment' => $row['comment'] ?? '', + ]); + $squidUser->user_id = $userId; + $squidUser->save(); + + $results['success']++; + } + + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + $results['failed'] = count($rows); + $results['success'] = 0; + $results['errors'][] = $e->getMessage(); + } + + return $results; + } +} diff --git a/app/UseCases/SquidUser/BulkDeleteAction.php b/app/UseCases/SquidUser/BulkDeleteAction.php new file mode 100644 index 0000000..4644de8 --- /dev/null +++ b/app/UseCases/SquidUser/BulkDeleteAction.php @@ -0,0 +1,45 @@ + 0, + 'failed' => 0, + 'errors' => [], + ]; + + DB::beginTransaction(); + + try { + foreach ($rows as $index => $row) { + $squidUser = SquidUser::query() + ->where('user', $row['user'] ?? '') + ->where('user_id', $userId) + ->first(); + + if (!$squidUser) { + throw new \Exception("Row " . ($index + 2) . ": User '{$row['user']}' not found"); + } + + $squidUser->delete(); + $results['success']++; + } + + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + $results['failed'] = count($rows); + $results['success'] = 0; + $results['errors'][] = $e->getMessage(); + } + + return $results; + } +} diff --git a/app/UseCases/SquidUser/BulkUpdateAction.php b/app/UseCases/SquidUser/BulkUpdateAction.php new file mode 100644 index 0000000..eee45d9 --- /dev/null +++ b/app/UseCases/SquidUser/BulkUpdateAction.php @@ -0,0 +1,58 @@ + 0, + 'failed' => 0, + 'errors' => [], + ]; + + DB::beginTransaction(); + + try { + foreach ($rows as $index => $row) { + $squidUser = SquidUser::query() + ->where('user', $row['user'] ?? '') + ->where('user_id', $userId) + ->first(); + + if (!$squidUser) { + throw new \Exception("Row " . ($index + 2) . ": User '{$row['user']}' not found"); + } + + if (isset($row['password']) && !empty($row['password'])) { + $squidUser->password = $row['password']; + } + if (isset($row['enabled'])) { + $squidUser->enabled = $row['enabled']; + } + if (isset($row['fullname'])) { + $squidUser->fullname = $row['fullname']; + } + if (isset($row['comment'])) { + $squidUser->comment = $row['comment']; + } + + $squidUser->save(); + $results['success']++; + } + + DB::commit(); + } catch (\Exception $e) { + DB::rollBack(); + $results['failed'] = count($rows); + $results['success'] = 0; + $results['errors'][] = $e->getMessage(); + } + + return $results; + } +} diff --git a/composer.json b/composer.json index a822bd3..4c95928 100644 --- a/composer.json +++ b/composer.json @@ -9,20 +9,20 @@ "license": "MIT", "require": { "php": "^8.1", - "guzzlehttp/guzzle": "^7.2", - "laravel/framework": "^10.4", - "laravel/sanctum": "^3.2", - "laravel/tinker": "^2.8", - "laravel/ui": "^4.2" + "guzzlehttp/guzzle": "^7.9", + "laravel/framework": "^10.48", + "laravel/sanctum": "^3.3", + "laravel/tinker": "^2.10", + "laravel/ui": "^4.5" }, "require-dev": { - "fakerphp/faker": "^1.9.1", - "laravel/sail": "^1.18", - "mockery/mockery": "^1.4.4", - "nunomaduro/collision": "^7.0", - "nunomaduro/larastan": "^2.4", - "phpunit/phpunit": "^9.5.10", - "spatie/laravel-ignition": "^2.0" + "fakerphp/faker": "^1.23", + "laravel/sail": "^1.38", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^7.10", + "nunomaduro/larastan": "^2.9", + "phpunit/phpunit": "^10.5", + "spatie/laravel-ignition": "^2.8" }, "autoload": { "psr-4": { diff --git a/package.json b/package.json index 7b71681..a10b30a 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ "production": "mix --production" }, "devDependencies": { - "@popperjs/core": "^2.10.2", - "axios": "^0.25", - "bootstrap": "^5.1.3", - "laravel-mix": "^6.0.6", - "lodash": "^4.17.19", - "postcss": "^8.1.14", - "sass": "^1.32.11", - "sass-loader": "^11.0.1" + "@popperjs/core": "^2.11.8", + "axios": "^1.7.9", + "bootstrap": "^5.3.3", + "laravel-mix": "^6.0.49", + "lodash": "^4.17.21", + "postcss": "^8.4.49", + "sass": "^1.83.4", + "sass-loader": "^16.0.4" } } diff --git a/resources/views/squidusers/bulk_import.blade.php b/resources/views/squidusers/bulk_import.blade.php new file mode 100644 index 0000000..0297722 --- /dev/null +++ b/resources/views/squidusers/bulk_import.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
{{ __('Bulk Import SquidUsers (CSV)') }}
+ +
+ @if(session('message')) +
+
{{ __('Import Results') }}
+
{{ session('message') }}
+
+ @endif + + @if($errors->any()) +
+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ @csrf + +
+
{{ __('CSV Format Instructions') }}
+
+

{{ __('CSV Header (First Row):') }}

+ user,password,enabled,fullname,comment + +

{{ __('Example for CREATE:') }}

+
user,password,enabled,fullname,comment
+testuser1,password123,1,Test User One,First test user
+testuser2,password456,1,Test User Two,Second test user
+ +

{{ __('Example for UPDATE:') }}

+
user,password,enabled,fullname,comment
+testuser1,newpassword,0,Updated Name,Updated comment
+ {{ __('Note: For UPDATE, the "user" field is used to find existing records. Empty fields will not be updated.') }} + +

{{ __('Example for DELETE:') }}

+
user,password,enabled,fullname,comment
+testuser1,,,,
+ {{ __('Note: For DELETE, only the "user" field is required.') }} +
+
+ +
+ + +
+ +
+ + + + {{ __('Maximum file size: 10MB. Supported formats: .csv, .txt') }} + +
+ +
+ + {{ __('Back to List') }} + + +
+
+ +
+ +
+
{{ __('Important Notes:') }}
+
    +
  • {{ __('The CSV file must use comma (,) as delimiter') }}
  • +
  • {{ __('The first row must contain column headers') }}
  • +
  • {{ __('For CREATE operation, all fields are required except fullname and comment') }}
  • +
  • {{ __('For UPDATE operation, existing users are matched by the "user" field') }}
  • +
  • {{ __('For DELETE operation, only the "user" field is needed') }}
  • +
  • {{ __('All operations are executed in a transaction - if one fails, all will be rolled back') }}
  • +
+
+
+
+
+
+
+@endsection diff --git a/resources/views/squidusers/search.blade.php b/resources/views/squidusers/search.blade.php index 814ab81..49408e0 100644 --- a/resources/views/squidusers/search.blade.php +++ b/resources/views/squidusers/search.blade.php @@ -7,8 +7,13 @@
{{ __('Search SquidUsers') }}
+ - Create SquidUser @can('create-user') diff --git a/routes/web.php b/routes/web.php index 6d80f70..0e4badc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -48,5 +48,7 @@ Route::post('create/to_specified_user/{user_id}', [SquidUserController::class, 'create'])->name('squiduser.create'); Route::post('modify/{id}', [SquidUserController::class, 'modify'])->name('squiduser.modify'); Route::post('destroy/{id}', [SquidUserController::class, 'destroy'])->name('squiduser.destroy'); + Route::get('bulk/importer', [SquidUserController::class, 'bulkImporter'])->name('squiduser.bulk.importer'); + Route::post('bulk/import', [SquidUserController::class, 'bulkImport'])->name('squiduser.bulk.import'); }); });