automatyzacje-comit6

This commit is contained in:
Mateusz Gruszczyński
2026-05-07 11:55:29 +02:00
parent 85e1e6adcd
commit 8334aa97e2
5 changed files with 130 additions and 11 deletions

View File

@@ -1047,6 +1047,36 @@ def automations_get():
return jsonify({'ok': False, 'error': str(exc), 'rules': [], 'history': []}), 500 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') @bp.post('/automations')
def automations_save(): def automations_save():
from ..services import automation_rules from ..services import automation_rules

View File

@@ -66,6 +66,44 @@ def get_rule(rule_id: int, profile_id: int, user_id: int | None = None) -> dict[
return _rule_row(row) 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]: 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() user_id = user_id or default_user_id()
name = str(data.get('name') or 'Automation rule').strip() or 'Automation rule' 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 == '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 == '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 == '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_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 == '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() 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

View File

@@ -2293,3 +2293,33 @@ body.mobile-mode .mobile-filter-bar {
width: 100%; 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