fix_labels #2
@@ -1047,6 +1047,36 @@ def automations_get():
|
||||
return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500
|
||||
|
||||
|
||||
@bp.get('/automations/export')
|
||||
def automations_export():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: JSON export is profile-scoped and excludes execution history/cooldown state.
|
||||
data = automation_rules.export_rules(profile['id'])
|
||||
return ok({'export': data, 'count': len(data.get('rules') or [])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post('/automations/import')
|
||||
def automations_import():
|
||||
from ..services import automation_rules
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
replace = str(request.args.get('replace') or '').lower() in {'1', 'true', 'yes'} or bool(payload.get('replace')) if isinstance(payload, dict) else False
|
||||
# Note: Import appends rules by default, so existing automations remain untouched.
|
||||
imported = automation_rules.import_rules(profile['id'], payload, replace=replace)
|
||||
return ok({'imported': len(imported), 'rules': automation_rules.list_rules(profile['id'])})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post('/automations')
|
||||
def automations_save():
|
||||
from ..services import automation_rules
|
||||
|
||||
@@ -66,6 +66,44 @@ def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[
|
||||
return _rule_row(row)
|
||||
|
||||
|
||||
def _portable_rule(rule: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
'name': str(rule.get('name') or 'Automation rule'),
|
||||
'enabled': bool(rule.get('enabled', True)),
|
||||
'cooldown_minutes': max(0, int(rule.get('cooldown_minutes') or 0)),
|
||||
'conditions': list(rule.get('conditions') or []),
|
||||
'effects': list(rule.get('effects') or []),
|
||||
}
|
||||
|
||||
|
||||
def export_rules(profile_id: int, user_id: int | None = None) -> dict[str, Any]:
|
||||
# Note: Export contains only portable rule definitions, never DB ids or execution history.
|
||||
rules = [_portable_rule(rule) for rule in list_rules(profile_id, user_id)]
|
||||
return {'version': 1, 'app': 'pyTorrent', 'exported_at': utcnow(), 'rules': rules}
|
||||
|
||||
|
||||
def import_rules(profile_id: int, payload: dict[str, Any] | list[Any], user_id: int | None = None, replace: bool = False) -> list[dict[str, Any]]:
|
||||
user_id = user_id or default_user_id()
|
||||
raw_rules = payload if isinstance(payload, list) else payload.get('rules', []) if isinstance(payload, dict) else []
|
||||
if not isinstance(raw_rules, list) or not raw_rules:
|
||||
raise ValueError('Import file does not contain automation rules')
|
||||
if replace:
|
||||
with connect() as conn:
|
||||
# Note: Optional replace is profile-scoped; it does not touch other profiles or history tables.
|
||||
conn.execute('DELETE FROM automation_rules WHERE user_id=? AND profile_id=?', (user_id, profile_id))
|
||||
conn.execute('DELETE FROM automation_rule_state WHERE profile_id=?', (profile_id,))
|
||||
imported = []
|
||||
for raw in raw_rules:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
rule = _portable_rule(raw)
|
||||
rule.pop('id', None)
|
||||
imported.append(save_rule(profile_id, rule, user_id))
|
||||
if not imported:
|
||||
raise ValueError('No valid automation rules found')
|
||||
return imported
|
||||
|
||||
|
||||
def save_rule(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]:
|
||||
user_id = user_id or default_user_id()
|
||||
name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule'
|
||||
@@ -111,6 +149,8 @@ def _condition_true(t: dict[str, Any], cond: dict[str, Any]) -> bool:
|
||||
if typ == 'completed': return bool(int(t.get('complete') or 0))
|
||||
if typ == 'no_seeds': return int(t.get('seeds') or 0) <= int(cond.get('seeds') or 0)
|
||||
if typ == 'ratio_gte': return float(t.get('ratio') or 0) >= float(cond.get('ratio') or 0)
|
||||
if typ == 'progress_gte': return float(t.get('progress') or 0) >= float(cond.get('progress') or 0)
|
||||
if typ == 'progress_lte': return float(t.get('progress') or 0) <= float(cond.get('progress') or 0)
|
||||
if typ == 'label_missing': return str(cond.get('label') or '').strip() not in _label_names(t.get('label'))
|
||||
if typ == 'label_has': return str(cond.get('label') or '').strip() in _label_names(t.get('label'))
|
||||
if typ == 'status': return str(t.get('status') or '').lower() == str(cond.get('status') or '').lower()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2293,3 +2293,33 @@ body.mobile-mode .mobile-filter-bar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.date-readable {
|
||||
display: inline-block;
|
||||
min-width: 9.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jobs-table {
|
||||
min-width: 980px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.jobs-table th:nth-child(7),
|
||||
.jobs-table td:nth-child(7),
|
||||
.jobs-table th:nth-child(8),
|
||||
.jobs-table td:nth-child(8) {
|
||||
min-width: 10.5rem;
|
||||
}
|
||||
|
||||
.jobs-table td:nth-child(5),
|
||||
.jobs-table td:nth-child(9) {
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.automation-history-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user