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:
Roland 2024-09-28 17:28:33 -06:00
parent d34301a085
commit acf3dee179
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
22 changed files with 767 additions and 157 deletions

View file

@ -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;
}

View file

@ -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 {

View file

@ -25,7 +25,6 @@ import {
HandPointMessage,
humanFileSize,
isoDatetimeToAgoPopup,
isoDatetimeToString,
mimetypeColor,
PageContainer,
Paginator,

View file

@ -53,3 +53,9 @@ export function TagPlaceholder() {
<PlaceholderImage/>
</Placeholder>
}
export function TableRowPlaceholder() {
return <Placeholder>
<PlaceholderLine/>
</Placeholder>
}

View file

@ -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>

View 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>
}

View file

@ -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>

View file

@ -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);

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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:

View file

@ -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()

View file

@ -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`."""

View file

@ -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

View file

@ -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=}'

View file

@ -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()

View file

@ -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

View file

@ -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__.

View file

@ -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

View file

@ -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

View file

@ -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