mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Setting system timezone using Controller.
This commit is contained in:
parent
b1db71f04a
commit
ccc6cf78d3
8 changed files with 390 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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')}")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue