Skip to content

Commit 179270e

Browse files
committed
Bring back bulk operations
1 parent 20a9529 commit 179270e

12 files changed

Lines changed: 819 additions & 8 deletions

File tree

docker-compose.yml

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ services:
77
build:
88
context: .
99
dockerfile: packages/api/Dockerfile
10-
target: development # Use development stage if available
10+
target: development
1111
environment:
1212
HOST: "0.0.0.0"
1313
PORT: "8000"
@@ -19,12 +19,11 @@ services:
1919
- ./packages/api/src:/app/src:ro
2020
- ./packages/core/src:/app/src_core:ro
2121
command: >
22-
sh -lc 'uvicorn api.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8000}
23-
--reload --reload-dir /app/src --reload-dir /app/src_core --log-level info'
22+
sh -lc 'uvicorn api.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8000} --reload --reload-dir /app/src --reload-dir /app/src_core --log-level info'
2423
ports:
2524
- "8000:8000"
2625
healthcheck:
27-
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"]
26+
test: [ "CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1" ]
2827
interval: 10s
2928
timeout: 3s
3029
retries: 5
@@ -35,7 +34,7 @@ services:
3534
build:
3635
context: packages/web
3736
dockerfile: Dockerfile
38-
target: development # Use development stage if available
37+
target: development
3938
environment:
4039
- VITE_BASE_API_URL=http://localhost:8000
4140
volumes:
@@ -50,12 +49,11 @@ services:
5049
- api
5150
restart: unless-stopped
5251

53-
# CLI development container (useful for testing CLI commands)
5452
cli:
5553
build:
5654
context: .
5755
dockerfile: packages/cli/Dockerfile
58-
target: development # Use development stage if available
56+
target: development
5957
environment:
6058
SHELLY_CONFIG_FILE: "/app/config.json"
6159
PYTHONPATH: "/app/src:/app/src_core"
@@ -65,5 +63,5 @@ services:
6563
- ./packages/core/src:/app/src_core:ro
6664
profiles:
6765
- cli # Optional service, start with: docker-compose --profile cli up
68-
command: tail -f /dev/null # Keep container running for interactive use
66+
command: tail -f /dev/null
6967
restart: unless-stopped

packages/api/src/api/controllers/devices.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from core.domain.value_objects.scan_request import ScanRequest
1818
from core.domain.value_objects.set_configuration_request import SetConfigurationRequest
19+
from core.use_cases.bulk_operations import BulkOperationsUseCase
1920
from core.use_cases.check_device_status import CheckDeviceStatusUseCase
2021
from core.use_cases.execute_component_action import ExecuteComponentActionUseCase
2122
from core.use_cases.get_component_actions import GetComponentActionsUseCase
@@ -197,6 +198,122 @@ async def set_device_config(
197198
return {"ip": ip, "success": False, "error": str(e)}
198199

199200

201+
@post("/{ip:str}/update", status_code=200)
202+
async def update_device(
203+
ip: str,
204+
data: dict = Body(),
205+
action_interactor: ExecuteComponentActionUseCase | None = None,
206+
) -> dict:
207+
"""Convenience endpoint for firmware updates (shortcut for component action)."""
208+
action_interactor = _require("action_interactor", action_interactor)
209+
210+
channel = data.get("channel", "stable")
211+
parameters = {"channel": channel} if channel != "stable" else {}
212+
213+
request = ComponentActionRequest(
214+
device_ip=ip,
215+
component_key="shelly",
216+
action="Update",
217+
parameters=parameters,
218+
)
219+
220+
result = await action_interactor.execute(request)
221+
222+
return {
223+
"ip": result.device_ip,
224+
"success": result.success,
225+
"message": result.message,
226+
"error": result.error,
227+
"action_type": result.action_type,
228+
"channel": channel,
229+
}
230+
231+
232+
@post("/{ip:str}/reboot", status_code=200)
233+
async def reboot_device(
234+
ip: str,
235+
action_interactor: ExecuteComponentActionUseCase | None = None,
236+
) -> dict:
237+
"""Convenience endpoint for device reboot (shortcut for component action)."""
238+
action_interactor = _require("action_interactor", action_interactor)
239+
240+
request = ComponentActionRequest(
241+
device_ip=ip,
242+
component_key="shelly",
243+
action="Reboot",
244+
parameters={},
245+
)
246+
247+
result = await action_interactor.execute(request)
248+
249+
return {
250+
"ip": result.device_ip,
251+
"success": result.success,
252+
"message": result.message,
253+
"error": result.error,
254+
"action_type": result.action_type,
255+
}
256+
257+
258+
@post("/bulk", status_code=200)
259+
async def execute_bulk_operations(
260+
data: dict = Body(),
261+
bulk_operations_use_case: BulkOperationsUseCase | None = None,
262+
) -> list[dict]:
263+
"""Unified bulk operations endpoint."""
264+
265+
bulk_operations_use_case = _require(
266+
"bulk_operations_use_case", bulk_operations_use_case
267+
)
268+
269+
device_ips = data.get("device_ips", [])
270+
operation = data.get("operation")
271+
272+
if not device_ips:
273+
raise HTTPException(status_code=400, detail="device_ips is required")
274+
275+
if not operation:
276+
raise HTTPException(status_code=400, detail="operation is required")
277+
278+
if operation not in ["update", "reboot", "factory_reset"]:
279+
raise HTTPException(
280+
status_code=400,
281+
detail=f"Unsupported operation: {operation}. Supported: update, reboot, factory_reset",
282+
)
283+
284+
try:
285+
if operation == "update":
286+
channel = data.get("channel", "stable")
287+
results = await bulk_operations_use_case.execute_bulk_update(
288+
device_ips, channel
289+
)
290+
elif operation == "reboot":
291+
results = await bulk_operations_use_case.execute_bulk_reboot(device_ips)
292+
elif operation == "factory_reset":
293+
results = await bulk_operations_use_case.execute_bulk_factory_reset(
294+
device_ips
295+
)
296+
297+
parameters = {
298+
k: v for k, v in data.items() if k not in ["device_ips", "operation"]
299+
}
300+
301+
return [
302+
{
303+
"ip": result.device_ip,
304+
"success": result.success,
305+
"message": result.message,
306+
"error": result.error,
307+
"action_type": result.action_type,
308+
"operation": operation,
309+
**parameters,
310+
}
311+
for result in results
312+
]
313+
except Exception as e:
314+
raise HTTPException(status_code=500, detail=str(e)) from e
315+
316+
200317
devices_router = Router(
201318
path="/devices",
202319
route_handlers=[
@@ -206,5 +323,8 @@ async def set_device_config(
206323
get_device_status,
207324
get_device_config,
208325
set_device_config,
326+
update_device,
327+
reboot_device,
328+
execute_bulk_operations,
209329
],
210330
)

packages/api/src/api/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ async def lifespan(app: Litestar) -> AsyncGenerator[None, None]:
124124
lambda: _container.get_device_config_set_interactor(),
125125
sync_to_thread=False,
126126
),
127+
"bulk_operations_use_case": Provide(
128+
lambda: _container.get_bulk_operations_interactor(),
129+
sync_to_thread=False,
130+
),
127131
},
128132
debug=os.getenv("DEBUG", "false").lower() == "true",
129133
lifespan=[lifespan],

packages/api/tests/unit/controllers/test_devices.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import datetime
22

33
from api.controllers.devices import (
4+
execute_bulk_operations,
45
execute_component_action,
56
get_component_actions,
67
get_device_config,
@@ -320,3 +321,150 @@ async def execute(self, request):
320321
assert data["ip"] == "192.168.1.100"
321322
assert data["success"] is False
322323
assert data["error"] == "Permission denied"
324+
325+
def test_bulk_operations_update_successfully(self):
326+
from core.use_cases.bulk_operations import BulkOperationsUseCase
327+
328+
class MockBulkOperationsUseCase(BulkOperationsUseCase):
329+
def __init__(self):
330+
pass # Skip parent constructor
331+
332+
async def execute_bulk_update(self, device_ips, channel="stable"):
333+
return [
334+
ActionResult(
335+
device_ip=ip,
336+
success=True,
337+
message="Update initiated",
338+
action_type="Update",
339+
)
340+
for ip in device_ips
341+
]
342+
343+
with create_test_client(
344+
route_handlers=[execute_bulk_operations],
345+
dependencies={
346+
"bulk_operations_use_case": Provide(
347+
lambda: MockBulkOperationsUseCase(), sync_to_thread=False
348+
)
349+
},
350+
) as client:
351+
response = client.post(
352+
"/bulk",
353+
json={
354+
"device_ips": ["192.168.1.100", "192.168.1.101"],
355+
"operation": "update",
356+
"channel": "beta",
357+
},
358+
)
359+
360+
assert response.status_code == 200
361+
data = response.json()
362+
assert len(data) == 2
363+
assert all(result["success"] for result in data)
364+
assert all(result["operation"] == "update" for result in data)
365+
assert all(result["channel"] == "beta" for result in data)
366+
367+
def test_bulk_operations_reboot_successfully(self):
368+
from core.use_cases.bulk_operations import BulkOperationsUseCase
369+
370+
class MockBulkOperationsUseCase(BulkOperationsUseCase):
371+
def __init__(self):
372+
pass # Skip parent constructor
373+
374+
async def execute_bulk_reboot(self, device_ips):
375+
return [
376+
ActionResult(
377+
device_ip="192.168.1.100",
378+
success=True,
379+
message="Reboot initiated",
380+
action_type="Reboot",
381+
)
382+
]
383+
384+
with create_test_client(
385+
route_handlers=[execute_bulk_operations],
386+
dependencies={
387+
"bulk_operations_use_case": Provide(
388+
lambda: MockBulkOperationsUseCase(), sync_to_thread=False
389+
)
390+
},
391+
) as client:
392+
response = client.post(
393+
"/bulk", json={"device_ips": ["192.168.1.100"], "operation": "reboot"}
394+
)
395+
396+
assert response.status_code == 200
397+
data = response.json()
398+
assert len(data) == 1
399+
assert data[0]["success"]
400+
assert data[0]["action_type"] == "Reboot"
401+
assert data[0]["operation"] == "reboot"
402+
403+
def test_bulk_operations_factory_reset_successfully(self):
404+
from core.use_cases.bulk_operations import BulkOperationsUseCase
405+
406+
class MockBulkOperationsUseCase(BulkOperationsUseCase):
407+
def __init__(self):
408+
pass # Skip parent constructor
409+
410+
async def execute_bulk_factory_reset(self, device_ips):
411+
return [
412+
ActionResult(
413+
device_ip="192.168.1.100",
414+
success=True,
415+
message="Factory reset initiated",
416+
action_type="FactoryReset",
417+
)
418+
]
419+
420+
with create_test_client(
421+
route_handlers=[execute_bulk_operations],
422+
dependencies={
423+
"bulk_operations_use_case": Provide(
424+
lambda: MockBulkOperationsUseCase(), sync_to_thread=False
425+
)
426+
},
427+
) as client:
428+
response = client.post(
429+
"/bulk",
430+
json={"device_ips": ["192.168.1.100"], "operation": "factory_reset"},
431+
)
432+
433+
assert response.status_code == 200
434+
data = response.json()
435+
assert len(data) == 1
436+
assert data[0]["success"]
437+
assert data[0]["action_type"] == "FactoryReset"
438+
assert data[0]["operation"] == "factory_reset"
439+
440+
def test_bulk_operations_validation_errors(self):
441+
from core.use_cases.bulk_operations import BulkOperationsUseCase
442+
443+
class MockBulkOperationsUseCase(BulkOperationsUseCase):
444+
def __init__(self):
445+
pass
446+
447+
with create_test_client(
448+
route_handlers=[execute_bulk_operations],
449+
dependencies={
450+
"bulk_operations_use_case": Provide(
451+
lambda: MockBulkOperationsUseCase(), sync_to_thread=False
452+
)
453+
},
454+
) as client:
455+
# Test missing device_ips
456+
response = client.post("/bulk", json={"operation": "update"})
457+
assert response.status_code == 400
458+
assert "device_ips is required" in response.json()["detail"]
459+
460+
# Test missing operation
461+
response = client.post("/bulk", json={"device_ips": ["192.168.1.100"]})
462+
assert response.status_code == 400
463+
assert "operation is required" in response.json()["detail"]
464+
465+
# Test invalid operation
466+
response = client.post(
467+
"/bulk", json={"device_ips": ["192.168.1.100"], "operation": "invalid"}
468+
)
469+
assert response.status_code == 400
470+
assert "Unsupported operation: invalid" in response.json()["detail"]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Commands package for Click-based CLI."""
22

3+
from .bulk_commands import bulk as bulk_commands
34
from .device_commands import device_commands
45
from .export_commands import export_commands
56

67
__all__ = [
8+
"bulk_commands",
79
"device_commands",
810
"export_commands",
911
]

0 commit comments

Comments
 (0)