Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
467a05542c |
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\DeviceCategory;
|
||||
use App\Http\Requests\AutomationRuleRequest;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\Zone;
|
||||
|
||||
class AutomationRuleController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->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<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapActionParams(array $data): array
|
||||
{
|
||||
$data['action_params'] = $data['action_type'] === 'set_level'
|
||||
? ['level' => $data['level']]
|
||||
: [];
|
||||
|
||||
unset($data['level']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
private function capabilityOptions(DeviceCategory $category)
|
||||
{
|
||||
return DeviceType::where('category', $category)
|
||||
->get()
|
||||
->pluck('capabilities')
|
||||
->flatten()
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\DeviceRequest;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\Zone;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->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<string, mixed>
|
||||
*/
|
||||
private function formOptions(): array
|
||||
{
|
||||
return [
|
||||
'zones' => Zone::orderBy('name')->get(),
|
||||
'deviceTypes' => DeviceType::orderBy('code')->get(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ZoneRequest;
|
||||
use App\Models\Zone;
|
||||
|
||||
class ZoneController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->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', 'Зона удалена.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\ConditionOperator;
|
||||
use App\Models\AutomationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AutomationRuleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$rule = $this->route('automation_rule');
|
||||
|
||||
return $rule
|
||||
? $this->user()->can('update', $rule)
|
||||
: $this->user()->can('create', AutomationRule::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class DeviceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$device = $this->route('device');
|
||||
|
||||
return $device
|
||||
? $this->user()->can('update', $device)
|
||||
: $this->user()->can('create', Device::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Zone;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ZoneRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$zone = $this->route('zone');
|
||||
|
||||
return $zone
|
||||
? $this->user()->can('update', $zone)
|
||||
: $this->user()->can('create', Zone::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
|
||||
/** See ZonePolicy for the household-wide RBAC rationale. */
|
||||
class AutomationRulePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
public function update(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
public function delete(User $user, AutomationRule $automationRule): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
|
||||
/** See ZonePolicy for the household-wide RBAC rationale. */
|
||||
class DevicePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Device $device): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
public function update(User $user, Device $device): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
public function delete(User $user, Device $device): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Zone;
|
||||
|
||||
/**
|
||||
* Household-wide RBAC, not per-user tenant isolation: every authenticated
|
||||
* user (owner or viewer) sees the same zones, only `owner` can write.
|
||||
* `zones.user_id` records who registered the zone, it isn't a visibility
|
||||
* boundary.
|
||||
*/
|
||||
class ZonePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Zone $zone): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
public function update(User $user, Zone $zone): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
|
||||
public function delete(User $user, Zone $zone): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\User;
|
||||
use App\Models\Zone;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Demo data for the growbox zone — the first zone implemented on the
|
||||
* platform. Purely example content so the CRUD screens aren't empty on
|
||||
* first run; nothing here is special-cased in application code.
|
||||
*/
|
||||
class DemoGrowboxSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$owner = User::where('email', 'owner@example.com')->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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@csrf
|
||||
@isset($rule)
|
||||
@method('PUT')
|
||||
@endisset
|
||||
|
||||
<div x-data="{ actionType: '{{ old('action_type', $rule->action_type ?? '') }}' }">
|
||||
<div>
|
||||
<x-input-label for="zone_id" :value="__('Зона')" />
|
||||
<select id="zone_id" name="zone_id" required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option value="">{{ __('— выбрать —') }}</option>
|
||||
@foreach ($zones as $zone)
|
||||
<option value="{{ $zone->id }}" @selected(old('zone_id', $rule->zone_id ?? '') == $zone->id)>{{ $zone->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('zone_id')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="condition_source_device_id" :value="__('Устройство-источник показания')" />
|
||||
<select id="condition_source_device_id" name="condition_source_device_id" required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option value="">{{ __('— выбрать —') }}</option>
|
||||
@foreach ($devices as $device)
|
||||
<option value="{{ $device->id }}" @selected(old('condition_source_device_id', $rule->condition_source_device_id ?? '') == $device->id)>
|
||||
{{ $device->zone->name }} — {{ $device->name }} ({{ $device->deviceType->code }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('condition_source_device_id')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<x-input-label for="condition_sensor_type" :value="__('Тип показания')" />
|
||||
<input list="sensor-types" id="condition_sensor_type" name="condition_sensor_type" type="text"
|
||||
value="{{ old('condition_sensor_type', $rule->condition_sensor_type ?? '') }}" required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" />
|
||||
<datalist id="sensor-types">
|
||||
@foreach ($sensorTypeOptions as $option)
|
||||
<option value="{{ $option }}"></option>
|
||||
@endforeach
|
||||
</datalist>
|
||||
<x-input-error :messages="$errors->get('condition_sensor_type')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="condition_operator" :value="__('Оператор')" />
|
||||
<select id="condition_operator" name="condition_operator" required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
@foreach (\App\Enums\ConditionOperator::cases() as $operator)
|
||||
<option value="{{ $operator->value }}" @selected(old('condition_operator', $rule->condition_operator->value ?? '') == $operator->value)>
|
||||
{{ $operator->value }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('condition_operator')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="condition_value" :value="__('Порог')" />
|
||||
<x-text-input id="condition_value" name="condition_value" type="number" step="any" class="mt-1 block w-full"
|
||||
:value="old('condition_value', $rule->condition_value ?? '')" required />
|
||||
<x-input-error :messages="$errors->get('condition_value')" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="target_device_id" :value="__('Устройство-исполнитель')" />
|
||||
<select id="target_device_id" name="target_device_id" required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option value="">{{ __('— выбрать —') }}</option>
|
||||
@foreach ($devices as $device)
|
||||
<option value="{{ $device->id }}" @selected(old('target_device_id', $rule->target_device_id ?? '') == $device->id)>
|
||||
{{ $device->zone->name }} — {{ $device->name }} ({{ $device->deviceType->code }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('target_device_id')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<x-input-label for="action_type" :value="__('Действие')" />
|
||||
<select id="action_type" name="action_type" required x-model="actionType"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option value="">{{ __('— выбрать —') }}</option>
|
||||
@foreach ($actionTypeOptions as $option)
|
||||
<option value="{{ $option }}">{{ $option }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('action_type')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div x-show="actionType === 'set_level'">
|
||||
<x-input-label for="level" :value="__('Уровень')" />
|
||||
<x-text-input id="level" name="level" type="number" step="any" class="mt-1 block w-full"
|
||||
:value="old('level', $rule->action_params['level'] ?? '')" />
|
||||
<x-input-error :messages="$errors->get('level')" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input type="checkbox" name="is_active" value="1" class="rounded border-gray-300 text-indigo-600 shadow-sm"
|
||||
@checked(old('is_active', $rule->is_active ?? true))>
|
||||
<span class="ms-2 text-sm text-gray-600">{{ __('Правило активно') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
|
||||
<a href="{{ route('automation-rules.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новое правило автоматизации') }}</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('automation-rules.store') }}">
|
||||
@include('automation-rules._form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,15 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить правило автоматизации') }}</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('automation-rules.update', $rule) }}">
|
||||
@include('automation-rules._form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,72 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Правила автоматизации') }}
|
||||
</h2>
|
||||
@can('create', \App\Models\AutomationRule::class)
|
||||
<x-primary-button onclick="window.location='{{ route('automation-rules.create') }}'">
|
||||
{{ __('Добавить правило') }}
|
||||
</x-primary-button>
|
||||
@endcan
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
@if (session('status'))
|
||||
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">{{ __('Зона') }}</th>
|
||||
<th class="py-2">{{ __('Условие') }}</th>
|
||||
<th class="py-2">{{ __('Действие') }}</th>
|
||||
<th class="py-2">{{ __('Активно') }}</th>
|
||||
<th class="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($rules as $rule)
|
||||
<tr class="border-b">
|
||||
<td class="py-2">{{ $rule->zone->name }}</td>
|
||||
<td class="py-2">
|
||||
{{ $rule->conditionSourceDevice->name }}:
|
||||
{{ $rule->condition_sensor_type }}
|
||||
{{ $rule->condition_operator->value }}
|
||||
{{ $rule->condition_value }}
|
||||
</td>
|
||||
<td class="py-2">
|
||||
{{ $rule->targetDevice->name }}: {{ $rule->action_type }}
|
||||
@if (!empty($rule->action_params))
|
||||
({{ json_encode($rule->action_params) }})
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2">{{ $rule->is_active ? __('да') : __('нет') }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
@can('update', $rule)
|
||||
<a href="{{ route('automation-rules.edit', $rule) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
|
||||
@endcan
|
||||
@can('delete', $rule)
|
||||
<form method="POST" action="{{ route('automation-rules.destroy', $rule) }}" class="inline" onsubmit="return confirm('{{ __('Удалить правило?') }}')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
|
||||
</form>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="py-4 text-gray-500">{{ __('Правил пока нет.') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -6,12 +6,19 @@
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
{{ __("You're logged in!") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<a href="{{ route('zones.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">{{ __('Зоны') }}</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ $zonesCount }}</div>
|
||||
</a>
|
||||
<a href="{{ route('devices.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">{{ __('Устройства') }}</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ $devicesCount }}</div>
|
||||
</a>
|
||||
<a href="{{ route('automation-rules.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">{{ __('Активных правил') }}</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ $activeRulesCount }}</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
@csrf
|
||||
@isset($device)
|
||||
@method('PUT')
|
||||
@endisset
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Название')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
|
||||
:value="old('name', $device->name ?? '')" required autofocus />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="zone_id" :value="__('Зона')" />
|
||||
<select id="zone_id" name="zone_id" required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option value="">{{ __('— выбрать —') }}</option>
|
||||
@foreach ($zones as $zone)
|
||||
<option value="{{ $zone->id }}" @selected(old('zone_id', $device->zone_id ?? '') == $zone->id)>{{ $zone->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('zone_id')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="device_type_id" :value="__('Тип устройства')" />
|
||||
<select id="device_type_id" name="device_type_id" required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
|
||||
<option value="">{{ __('— выбрать —') }}</option>
|
||||
@foreach ($deviceTypes as $deviceType)
|
||||
<option value="{{ $deviceType->id }}" @selected(old('device_type_id', $device->device_type_id ?? '') == $deviceType->id)>
|
||||
{{ $deviceType->code }} ({{ $deviceType->category->value }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('device_type_id')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="external_id" :value="__('External ID (идентификатор физического устройства)')" />
|
||||
<x-text-input id="external_id" name="external_id" type="text" class="mt-1 block w-full font-mono"
|
||||
:value="old('external_id', $device->external_id ?? '')" required />
|
||||
<x-input-error :messages="$errors->get('external_id')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="protocol" :value="__('Протокол')" />
|
||||
<x-text-input id="protocol" name="protocol" type="text" class="mt-1 block w-full"
|
||||
:value="old('protocol', $device->protocol ?? 'mqtt')" required />
|
||||
<x-input-error :messages="$errors->get('protocol')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
|
||||
<a href="{{ route('devices.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новое устройство') }}</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('devices.store') }}">
|
||||
@include('devices._form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,15 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить устройство') }}</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('devices.update', $device) }}">
|
||||
@include('devices._form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,64 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Устройства') }}
|
||||
</h2>
|
||||
@can('create', \App\Models\Device::class)
|
||||
<x-primary-button onclick="window.location='{{ route('devices.create') }}'">
|
||||
{{ __('Добавить устройство') }}
|
||||
</x-primary-button>
|
||||
@endcan
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
@if (session('status'))
|
||||
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">{{ __('Название') }}</th>
|
||||
<th class="py-2">{{ __('Зона') }}</th>
|
||||
<th class="py-2">{{ __('Тип') }}</th>
|
||||
<th class="py-2">{{ __('External ID') }}</th>
|
||||
<th class="py-2">{{ __('Протокол') }}</th>
|
||||
<th class="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($devices as $device)
|
||||
<tr class="border-b">
|
||||
<td class="py-2">{{ $device->name }}</td>
|
||||
<td class="py-2">{{ $device->zone->name }}</td>
|
||||
<td class="py-2">{{ $device->deviceType->code }}</td>
|
||||
<td class="py-2 font-mono text-xs">{{ $device->external_id }}</td>
|
||||
<td class="py-2">{{ $device->protocol }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
@can('update', $device)
|
||||
<a href="{{ route('devices.edit', $device) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
|
||||
@endcan
|
||||
@can('delete', $device)
|
||||
<form method="POST" action="{{ route('devices.destroy', $device) }}" class="inline" onsubmit="return confirm('{{ __('Удалить устройство?') }}')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
|
||||
</form>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="py-4 text-gray-500">{{ __('Устройств пока нет.') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -15,6 +15,15 @@
|
||||
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('zones.index')" :active="request()->routeIs('zones.*')">
|
||||
{{ __('Зоны') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('devices.index')" :active="request()->routeIs('devices.*')">
|
||||
{{ __('Устройства') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('automation-rules.index')" :active="request()->routeIs('automation-rules.*')">
|
||||
{{ __('Правила') }}
|
||||
</x-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +79,15 @@
|
||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('zones.index')" :active="request()->routeIs('zones.*')">
|
||||
{{ __('Зоны') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('devices.index')" :active="request()->routeIs('devices.*')">
|
||||
{{ __('Устройства') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('automation-rules.index')" :active="request()->routeIs('automation-rules.*')">
|
||||
{{ __('Правила') }}
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@csrf
|
||||
@isset($zone)
|
||||
@method('PUT')
|
||||
@endisset
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Название')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
|
||||
:value="old('name', $zone->name ?? '')" required autofocus />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-input-label for="description" :value="__('Описание')" />
|
||||
<textarea id="description" name="description" rows="3"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">{{ old('description', $zone->description ?? '') }}</textarea>
|
||||
<x-input-error :messages="$errors->get('description')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
|
||||
<a href="{{ route('zones.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новая зона') }}</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('zones.store') }}">
|
||||
@include('zones._form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,15 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить зону') }}</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('zones.update', $zone) }}">
|
||||
@include('zones._form')
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,60 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Зоны') }}
|
||||
</h2>
|
||||
@can('create', \App\Models\Zone::class)
|
||||
<x-primary-button onclick="window.location='{{ route('zones.create') }}'">
|
||||
{{ __('Добавить зону') }}
|
||||
</x-primary-button>
|
||||
@endcan
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
@if (session('status'))
|
||||
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">{{ __('Название') }}</th>
|
||||
<th class="py-2">{{ __('Описание') }}</th>
|
||||
<th class="py-2">{{ __('Устройств') }}</th>
|
||||
<th class="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($zones as $zone)
|
||||
<tr class="border-b">
|
||||
<td class="py-2">{{ $zone->name }}</td>
|
||||
<td class="py-2 text-gray-500">{{ $zone->description }}</td>
|
||||
<td class="py-2">{{ $zone->devices_count }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
@can('update', $zone)
|
||||
<a href="{{ route('zones.edit', $zone) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
|
||||
@endcan
|
||||
@can('delete', $zone)
|
||||
<form method="POST" action="{{ route('zones.destroy', $zone) }}" class="inline" onsubmit="return confirm('{{ __('Удалить зону?') }}')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
|
||||
</form>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="py-4 text-gray-500">{{ __('Зон пока нет.') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,6 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AutomationRuleController;
|
||||
use App\Http\Controllers\DeviceController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ZoneController;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Device;
|
||||
use App\Models\Zone;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
@@ -8,9 +14,21 @@ Route::get('/', function () {
|
||||
});
|
||||
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard');
|
||||
return view('dashboard', [
|
||||
'zonesCount' => 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');
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\DeviceCategory;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\User;
|
||||
use App\Models\Zone;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AutomationRuleControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeZoneWithDevices(User $owner): array
|
||||
{
|
||||
$zone = Zone::forceCreate(['user_id' => $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\User;
|
||||
use App\Models\Zone;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ZoneControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_owner_can_create_zone(): void
|
||||
{
|
||||
$owner = User::factory()->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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user