mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
Adding timezone to WROLPi config to schedule download window.
This commit is contained in:
parent
c2bab03f42
commit
c2c5d237d7
8 changed files with 162 additions and 3 deletions
|
|
@ -320,6 +320,7 @@ export async function getSettings() {
|
|||
tags_directory: content.tags_directory,
|
||||
throttle_on_startup: content.throttle_on_startup,
|
||||
throttle_status: content.throttle_status,
|
||||
timezone: content.timezone,
|
||||
videos_destination: content.videos_destination,
|
||||
wrol_mode: content.wrol_mode,
|
||||
zims_destination: content.zims_destination,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,17 @@ import {SettingsContext, StatusContext} from "../../contexts/contexts";
|
|||
import {checkUpgrade, postRestart, postShutdown, saveSettings as saveSettingsApi, triggerUpgrade} from "../../api";
|
||||
import {getServices, startService, stopService} from "../../api/controller";
|
||||
import {Button, Divider, Form, Header, Loader, Modal, Segment} from "../Theme";
|
||||
import {ButtonGroup, Container, Dimmer, Dropdown, GridColumn, GridRow, Icon, Input} from "semantic-ui-react";
|
||||
import {
|
||||
ButtonGroup,
|
||||
Container,
|
||||
Dimmer,
|
||||
Dropdown,
|
||||
FormDropdown,
|
||||
GridColumn,
|
||||
GridRow,
|
||||
Icon,
|
||||
Input
|
||||
} from "semantic-ui-react";
|
||||
import {
|
||||
APIButton,
|
||||
ErrorMessage,
|
||||
|
|
@ -301,6 +311,7 @@ export function SettingsPage() {
|
|||
|
||||
// Get current settings from the API.
|
||||
const {settings, saveSettings, fetchSettings} = React.useContext(SettingsContext);
|
||||
const {status} = React.useContext(StatusContext);
|
||||
// Used to track changes to the settings form between saves/loads.
|
||||
const [state, setState] = React.useState({});
|
||||
|
||||
|
|
@ -445,6 +456,7 @@ export function SettingsPage() {
|
|||
download_wait: settings.download_wait,
|
||||
download_window_start: settings.download_window_start || '',
|
||||
download_window_end: settings.download_window_end || '',
|
||||
timezone: settings.timezone || '',
|
||||
hotspot_device: settings.hotspot_device,
|
||||
hotspot_on_startup: settings.hotspot_on_startup,
|
||||
hotspot_password: settings.hotspot_password,
|
||||
|
|
@ -492,6 +504,10 @@ export function SettingsPage() {
|
|||
<Icon name='qrcode' size='big'/>
|
||||
</Button>;
|
||||
|
||||
const timezoneOptions = Intl.supportedValuesOf('timeZone').map(tz => (
|
||||
{key: tz, value: tz, text: tz}
|
||||
));
|
||||
|
||||
const navColorOptions = Object.keys(semanticUIColorMap).map(i => {
|
||||
return {key: i, value: i, text: i.charAt(0).toUpperCase() + i.slice(1)}
|
||||
});
|
||||
|
|
@ -709,6 +725,25 @@ export function SettingsPage() {
|
|||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Field width={3}>
|
||||
<label><b>Timezone</b>
|
||||
<InfoPopup
|
||||
content='IANA timezone (e.g. America/Denver). Leave empty to use system timezone.'/>
|
||||
</label>
|
||||
<FormDropdown
|
||||
placeholder='System default'
|
||||
options={timezoneOptions}
|
||||
value={state.timezone || ''}
|
||||
disabled={disabled}
|
||||
search
|
||||
selection
|
||||
clearable
|
||||
onChange={(e, {value}) => setState({...state, timezone: value || ''})}
|
||||
/>
|
||||
</Form.Field>
|
||||
{status && status.local_time &&
|
||||
<p>Current local time: <b>{status.local_time.slice(0, 16).replace('T', ' ')}</b></p>}
|
||||
|
||||
<Divider/>
|
||||
|
||||
<Header as='h3'>Special Directories</Header>
|
||||
|
|
|
|||
|
|
@ -805,6 +805,7 @@ class WROLPiConfigValidator:
|
|||
save_ffprobe_json: bool = None
|
||||
tags_directory: bool = None
|
||||
throttle_on_startup: bool = None
|
||||
timezone: str = None
|
||||
version: int = None
|
||||
videos_destination: str = None
|
||||
wrol_mode: bool = None
|
||||
|
|
@ -949,6 +950,7 @@ class WROLPiConfig(ConfigFile):
|
|||
save_ffprobe_json=True,
|
||||
tags_directory=True,
|
||||
throttle_on_startup=False,
|
||||
timezone=None,
|
||||
version=0,
|
||||
videos_destination='videos/%(channel_tag)s/%(channel_name)s',
|
||||
wrol_mode=False,
|
||||
|
|
@ -1184,6 +1186,14 @@ class WROLPiConfig(ConfigFile):
|
|||
def tags_directory(self, value: bool):
|
||||
self.update({'tags_directory': value})
|
||||
|
||||
@property
|
||||
def timezone(self) -> str:
|
||||
return self._config.get('timezone')
|
||||
|
||||
@timezone.setter
|
||||
def timezone(self, value: str):
|
||||
self.update({'timezone': value})
|
||||
|
||||
|
||||
WROLPI_CONFIG: WROLPiConfig = WROLPiConfig()
|
||||
TEST_WROLPI_CONFIG: WROLPiConfig = None
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from http import HTTPStatus
|
|||
from itertools import filterfalse
|
||||
from typing import List, Dict, Generator, Iterable, Coroutine, Any
|
||||
from typing import Tuple, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import feedparser
|
||||
|
|
@ -597,7 +598,12 @@ class DownloadManager:
|
|||
|
||||
@staticmethod
|
||||
def _get_local_time():
|
||||
"""Return the current local time, converting from UTC."""
|
||||
"""Return the current local time, using configured timezone or system default."""
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
config = get_wrolpi_config()
|
||||
if config.timezone:
|
||||
tz = ZoneInfo(config.timezone)
|
||||
return now().astimezone(tz).time()
|
||||
return now().astimezone().time()
|
||||
|
||||
def is_within_download_window(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import pathlib
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import vininfo.exceptions
|
||||
from sanic import response, Blueprint, __version__ as sanic_version
|
||||
|
|
@ -178,6 +179,7 @@ def get_settings(_: Request):
|
|||
'wrol_mode': wrolpi_config.wrol_mode,
|
||||
'zims_destination': wrolpi_config.zims_destination,
|
||||
'save_ffprobe_json': wrolpi_config.save_ffprobe_json,
|
||||
'timezone': wrolpi_config.timezone,
|
||||
}
|
||||
return json_response(settings)
|
||||
|
||||
|
|
@ -232,6 +234,16 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
|
|||
elif not body.zims_destination:
|
||||
new_config['zims_destination'] = wrolpi_config.default_config['zims_destination']
|
||||
|
||||
# Validate timezone if provided; empty string clears it.
|
||||
if body.timezone is not None:
|
||||
if body.timezone == '':
|
||||
new_config['timezone'] = None
|
||||
else:
|
||||
try:
|
||||
ZoneInfo(body.timezone)
|
||||
except (KeyError, Exception):
|
||||
raise InvalidConfig(f'Invalid timezone: {body.timezone}')
|
||||
|
||||
# Handle download window fields: empty string clears the value.
|
||||
for field_name in ('download_window_start', 'download_window_end'):
|
||||
value = getattr(body, field_name, None)
|
||||
|
|
@ -488,6 +500,13 @@ async def get_status(request: Request):
|
|||
if i.startswith('Sanic-Server')
|
||||
}
|
||||
|
||||
wrolpi_config = get_wrolpi_config()
|
||||
if wrolpi_config.timezone:
|
||||
tz = ZoneInfo(wrolpi_config.timezone)
|
||||
local_time = now().astimezone(tz).isoformat()
|
||||
else:
|
||||
local_time = now().astimezone().isoformat()
|
||||
|
||||
ret = dict(
|
||||
dockerized=DOCKERIZED,
|
||||
downloads=downloads,
|
||||
|
|
@ -496,6 +515,7 @@ async def get_status(request: Request):
|
|||
is_rpi4=IS_RPI4,
|
||||
is_rpi5=IS_RPI5,
|
||||
is_rpi=IS_RPI,
|
||||
local_time=local_time,
|
||||
sanic_workers=sanic_workers,
|
||||
version=__version__,
|
||||
wrol_mode=wrol_mode_enabled(),
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ class SettingsRequest:
|
|||
wrol_mode: Optional[bool] = None
|
||||
zims_destination: Optional[str] = None
|
||||
save_ffprobe_json: Optional[bool] = None
|
||||
timezone: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.nav_color = self.nav_color.lower() if self.nav_color else None
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
import json
|
||||
import pathlib
|
||||
from abc import ABC
|
||||
from datetime import datetime, time
|
||||
from datetime import datetime, time, timezone, timedelta
|
||||
from http import HTTPStatus
|
||||
from itertools import zip_longest
|
||||
from unittest import mock
|
||||
|
|
@ -1175,3 +1175,38 @@ def test_is_within_download_window_uses_local_time(test_download_manager):
|
|||
with mock.patch.object(type(test_download_manager), '_get_local_time',
|
||||
return_value=time(12, 0)):
|
||||
assert test_download_manager.is_within_download_window() is True
|
||||
|
||||
|
||||
def test_get_local_time_with_configured_timezone(test_download_manager):
|
||||
"""When config.timezone is set, _get_local_time() converts to that timezone."""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
config = get_wrolpi_config()
|
||||
config.timezone = 'America/Denver'
|
||||
|
||||
# Mock now() to return a known UTC time: 2024-01-15 18:00:00 UTC
|
||||
# Denver is UTC-7 in January (MST), so local time should be 11:00:00.
|
||||
mock_utc = datetime(2024, 1, 15, 18, 0, 0, tzinfo=timezone.utc)
|
||||
with mock.patch('wrolpi.downloader.now', return_value=mock_utc):
|
||||
result = test_download_manager._get_local_time()
|
||||
assert result == time(11, 0, 0)
|
||||
|
||||
# Summer time: 2024-07-15 18:00:00 UTC
|
||||
# Denver is UTC-6 in July (MDT), so local time should be 12:00:00.
|
||||
mock_utc_summer = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
|
||||
with mock.patch('wrolpi.downloader.now', return_value=mock_utc_summer):
|
||||
result = test_download_manager._get_local_time()
|
||||
assert result == time(12, 0, 0)
|
||||
|
||||
|
||||
def test_get_local_time_without_configured_timezone(test_download_manager):
|
||||
"""When config.timezone is None, _get_local_time() falls back to system timezone."""
|
||||
config = get_wrolpi_config()
|
||||
config.timezone = None
|
||||
|
||||
mock_utc = datetime(2024, 1, 15, 18, 0, 0, tzinfo=timezone.utc)
|
||||
with mock.patch('wrolpi.downloader.now', return_value=mock_utc):
|
||||
result = test_download_manager._get_local_time()
|
||||
# Should return the system's local time conversion.
|
||||
expected = mock_utc.astimezone().time()
|
||||
assert result == expected
|
||||
|
|
|
|||
|
|
@ -355,6 +355,21 @@ async def test_get_status(async_client, test_session):
|
|||
assert isinstance(response.json.get('memory_stats'), dict), 'memory_stats should be a dict'
|
||||
assert isinstance(response.json.get('flags'), dict), 'flags should be a dict'
|
||||
assert isinstance(response.json.get('sanic_workers'), dict), 'Sanic worker status should be a dict'
|
||||
assert isinstance(response.json.get('local_time'), str), 'local_time should be a str'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_local_time_with_timezone(async_client, test_session, test_wrolpi_config):
|
||||
"""Status endpoint includes local_time using configured timezone."""
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
config = get_wrolpi_config()
|
||||
config.timezone = 'America/Denver'
|
||||
|
||||
request, response = await async_client.get('/api/status')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
local_time = response.json['local_time']
|
||||
# ISO 8601 with timezone offset for Denver (UTC-7 or UTC-6).
|
||||
assert '+' in local_time or '-' in local_time[10:], 'local_time should include timezone offset'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -805,6 +820,42 @@ async def test_settings_save_ffprobe_json(async_client, test_wrolpi_config):
|
|||
assert config.save_ffprobe_json is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_timezone(async_client, test_wrolpi_config):
|
||||
"""Maintainer can set and clear the timezone setting."""
|
||||
config = get_wrolpi_config()
|
||||
|
||||
# Default is None.
|
||||
assert config.timezone is None
|
||||
|
||||
# GET returns the setting.
|
||||
request, response = await async_client.get('/api/settings')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['timezone'] is None
|
||||
|
||||
# Setting a valid timezone.
|
||||
data = {'timezone': 'America/Denver'}
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert config.timezone == 'America/Denver'
|
||||
|
||||
# GET reflects the change.
|
||||
request, response = await async_client.get('/api/settings')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['timezone'] == 'America/Denver'
|
||||
|
||||
# Empty string clears the timezone.
|
||||
data = {'timezone': ''}
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert config.timezone is None
|
||||
|
||||
# Invalid timezone is rejected.
|
||||
data = {'timezone': 'Not/A/Timezone'}
|
||||
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_other_estimates(async_client, test_session, channel_factory, tag_factory):
|
||||
# Can search without Tags or Channels.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue