diff --git a/controller/api/admin.py b/controller/api/admin.py index ec7fe645..f4ad1594 100644 --- a/controller/api/admin.py +++ b/controller/api/admin.py @@ -12,6 +12,9 @@ from controller.api.schemas import ( SystemActionResponse, ThrottleActionResponse, ThrottleStatusResponse, + TimezoneSetRequest, + TimezoneSetResponse, + TimezoneStatusResponse, ) from controller.lib.admin import ( disable_hotspot, @@ -20,8 +23,10 @@ from controller.lib.admin import ( enable_throttle, get_hotspot_status_dict, get_throttle_status_dict, + get_timezone_status_dict, reboot_system, restart_all_services, + set_timezone, shutdown_system, ) from controller.lib.config import is_docker_mode @@ -81,6 +86,23 @@ async def throttle_disable() -> ThrottleActionResponse: return ThrottleActionResponse(**result) +# --- Timezone --- + +@router.get("/api/timezone/status", response_model=TimezoneStatusResponse) +async def timezone_status() -> TimezoneStatusResponse: + """Get current system timezone.""" + return TimezoneStatusResponse(**get_timezone_status_dict()) + + +@router.post("/api/timezone/set", response_model=TimezoneSetResponse) +async def timezone_set(request: TimezoneSetRequest) -> TimezoneSetResponse: + """Set the system timezone.""" + result = set_timezone(request.timezone) + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return TimezoneSetResponse(**result) + + # --- System Control --- @router.post("/api/shutdown", response_model=SystemActionResponse) diff --git a/controller/api/schemas.py b/controller/api/schemas.py index b1b5065c..72877c43 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -216,6 +216,32 @@ class RestartServicesResponse(BaseModel): error: Optional[str] = Field(default=None, description="Error message if failed") +# ============================================================================ +# Admin - Timezone +# ============================================================================ + +class TimezoneStatusResponse(BaseModel): + """Response model for /api/timezone/status endpoint.""" + + available: bool = Field(description="Whether system timezone control is available") + timezone: Optional[str] = Field(default=None, description="Current system timezone (IANA)") + reason: Optional[str] = Field(default=None, description="Reason if unavailable") + + +class TimezoneSetRequest(BaseModel): + """Request model for setting the system timezone.""" + + timezone: str = Field(description="IANA timezone string (e.g. America/Denver)") + + +class TimezoneSetResponse(BaseModel): + """Response model for setting the system timezone.""" + + success: bool = Field(description="Whether the timezone was set") + timezone: Optional[str] = Field(default=None, description="New timezone if successful") + error: Optional[str] = Field(default=None, description="Error message if failed") + + # ============================================================================ # Services - Status # ============================================================================ diff --git a/controller/lib/admin.py b/controller/lib/admin.py index 62ffbb12..6e4c95ee 100644 --- a/controller/lib/admin.py +++ b/controller/lib/admin.py @@ -9,7 +9,9 @@ import enum import subprocess from pathlib import Path -from controller.lib.config import is_docker_mode, get_config_value +import yaml + +from controller.lib.config import is_docker_mode, get_config_value, get_media_directory # Constants - use controller's own config instead of wrolpi module DEFAULT_CPU_FREQUENCY = get_config_value('throttle.default_governor', 'ondemand') @@ -503,3 +505,128 @@ def _restart_self(): except Exception: # If we can't restart ourselves, not much we can do pass + + +# --- Timezone --- + + +def get_timezone_status_dict() -> dict: + """ + Get current system timezone status. + + Returns dict matching TimezoneStatusResponse schema: + available: bool - Whether system timezone control is available + timezone: Optional[str] - Current system timezone (IANA) + reason: Optional[str] - Reason if unavailable + """ + if is_docker_mode(): + return { + "available": False, + "timezone": None, + "reason": "Docker mode", + } + + try: + result = subprocess.run( + ["timedatectl", "show", "--property=Timezone", "--value"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return { + "available": True, + "timezone": result.stdout.strip(), + "reason": None, + } + else: + return { + "available": False, + "timezone": None, + "reason": result.stderr.strip() or "timedatectl returned an error", + } + except FileNotFoundError: + return { + "available": False, + "timezone": None, + "reason": "timedatectl not found", + } + except subprocess.TimeoutExpired: + return { + "available": False, + "timezone": None, + "reason": "timedatectl timed out", + } + except subprocess.SubprocessError as e: + return { + "available": False, + "timezone": None, + "reason": str(e), + } + + +def set_timezone(timezone: str) -> dict: + """ + Set the system timezone using timedatectl. + + Args: + timezone: IANA timezone string (e.g. "America/Denver") + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "timezone": None, "error": "Not available in Docker mode"} + + if not timezone or not timezone.strip(): + return {"success": False, "timezone": None, "error": "Timezone must not be empty"} + + try: + result = subprocess.run( + ["timedatectl", "set-timezone", timezone], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + return {"success": True, "timezone": timezone, "error": None} + else: + return {"success": False, "timezone": None, "error": result.stderr.strip() or "Unknown error"} + + except FileNotFoundError: + return {"success": False, "timezone": None, "error": "timedatectl not found"} + except subprocess.TimeoutExpired: + return {"success": False, "timezone": None, "error": "Timeout setting timezone"} + except subprocess.SubprocessError as e: + return {"success": False, "timezone": None, "error": str(e)} + + +def apply_timezone_from_config(): + """ + Read the timezone from wrolpi.yaml and apply it to the system. + Called on Controller startup to ensure system timezone matches config. + """ + if is_docker_mode(): + return + + config_path = get_media_directory() / 'config' / 'wrolpi.yaml' + if not config_path.exists(): + return + + try: + with open(config_path) as f: + config = yaml.safe_load(f) or {} + except (IOError, yaml.YAMLError) as e: + print(f"Warning: Failed to read wrolpi.yaml for timezone: {e}") + return + + timezone = config.get('timezone') + if not timezone: + return + + result = set_timezone(timezone) + if result.get('success'): + print(f"Applied system timezone: {timezone}") + else: + print(f"Warning: Failed to apply system timezone '{timezone}': {result.get('error')}") diff --git a/controller/main.py b/controller/main.py index f63bc63f..8f672bda 100644 --- a/controller/main.py +++ b/controller/main.py @@ -68,6 +68,10 @@ async def lifespan(app: FastAPI): print("Loaded configuration from drive") else: print("Using default configuration (no controller.yaml found)") + + # Apply timezone from wrolpi.yaml to the system. + from controller.lib.admin import apply_timezone_from_config + apply_timezone_from_config() else: print("Primary drive not mounted - using default configuration") diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py index ffff9308..7bb19155 100644 --- a/controller/test/test_admin.py +++ b/controller/test/test_admin.py @@ -7,14 +7,17 @@ from unittest import mock import pytest from controller.lib.admin import ( + apply_timezone_from_config, disable_hotspot, disable_throttle, enable_hotspot, enable_throttle, get_hotspot_status, get_throttle_status, + get_timezone_status_dict, reboot_system, restart_all_services, + set_timezone, shutdown_system, HotspotStatus, GovernorStatus, @@ -279,3 +282,145 @@ class TestRestartAllServices: assert "services" in result # Should include controller as pending assert result["services"]["wrolpi-controller"]["pending"] is True + + +class TestGetTimezoneStatus: + """Tests for get_timezone_status_dict function.""" + + def test_returns_unavailable_in_docker_mode(self): + """Should return unavailable when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = get_timezone_status_dict() + assert result["available"] is False + assert result["timezone"] is None + assert "Docker" in result["reason"] + + def test_returns_timezone_from_timedatectl(self): + """Should return the current system timezone.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stdout = "America/Denver\n" + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", return_value=mock_result): + result = get_timezone_status_dict() + assert result["available"] is True + assert result["timezone"] == "America/Denver" + assert result["reason"] is None + + def test_handles_timedatectl_not_found(self): + """Should handle timedatectl not being available.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + result = get_timezone_status_dict() + assert result["available"] is False + assert "not found" in result["reason"] + + def test_handles_timeout(self): + """Should handle subprocess timeout.""" + import subprocess + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("timedatectl", 5)): + result = get_timezone_status_dict() + assert result["available"] is False + assert "timed out" in result["reason"] + + +class TestSetTimezone: + """Tests for set_timezone function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = set_timezone("America/Denver") + assert result["success"] is False + assert "Docker" in result["error"] + + def test_sets_timezone_successfully(self): + """Should set timezone via timedatectl.""" + mock_result = mock.Mock() + mock_result.returncode = 0 + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", return_value=mock_result) as mock_run: + result = set_timezone("America/Denver") + assert result["success"] is True + assert result["timezone"] == "America/Denver" + mock_run.assert_called_once_with( + ["timedatectl", "set-timezone", "America/Denver"], + capture_output=True, + text=True, + timeout=10, + ) + + def test_handles_invalid_timezone(self): + """Should handle timedatectl rejecting an invalid timezone.""" + mock_result = mock.Mock() + mock_result.returncode = 1 + mock_result.stderr = "Failed to set time zone: Invalid time zone" + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", return_value=mock_result): + result = set_timezone("Invalid/Timezone") + assert result["success"] is False + assert "Invalid" in result["error"] + + def test_handles_empty_timezone(self): + """Should reject empty timezone strings.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + result = set_timezone("") + assert result["success"] is False + assert "empty" in result["error"].lower() + + def test_handles_timedatectl_not_found(self): + """Should handle timedatectl not being available.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + result = set_timezone("America/Denver") + assert result["success"] is False + assert "not found" in result["error"] + + +class TestApplyTimezoneFromConfig: + """Tests for apply_timezone_from_config function.""" + + def test_skips_in_docker_mode(self): + """Should do nothing in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + with mock.patch("controller.lib.admin.set_timezone") as mock_set: + apply_timezone_from_config() + mock_set.assert_not_called() + + def test_applies_timezone_from_config(self, tmp_path): + """Should read timezone from wrolpi.yaml and apply it.""" + config_dir = tmp_path / 'config' + config_dir.mkdir() + config_file = config_dir / 'wrolpi.yaml' + config_file.write_text("timezone: America/New_York\n") + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path): + with mock.patch("controller.lib.admin.set_timezone", return_value={"success": True}) as mock_set: + apply_timezone_from_config() + mock_set.assert_called_once_with("America/New_York") + + def test_skips_when_no_timezone_in_config(self, tmp_path): + """Should do nothing when timezone is not set in config.""" + config_dir = tmp_path / 'config' + config_dir.mkdir() + config_file = config_dir / 'wrolpi.yaml' + config_file.write_text("download_wait: 60\n") + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path): + with mock.patch("controller.lib.admin.set_timezone") as mock_set: + apply_timezone_from_config() + mock_set.assert_not_called() + + def test_skips_when_config_file_missing(self, tmp_path): + """Should do nothing when wrolpi.yaml doesn't exist.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_media_directory", return_value=tmp_path): + with mock.patch("controller.lib.admin.set_timezone") as mock_set: + apply_timezone_from_config() + mock_set.assert_not_called() diff --git a/controller/test/test_admin_api.py b/controller/test/test_admin_api.py index 5fd31685..2f12e0e9 100644 --- a/controller/test/test_admin_api.py +++ b/controller/test/test_admin_api.py @@ -117,6 +117,38 @@ class TestRestartEndpoint: assert "Docker" in response.json()["detail"] +class TestTimezoneStatusEndpoint: + """Tests for /api/timezone/status endpoint.""" + + def test_timezone_status_returns_200(self, test_client): + """Timezone status endpoint should return 200 OK.""" + response = test_client.get("/api/timezone/status") + assert response.status_code == 200 + + def test_timezone_status_returns_expected_fields(self, test_client): + """Timezone status should return available and timezone fields.""" + response = test_client.get("/api/timezone/status") + data = response.json() + assert "available" in data + assert "timezone" in data + + def test_timezone_status_shows_unavailable_in_docker(self, test_client_docker_mode): + """Timezone should be unavailable in Docker mode.""" + response = test_client_docker_mode.get("/api/timezone/status") + data = response.json() + assert data["available"] is False + + +class TestTimezoneSetEndpoint: + """Tests for /api/timezone/set endpoint.""" + + def test_timezone_set_fails_in_docker(self, test_client_docker_mode): + """Timezone set should fail in Docker mode.""" + response = test_client_docker_mode.post("/api/timezone/set", json={"timezone": "America/Denver"}) + assert response.status_code == 500 + assert "Docker" in response.json()["detail"] + + class TestOpenAPIIncludesAdminEndpoints: """Tests that OpenAPI documentation includes admin endpoints.""" @@ -136,6 +168,13 @@ class TestOpenAPIIncludesAdminEndpoints: assert "/api/throttle/enable" in data["paths"] assert "/api/throttle/disable" in data["paths"] + def test_openapi_has_timezone_paths(self, test_client): + """OpenAPI schema should include timezone paths.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/timezone/status" in data["paths"] + assert "/api/timezone/set" in data["paths"] + def test_openapi_has_system_control_paths(self, test_client): """OpenAPI schema should include system control paths.""" response = test_client.get("/openapi.json") diff --git a/wrolpi/common.py b/wrolpi/common.py index a2a04073..5a5628a5 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -318,6 +318,7 @@ __all__ = [ 'run_after', 'set_global_log_level', 'set_log_level', + 'set_system_timezone', 'set_test_config', 'set_test_media_directory', 'slow_logger', @@ -1732,6 +1733,24 @@ async def aiohttp_head(url: str, timeout: int = None, headers: dict = None) -> A yield response +CONTROLLER_URL = 'http://127.0.0.1:8087' + + +async def set_system_timezone(timezone: str): + """Ask the Controller to apply the timezone to the system. + + This is best-effort: if the Controller is unavailable or rejects the change, + the application-level timezone in wrolpi.yaml still takes effect.""" + try: + async with aiohttp_post(f'{CONTROLLER_URL}/api/timezone/set', + json_={'timezone': timezone}, timeout=10) as resp: + if resp.status != 200: + contents = await resp.json() + logger.warning(f'Controller failed to set system timezone: {contents}') + except Exception as e: + logger.warning(f'Could not reach Controller to set system timezone: {e}') + + @dataclass class DownloadFileInfo: """Information about a file that can be downloaded.""" diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 185e5ce5..3722aea6 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -24,7 +24,7 @@ from wrolpi.api_utils import json_response, api_app from wrolpi.collections.api import collection_bp from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \ wrol_mode_check, native_only, disable_wrol_mode, enable_wrol_mode, get_global_statistics, url_strip_host, \ - set_global_log_level, get_relative_to_media_directory, search_other_estimates + set_global_log_level, get_relative_to_media_directory, search_other_estimates, set_system_timezone from wrolpi.config_api import config_bp from wrolpi.dates import now from wrolpi.downloader import download_manager @@ -264,6 +264,12 @@ async def update_settings(_: Request, body: schema.SettingsRequest): # Save config settings (hotspot control is now through Controller endpoints) wrolpi_config.update(new_config) + # Apply timezone to the system via Controller (native deployments only). + if body.timezone is not None and not DOCKERIZED: + timezone_value = new_config.get('timezone') + if timezone_value: + await set_system_timezone(timezone_value) + return response.empty()