Add bulk update gateway endpoint to vCenter API routes
- Introduced a new POST route 'bulk-update-gateway' for bulk updating gateways. - This route is protected by the 'module-vcenter.manager' permission middleware.
This commit is contained in:
@@ -15,8 +15,11 @@ use Maatwebsite\Excel\Validators\ValidationException;
|
||||
|
||||
class IpPoolController extends Controller
|
||||
{
|
||||
/**
|
||||
* Descriptive/CSV-import fields — plain pass-through, unlike gateway/cidr/netmask/
|
||||
* status/service_id which have defaulting + module_ip_address mirror logic below.
|
||||
*/
|
||||
private const EDITABLE_FIELDS = [
|
||||
'gateway', 'netmask', 'service_id', 'status',
|
||||
'owner_type', 'ip_role', 'parent_ip', 'customer_name', 'service_name',
|
||||
'allocated_at', 'revoked_at', 'allocation_status',
|
||||
'contact_email', 'contact_cc', 'contact_phone',
|
||||
@@ -24,9 +27,25 @@ class IpPoolController extends Controller
|
||||
'last_email_type', 'last_email_at', 'updated_by', 'note',
|
||||
];
|
||||
|
||||
/**
|
||||
* contact_email/contact_cc may hold multiple addresses separated by comma/semicolon.
|
||||
*/
|
||||
private function multiEmailRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail) {
|
||||
foreach (preg_split('/[,;]+/', (string) $value) as $email) {
|
||||
$email = trim($email);
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$fail("Email không hợp lệ: {$email}");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 15), 500);
|
||||
// 'all' lets the admin grid load the full dataset once and filter/sort client-side.
|
||||
$perPage = $request->boolean('all') ? 20000 : min((int) $request->get('per_page', 15), 500);
|
||||
$filter = $request->get('filter');
|
||||
$networkId = $request->get('network_id');
|
||||
|
||||
@@ -133,6 +152,10 @@ class IpPoolController extends Controller
|
||||
'netmask' => 'nullable|string|max:45',
|
||||
'status' => 'nullable|in:free,assigned,reserved',
|
||||
'service_id' => 'nullable|integer|exists:services,id',
|
||||
'contact_email' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'contact_cc' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'monthly_cost' => 'nullable|numeric',
|
||||
'selling_price' => 'nullable|numeric',
|
||||
]);
|
||||
|
||||
$networkId = (int) $request->module_network_id;
|
||||
@@ -174,6 +197,7 @@ class IpPoolController extends Controller
|
||||
$hostId = ModuleNetwork::find($networkId)?->module_host_id;
|
||||
$netmask = $request->netmask ?: '255.255.255.0';
|
||||
$cidr = $request->cidr ?? 24;
|
||||
$extra = $request->only(self::EDITABLE_FIELDS);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
@@ -187,6 +211,7 @@ class IpPoolController extends Controller
|
||||
'netmask' => $netmask,
|
||||
'status' => $request->status ?: 'free',
|
||||
'service_id' => $request->service_id,
|
||||
...$extra,
|
||||
]);
|
||||
|
||||
// Mirror into module_ip_address so both tables stay in sync.
|
||||
@@ -229,6 +254,10 @@ class IpPoolController extends Controller
|
||||
'netmask' => 'nullable|string|max:45',
|
||||
'status' => 'nullable|in:free,assigned,reserved',
|
||||
'service_id' => 'nullable|integer|exists:services,id',
|
||||
'contact_email' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'contact_cc' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'monthly_cost' => 'nullable|numeric',
|
||||
'selling_price' => 'nullable|numeric',
|
||||
]);
|
||||
|
||||
if (!filter_var($request->ip_address, FILTER_VALIDATE_IP)) {
|
||||
@@ -256,6 +285,7 @@ class IpPoolController extends Controller
|
||||
try {
|
||||
$pool->update([
|
||||
...$request->only(['ip_address', 'module_network_id', 'gateway', 'cidr', 'service_id']),
|
||||
...$request->only(self::EDITABLE_FIELDS),
|
||||
'netmask' => $netmask,
|
||||
'status' => $request->status ?: $pool->status,
|
||||
]);
|
||||
@@ -312,6 +342,44 @@ class IpPoolController extends Controller
|
||||
return response()->json(['error' => false, 'message' => 'Đã xoá IP Pool thành công']);
|
||||
}
|
||||
|
||||
public function bulkUpdateGateway(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer',
|
||||
'gateway' => 'required|string|max:45',
|
||||
]);
|
||||
|
||||
$pools = IpPool::whereIn('id', $request->ids)->get(['id', 'ip_address', 'module_network_id']);
|
||||
|
||||
if ($pools->isEmpty()) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy IP Pool nào'], 404);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
IpPool::whereIn('id', $pools->pluck('id'))->update(['gateway' => $request->gateway]);
|
||||
|
||||
// Mirror into module_ip_address, grouped by network to keep this to a handful of queries.
|
||||
foreach ($pools->groupBy('module_network_id') as $networkId => $group) {
|
||||
ModuleIpAddress::whereIn('ip', $group->pluck('ip_address'))
|
||||
->where('module_network_id', $networkId)
|
||||
->update(['gateway' => $request->gateway]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => "Đã cập nhật gateway cho {$pools->count()} IP",
|
||||
'updated' => $pools->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkDelete(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
@@ -354,6 +422,42 @@ class IpPoolController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:csv,txt|max:10240',
|
||||
]);
|
||||
|
||||
$countBefore = IpPool::withTrashed()->count();
|
||||
|
||||
try {
|
||||
Excel::import(new IpPoolImport(), $request->file('file'));
|
||||
} catch (ValidationException $e) {
|
||||
$errors = [];
|
||||
foreach ($e->failures() as $failure) {
|
||||
$errors[] = 'Dòng ' . $failure->row() . ': ' . implode(', ', $failure->errors());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => 'Import thất bại',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => true, 'message' => 'Import thất bại: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
$countAfter = IpPool::withTrashed()->count();
|
||||
|
||||
IpPool::resyncAllContacts();
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Import thành công',
|
||||
'created' => max(0, $countAfter - $countBefore),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill/repair the module_ip_address mirror for every ip_pools row.
|
||||
* Existing mirror rows only get gateway/netmask refreshed — status/vm_name
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Imports;
|
||||
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleNetwork;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
@@ -13,17 +14,22 @@ use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
|
||||
class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpserts, WithUpsertColumns
|
||||
{
|
||||
/** @var array<string, int> */
|
||||
/** @var array<string, ModuleNetwork> */
|
||||
private array $networkCache = [];
|
||||
|
||||
public function model(array $row)
|
||||
{
|
||||
$network = $this->resolveNetwork($row['ten_mang'] ?? null, $row['cidr'] ?? null);
|
||||
|
||||
return new IpPool([
|
||||
// uuid has no DB default and must be set on insert; excluded from upsertColumns() so it's never overwritten
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'ip_address' => $row['ip_address'],
|
||||
'module_network_id' => $this->resolveNetworkId($row['ten_mang'] ?? null, $row['cidr'] ?? null),
|
||||
'cidr' => $this->parsePrefixLength($row['cidr'] ?? null),
|
||||
'module_network_id' => $network?->id,
|
||||
// ip_pools.cidr is NOT NULL; fall back to the network's own prefix (or 24) when the row omits it
|
||||
'cidr' => $this->parsePrefixLength($row['cidr'] ?? null)
|
||||
?? $this->parsePrefixLength($network?->network)
|
||||
?? 24,
|
||||
'gateway' => $row['gateway'] ?? null,
|
||||
'netmask' => $row['subnet_mask'] ?? null,
|
||||
'owner_type' => $row['owner_type'] ?? null,
|
||||
@@ -31,8 +37,8 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
'parent_ip' => $row['parent_ip'] ?? null,
|
||||
'customer_name' => $row['ten_khach_hang'] ?? null,
|
||||
'service_name' => $row['ten_dich_vu'] ?? null,
|
||||
'allocated_at' => $row['ngay_cap_phat'] ?: null,
|
||||
'revoked_at' => $row['ngay_thu_hoi'] ?: null,
|
||||
'allocated_at' => $this->parseDate($row['ngay_cap_phat'] ?? null),
|
||||
'revoked_at' => $this->parseDate($row['ngay_thu_hoi'] ?? null),
|
||||
'allocation_status' => $row['trang_thai'] ?? null,
|
||||
'contact_email' => $row['contact_email'] ?? null,
|
||||
'contact_cc' => $row['contact_cc'] ?? null,
|
||||
@@ -41,7 +47,7 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
'selling_price' => $row['selling_price'] ?: null,
|
||||
'additional_info' => $row['thong_tin_bo_sung'] ?? null,
|
||||
'last_email_type' => $row['last_email_type'] ?? null,
|
||||
'last_email_at' => $row['last_email_at'] ?: null,
|
||||
'last_email_at' => $this->parseDate($row['last_email_at'] ?? null),
|
||||
'updated_by' => $row['updated_by'] ?? null,
|
||||
'note' => $row['ghi_chu'] ?? null,
|
||||
]);
|
||||
@@ -49,16 +55,49 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$multiEmail = function (string $attribute, mixed $value, \Closure $fail) {
|
||||
foreach (preg_split('/[,;]+/', (string) $value) as $email) {
|
||||
$email = trim($email);
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$fail("Email không hợp lệ: {$email}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
'ten_mang' => 'required|string|max:255',
|
||||
'cidr' => 'nullable|string|max:45',
|
||||
'ip_address' => 'required|ip',
|
||||
'contact_email' => 'nullable|email',
|
||||
'contact_email' => ['nullable', 'string', $multiEmail],
|
||||
'contact_cc' => ['nullable', 'string', $multiEmail],
|
||||
'monthly_cost' => 'nullable|numeric',
|
||||
'selling_price' => 'nullable|numeric',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV dates come as "dd/mm/yyyy" or "dd/mm/yyyy H:i:s" (Vietnamese sheet export).
|
||||
*/
|
||||
private function parseDate(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('d/m/Y H:i:s', $value)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
// not a datetime, try date-only below
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('d/m/Y', $value)->startOfDay()->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV gives CIDR as "/22"; the ip_pools.cidr column only stores the prefix length.
|
||||
*/
|
||||
@@ -83,7 +122,7 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveNetworkId(?string $name, ?string $cidr): ?int
|
||||
private function resolveNetwork(?string $name, ?string $cidr): ?ModuleNetwork
|
||||
{
|
||||
if (!$name) {
|
||||
return null;
|
||||
@@ -99,6 +138,6 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
?? ($cidr ? ModuleNetwork::where('network', $cidr)->first() : null)
|
||||
?? ModuleNetwork::create(['name' => $name, 'network' => $cidr]);
|
||||
|
||||
return $this->networkCache[$key] = $network->id;
|
||||
return $this->networkCache[$key] = $network;
|
||||
}
|
||||
}
|
||||
|
||||
+64
-7
@@ -13,16 +13,12 @@ class IpPool extends Model
|
||||
use HasFactory, SoftDeletes, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'uuid',
|
||||
'ip_address',
|
||||
'gateway',
|
||||
'cidr',
|
||||
'netmask',
|
||||
'module_network_id',
|
||||
'gateway',
|
||||
'cidr',
|
||||
'netmask',
|
||||
'status',
|
||||
'owner_type',
|
||||
'ip_role',
|
||||
@@ -32,6 +28,7 @@ class IpPool extends Model
|
||||
'allocated_at',
|
||||
'revoked_at',
|
||||
'allocation_status',
|
||||
'service_id',
|
||||
'contact_email',
|
||||
'contact_cc',
|
||||
'contact_phone',
|
||||
@@ -51,9 +48,6 @@ class IpPool extends Model
|
||||
'monthly_cost' => 'decimal:2',
|
||||
'selling_price' => 'decimal:2',
|
||||
];
|
||||
'status',
|
||||
'service_id',
|
||||
];
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
@@ -64,6 +58,14 @@ class IpPool extends Model
|
||||
$model->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep ip_pool_contacts in sync whenever contact_email/contact_cc change via normal Eloquent saves.
|
||||
// Bulk import (WithUpserts) bypasses this — call resyncAllContacts() after Excel::import() instead.
|
||||
static::saved(function (self $model) {
|
||||
if ($model->wasChanged(['contact_email', 'contact_cc']) || $model->wasRecentlyCreated) {
|
||||
$model->syncContacts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function network()
|
||||
@@ -80,4 +82,59 @@ class IpPool extends Model
|
||||
{
|
||||
return $this->hasMany(VirtualMachines::class, 'ip_pool_id');
|
||||
}
|
||||
|
||||
public function contacts()
|
||||
{
|
||||
return $this->hasMany(IpPoolContact::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* contact_email/contact_cc may hold multiple addresses separated by comma/semicolon
|
||||
* (e.g. imported from Google Sheet). Split into a clean array, dropping invalid/empty entries.
|
||||
*/
|
||||
public static function splitEmails(?string $value): array
|
||||
{
|
||||
if (!$value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(preg_split('/[,;]+/', $value))
|
||||
->map(fn ($e) => trim($e))
|
||||
->filter(fn ($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function syncContacts(): void
|
||||
{
|
||||
$rows = [];
|
||||
foreach (self::splitEmails($this->contact_email) as $email) {
|
||||
$rows[] = ['ip_pool_id' => $this->id, 'email' => $email, 'type' => 'to'];
|
||||
}
|
||||
foreach (self::splitEmails($this->contact_cc) as $email) {
|
||||
$rows[] = ['ip_pool_id' => $this->id, 'email' => $email, 'type' => 'cc'];
|
||||
}
|
||||
|
||||
$this->contacts()->delete();
|
||||
if ($rows) {
|
||||
IpPoolContact::insert($rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full resync for bulk paths (CSV import) that bypass the saved() hook above.
|
||||
*/
|
||||
public static function resyncAllContacts(): void
|
||||
{
|
||||
self::withTrashed()
|
||||
->where(function ($q) {
|
||||
$q->whereNotNull('contact_email')->orWhereNotNull('contact_cc');
|
||||
})
|
||||
->chunkById(500, function ($pools) {
|
||||
foreach ($pools as $pool) {
|
||||
$pool->syncContacts();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IpPoolContact extends Model
|
||||
{
|
||||
protected $fillable = ['ip_pool_id', 'email', 'type'];
|
||||
|
||||
public function ipPool()
|
||||
{
|
||||
return $this->belongsTo(IpPool::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user