mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Configs are displayed in new ConfigsPage under Admin.
- Configs can be imported and saved. - Configs are validated before imported. - API will refuse to save a config if it was not imported successfully.
This commit is contained in:
parent
d34301a085
commit
acf3dee179
22 changed files with 767 additions and 157 deletions
|
|
@ -83,6 +83,14 @@ function handleEvents(events) {
|
|||
eventToast('Move Failed', message, 'error');
|
||||
}
|
||||
|
||||
if (event === 'config_import_failed') {
|
||||
eventToast('Config Import Failed', message, 'error');
|
||||
}
|
||||
|
||||
if (event === 'config_save_failed') {
|
||||
eventToast('Config Save Failed', message, 'error');
|
||||
}
|
||||
|
||||
if (subject) {
|
||||
newestEvents[subject] = dt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,6 +313,55 @@ export async function getDownloads() {
|
|||
return response
|
||||
}
|
||||
|
||||
export async function getConfigs() {
|
||||
const response = await apiGet(`${API_URI}/configs`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
configs: data.configs,
|
||||
}
|
||||
} else {
|
||||
const message = getErrorMessage(response, 'Could not get configs.');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Getting Configs Failed',
|
||||
description: message,
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export async function postImportConfig(fileName) {
|
||||
const body = {file_name: fileName};
|
||||
const response = await apiPost(`${API_URI}/configs/import`, body);
|
||||
if (!response.ok) {
|
||||
const message = getErrorMessage(response, 'Could not import config.');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Importing config Failed',
|
||||
description: message,
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export async function postSaveConfig(fileName) {
|
||||
const body = {file_name: fileName, overwrite: true};
|
||||
const response = await apiPost(`${API_URI}/configs/save`, body);
|
||||
if (!response.ok) {
|
||||
const message = getErrorMessage(response, 'Could not save config.');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Saving config Failed',
|
||||
description: message,
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export async function validateRegex(regex) {
|
||||
const response = await apiPost(`${API_URI}/valid_regex`, {regex});
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import {
|
|||
HandPointMessage,
|
||||
humanFileSize,
|
||||
isoDatetimeToAgoPopup,
|
||||
isoDatetimeToString,
|
||||
mimetypeColor,
|
||||
PageContainer,
|
||||
Paginator,
|
||||
|
|
|
|||
|
|
@ -53,3 +53,9 @@ export function TagPlaceholder() {
|
|||
<PlaceholderImage/>
|
||||
</Placeholder>
|
||||
}
|
||||
|
||||
export function TableRowPlaceholder() {
|
||||
return <Placeholder>
|
||||
<PlaceholderLine/>
|
||||
</Placeholder>
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {Header, Loader, Segment} from "../Theme";
|
|||
import {DownloadsPage} from "./Downloads";
|
||||
import {SettingsPage} from "./Settings";
|
||||
import {StatusPage} from "./Status";
|
||||
import {ConfigsPage} from "./Configs";
|
||||
|
||||
class WROLMode extends React.Component {
|
||||
|
||||
|
|
@ -71,6 +72,7 @@ export default function AdminRoute() {
|
|||
{text: 'Downloads', to: '/admin', key: 'admin', end: true},
|
||||
{text: 'Settings', to: '/admin/settings', key: 'settings'},
|
||||
{text: 'Status', to: '/admin/status', key: 'status'},
|
||||
{text: 'Configs', to: '/admin/configs', key: 'configs'},
|
||||
{text: 'WROL Mode', to: '/admin/wrol', key: 'wrol'},
|
||||
];
|
||||
|
||||
|
|
@ -80,6 +82,7 @@ export default function AdminRoute() {
|
|||
<Route path='/' exact element={<DownloadsPage/>}/>
|
||||
<Route path='settings' exact element={<SettingsPage/>}/>
|
||||
<Route path='status' exact element={<StatusPage/>}/>
|
||||
<Route path='configs' exact element={<ConfigsPage/>}/>
|
||||
<Route path='wrol' exact element={<WROLMode/>}/>
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
|
|
|
|||
148
app/src/components/admin/Configs.js
Normal file
148
app/src/components/admin/Configs.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import {useConfigs} from "../../hooks/customHooks";
|
||||
import {APIButton, PageContainer} from "../Common";
|
||||
import {Header, Icon, Popup, Table} from "../Theme";
|
||||
import React from "react";
|
||||
import {
|
||||
Grid,
|
||||
GridColumn,
|
||||
GridRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow
|
||||
} from "semantic-ui-react";
|
||||
import {TableRowPlaceholder} from "../Placeholder";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
function WarningIcon({ok}) {
|
||||
return <Icon color={ok ? 'violet' : 'red'} name={ok ? 'check' : 'close'}/>
|
||||
}
|
||||
|
||||
function ConfigTableRow({config, importConfig, saveConfig, fetchConfigs}) {
|
||||
const {file_name, rel_path, valid, successful_import} = config;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const localImportConfig = async () => {
|
||||
await importConfig(file_name);
|
||||
await fetchConfigs();
|
||||
}
|
||||
|
||||
const localSaveConfig = async () => {
|
||||
await saveConfig(file_name);
|
||||
await fetchConfigs();
|
||||
}
|
||||
|
||||
const importButton = <APIButton
|
||||
icon='upload'
|
||||
disabled={!valid}
|
||||
onClick={localImportConfig}
|
||||
/>;
|
||||
|
||||
let saveButton;
|
||||
if (successful_import) {
|
||||
saveButton = <APIButton
|
||||
color='grey'
|
||||
icon='download'
|
||||
onClick={localSaveConfig}
|
||||
/>;
|
||||
} else {
|
||||
// Warn the user they may overwrite because import was not successful.
|
||||
saveButton = <APIButton
|
||||
color='grey'
|
||||
icon='download'
|
||||
onClick={localSaveConfig}
|
||||
confirmButton='Overwrite'
|
||||
confirmContent='Config was not imported successfully, overwrite the config? Data may be lost!'
|
||||
/>;
|
||||
}
|
||||
|
||||
return <TableRow>
|
||||
<TableCell><span onClick={() => navigate(`?preview=${rel_path}`)}>{file_name}</span></TableCell>
|
||||
<TableCell><WarningIcon ok={successful_import}/></TableCell>
|
||||
<TableCell><WarningIcon ok={valid}/></TableCell>
|
||||
<TableCell>{importButton}</TableCell>
|
||||
<TableCell>{saveButton}</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
|
||||
function ConfigsTable({configs, loading, importConfig, saveConfig, fetchConfigs}) {
|
||||
let body = <TableRow>
|
||||
<TableCell colSpan={4}><TableRowPlaceholder/></TableCell>
|
||||
</TableRow>
|
||||
|
||||
if (loading) {
|
||||
// Configs are being fetched again.
|
||||
} else if (configs === undefined) {
|
||||
body = <TableRow>
|
||||
<TableCell colSpan={2}>Failed to fetch configs</TableCell>
|
||||
</TableRow>;
|
||||
} else if (configs && Object.keys(configs).length === 0) {
|
||||
body = <TableRow>
|
||||
<TableCell colSpan={2}>No configs exist. Try tagging some files.</TableCell>
|
||||
</TableRow>;
|
||||
} else if (configs) {
|
||||
body = Object.entries(configs).map(([key, value]) =>
|
||||
<ConfigTableRow
|
||||
key={key}
|
||||
config={value}
|
||||
importConfig={importConfig}
|
||||
saveConfig={saveConfig}
|
||||
fetchConfigs={fetchConfigs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Table unstackable striped>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell width={10}>File Name</TableHeaderCell>
|
||||
<TableHeaderCell width={1}>Imported</TableHeaderCell>
|
||||
<TableHeaderCell width={1}>Valid</TableHeaderCell>
|
||||
<TableHeaderCell width={1}>Import</TableHeaderCell>
|
||||
<TableHeaderCell width={1}>Save</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{body}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
|
||||
export function ConfigsPage() {
|
||||
const {configs, loading, importConfig, saveConfig, fetchConfigs} = useConfigs();
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
|
||||
const refreshButton = <APIButton
|
||||
icon='refresh'
|
||||
onClick={fetchConfigs}
|
||||
/>;
|
||||
const refreshPopupButton = <Popup
|
||||
content='Check if configs are valid'
|
||||
on='hover'
|
||||
trigger={refreshButton}
|
||||
/>;
|
||||
|
||||
return <PageContainer>
|
||||
<Grid columns={2}>
|
||||
<GridRow>
|
||||
<GridColumn>
|
||||
<Header as='h1'>Configs</Header>
|
||||
</GridColumn>
|
||||
<GridColumn textAlign='right'>{refreshPopupButton}</GridColumn>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
|
||||
<ConfigsTable
|
||||
configs={configs}
|
||||
loading={loading}
|
||||
importConfig={importConfig}
|
||||
saveConfig={saveConfig}
|
||||
fetchConfigs={fetchConfigs}
|
||||
/>
|
||||
</PageContainer>
|
||||
}
|
||||
|
|
@ -297,8 +297,17 @@ export class SettingsPage extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const controlSegment = <Segment>
|
||||
<Header as='h3'>Control WROLPi</Header>
|
||||
<RestartButton/>
|
||||
<ShutdownButton/>
|
||||
</Segment>;
|
||||
|
||||
if (this.state.ready === false) {
|
||||
return <Loader active inline='centered'/>
|
||||
return <>
|
||||
<Loader active inline='centered'/>
|
||||
{controlSegment}
|
||||
</>
|
||||
} else if (this.state.ready === undefined) {
|
||||
return <ErrorMessage>Unable to fetch settings</ErrorMessage>
|
||||
}
|
||||
|
|
@ -539,11 +548,7 @@ export class SettingsPage extends React.Component {
|
|||
</Form>
|
||||
</Segment>
|
||||
|
||||
<Segment>
|
||||
<Header as='h3'>Control WROLPi</Header>
|
||||
<RestartButton/>
|
||||
<ShutdownButton/>
|
||||
</Segment>
|
||||
{controlSegment}
|
||||
</Container>;
|
||||
}
|
||||
}</SettingsContext.Consumer>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getArchive,
|
||||
getChannel,
|
||||
getChannels,
|
||||
getConfigs,
|
||||
getDirectories,
|
||||
getDownloads,
|
||||
getFiles,
|
||||
|
|
@ -17,7 +18,10 @@ import {
|
|||
getStatus,
|
||||
getVideo,
|
||||
getVideosStatistics,
|
||||
searchArchives, searchChannels,
|
||||
postImportConfig,
|
||||
postSaveConfig,
|
||||
searchArchives,
|
||||
searchChannels,
|
||||
searchDirectories,
|
||||
searchVideos,
|
||||
searchZim,
|
||||
|
|
@ -679,6 +683,44 @@ export const useDownloads = () => {
|
|||
return {onceDownloads, recurringDownloads, pendingOnceDownloads, fetchDownloads}
|
||||
}
|
||||
|
||||
export const useConfigs = () => {
|
||||
const [configs, setConfigs] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
setLoading(true);
|
||||
console.log('fetching configs...');
|
||||
try {
|
||||
const data = await getConfigs();
|
||||
setConfigs(data['configs']);
|
||||
console.debug('fetching configs successful');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setConfigs(undefined);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const importConfig = async (fileName) => {
|
||||
try {
|
||||
await postImportConfig(fileName);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async (fileName) => {
|
||||
try {
|
||||
await postSaveConfig(fileName);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return {configs, loading, fetchConfigs, importConfig, saveConfig}
|
||||
}
|
||||
|
||||
export const useThrottle = () => {
|
||||
const [on, setOn] = useState(null);
|
||||
const {settings, fetchSettings} = React.useContext(SettingsContext);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import pathlib
|
||||
from dataclasses import field, dataclass
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
|
@ -8,6 +9,7 @@ from pint import Quantity
|
|||
|
||||
from wrolpi.common import logger, Base, ConfigFile
|
||||
from wrolpi.db import get_db_session
|
||||
from wrolpi.events import Events
|
||||
from .errors import NoInventories
|
||||
from .inventory import get_items, get_inventories, get_unit_registry
|
||||
from .models import Inventory, Item
|
||||
|
|
@ -116,12 +118,19 @@ def cleanup_quantity(quantity: Quantity) -> Quantity:
|
|||
return Decimal(num) * unit
|
||||
|
||||
|
||||
@dataclass
|
||||
class InventoriesConfigValidator:
|
||||
version: int = None
|
||||
inventories: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class InventoriesConfig(ConfigFile):
|
||||
file_name = 'inventories.yaml'
|
||||
default_config = dict(
|
||||
inventories=[],
|
||||
version=0,
|
||||
)
|
||||
validator = InventoriesConfigValidator
|
||||
|
||||
@property
|
||||
def inventories(self) -> dict:
|
||||
|
|
@ -131,7 +140,7 @@ class InventoriesConfig(ConfigFile):
|
|||
def inventories(self, value: dict):
|
||||
self.update({'inventories': value})
|
||||
|
||||
def dump_config(self, file: pathlib.Path = None):
|
||||
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
|
||||
inventories = []
|
||||
for inventory in get_inventories():
|
||||
inventories.append(inventory.dict())
|
||||
|
|
@ -140,46 +149,51 @@ class InventoriesConfig(ConfigFile):
|
|||
raise NoInventories('No Inventories are in the database!')
|
||||
|
||||
self._config['inventories'] = inventories
|
||||
super().dump_config(file)
|
||||
super().dump_config(file, send_events, overwrite)
|
||||
|
||||
def import_config(self, file: pathlib.Path = None):
|
||||
def import_config(self, file: pathlib.Path = None, send_events=False):
|
||||
super().import_config(file)
|
||||
|
||||
try:
|
||||
with get_db_session(commit=True) as session:
|
||||
db_inventories = get_inventories(session)
|
||||
config_inventory_names = {i['name'] for i in self.inventories}
|
||||
keep_inventories_names = []
|
||||
for inventory in db_inventories:
|
||||
if inventory.name in config_inventory_names:
|
||||
keep_inventories_names.append(inventory.name)
|
||||
# TODO delete missing items, add new items.
|
||||
else:
|
||||
# Inventory was deleted from the config file.
|
||||
inventory.delete()
|
||||
continue
|
||||
db_inventories = session.query(Inventory).all()
|
||||
db_inventories_by_name = {i.name: i for i in db_inventories}
|
||||
config_inventories = [i['name'] for i in self.inventories]
|
||||
for inventory_dict in self.inventories:
|
||||
if inventory_dict['name'] not in db_inventories_by_name:
|
||||
inventory = Inventory(
|
||||
name=inventory_dict['name'],
|
||||
created_at=inventory_dict['created_at'],
|
||||
deleted_at=inventory_dict['deleted_at'],
|
||||
)
|
||||
session.add(inventory)
|
||||
# Get the Inventory from the DB so we can use its ID.
|
||||
inventory.flush()
|
||||
db_inventories_by_name[inventory.name] = inventory
|
||||
|
||||
new_inventories = [i for i in self.inventories if i['name'] not in keep_inventories_names]
|
||||
for inventory_dict in new_inventories:
|
||||
# New Inventory in config, copy it into the DB.
|
||||
inventory = Inventory(
|
||||
name=inventory_dict['name'],
|
||||
created_at=inventory_dict['created_at'],
|
||||
deleted_at=inventory_dict['deleted_at'],
|
||||
)
|
||||
session.add(inventory)
|
||||
# Get the Inventory from the DB so we can use its ID.
|
||||
inventory.flush()
|
||||
# Delete any inventories no longer in the config.
|
||||
for name, inventory in db_inventories_by_name.items():
|
||||
if name not in config_inventories:
|
||||
session.delete(inventory)
|
||||
|
||||
for inventory_dict in self.inventories:
|
||||
# Items are complex and do not have useful primary keys. Delete all items, create new items.
|
||||
session.query(Item).filter(Item.inventory_id == inventory_dict['id']).delete()
|
||||
inventory = db_inventories_by_name[inventory_dict['name']]
|
||||
for item in inventory_dict['items']:
|
||||
del item['inventory_id']
|
||||
item = Item(inventory_id=inventory.id, **item)
|
||||
item.inventory_id = inventory.id
|
||||
del item['id']
|
||||
item = Item(inventory=inventory, inventory_id=inventory.id, **item)
|
||||
session.add(item)
|
||||
item.flush()
|
||||
|
||||
self.successful_import = True
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to import {self.file_name}', exc_info=e)
|
||||
self.successful_import = False
|
||||
message = f'Failed to import {self.file_name}'
|
||||
logger.error(message, exc_info=e)
|
||||
if send_events:
|
||||
Events.send_config_import_failed(message)
|
||||
raise
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from sqlalchemy import Column, Integer, String, Date, ForeignKey, DECIMAL
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import relationship, Session
|
||||
from sqlalchemy.orm.collections import InstrumentedList
|
||||
|
||||
from wrolpi.common import Base, ModelHelper
|
||||
|
|
@ -56,6 +56,10 @@ class Inventory(Base, ModelHelper):
|
|||
"""
|
||||
self.deleted_at = now()
|
||||
|
||||
@staticmethod
|
||||
def find_by_name(session: Session, name: str) -> 'Inventory':
|
||||
return session.query(Inventory).filter_by(name=name).one()
|
||||
|
||||
|
||||
class InventoriesVersion(Base, ModelHelper):
|
||||
__tablename__ = 'inventories_version'
|
||||
|
|
|
|||
|
|
@ -303,3 +303,16 @@ async def test_inventories_config(async_client, test_session, test_directory, in
|
|||
test_items = {(i['name'], i['brand'], i['count']) for i in TEST_ITEMS[:-1]}
|
||||
db_items = {(i.name, i.brand, i.count) for i in non_deleted_items}
|
||||
assert test_items == db_items
|
||||
|
||||
# An extra Inventory with items should be deleted from the DB.
|
||||
inventory = Inventory(name='delete me')
|
||||
test_session.add(inventory)
|
||||
inventory.flush()
|
||||
test_session.add(Item(inventory_id=inventory.id))
|
||||
test_session.commit()
|
||||
assert test_session.query(Inventory).count() == 2
|
||||
assert {i for i, in test_session.query(Inventory.name)} == {'Food Storage', 'delete me'}
|
||||
|
||||
import_inventories_config()
|
||||
assert test_session.query(Inventory).count() == 1
|
||||
assert test_session.query(Inventory).one().name == 'Food Storage'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import dataclasses
|
|||
import html
|
||||
import pathlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Generator, Type
|
||||
from typing import Tuple, Optional
|
||||
|
|
@ -20,6 +21,7 @@ from wrolpi.dates import Seconds, from_timestamp
|
|||
from wrolpi.db import get_db_curs, get_db_session
|
||||
from wrolpi.downloader import Download
|
||||
from wrolpi.errors import UnknownDirectory
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.files.lib import split_path_stem_and_suffix
|
||||
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
|
||||
from wrolpi.vars import YTDLP_CACHE_DIR, PYTEST
|
||||
|
|
@ -207,6 +209,20 @@ def convert_or_generate_poster(video: Video) -> Tuple[Optional[pathlib.Path], Op
|
|||
return None, None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelDictValidator:
|
||||
name: str
|
||||
directory: str
|
||||
download_frequency: int
|
||||
url: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelsConfigValidator:
|
||||
version: int = None
|
||||
channels: list[ChannelDictValidator] = dataclasses.field(default_factory=list)
|
||||
|
||||
|
||||
class ChannelsConfig(ConfigFile):
|
||||
file_name = 'channels.yaml'
|
||||
default_config = dict(
|
||||
|
|
@ -218,6 +234,7 @@ class ChannelsConfig(ConfigFile):
|
|||
)],
|
||||
version=0,
|
||||
)
|
||||
validator = ChannelsConfigValidator
|
||||
|
||||
@property
|
||||
def channels(self) -> dict:
|
||||
|
|
@ -227,7 +244,7 @@ class ChannelsConfig(ConfigFile):
|
|||
def channels(self, value: dict):
|
||||
self.update({'channels': value})
|
||||
|
||||
def import_config(self, file: pathlib.Path = None):
|
||||
def import_config(self, file: pathlib.Path = None, send_events=False):
|
||||
from modules.videos.channel.lib import get_channel
|
||||
super().import_config()
|
||||
try:
|
||||
|
|
@ -296,10 +313,12 @@ class ChannelsConfig(ConfigFile):
|
|||
self.successful_import = True
|
||||
channel_import_logger.info('Importing channels config complete')
|
||||
except Exception as e:
|
||||
channel_import_logger.warning('Failed to load channels config!', exc_info=e)
|
||||
if PYTEST:
|
||||
# Do not interrupt startup, only raise during testing.
|
||||
raise
|
||||
self.successful_import = False
|
||||
message = f'Failed to import {self.get_relative_file()} config!'
|
||||
if send_events:
|
||||
channel_import_logger.warning(message, exc_info=e)
|
||||
Events.send_config_import_failed(message)
|
||||
raise
|
||||
|
||||
|
||||
CHANNELS_CONFIG: ChannelsConfig = ChannelsConfig()
|
||||
|
|
@ -328,6 +347,21 @@ def set_test_channels_config():
|
|||
TEST_CHANNELS_CONFIG = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoDownloaderConfigValidator:
|
||||
continue_dl: bool
|
||||
dateafter: str
|
||||
file_name_format: str
|
||||
nooverwrites: bool
|
||||
quiet: bool
|
||||
writeautomaticsub: bool
|
||||
writeinfojson: bool
|
||||
writesubtitles: bool
|
||||
writethumbnail: bool
|
||||
youtube_include_dash_manifest: bool
|
||||
version: int = None
|
||||
|
||||
|
||||
class VideoDownloaderConfig(ConfigFile):
|
||||
file_name = 'videos_downloader.yaml'
|
||||
default_config = dict(
|
||||
|
|
@ -343,6 +377,7 @@ class VideoDownloaderConfig(ConfigFile):
|
|||
writethumbnail=True,
|
||||
youtube_include_dash_manifest=False,
|
||||
)
|
||||
validator = VideoDownloaderConfigValidator
|
||||
|
||||
@property
|
||||
def continue_dl(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ def json_error_handler(request: Request, exception: Exception):
|
|||
return json_response(body, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
api_app.error_handler.add(Exception, json_error_handler)
|
||||
|
||||
PERPETUAL_WORKERS = list()
|
||||
|
||||
|
||||
|
|
|
|||
169
wrolpi/common.py
169
wrolpi/common.py
|
|
@ -14,10 +14,9 @@ import socket
|
|||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from asyncio import Task
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, asdict, field, fields
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
|
|
@ -26,7 +25,7 @@ from itertools import islice, filterfalse, tee
|
|||
from multiprocessing.managers import DictProxy
|
||||
from pathlib import Path
|
||||
from types import GeneratorType
|
||||
from typing import Union, Callable, Tuple, Dict, List, Iterable, Optional, Generator, Any, Set, Coroutine
|
||||
from typing import Union, Callable, Tuple, Dict, List, Iterable, Optional, Generator, Any, Set
|
||||
from urllib.parse import urlparse, urlunsplit
|
||||
|
||||
import aiohttp
|
||||
|
|
@ -41,7 +40,7 @@ from sqlalchemy.ext.declarative import declarative_base
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from wrolpi.dates import now, from_timestamp, seconds_to_timestamp
|
||||
from wrolpi.errors import WROLModeEnabled, NativeOnly, UnrecoverableDownloadError, LogLevelError
|
||||
from wrolpi.errors import WROLModeEnabled, NativeOnly, UnrecoverableDownloadError, LogLevelError, InvalidConfig
|
||||
from wrolpi.vars import PYTEST, DOCKERIZED, CONFIG_DIR, MEDIA_DIRECTORY, DEFAULT_HTTP_HEADERS, LOG_LEVEL
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
|
|
@ -192,6 +191,7 @@ __all__ = [
|
|||
'format_html_string',
|
||||
'format_json_file',
|
||||
'get_absolute_media_path',
|
||||
'get_all_configs',
|
||||
'get_download_info',
|
||||
'get_files_and_directories',
|
||||
'get_global_statistics',
|
||||
|
|
@ -346,10 +346,12 @@ class ConfigFile:
|
|||
|
||||
Configs cannot be saved until they are imported without error.
|
||||
"""
|
||||
file_name: str = None
|
||||
default_config: dict = None
|
||||
width: int = None
|
||||
background_dump: callable = None
|
||||
background_save: callable = None
|
||||
default_config: dict = None
|
||||
file_name: str = None
|
||||
validator: dataclass = None
|
||||
width: int = None
|
||||
|
||||
def __init__(self):
|
||||
self.width = self.width or 90
|
||||
|
|
@ -377,6 +379,15 @@ class ConfigFile:
|
|||
def __repr__(self):
|
||||
return f'<{self.__class__.__name__} file={self.get_file()}>'
|
||||
|
||||
def __json__(self) -> dict:
|
||||
d = dict(
|
||||
file_name=self.file_name,
|
||||
rel_path=get_relative_to_media_directory(self.get_file()),
|
||||
successful_import=self.successful_import,
|
||||
valid=self.is_valid(),
|
||||
)
|
||||
return d
|
||||
|
||||
def initialize(self, multiprocessing_dict: Optional[DictProxy] = None):
|
||||
"""Initializes this config dict using the default config and the config file."""
|
||||
# Use the provided multiprocessing.Manager().dict(), or dict() for testing.
|
||||
|
|
@ -386,8 +397,10 @@ class ConfigFile:
|
|||
|
||||
file = self.get_file()
|
||||
if file.is_file():
|
||||
config_data = {k: v for k, v in self.read_config_file(file).items() if k in self.default_config}
|
||||
self._config.update(config_data)
|
||||
if not self.is_valid(file):
|
||||
raise InvalidConfig(f'Config file is invalid: {str(self.get_relative_file())}')
|
||||
config_data = self.read_config_file(file)
|
||||
self._config.update(asdict(self.validator(**config_data)))
|
||||
return self
|
||||
|
||||
@property
|
||||
|
|
@ -405,7 +418,7 @@ class ConfigFile:
|
|||
with file.open('rt') as fh:
|
||||
config_data = yaml.load(fh, Loader=yaml.Loader)
|
||||
if not isinstance(config_data, dict):
|
||||
raise RuntimeError(f'Config file is invalid: {file}')
|
||||
raise InvalidConfig(f'Config file is invalid: {file}')
|
||||
return config_data
|
||||
|
||||
def _get_backup_filename(self):
|
||||
|
|
@ -417,9 +430,13 @@ class ConfigFile:
|
|||
path = path.with_name(name)
|
||||
return path
|
||||
|
||||
def save(self, file: pathlib.Path = None):
|
||||
def save(self, file: pathlib.Path = None, send_events: bool = False, overwrite: bool = False):
|
||||
"""
|
||||
Write this config to its file.
|
||||
|
||||
@param file: The destination of the config file (defaults to `self.get_file()`).
|
||||
@param send_events: Send failure Events to UI.
|
||||
@param overwrite: Will overwrite the config file even if it was not imported successfully.
|
||||
"""
|
||||
from wrolpi.api_utils import api_app
|
||||
|
||||
|
|
@ -429,13 +446,12 @@ class ConfigFile:
|
|||
|
||||
rel_path = get_relative_to_media_directory(file)
|
||||
|
||||
if file.exists():
|
||||
if file.exists() and overwrite is False:
|
||||
if not self.successful_import:
|
||||
raise RuntimeError(f'Refusing to save config because it was never successfully imported! {rel_path}')
|
||||
version = self.read_config_file(file).get('version')
|
||||
if version and version > self.version:
|
||||
raise RuntimeError(f'Refusing to overwrite newer config ({rel_path}): '
|
||||
f'{version} > {self.version}')
|
||||
raise RuntimeError(f'Refusing to overwrite newer config ({rel_path}): {version} > {self.version}')
|
||||
|
||||
# Don't overwrite a real config while testing.
|
||||
if PYTEST and not str(file).startswith('/tmp'):
|
||||
|
|
@ -469,7 +485,11 @@ class ConfigFile:
|
|||
logger_.info(f'Saved config: {rel_path}')
|
||||
except Exception as e:
|
||||
# Configs are vital, raise a big error when this fails.
|
||||
logger_.critical(f'Failed to save config: {rel_path}', exc_info=e)
|
||||
message = f'Failed to save config: {rel_path}'
|
||||
logger_.critical(message, exc_info=e)
|
||||
if send_events:
|
||||
from wrolpi.events import Events
|
||||
Events.send_config_save_failed(message)
|
||||
raise e
|
||||
|
||||
def write_config_data(self, config: dict, config_file: pathlib.Path):
|
||||
|
|
@ -488,11 +508,19 @@ class ConfigFile:
|
|||
|
||||
return CONFIG_DIR / self.file_name
|
||||
|
||||
def import_config(self, file: pathlib.Path = None):
|
||||
def get_relative_file(self):
|
||||
return get_relative_to_media_directory(self.get_file())
|
||||
|
||||
def import_config(self, file: pathlib.Path = None, send_events=False):
|
||||
"""Read config file data, apply it to the in-memory config and database."""
|
||||
file = file or self.get_file()
|
||||
file_str = str(get_relative_to_media_directory(file))
|
||||
# Caller will set to successful if it works.
|
||||
self.successful_import = False
|
||||
if file.is_file():
|
||||
if not self.is_valid(file):
|
||||
raise InvalidConfig(f'Config is invalid: {file_str}')
|
||||
|
||||
data = self.read_config_file(file)
|
||||
new_data, extra_data = partition(lambda i: i[0] in self.default_config, data.items())
|
||||
new_data, extra_data = dict(new_data), dict(extra_data)
|
||||
|
|
@ -504,10 +532,10 @@ class ConfigFile:
|
|||
else:
|
||||
logger_.error(f'Failed to import {file_str} because it does not exist.')
|
||||
|
||||
def dump_config(self, file: pathlib.Path = None):
|
||||
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
|
||||
"""Dump database data, copy it to this the in-memory config, then write it to the config file."""
|
||||
# Copy the data as-is by default. Other classes will overwrite this and dump the database before saving.
|
||||
self.save(file)
|
||||
self.save(file, send_events, overwrite)
|
||||
|
||||
def update(self, config: dict):
|
||||
"""Update any values of this config. Save the config to its file."""
|
||||
|
|
@ -524,6 +552,28 @@ class ConfigFile:
|
|||
|
||||
return deepcopy(self._config)
|
||||
|
||||
def is_valid(self, file: pathlib.Path = None) -> bool:
|
||||
if not self.validator:
|
||||
raise NotImplementedError(f'Cannot validate {self.file_name} without validator!')
|
||||
|
||||
file = file or self.get_file()
|
||||
if not file.is_file():
|
||||
return False
|
||||
|
||||
allowed_fields = {i.name for i in fields(self.validator)}
|
||||
try:
|
||||
# Remove keys no longer in the config.
|
||||
config_items = self.read_config_file(file).items()
|
||||
extra_items = {k: v for k, v in config_items if k not in allowed_fields}
|
||||
if extra_items:
|
||||
logger.error(f'Invalid config items ({get_relative_to_media_directory(file)}): {extra_items}')
|
||||
config = {k: v for k, v in config_items if k in allowed_fields}
|
||||
self.validator(**config)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to validate config: {file}', exc_info=e)
|
||||
return False
|
||||
|
||||
# `version` is used to prevent overwriting of newer configs. Cannot not be modified directly.
|
||||
|
||||
@property
|
||||
|
|
@ -531,6 +581,63 @@ class ConfigFile:
|
|||
return self._config['version']
|
||||
|
||||
|
||||
@dataclass
|
||||
class WROLPiConfigValidator:
|
||||
archive_destination: str = None
|
||||
download_on_startup: bool = None
|
||||
download_timeout: int = None
|
||||
hotspot_device: str = None
|
||||
hotspot_on_startup: bool = None
|
||||
hotspot_password: str = None
|
||||
hotspot_ssid: str = None
|
||||
ignore_outdated_zims: bool = None
|
||||
map_destination: str = None
|
||||
nav_color: str = None
|
||||
throttle_on_startup: bool = None
|
||||
version: int = None
|
||||
videos_destination: str = None
|
||||
wrol_mode: bool = None
|
||||
zims_destination: str = None
|
||||
ignored_directories: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def get_all_configs() -> Dict[str, ConfigFile]:
|
||||
all_configs = dict()
|
||||
|
||||
if wrolpi_config := get_wrolpi_config():
|
||||
all_configs[wrolpi_config.file_name] = wrolpi_config
|
||||
|
||||
from wrolpi.tags import get_tags_config
|
||||
if tags_config := get_tags_config():
|
||||
all_configs[tags_config.file_name] = tags_config
|
||||
|
||||
from modules.videos.lib import get_channels_config
|
||||
if channels_config := get_channels_config():
|
||||
all_configs[channels_config.file_name] = channels_config
|
||||
|
||||
from modules.inventory.common import get_inventories_config
|
||||
if inventories_config := get_inventories_config():
|
||||
all_configs[inventories_config.file_name] = inventories_config
|
||||
|
||||
from modules.videos.lib import get_downloader_config
|
||||
if videos_downloader_config := get_downloader_config():
|
||||
all_configs[videos_downloader_config.file_name] = videos_downloader_config
|
||||
|
||||
from wrolpi.downloader import get_download_manager_config
|
||||
if download_manager_config := get_download_manager_config():
|
||||
all_configs[download_manager_config.file_name] = download_manager_config
|
||||
|
||||
return all_configs
|
||||
|
||||
|
||||
def get_config_by_file_name(file_name: str) -> ConfigFile:
|
||||
configs = get_all_configs()
|
||||
if file_name not in configs:
|
||||
raise InvalidConfig(f'No config with {file_name} exists')
|
||||
|
||||
return configs[file_name]
|
||||
|
||||
|
||||
class WROLPiConfig(ConfigFile):
|
||||
file_name = 'wrolpi.yaml'
|
||||
default_config = dict(
|
||||
|
|
@ -551,6 +658,7 @@ class WROLPiConfig(ConfigFile):
|
|||
wrol_mode=False,
|
||||
zims_destination='zims',
|
||||
)
|
||||
validator = WROLPiConfigValidator
|
||||
|
||||
def get_file(self) -> Path:
|
||||
"""WROLPiConfig must be discovered so that other config files can be found."""
|
||||
|
|
@ -573,13 +681,18 @@ class WROLPiConfig(ConfigFile):
|
|||
# Not testing, and can't find file deep in media directory. Use the default.
|
||||
return default_config_path
|
||||
|
||||
def import_config(self, file: pathlib.Path = None):
|
||||
def import_config(self, file: pathlib.Path = None, send_events=False):
|
||||
try:
|
||||
super().import_config(file)
|
||||
# WROLPiConfig does not sync to database.
|
||||
self.successful_import = True
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to import {self.file_name}', exc_info=e)
|
||||
self.successful_import = False
|
||||
message = f'Failed to import {self.file_name}'
|
||||
logger.error(message, exc_info=e)
|
||||
if send_events:
|
||||
from wrolpi.events import Events
|
||||
Events.send_config_import_failed(message)
|
||||
raise
|
||||
|
||||
@property
|
||||
|
|
@ -820,7 +933,6 @@ def run_after(after: callable, *args, **kwargs) -> callable:
|
|||
|
||||
async def after(*a, **kw):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
traceback.print_stack()
|
||||
logger.debug(f'run_after synchronous_after called for {after}')
|
||||
return synchronous_after(*a, **kw)
|
||||
|
||||
|
|
@ -833,7 +945,6 @@ def run_after(after: callable, *args, **kwargs) -> callable:
|
|||
async def wrapped(*a, **kw):
|
||||
results = await func(*a, **kw)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
traceback.print_stack()
|
||||
logger.debug(f'run_after async called for {func}')
|
||||
coro = after(*args, **kwargs)
|
||||
asyncio.ensure_future(coro)
|
||||
|
|
@ -843,7 +954,6 @@ def run_after(after: callable, *args, **kwargs) -> callable:
|
|||
def wrapped(*a, **kw):
|
||||
results = func(*a, **kw)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
traceback.print_stack()
|
||||
logger.debug(f'run_after sync called for {func}')
|
||||
coro = after(*args, **kwargs)
|
||||
asyncio.ensure_future(coro)
|
||||
|
|
@ -1483,7 +1593,7 @@ def chunks_by_stem(it: List[Union[pathlib.Path, str, int]], size: int) -> Genera
|
|||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def timer(name, level: str = 'debug', logger__: logging.Logger = logger_):
|
||||
def timer(name, level: str = 'debug', logger__: logging.Logger = None):
|
||||
"""Prints out the time elapsed during the call of some block.
|
||||
|
||||
Example:
|
||||
|
|
@ -1491,6 +1601,7 @@ def timer(name, level: str = 'debug', logger__: logging.Logger = logger_):
|
|||
time.sleep(10)
|
||||
|
||||
"""
|
||||
logger__ = logger__ or logger_
|
||||
before = datetime.now()
|
||||
log_method = getattr(logger__, level)
|
||||
try:
|
||||
|
|
@ -1500,16 +1611,6 @@ def timer(name, level: str = 'debug', logger__: logging.Logger = logger_):
|
|||
log_method(f'{name} elapsed {elapsed} seconds')
|
||||
|
||||
|
||||
def async_timer(coro: Coroutine, name: str = 'async timer', level: str = 'debug') -> callable:
|
||||
"""Returns a new coroutine which prints out time elapsed when calling the coroutine."""
|
||||
|
||||
async def _():
|
||||
with timer(name, level):
|
||||
return await coro
|
||||
|
||||
return _()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def slow_logger(max_seconds: int, message: str, logger__=logger_, level=logging.WARNING):
|
||||
"""Only logs when the context duration exceeds the `max_seconds`."""
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from wrolpi.common import Base, ModelHelper, logger, wrol_mode_check, zig_zag, C
|
|||
from wrolpi.dates import TZDateTime, now, Seconds
|
||||
from wrolpi.db import get_db_session, get_db_curs, optional_session
|
||||
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, UnknownDownload
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod, await_switches
|
||||
from wrolpi.vars import PYTEST, SIMULTANEOUS_DOWNLOAD_DOMAINS
|
||||
|
||||
|
|
@ -1143,6 +1144,25 @@ async def signal_download_download(download_id: int, download_url: str):
|
|||
download_manager._delete_processing_domain(download_domain)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadDictConfig:
|
||||
downloader: str
|
||||
last_successful_download: str
|
||||
next_download: str
|
||||
url: str
|
||||
frequency: int = None
|
||||
settings: dict = None
|
||||
status: str = None
|
||||
sub_downloader: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadManagerConfigValidator:
|
||||
version: int = None
|
||||
downloads: list[DownloadDictConfig] = field(default_factory=list)
|
||||
skip_urls: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class DownloadManagerConfig(ConfigFile):
|
||||
file_name = 'download_manager.yaml'
|
||||
default_config = dict(
|
||||
|
|
@ -1150,6 +1170,7 @@ class DownloadManagerConfig(ConfigFile):
|
|||
skip_urls=[],
|
||||
version=0,
|
||||
)
|
||||
validator = DownloadManagerConfigValidator
|
||||
|
||||
@property
|
||||
def skip_urls(self) -> List[str]:
|
||||
|
|
@ -1167,27 +1188,33 @@ class DownloadManagerConfig(ConfigFile):
|
|||
def downloads(self, value: List[dict]):
|
||||
self.update({'downloads': value})
|
||||
|
||||
def dump_config(self, file: pathlib.Path = None):
|
||||
with get_db_session() as session:
|
||||
downloads: Iterable[Download] = session.query(Download).order_by(Download.url)
|
||||
new_downloads = []
|
||||
for download in downloads:
|
||||
if download.last_successful_download and not download.frequency:
|
||||
# This once-download has completed, do not save it.
|
||||
continue
|
||||
new_downloads.append(dict(
|
||||
downloader=download.downloader,
|
||||
frequency=download.frequency,
|
||||
last_successful_download=download.last_successful_download,
|
||||
next_download=download.next_download,
|
||||
settings=download.settings,
|
||||
status=download.status,
|
||||
sub_downloader=download.sub_downloader,
|
||||
url=download.url,
|
||||
))
|
||||
get_download_manager_config().downloads = new_downloads
|
||||
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
|
||||
try:
|
||||
with get_db_session() as session:
|
||||
downloads: Iterable[Download] = session.query(Download).order_by(Download.url)
|
||||
new_downloads = []
|
||||
for download in downloads:
|
||||
if download.last_successful_download and not download.frequency:
|
||||
# This once-download has completed, do not save it.
|
||||
continue
|
||||
new_downloads.append(dict(
|
||||
downloader=download.downloader,
|
||||
frequency=download.frequency,
|
||||
last_successful_download=download.last_successful_download,
|
||||
next_download=download.next_download,
|
||||
settings=download.settings,
|
||||
status=download.status,
|
||||
sub_downloader=download.sub_downloader,
|
||||
url=download.url,
|
||||
))
|
||||
get_download_manager_config().downloads = new_downloads
|
||||
except Exception as e:
|
||||
message = f'Failed to save {self.get_relative_file()} config'
|
||||
logger.error(message, exc_info=e)
|
||||
if send_events:
|
||||
Events.send_config_save_failed(message)
|
||||
|
||||
def import_config(self, file: pathlib.Path = None):
|
||||
def import_config(self, file: pathlib.Path = None, send_events=False):
|
||||
super().import_config(file)
|
||||
with get_db_session(commit=True) as session:
|
||||
from modules.zim.lib import zim_download_url_to_name
|
||||
|
|
@ -1231,8 +1258,13 @@ class DownloadManagerConfig(ConfigFile):
|
|||
logger.info(f'Adding new download {url}')
|
||||
|
||||
session.commit()
|
||||
self.successful_import = True
|
||||
except Exception as e:
|
||||
logger.error('Failed to import downloads', exc_info=e)
|
||||
self.successful_import = False
|
||||
message = f'Failed to import {self.file_name}'
|
||||
logger.error(message, exc_info=e)
|
||||
if send_events:
|
||||
Events.send_config_import_failed(message)
|
||||
raise
|
||||
|
||||
try:
|
||||
|
|
@ -1254,8 +1286,13 @@ class DownloadManagerConfig(ConfigFile):
|
|||
|
||||
if need_commit:
|
||||
session.commit()
|
||||
self.successful_import = True
|
||||
except Exception as e:
|
||||
logger.error('Failed to restore ZimSubscriptions', exc_info=e)
|
||||
self.successful_import = False
|
||||
message = 'Failed to restore ZimSubscriptions'
|
||||
logger.error(message, exc_info=e)
|
||||
if send_events:
|
||||
Events.send_config_import_failed(message)
|
||||
raise
|
||||
|
||||
self.successful_import = True
|
||||
|
|
|
|||
|
|
@ -84,6 +84,14 @@ class Events:
|
|||
def send_file_move_failed(message: str = None):
|
||||
send_event('file_move_failed', message, subject='refresh')
|
||||
|
||||
@staticmethod
|
||||
def send_config_import_failed(message: str = None):
|
||||
send_event('config_import_failed', message, subject='configs')
|
||||
|
||||
@staticmethod
|
||||
def send_config_save_failed(message: str = None):
|
||||
send_event('config_save_failed', message, subject='configs')
|
||||
|
||||
|
||||
def log_event(event: str, message: str = None, action: str = None, subject: str = None):
|
||||
log = f'{event=}'
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ from modules.zim.api import zim_bp
|
|||
from wrolpi import admin, flags, schema, dates
|
||||
from wrolpi import tags
|
||||
from wrolpi.admin import HotspotStatus
|
||||
from wrolpi.api_utils import json_response, json_error_handler, api_app
|
||||
from wrolpi.api_utils import json_response, api_app
|
||||
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, get_all_configs, \
|
||||
get_config_by_file_name
|
||||
from wrolpi.dates import now
|
||||
from wrolpi.downloader import download_manager
|
||||
from wrolpi.errors import WROLModeEnabled, HotspotError, InvalidDownload, \
|
||||
|
|
@ -605,4 +606,33 @@ async def post_search_other_estimates(_: Request, body: schema.SearchOtherEstima
|
|||
return json_response(ret)
|
||||
|
||||
|
||||
api_app.error_handler.add(Exception, json_error_handler)
|
||||
@api_bp.get('/configs')
|
||||
def get_configs(_: Request):
|
||||
configs = get_all_configs()
|
||||
return json_response(dict(configs=configs))
|
||||
|
||||
|
||||
@api_bp.post('/configs/import')
|
||||
@validate(json=schema.ConfigsImportRequest)
|
||||
def post_configs_import(_: Request, body: schema.ConfigsImportRequest):
|
||||
config = get_config_by_file_name(body.file_name)
|
||||
try:
|
||||
config.import_config(send_events=True)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to import config: {body.file_name}', exc_info=e)
|
||||
raise InvalidConfig(f'Failed to import config {body.file_name}')
|
||||
|
||||
return response.empty()
|
||||
|
||||
|
||||
@api_bp.post('/configs/save')
|
||||
@validate(json=schema.ConfigsImportRequest)
|
||||
def post_configs_save(_: Request, body: schema.ConfigsImportRequest):
|
||||
config = get_config_by_file_name(body.file_name)
|
||||
try:
|
||||
config.dump_config(send_events=True, overwrite=body.overwrite)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to dump config: {body.file_name}', exc_info=e)
|
||||
raise InvalidConfig(f'Failed to dump config {body.file_name}')
|
||||
|
||||
return response.empty()
|
||||
|
|
|
|||
|
|
@ -213,3 +213,9 @@ class SearchFileEstimateRequest:
|
|||
@dataclass
|
||||
class SearchOtherEstimateRequest:
|
||||
tag_names: List[str] = field(default_factory=lambda: list())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigsImportRequest:
|
||||
file_name: str
|
||||
overwrite: bool = False
|
||||
|
|
|
|||
|
|
@ -527,7 +527,7 @@ async def status_worker(count: int = None, sleep_time: int = 5):
|
|||
# Always sleep so this does not become a busy loop with errors.
|
||||
if load_stats and load_stats.minute_1 and load_stats.minute_1 > multiprocessing.cpu_count():
|
||||
# System is stressed, slow down status updates.
|
||||
await asyncio.sleep((sleep_time * load_stats.minute_1) / multiprocessing.cpu_count())
|
||||
await asyncio.sleep((sleep_time * float(load_stats.minute_1)) / multiprocessing.cpu_count())
|
||||
|
||||
if count:
|
||||
# Running in testing, or __main__.
|
||||
|
|
|
|||
124
wrolpi/tags.py
124
wrolpi/tags.py
|
|
@ -1,5 +1,6 @@
|
|||
import contextlib
|
||||
import pathlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ from wrolpi.common import ModelHelper, Base, logger, ConfigFile, get_media_direc
|
|||
from wrolpi.dates import TZDateTime
|
||||
from wrolpi.db import optional_session, get_db_curs, get_db_session
|
||||
from wrolpi.errors import UnknownTag, UsedTag, InvalidTag, FileGroupAlreadyTagged, RefreshConflict, NoPrimaryFile
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
|
||||
from wrolpi.vars import PYTEST
|
||||
|
||||
|
|
@ -218,10 +220,18 @@ class Tag(ModelHelper, Base):
|
|||
sync_tags_directory.activate_switch()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagsConfigValidator:
|
||||
tag_files: list[list] = field(default_factory=list)
|
||||
tag_zims: list[list] = field(default_factory=list)
|
||||
tags: dict = field(default_factory=dict)
|
||||
version: int = None
|
||||
|
||||
|
||||
class TagsConfig(ConfigFile):
|
||||
file_name = 'tags.yaml'
|
||||
width = 500
|
||||
|
||||
validator = TagsConfigValidator
|
||||
default_config = dict(
|
||||
tag_files=list(),
|
||||
tag_zims=list(),
|
||||
|
|
@ -255,58 +265,64 @@ class TagsConfig(ConfigFile):
|
|||
def tags(self, value: dict):
|
||||
self.update({'tags': value})
|
||||
|
||||
def dump_config(self, file: pathlib.Path = None):
|
||||
media_directory = get_media_directory()
|
||||
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
|
||||
try:
|
||||
media_directory = get_media_directory()
|
||||
|
||||
with get_db_session() as session:
|
||||
tags = dict()
|
||||
tag_rows = session.query(Tag)
|
||||
for tag in tag_rows:
|
||||
tags[tag.name] = dict(color=tag.color)
|
||||
with get_db_session() as session:
|
||||
tags = dict()
|
||||
tag_rows = session.query(Tag)
|
||||
for tag in tag_rows:
|
||||
tags[tag.name] = dict(color=tag.color)
|
||||
|
||||
from wrolpi.files.models import FileGroup
|
||||
results = session.query(Tag, TagFile, FileGroup) \
|
||||
.filter(TagFile.tag_id == Tag.id, TagFile.file_group_id == FileGroup.id) \
|
||||
.order_by(FileGroup.primary_path)
|
||||
from wrolpi.files.models import FileGroup
|
||||
results = session.query(Tag, TagFile, FileGroup) \
|
||||
.filter(TagFile.tag_id == Tag.id, TagFile.file_group_id == FileGroup.id) \
|
||||
.order_by(FileGroup.primary_path)
|
||||
|
||||
tag_files = []
|
||||
for tag, tag_file, file_group in results:
|
||||
value = [
|
||||
tag.name,
|
||||
str(file_group.primary_path.relative_to(media_directory)),
|
||||
# Fallback to current time if not set.
|
||||
tag_file.created_at.isoformat() if tag_file.created_at else dates.now().isoformat(),
|
||||
]
|
||||
tag_files.append(value)
|
||||
logger.debug(f'Got {len(tag_files)} tag files for config.')
|
||||
tag_files = []
|
||||
for tag, tag_file, file_group in results:
|
||||
value = [
|
||||
tag.name,
|
||||
str(file_group.primary_path.relative_to(media_directory)),
|
||||
# Fallback to current time if not set.
|
||||
tag_file.created_at.isoformat() if tag_file.created_at else dates.now().isoformat(),
|
||||
]
|
||||
tag_files.append(value)
|
||||
logger.debug(f'Got {len(tag_files)} tag files for config.')
|
||||
|
||||
from modules.zim.models import Zim, TagZimEntry
|
||||
results = session.query(Tag, Zim, TagZimEntry) \
|
||||
.filter(Tag.id == TagZimEntry.tag_id, Zim.id == TagZimEntry.zim_id) \
|
||||
.order_by(TagZimEntry.zim_id, TagZimEntry.zim_entry)
|
||||
from modules.zim.models import Zim, TagZimEntry
|
||||
results = session.query(Tag, Zim, TagZimEntry) \
|
||||
.filter(Tag.id == TagZimEntry.tag_id, Zim.id == TagZimEntry.zim_id) \
|
||||
.order_by(TagZimEntry.zim_id, TagZimEntry.zim_entry)
|
||||
|
||||
tag_zims = []
|
||||
for tag, zim, tag_zim_entry in results:
|
||||
zim: Zim
|
||||
tag_zim_entry: TagZimEntry
|
||||
value = [
|
||||
tag.name,
|
||||
str(get_relative_to_media_directory(zim.path)),
|
||||
tag_zim_entry.zim_entry,
|
||||
# Fallback to current time if not set.
|
||||
tag_zim_entry.created_at.isoformat() if tag_zim_entry.created_at else dates.now().isoformat(),
|
||||
]
|
||||
tag_zims.append(value)
|
||||
logger.debug(f'Got {len(tag_zims)} tag zims for config.')
|
||||
tag_zims = []
|
||||
for tag, zim, tag_zim_entry in results:
|
||||
zim: Zim
|
||||
tag_zim_entry: TagZimEntry
|
||||
value = [
|
||||
tag.name,
|
||||
str(get_relative_to_media_directory(zim.path)),
|
||||
tag_zim_entry.zim_entry,
|
||||
# Fallback to current time if not set.
|
||||
tag_zim_entry.created_at.isoformat() if tag_zim_entry.created_at else dates.now().isoformat(),
|
||||
]
|
||||
tag_zims.append(value)
|
||||
logger.debug(f'Got {len(tag_zims)} tag zims for config.')
|
||||
|
||||
# Write to the config.
|
||||
self.update({
|
||||
'tag_files': tag_files,
|
||||
'tag_zims': tag_zims,
|
||||
'tags': tags,
|
||||
})
|
||||
# Write to the config.
|
||||
self.update({
|
||||
'tag_files': tag_files,
|
||||
'tag_zims': tag_zims,
|
||||
'tags': tags,
|
||||
})
|
||||
except Exception as e:
|
||||
message = f'Failed to save {self.get_relative_file()} config'
|
||||
logger.error(message, exc_info=e)
|
||||
if send_events:
|
||||
Events.send_config_save_failed(message)
|
||||
|
||||
def import_config(self, file: pathlib.Path = None):
|
||||
def import_config(self, file: pathlib.Path = None, send_events=False):
|
||||
from modules.zim import lib as zim_lib
|
||||
from modules.zim.models import Zim, TagZimEntry
|
||||
from wrolpi.files.lib import glob_shared_stem
|
||||
|
|
@ -317,14 +333,12 @@ class TagsConfig(ConfigFile):
|
|||
return
|
||||
|
||||
config = get_tags_config()
|
||||
if not (path := config.get_file()).is_file():
|
||||
logger.warning(f'Refusing to import tags config because it does not exist: {path}')
|
||||
return
|
||||
|
||||
logger.info('Importing tags config')
|
||||
super().import_config(file)
|
||||
|
||||
try:
|
||||
need_commit = False
|
||||
|
||||
with get_db_session() as session:
|
||||
if config.tags:
|
||||
# Tags have been saved to config, import them
|
||||
|
|
@ -341,11 +355,10 @@ class TagsConfig(ConfigFile):
|
|||
|
||||
if new_tags:
|
||||
session.add_all(new_tags)
|
||||
need_commit = True
|
||||
|
||||
media_directory = get_media_directory()
|
||||
|
||||
need_commit = False
|
||||
|
||||
# Get all Tags again because new ones may exist.
|
||||
tags_by_name: Dict[str, Tag] = {i.name: i for i in session.query(Tag)}
|
||||
|
||||
|
|
@ -451,7 +464,12 @@ class TagsConfig(ConfigFile):
|
|||
self.successful_import = True
|
||||
logger.info('Importing tags config complete')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to import tags config', exc_info=e)
|
||||
self.successful_import = False
|
||||
message = f'Failed to import {self.file_name}'
|
||||
logger.error(message, exc_info=e)
|
||||
if send_events:
|
||||
from wrolpi.events import Events
|
||||
Events.send_config_import_failed(message)
|
||||
raise
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os
|
|||
import pathlib
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from http import HTTPStatus
|
||||
|
|
@ -19,6 +20,7 @@ import pytz
|
|||
import wrolpi.vars
|
||||
from wrolpi import common
|
||||
from wrolpi.common import cum_timer, TIMERS, print_timer, limit_concurrent, run_after, get_wrolpi_config
|
||||
from wrolpi.errors import InvalidConfig
|
||||
from wrolpi.switches import await_switches
|
||||
from wrolpi.test.common import build_test_directories, skip_circleci
|
||||
|
||||
|
|
@ -462,6 +464,12 @@ async def test_cum_timer():
|
|||
print_timer()
|
||||
|
||||
|
||||
def test_timer():
|
||||
"""`cum_timer` can be used to profile code."""
|
||||
with common.timer(name='test_timer'):
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_limit_concurrent_async():
|
||||
"""`limit_concurrent` can throw an error when the limit is reached."""
|
||||
|
|
@ -926,12 +934,14 @@ async def test_config_lifecycle(async_client, test_wrolpi_config):
|
|||
# Config is not imported, default values are used.
|
||||
assert config.successful_import is False
|
||||
assert config.wrol_mode is False
|
||||
assert config.is_valid() is False
|
||||
assert config.version == 0
|
||||
|
||||
# Bad config cannot be imported.
|
||||
test_wrolpi_config.parent.mkdir(exist_ok=True)
|
||||
test_wrolpi_config.write_text('bad config\ndata')
|
||||
with pytest.raises(RuntimeError) as e:
|
||||
assert config.is_valid() is False
|
||||
with pytest.raises(InvalidConfig) as e:
|
||||
config.import_config()
|
||||
assert config.successful_import is False
|
||||
assert config.version == 0
|
||||
|
|
@ -945,29 +955,35 @@ async def test_config_lifecycle(async_client, test_wrolpi_config):
|
|||
|
||||
# Incomplete config can still be imported.
|
||||
test_wrolpi_config.write_text('wrol_mode: true')
|
||||
assert config.is_valid() is True
|
||||
config.import_config()
|
||||
assert config.successful_import is True
|
||||
assert config.wrol_mode is True
|
||||
assert config.is_valid() is True
|
||||
assert config.version == 0
|
||||
|
||||
# Items from the default config are written to the file.
|
||||
config.dump_config()
|
||||
assert 'version: ' in test_wrolpi_config.read_text()
|
||||
assert config.wrol_mode is True
|
||||
assert config.is_valid() is True
|
||||
assert config.version == 1
|
||||
|
||||
# Other data is ignored.
|
||||
test_wrolpi_config.write_text('wrol_mode: false\nother_data: false')
|
||||
assert config.is_valid() is True
|
||||
config.import_config()
|
||||
assert 'other_data' not in config._config, 'Extra data in config should be ignored'
|
||||
assert config.is_valid() is True
|
||||
assert config.wrol_mode is False
|
||||
|
||||
# Changing value changes both file and config data.
|
||||
config.wrol_mode = True
|
||||
assert config._config['wrol_mode'] is True
|
||||
await await_switches()
|
||||
await await_switches() # `wrol_mode = True` calls background switch
|
||||
assert 'wrol_mode: true' in test_wrolpi_config.read_text()
|
||||
assert 'other_data' not in test_wrolpi_config.read_text(), 'Other data should be removed on save.'
|
||||
assert config.is_valid() is True
|
||||
assert config.version == 2
|
||||
|
||||
# Cannot overwrite newer config.
|
||||
|
|
@ -981,3 +997,20 @@ async def test_config_lifecycle(async_client, test_wrolpi_config):
|
|||
# Can import, then config can be saved.
|
||||
config.import_config()
|
||||
config.dump_config()
|
||||
assert config.is_valid() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_valid(async_client, test_wrolpi_config):
|
||||
# Config file does not yet exist.
|
||||
config = get_wrolpi_config()
|
||||
assert config.is_valid() is False
|
||||
|
||||
config.dump_config()
|
||||
assert config.is_valid() is True
|
||||
|
||||
test_wrolpi_config.write_text('bad config')
|
||||
assert config.is_valid() is False
|
||||
|
||||
test_wrolpi_config.write_text('wrol_mode: true')
|
||||
assert config.is_valid() is True
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from wrolpi.common import get_wrolpi_config
|
|||
from wrolpi.downloader import Download, get_download_manager_config
|
||||
from wrolpi.errors import ValidationError, SearchEmpty
|
||||
from wrolpi.switches import await_switches
|
||||
from wrolpi.tags import Tag
|
||||
from wrolpi.test.common import skip_circleci, assert_dict_contains
|
||||
|
||||
|
||||
|
|
@ -759,3 +760,51 @@ async def test_search_other_estimates(async_client, test_session, channel_factor
|
|||
request, response = await async_client.post('/api/search_other_estimates', json=body)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['others']['channel_count'] == 1, 'Only one Channel is tagged.'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_configs(async_client, test_session, test_tags_config, tag_factory, test_wrolpi_config,
|
||||
test_channels_config):
|
||||
"""Tags can be imported or saved on demand."""
|
||||
# Create a tag, save it to config, delete it from the DB.
|
||||
one = await tag_factory()
|
||||
test_session.commit()
|
||||
await await_switches()
|
||||
test_session.delete(one)
|
||||
test_session.commit()
|
||||
assert 'one' in test_tags_config.read_text()
|
||||
assert test_session.query(Tag).count() == 0, 'Tag should have been deleted'
|
||||
|
||||
# Can get list of configs, and if they are valid.
|
||||
request, response = await async_client.get('/api/configs')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
# WROLPiConfig was not saved.
|
||||
assert 'wrolpi.yaml' in response.json['configs']
|
||||
assert response.json['configs']['wrolpi.yaml']['valid'] is False
|
||||
assert response.json['configs']['wrolpi.yaml']['successful_import'] is False
|
||||
# Tags was saved, and is valid.
|
||||
assert 'tags.yaml' in response.json['configs']
|
||||
assert response.json['configs']['tags.yaml']['valid'] is True
|
||||
assert response.json['configs']['wrolpi.yaml']['successful_import'] is False
|
||||
|
||||
# Can import the config.
|
||||
body = dict(file_name='tags.yaml')
|
||||
request, response = await async_client.post('/api/configs/import', json=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
# Deleted Tag was re-created.
|
||||
assert [i for i, in test_session.query(Tag.name)] == ['one', ]
|
||||
|
||||
# Can trigger a save of a config
|
||||
assert not test_wrolpi_config.exists()
|
||||
body = dict(file_name='wrolpi.yaml')
|
||||
request, response = await async_client.post('/api/configs/save', json=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert test_wrolpi_config.is_file()
|
||||
assert 'wrol_mode:' in test_wrolpi_config.read_text()
|
||||
|
||||
# Config is now valid because it exists.
|
||||
request, response = await async_client.get('/api/configs')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert 'wrolpi.yaml' in response.json['configs']
|
||||
assert response.json['configs']['wrolpi.yaml']['valid'] is True
|
||||
assert response.json['configs']['tags.yaml']['successful_import'] is True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue