From 467a05542c4202f3ac3f5b698beef8f305d44fa9 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Tue, 11 Aug 2026 16:54:26 +0500 Subject: [PATCH] =?UTF-8?q?CRUD=20=D0=B7=D0=BE=D0=BD/=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2/=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20+=20RBAC=20owner/viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policies (ZonePolicy/DevicePolicy/AutomationRulePolicy): не multi-tenant изоляция по user_id, а household-wide RBAC — все авторизованные видят одни и те же данные, только owner может создавать/менять/удалять. user_id на записи — это created_by, а не граница видимости. Form Requests с authorize() через политики; DeviceRequest учитывает unique external_id с ignore на update. AutomationRuleRequest: action_type остаётся строкой (не enum), т.к. это открытый список из device_types.capabilities; для действия set_level в форме есть отдельное поле "level" (Alpine.js переключает его видимость), которое контроллер мапит в action_params — без сырого JSON-редактора для MVP. Контроллеры без show (index+create/edit достаточно), Blade-формы на базе Breeze-компонентов. Навигация и дашборд (счётчики зон/устройств/активных правил) обновлены. Добавлен DemoGrowboxSeeder (демо-зона с датчиком, вентилятором и правилом) и viewer-пользователь в сидер. Восстановлен трейт AuthorizesRequests в базовом Controller (Laravel 11+ убрал его по умолчанию) — нужен для $this->authorize(). Проверено: 33/33 теста (включая новые ZoneControllerTest, AutomationRuleControllerTest — маппинг level→action_params, 403 для viewer), полный CRUD-цикл вживую через браузер под owner (создание зоны, правила с set_level, Alpine-переключение поля "Уровень"), доступ viewer подтверждён как read-only (нет кнопок изменения, прямой заход на /zones/create отдаёт 403). --- .../Controllers/AutomationRuleController.php | 117 ++++++++++++++++ .../app/Http/Controllers/Controller.php | 4 +- .../app/Http/Controllers/DeviceController.php | 70 ++++++++++ .../app/Http/Controllers/ZoneController.php | 55 ++++++++ .../Http/Requests/AutomationRuleRequest.php | 38 ++++++ .../app/Http/Requests/DeviceRequest.php | 38 ++++++ laravel-app/app/Http/Requests/ZoneRequest.php | 29 ++++ .../app/Policies/AutomationRulePolicy.php | 35 +++++ laravel-app/app/Policies/DevicePolicy.php | 35 +++++ laravel-app/app/Policies/ZonePolicy.php | 40 ++++++ .../database/seeders/DatabaseSeeder.php | 11 +- .../database/seeders/DemoGrowboxSeeder.php | 70 ++++++++++ .../views/automation-rules/_form.blade.php | 116 ++++++++++++++++ .../views/automation-rules/create.blade.php | 15 +++ .../views/automation-rules/edit.blade.php | 15 +++ .../views/automation-rules/index.blade.php | 72 ++++++++++ .../resources/views/dashboard.blade.php | 19 ++- .../resources/views/devices/_form.blade.php | 56 ++++++++ .../resources/views/devices/create.blade.php | 15 +++ .../resources/views/devices/edit.blade.php | 15 +++ .../resources/views/devices/index.blade.php | 64 +++++++++ .../views/layouts/navigation.blade.php | 18 +++ .../resources/views/zones/_form.blade.php | 23 ++++ .../resources/views/zones/create.blade.php | 15 +++ .../resources/views/zones/edit.blade.php | 15 +++ .../resources/views/zones/index.blade.php | 60 +++++++++ laravel-app/routes/web.php | 20 ++- .../Feature/AutomationRuleControllerTest.php | 125 ++++++++++++++++++ .../tests/Feature/ZoneControllerTest.php | 68 ++++++++++ 29 files changed, 1264 insertions(+), 9 deletions(-) create mode 100644 laravel-app/app/Http/Controllers/AutomationRuleController.php create mode 100644 laravel-app/app/Http/Controllers/DeviceController.php create mode 100644 laravel-app/app/Http/Controllers/ZoneController.php create mode 100644 laravel-app/app/Http/Requests/AutomationRuleRequest.php create mode 100644 laravel-app/app/Http/Requests/DeviceRequest.php create mode 100644 laravel-app/app/Http/Requests/ZoneRequest.php create mode 100644 laravel-app/app/Policies/AutomationRulePolicy.php create mode 100644 laravel-app/app/Policies/DevicePolicy.php create mode 100644 laravel-app/app/Policies/ZonePolicy.php create mode 100644 laravel-app/database/seeders/DemoGrowboxSeeder.php create mode 100644 laravel-app/resources/views/automation-rules/_form.blade.php create mode 100644 laravel-app/resources/views/automation-rules/create.blade.php create mode 100644 laravel-app/resources/views/automation-rules/edit.blade.php create mode 100644 laravel-app/resources/views/automation-rules/index.blade.php create mode 100644 laravel-app/resources/views/devices/_form.blade.php create mode 100644 laravel-app/resources/views/devices/create.blade.php create mode 100644 laravel-app/resources/views/devices/edit.blade.php create mode 100644 laravel-app/resources/views/devices/index.blade.php create mode 100644 laravel-app/resources/views/zones/_form.blade.php create mode 100644 laravel-app/resources/views/zones/create.blade.php create mode 100644 laravel-app/resources/views/zones/edit.blade.php create mode 100644 laravel-app/resources/views/zones/index.blade.php create mode 100644 laravel-app/tests/Feature/AutomationRuleControllerTest.php create mode 100644 laravel-app/tests/Feature/ZoneControllerTest.php diff --git a/laravel-app/app/Http/Controllers/AutomationRuleController.php b/laravel-app/app/Http/Controllers/AutomationRuleController.php new file mode 100644 index 0000000..ae492a0 --- /dev/null +++ b/laravel-app/app/Http/Controllers/AutomationRuleController.php @@ -0,0 +1,117 @@ +authorize('viewAny', AutomationRule::class); + + $rules = AutomationRule::with(['zone', 'targetDevice', 'conditionSourceDevice']) + ->orderByDesc('id') + ->get(); + + return view('automation-rules.index', ['rules' => $rules]); + } + + public function create() + { + $this->authorize('create', AutomationRule::class); + + return view('automation-rules.create', $this->formOptions()); + } + + public function store(AutomationRuleRequest $request) + { + $rule = new AutomationRule($this->mapActionParams($request->validated())); + $rule->user()->associate($request->user()); + $rule->save(); + + return redirect()->route('automation-rules.index')->with('status', 'Правило создано.'); + } + + public function edit(AutomationRule $automation_rule) + { + $this->authorize('update', $automation_rule); + + return view('automation-rules.edit', [ + 'rule' => $automation_rule, + ...$this->formOptions(), + ]); + } + + public function update(AutomationRuleRequest $request, AutomationRule $automation_rule) + { + $automation_rule->update($this->mapActionParams($request->validated())); + + return redirect()->route('automation-rules.index')->with('status', 'Правило обновлено.'); + } + + public function destroy(AutomationRule $automation_rule) + { + $this->authorize('delete', $automation_rule); + + $automation_rule->delete(); + + return redirect()->route('automation-rules.index')->with('status', 'Правило удалено.'); + } + + /** + * The "level" form field is a friendlier stand-in for action_params — + * only set_level currently takes a parameter, so there's no need for a + * raw JSON editor yet. + * + * @param array $data + * @return array + */ + private function mapActionParams(array $data): array + { + $data['action_params'] = $data['action_type'] === 'set_level' + ? ['level' => $data['level']] + : []; + + unset($data['level']); + + return $data; + } + + /** + * @return array + */ + private function formOptions(): array + { + $devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get(); + + return [ + 'zones' => Zone::orderBy('name')->get(), + 'devices' => $devices, + 'actionTypeOptions' => $this->capabilityOptions(DeviceCategory::Actuator), + 'sensorTypeOptions' => $this->capabilityOptions(DeviceCategory::Sensor), + ]; + } + + /** + * Distinct capability values across device_types of the given category — + * used as friendly select/datalist suggestions, not a hardcoded list. + * + * @return \Illuminate\Support\Collection + */ + private function capabilityOptions(DeviceCategory $category) + { + return DeviceType::where('category', $category) + ->get() + ->pluck('capabilities') + ->flatten() + ->unique() + ->sort() + ->values(); + } +} diff --git a/laravel-app/app/Http/Controllers/Controller.php b/laravel-app/app/Http/Controllers/Controller.php index 8677cd5..e7f7c94 100644 --- a/laravel-app/app/Http/Controllers/Controller.php +++ b/laravel-app/app/Http/Controllers/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + abstract class Controller { - // + use AuthorizesRequests; } diff --git a/laravel-app/app/Http/Controllers/DeviceController.php b/laravel-app/app/Http/Controllers/DeviceController.php new file mode 100644 index 0000000..7342830 --- /dev/null +++ b/laravel-app/app/Http/Controllers/DeviceController.php @@ -0,0 +1,70 @@ +authorize('viewAny', Device::class); + + $devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get(); + + return view('devices.index', compact('devices')); + } + + public function create() + { + $this->authorize('create', Device::class); + + return view('devices.create', $this->formOptions()); + } + + public function store(DeviceRequest $request) + { + $device = new Device($request->validated()); + $device->user()->associate($request->user()); + $device->save(); + + return redirect()->route('devices.index')->with('status', 'Устройство добавлено.'); + } + + public function edit(Device $device) + { + $this->authorize('update', $device); + + return view('devices.edit', ['device' => $device, ...$this->formOptions()]); + } + + public function update(DeviceRequest $request, Device $device) + { + $device->update($request->validated()); + + return redirect()->route('devices.index')->with('status', 'Устройство обновлено.'); + } + + public function destroy(Device $device) + { + $this->authorize('delete', $device); + + $device->delete(); + + return redirect()->route('devices.index')->with('status', 'Устройство удалено.'); + } + + /** + * @return array + */ + private function formOptions(): array + { + return [ + 'zones' => Zone::orderBy('name')->get(), + 'deviceTypes' => DeviceType::orderBy('code')->get(), + ]; + } +} diff --git a/laravel-app/app/Http/Controllers/ZoneController.php b/laravel-app/app/Http/Controllers/ZoneController.php new file mode 100644 index 0000000..b29d829 --- /dev/null +++ b/laravel-app/app/Http/Controllers/ZoneController.php @@ -0,0 +1,55 @@ +authorize('viewAny', Zone::class); + + $zones = Zone::withCount('devices')->orderBy('name')->get(); + + return view('zones.index', compact('zones')); + } + + public function create() + { + $this->authorize('create', Zone::class); + + return view('zones.create'); + } + + public function store(ZoneRequest $request) + { + $request->user()->zones()->create($request->validated()); + + return redirect()->route('zones.index')->with('status', 'Зона создана.'); + } + + public function edit(Zone $zone) + { + $this->authorize('update', $zone); + + return view('zones.edit', compact('zone')); + } + + public function update(ZoneRequest $request, Zone $zone) + { + $zone->update($request->validated()); + + return redirect()->route('zones.index')->with('status', 'Зона обновлена.'); + } + + public function destroy(Zone $zone) + { + $this->authorize('delete', $zone); + + $zone->delete(); + + return redirect()->route('zones.index')->with('status', 'Зона удалена.'); + } +} diff --git a/laravel-app/app/Http/Requests/AutomationRuleRequest.php b/laravel-app/app/Http/Requests/AutomationRuleRequest.php new file mode 100644 index 0000000..c701c39 --- /dev/null +++ b/laravel-app/app/Http/Requests/AutomationRuleRequest.php @@ -0,0 +1,38 @@ +route('automation_rule'); + + return $rule + ? $this->user()->can('update', $rule) + : $this->user()->can('create', AutomationRule::class); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'zone_id' => ['required', 'exists:zones,id'], + 'target_device_id' => ['required', 'exists:devices,id'], + 'condition_source_device_id' => ['required', 'exists:devices,id'], + 'condition_sensor_type' => ['required', 'string', 'max:255'], + 'condition_operator' => ['required', Rule::enum(ConditionOperator::class)], + 'condition_value' => ['required', 'numeric'], + 'action_type' => ['required', 'string', 'max:255'], + 'level' => ['nullable', 'numeric', 'required_if:action_type,set_level'], + 'is_active' => ['boolean'], + ]; + } +} diff --git a/laravel-app/app/Http/Requests/DeviceRequest.php b/laravel-app/app/Http/Requests/DeviceRequest.php new file mode 100644 index 0000000..a466dd9 --- /dev/null +++ b/laravel-app/app/Http/Requests/DeviceRequest.php @@ -0,0 +1,38 @@ +route('device'); + + return $device + ? $this->user()->can('update', $device) + : $this->user()->can('create', Device::class); + } + + /** + * @return array + */ + public function rules(): array + { + $device = $this->route('device'); + + return [ + 'zone_id' => ['required', 'exists:zones,id'], + 'device_type_id' => ['required', 'exists:device_types,id'], + 'name' => ['required', 'string', 'max:255'], + 'external_id' => [ + 'required', 'string', 'max:255', + Rule::unique('devices', 'external_id')->ignore($device), + ], + 'protocol' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/laravel-app/app/Http/Requests/ZoneRequest.php b/laravel-app/app/Http/Requests/ZoneRequest.php new file mode 100644 index 0000000..28d0f5d --- /dev/null +++ b/laravel-app/app/Http/Requests/ZoneRequest.php @@ -0,0 +1,29 @@ +route('zone'); + + return $zone + ? $this->user()->can('update', $zone) + : $this->user()->can('create', Zone::class); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + ]; + } +} diff --git a/laravel-app/app/Policies/AutomationRulePolicy.php b/laravel-app/app/Policies/AutomationRulePolicy.php new file mode 100644 index 0000000..fa7780d --- /dev/null +++ b/laravel-app/app/Policies/AutomationRulePolicy.php @@ -0,0 +1,35 @@ +isOwner(); + } + + public function update(User $user, AutomationRule $automationRule): bool + { + return $user->isOwner(); + } + + public function delete(User $user, AutomationRule $automationRule): bool + { + return $user->isOwner(); + } +} diff --git a/laravel-app/app/Policies/DevicePolicy.php b/laravel-app/app/Policies/DevicePolicy.php new file mode 100644 index 0000000..2a045ec --- /dev/null +++ b/laravel-app/app/Policies/DevicePolicy.php @@ -0,0 +1,35 @@ +isOwner(); + } + + public function update(User $user, Device $device): bool + { + return $user->isOwner(); + } + + public function delete(User $user, Device $device): bool + { + return $user->isOwner(); + } +} diff --git a/laravel-app/app/Policies/ZonePolicy.php b/laravel-app/app/Policies/ZonePolicy.php new file mode 100644 index 0000000..a6ce155 --- /dev/null +++ b/laravel-app/app/Policies/ZonePolicy.php @@ -0,0 +1,40 @@ +isOwner(); + } + + public function update(User $user, Zone $zone): bool + { + return $user->isOwner(); + } + + public function delete(User $user, Zone $zone): bool + { + return $user->isOwner(); + } +} diff --git a/laravel-app/database/seeders/DatabaseSeeder.php b/laravel-app/database/seeders/DatabaseSeeder.php index f3b30ca..c14e302 100644 --- a/laravel-app/database/seeders/DatabaseSeeder.php +++ b/laravel-app/database/seeders/DatabaseSeeder.php @@ -22,6 +22,15 @@ class DatabaseSeeder extends Seeder 'role' => UserRole::Owner, ]); - $this->call(DeviceTypeSeeder::class); + User::factory()->create([ + 'name' => 'Test Viewer', + 'email' => 'viewer@example.com', + 'role' => UserRole::Viewer, + ]); + + $this->call([ + DeviceTypeSeeder::class, + DemoGrowboxSeeder::class, + ]); } } diff --git a/laravel-app/database/seeders/DemoGrowboxSeeder.php b/laravel-app/database/seeders/DemoGrowboxSeeder.php new file mode 100644 index 0000000..45b5229 --- /dev/null +++ b/laravel-app/database/seeders/DemoGrowboxSeeder.php @@ -0,0 +1,70 @@ +firstOrFail(); + + $zone = Zone::query()->firstOrCreate( + ['name' => 'Гроубокс'], + ['user_id' => $owner->id, 'description' => 'Демо-зона для проверки платформы'] + ); + + $sensorType = DeviceType::where('code', 'sensor_temp_humidity')->firstOrFail(); + $fanType = DeviceType::where('code', 'fan')->firstOrFail(); + + $sensor = Device::query()->firstOrCreate( + ['external_id' => 'sensor-1'], + [ + 'user_id' => $owner->id, + 'zone_id' => $zone->id, + 'device_type_id' => $sensorType->id, + 'name' => 'Датчик температуры', + 'protocol' => 'mqtt', + ] + ); + + $fan = Device::query()->firstOrCreate( + ['external_id' => 'fan-1'], + [ + 'user_id' => $owner->id, + 'zone_id' => $zone->id, + 'device_type_id' => $fanType->id, + 'name' => 'Вентилятор', + 'protocol' => 'mqtt', + ] + ); + + AutomationRule::query()->firstOrCreate( + [ + 'condition_source_device_id' => $sensor->id, + 'target_device_id' => $fan->id, + ], + [ + 'user_id' => $owner->id, + 'zone_id' => $zone->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'action_type' => 'turn_on', + 'action_params' => [], + 'is_active' => true, + ] + ); + } +} diff --git a/laravel-app/resources/views/automation-rules/_form.blade.php b/laravel-app/resources/views/automation-rules/_form.blade.php new file mode 100644 index 0000000..b51dae8 --- /dev/null +++ b/laravel-app/resources/views/automation-rules/_form.blade.php @@ -0,0 +1,116 @@ +@csrf +@isset($rule) + @method('PUT') +@endisset + +
+
+ + + +
+ +
+ + + +
+ +
+
+ + + + @foreach ($sensorTypeOptions as $option) + + @endforeach + + +
+ +
+ + + +
+ +
+ + + +
+
+ +
+ + + +
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+ +
+ +
+ {{ __('Сохранить') }} + {{ __('Отмена') }} +
+
diff --git a/laravel-app/resources/views/automation-rules/create.blade.php b/laravel-app/resources/views/automation-rules/create.blade.php new file mode 100644 index 0000000..96ec6d3 --- /dev/null +++ b/laravel-app/resources/views/automation-rules/create.blade.php @@ -0,0 +1,15 @@ + + +

{{ __('Новое правило автоматизации') }}

+
+ +
+
+
+
+ @include('automation-rules._form') +
+
+
+
+
diff --git a/laravel-app/resources/views/automation-rules/edit.blade.php b/laravel-app/resources/views/automation-rules/edit.blade.php new file mode 100644 index 0000000..238d416 --- /dev/null +++ b/laravel-app/resources/views/automation-rules/edit.blade.php @@ -0,0 +1,15 @@ + + +

{{ __('Изменить правило автоматизации') }}

+
+ +
+
+
+
+ @include('automation-rules._form') +
+
+
+
+
diff --git a/laravel-app/resources/views/automation-rules/index.blade.php b/laravel-app/resources/views/automation-rules/index.blade.php new file mode 100644 index 0000000..0eac5c3 --- /dev/null +++ b/laravel-app/resources/views/automation-rules/index.blade.php @@ -0,0 +1,72 @@ + + +
+

+ {{ __('Правила автоматизации') }} +

+ @can('create', \App\Models\AutomationRule::class) + + {{ __('Добавить правило') }} + + @endcan +
+
+ +
+
+
+ @if (session('status')) +
{{ session('status') }}
+ @endif + + + + + + + + + + + + + @forelse ($rules as $rule) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Зона') }}{{ __('Условие') }}{{ __('Действие') }}{{ __('Активно') }}
{{ $rule->zone->name }} + {{ $rule->conditionSourceDevice->name }}: + {{ $rule->condition_sensor_type }} + {{ $rule->condition_operator->value }} + {{ $rule->condition_value }} + + {{ $rule->targetDevice->name }}: {{ $rule->action_type }} + @if (!empty($rule->action_params)) + ({{ json_encode($rule->action_params) }}) + @endif + {{ $rule->is_active ? __('да') : __('нет') }} + @can('update', $rule) + {{ __('Изменить') }} + @endcan + @can('delete', $rule) +
+ @csrf + @method('DELETE') + +
+ @endcan +
{{ __('Правил пока нет.') }}
+
+
+
+
diff --git a/laravel-app/resources/views/dashboard.blade.php b/laravel-app/resources/views/dashboard.blade.php index 66028f2..0416ff9 100644 --- a/laravel-app/resources/views/dashboard.blade.php +++ b/laravel-app/resources/views/dashboard.blade.php @@ -6,12 +6,19 @@
- diff --git a/laravel-app/resources/views/devices/_form.blade.php b/laravel-app/resources/views/devices/_form.blade.php new file mode 100644 index 0000000..b926aab --- /dev/null +++ b/laravel-app/resources/views/devices/_form.blade.php @@ -0,0 +1,56 @@ +@csrf +@isset($device) + @method('PUT') +@endisset + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ {{ __('Сохранить') }} + {{ __('Отмена') }} +
diff --git a/laravel-app/resources/views/devices/create.blade.php b/laravel-app/resources/views/devices/create.blade.php new file mode 100644 index 0000000..28800ef --- /dev/null +++ b/laravel-app/resources/views/devices/create.blade.php @@ -0,0 +1,15 @@ + + +

{{ __('Новое устройство') }}

+
+ +
+
+
+
+ @include('devices._form') +
+
+
+
+
diff --git a/laravel-app/resources/views/devices/edit.blade.php b/laravel-app/resources/views/devices/edit.blade.php new file mode 100644 index 0000000..10e5164 --- /dev/null +++ b/laravel-app/resources/views/devices/edit.blade.php @@ -0,0 +1,15 @@ + + +

{{ __('Изменить устройство') }}

+
+ +
+
+
+
+ @include('devices._form') +
+
+
+
+
diff --git a/laravel-app/resources/views/devices/index.blade.php b/laravel-app/resources/views/devices/index.blade.php new file mode 100644 index 0000000..cb392e0 --- /dev/null +++ b/laravel-app/resources/views/devices/index.blade.php @@ -0,0 +1,64 @@ + + +
+

+ {{ __('Устройства') }} +

+ @can('create', \App\Models\Device::class) + + {{ __('Добавить устройство') }} + + @endcan +
+
+ +
+
+
+ @if (session('status')) +
{{ session('status') }}
+ @endif + + + + + + + + + + + + + + @forelse ($devices as $device) + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Название') }}{{ __('Зона') }}{{ __('Тип') }}{{ __('External ID') }}{{ __('Протокол') }}
{{ $device->name }}{{ $device->zone->name }}{{ $device->deviceType->code }}{{ $device->external_id }}{{ $device->protocol }} + @can('update', $device) + {{ __('Изменить') }} + @endcan + @can('delete', $device) +
+ @csrf + @method('DELETE') + +
+ @endcan +
{{ __('Устройств пока нет.') }}
+
+
+
+
diff --git a/laravel-app/resources/views/layouts/navigation.blade.php b/laravel-app/resources/views/layouts/navigation.blade.php index c2d3a65..a0a0253 100644 --- a/laravel-app/resources/views/layouts/navigation.blade.php +++ b/laravel-app/resources/views/layouts/navigation.blade.php @@ -15,6 +15,15 @@ {{ __('Dashboard') }} + + {{ __('Зоны') }} + + + {{ __('Устройства') }} + + + {{ __('Правила') }} +
@@ -70,6 +79,15 @@ {{ __('Dashboard') }} + + {{ __('Зоны') }} + + + {{ __('Устройства') }} + + + {{ __('Правила') }} + diff --git a/laravel-app/resources/views/zones/_form.blade.php b/laravel-app/resources/views/zones/_form.blade.php new file mode 100644 index 0000000..49d6d6a --- /dev/null +++ b/laravel-app/resources/views/zones/_form.blade.php @@ -0,0 +1,23 @@ +@csrf +@isset($zone) + @method('PUT') +@endisset + +
+ + + +
+ +
+ + + +
+ +
+ {{ __('Сохранить') }} + {{ __('Отмена') }} +
diff --git a/laravel-app/resources/views/zones/create.blade.php b/laravel-app/resources/views/zones/create.blade.php new file mode 100644 index 0000000..e9fbd9e --- /dev/null +++ b/laravel-app/resources/views/zones/create.blade.php @@ -0,0 +1,15 @@ + + +

{{ __('Новая зона') }}

+
+ +
+
+
+
+ @include('zones._form') +
+
+
+
+
diff --git a/laravel-app/resources/views/zones/edit.blade.php b/laravel-app/resources/views/zones/edit.blade.php new file mode 100644 index 0000000..f03eadf --- /dev/null +++ b/laravel-app/resources/views/zones/edit.blade.php @@ -0,0 +1,15 @@ + + +

{{ __('Изменить зону') }}

+
+ +
+
+
+
+ @include('zones._form') +
+
+
+
+
diff --git a/laravel-app/resources/views/zones/index.blade.php b/laravel-app/resources/views/zones/index.blade.php new file mode 100644 index 0000000..5f76028 --- /dev/null +++ b/laravel-app/resources/views/zones/index.blade.php @@ -0,0 +1,60 @@ + + +
+

+ {{ __('Зоны') }} +

+ @can('create', \App\Models\Zone::class) + + {{ __('Добавить зону') }} + + @endcan +
+
+ +
+
+
+ @if (session('status')) +
{{ session('status') }}
+ @endif + + + + + + + + + + + + @forelse ($zones as $zone) + + + + + + + @empty + + + + @endforelse + +
{{ __('Название') }}{{ __('Описание') }}{{ __('Устройств') }}
{{ $zone->name }}{{ $zone->description }}{{ $zone->devices_count }} + @can('update', $zone) + {{ __('Изменить') }} + @endcan + @can('delete', $zone) +
+ @csrf + @method('DELETE') + +
+ @endcan +
{{ __('Зон пока нет.') }}
+
+
+
+
diff --git a/laravel-app/routes/web.php b/laravel-app/routes/web.php index 74bb7ca..d0af766 100644 --- a/laravel-app/routes/web.php +++ b/laravel-app/routes/web.php @@ -1,6 +1,12 @@ Zone::count(), + 'devicesCount' => Device::count(), + 'activeRulesCount' => AutomationRule::where('is_active', true)->count(), + ]); })->middleware(['auth', 'verified'])->name('dashboard'); +Route::middleware(['auth', 'verified'])->group(function () { + Route::resource('zones', ZoneController::class)->except('show'); + Route::resource('devices', DeviceController::class)->except('show'); + Route::resource('automation-rules', AutomationRuleController::class) + ->except('show') + ->parameters(['automation-rules' => 'automation_rule']); +}); + Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); diff --git a/laravel-app/tests/Feature/AutomationRuleControllerTest.php b/laravel-app/tests/Feature/AutomationRuleControllerTest.php new file mode 100644 index 0000000..20b4231 --- /dev/null +++ b/laravel-app/tests/Feature/AutomationRuleControllerTest.php @@ -0,0 +1,125 @@ + $owner->id, 'name' => 'Гроубокс']); + + $sensorType = DeviceType::create([ + 'code' => 'sensor_temp_humidity', + 'category' => DeviceCategory::Sensor, + 'capabilities' => ['temperature', 'humidity'], + ]); + $fanType = DeviceType::create([ + 'code' => 'fan', + 'category' => DeviceCategory::Actuator, + 'capabilities' => ['turn_on', 'turn_off', 'set_level'], + ]); + + $sensor = Device::forceCreate([ + 'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $sensorType->id, + 'name' => 'Датчик', 'external_id' => 'sensor-1', 'protocol' => 'mqtt', + ]); + $fan = Device::forceCreate([ + 'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $fanType->id, + 'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt', + ]); + + return [$zone, $sensor, $fan]; + } + + public function test_owner_can_create_rule_with_turn_on_action(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + [$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner); + + $response = $this->actingAs($owner)->post(route('automation-rules.store'), [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'turn_on', + 'is_active' => '1', + ]); + + $response->assertRedirect(route('automation-rules.index')); + $this->assertDatabaseHas('automation_rules', [ + 'condition_source_device_id' => $sensor->id, + 'target_device_id' => $fan->id, + 'action_type' => 'turn_on', + 'action_params' => '[]', + ]); + } + + public function test_set_level_action_requires_and_stores_level_param(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + [$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner); + + // Missing level should fail validation. + $missingLevel = $this->actingAs($owner)->post(route('automation-rules.store'), [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'set_level', + ]); + $missingLevel->assertSessionHasErrors('level'); + + $response = $this->actingAs($owner)->post(route('automation-rules.store'), [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'set_level', + 'level' => 42, + ]); + + $response->assertRedirect(route('automation-rules.index')); + $this->assertDatabaseHas('automation_rules', [ + 'target_device_id' => $fan->id, + 'action_type' => 'set_level', + 'action_params' => '{"level":42}', + ]); + } + + public function test_viewer_cannot_create_rule(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + [$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner); + + $response = $this->actingAs($viewer)->post(route('automation-rules.store'), [ + 'zone_id' => $zone->id, + 'condition_source_device_id' => $sensor->id, + 'condition_sensor_type' => 'temperature', + 'condition_operator' => '>', + 'condition_value' => 28, + 'target_device_id' => $fan->id, + 'action_type' => 'turn_on', + ]); + + $response->assertForbidden(); + $this->assertDatabaseCount('automation_rules', 0); + } +} diff --git a/laravel-app/tests/Feature/ZoneControllerTest.php b/laravel-app/tests/Feature/ZoneControllerTest.php new file mode 100644 index 0000000..6dfb567 --- /dev/null +++ b/laravel-app/tests/Feature/ZoneControllerTest.php @@ -0,0 +1,68 @@ +create(['role' => UserRole::Owner]); + + $response = $this->actingAs($owner)->post(route('zones.store'), [ + 'name' => 'Гостиная', + 'description' => 'Тест', + ]); + + $response->assertRedirect(route('zones.index')); + $this->assertDatabaseHas('zones', ['name' => 'Гостиная', 'user_id' => $owner->id]); + } + + public function test_viewer_cannot_create_zone(): void + { + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + + $response = $this->actingAs($viewer)->post(route('zones.store'), [ + 'name' => 'Гостиная', + ]); + + $response->assertForbidden(); + $this->assertDatabaseMissing('zones', ['name' => 'Гостиная']); + } + + public function test_viewer_can_view_zones_index(): void + { + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']); + + $response = $this->actingAs($viewer)->get(route('zones.index')); + + $response->assertOk(); + $response->assertSee('Гроубокс'); + } + + public function test_viewer_cannot_delete_zone(): void + { + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + $zone = Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']); + + $response = $this->actingAs($viewer)->delete(route('zones.destroy', $zone)); + + $response->assertForbidden(); + $this->assertDatabaseHas('zones', ['id' => $zone->id]); + } + + public function test_guest_is_redirected_to_login(): void + { + $response = $this->get(route('zones.index')); + + $response->assertRedirect(route('login')); + } +}