feat: Add automatic scheduled contact cleanup

Add auto-cleanup feature that runs daily at 01:00 UTC using APScheduler:
- New cleanup settings stored in .webui_settings.json (enabled, types, date_field, days, name_filter)
- API endpoints: GET/POST /api/contacts/cleanup-settings
- Scheduler functions: _cleanup_job(), schedule_cleanup(), init_cleanup_schedule()
- UI toggle in Advanced Filters with validation (requires days > 0)
- Debounced auto-save for filter criteria changes
- Protected contacts are excluded from auto-cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-24 21:49:27 +01:00
parent a4baa438c5
commit a23eb2a5f4
4 changed files with 644 additions and 0 deletions

View File

@@ -18,6 +18,9 @@ logger = logging.getLogger(__name__)
# Global scheduler instance
_scheduler: Optional[BackgroundScheduler] = None
# Job IDs
CLEANUP_JOB_ID = 'daily_cleanup'
def get_archive_path(archive_date: str) -> Path:
"""
@@ -225,6 +228,180 @@ def _archive_job():
logger.error(f"Archive job failed: {result.get('error', 'Unknown error')}")
def _cleanup_job():
"""
Background job that runs daily at 01:00 UTC to clean up contacts.
Uses saved cleanup settings to filter and delete contacts.
"""
logger.info("Running daily cleanup job...")
try:
# Import here to avoid circular imports
from app.routes.api import (
get_cleanup_settings,
get_protected_contacts,
_filter_contacts_by_criteria
)
# Get cleanup settings
settings = get_cleanup_settings()
if not settings.get('enabled'):
logger.info("Auto-cleanup is disabled, skipping")
return
# Get contacts from device
import requests
response = requests.get(
f"{config.BRIDGE_URL}/cli",
json={'args': ['contacts']},
timeout=30
)
if response.status_code != 200:
logger.error(f"Failed to get contacts: HTTP {response.status_code}")
return
data = response.json()
if not data.get('success'):
logger.error(f"Failed to get contacts: {data.get('error', 'Unknown error')}")
return
# Parse contacts from output
contacts = []
output = data.get('output', '')
import json as json_module
for line in output.strip().split('\n'):
if line.strip():
try:
contact = json_module.loads(line)
contacts.append(contact)
except json_module.JSONDecodeError:
continue
if not contacts:
logger.info("No contacts found, nothing to clean up")
return
# Filter contacts using saved criteria
criteria = {
'types': settings.get('types', [1, 2, 3, 4]),
'date_field': settings.get('date_field', 'last_advert'),
'days': settings.get('days', 30),
'name_filter': settings.get('name_filter', '')
}
# Get protected contacts
protected = get_protected_contacts()
# Filter contacts (this function excludes protected contacts)
matching_contacts = _filter_contacts_by_criteria(contacts, criteria, protected)
if not matching_contacts:
logger.info("No contacts match cleanup criteria")
return
logger.info(f"Found {len(matching_contacts)} contacts to clean up")
# Delete matching contacts
deleted_count = 0
for contact in matching_contacts:
name = contact.get('name', '')
if not name:
continue
try:
delete_response = requests.post(
f"{config.BRIDGE_URL}/cli",
json={'args': ['contact', '-d', name]},
timeout=30
)
if delete_response.status_code == 200:
delete_data = delete_response.json()
if delete_data.get('success'):
deleted_count += 1
logger.debug(f"Deleted contact: {name}")
else:
logger.warning(f"Failed to delete contact {name}: {delete_data.get('error')}")
else:
logger.warning(f"Failed to delete contact {name}: HTTP {delete_response.status_code}")
except Exception as e:
logger.warning(f"Error deleting contact {name}: {e}")
logger.info(f"Cleanup job completed: deleted {deleted_count}/{len(matching_contacts)} contacts")
except Exception as e:
logger.error(f"Cleanup job failed: {e}", exc_info=True)
def schedule_cleanup(enabled: bool) -> bool:
"""
Add or remove the cleanup job from the scheduler.
Args:
enabled: True to enable cleanup job, False to disable
Returns:
True if successful, False otherwise
"""
global _scheduler
if _scheduler is None:
logger.warning("Scheduler not initialized, cannot schedule cleanup")
return False
try:
if enabled:
# Add cleanup job at 01:00 UTC
trigger = CronTrigger(hour=1, minute=0)
_scheduler.add_job(
func=_cleanup_job,
trigger=trigger,
id=CLEANUP_JOB_ID,
name='Daily Contact Cleanup',
replace_existing=True
)
logger.info("Cleanup job scheduled - will run daily at 01:00 UTC")
else:
# Remove cleanup job if it exists
try:
_scheduler.remove_job(CLEANUP_JOB_ID)
logger.info("Cleanup job removed from scheduler")
except Exception:
# Job might not exist, that's OK
pass
return True
except Exception as e:
logger.error(f"Error scheduling cleanup: {e}", exc_info=True)
return False
def init_cleanup_schedule():
"""
Initialize cleanup schedule from saved settings.
Called at startup after scheduler is started.
"""
try:
# Import here to avoid circular imports
from app.routes.api import get_cleanup_settings
settings = get_cleanup_settings()
if settings.get('enabled'):
schedule_cleanup(enabled=True)
logger.info("Auto-cleanup enabled from saved settings")
else:
logger.info("Auto-cleanup is disabled in saved settings")
except Exception as e:
logger.error(f"Error initializing cleanup schedule: {e}", exc_info=True)
def schedule_daily_archiving():
"""
Initialize and start the background scheduler for daily archiving.
@@ -261,6 +438,9 @@ def schedule_daily_archiving():
logger.info("Archive scheduler started - will run daily at 00:00 UTC")
# Initialize cleanup schedule from saved settings
init_cleanup_schedule()
except Exception as e:
logger.error(f"Failed to start archive scheduler: {e}", exc_info=True)

View File

@@ -177,6 +177,84 @@ def save_protected_contacts(protected_list: list) -> bool:
return False
# =============================================================================
# Cleanup Settings Management
# =============================================================================
def get_cleanup_settings() -> dict:
"""
Get auto-cleanup settings from .webui_settings.json.
Returns:
Dict with cleanup settings:
{
'enabled': bool,
'types': list[int],
'date_field': str,
'days': int,
'name_filter': str
}
"""
from pathlib import Path
defaults = {
'enabled': False,
'types': [1, 2, 3, 4],
'date_field': 'last_advert',
'days': 30,
'name_filter': ''
}
settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json"
try:
if not settings_path.exists():
return defaults
with open(settings_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
cleanup = settings.get('cleanup_settings', {})
# Merge with defaults to ensure all fields exist
return {**defaults, **cleanup}
except Exception as e:
logger.error(f"Failed to read cleanup settings: {e}")
return defaults
def save_cleanup_settings(cleanup_settings: dict) -> bool:
"""
Save auto-cleanup settings to .webui_settings.json (atomic write).
Args:
cleanup_settings: Dict with cleanup configuration
Returns:
True if successful, False otherwise
"""
from pathlib import Path
settings_path = Path(config.MC_CONFIG_DIR) / ".webui_settings.json"
try:
# Read existing settings
settings = {}
if settings_path.exists():
with open(settings_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
# Update cleanup settings
settings['cleanup_settings'] = cleanup_settings
# Write back atomically
temp_file = settings_path.with_suffix('.tmp')
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=2, ensure_ascii=False)
temp_file.replace(settings_path)
return True
except Exception as e:
logger.error(f"Failed to save cleanup settings: {e}")
return False
@api_bp.route('/messages', methods=['GET'])
def get_messages():
"""
@@ -1895,6 +1973,128 @@ def toggle_contact_protection(public_key):
}), 500
@api_bp.route('/contacts/cleanup-settings', methods=['GET'])
def get_cleanup_settings_api():
"""
Get auto-cleanup settings.
Returns:
JSON with cleanup settings:
{
"success": true,
"settings": {
"enabled": false,
"types": [1, 2, 3, 4],
"date_field": "last_advert",
"days": 30,
"name_filter": ""
}
}
"""
try:
settings = get_cleanup_settings()
return jsonify({
'success': True,
'settings': settings
}), 200
except Exception as e:
logger.error(f"Error getting cleanup settings: {e}")
return jsonify({
'success': False,
'error': str(e),
'settings': {
'enabled': False,
'types': [1, 2, 3, 4],
'date_field': 'last_advert',
'days': 30,
'name_filter': ''
}
}), 500
@api_bp.route('/contacts/cleanup-settings', methods=['POST'])
def update_cleanup_settings_api():
"""
Update auto-cleanup settings.
JSON body:
{
"enabled": true,
"types": [1, 2],
"date_field": "last_advert",
"days": 30,
"name_filter": ""
}
Returns:
JSON with update result:
{
"success": true,
"message": "Cleanup settings updated",
"settings": {...}
}
"""
try:
data = request.get_json() or {}
# Validate fields
if 'types' in data:
if not isinstance(data['types'], list) or not all(t in [1, 2, 3, 4] for t in data['types']):
return jsonify({
'success': False,
'error': 'Invalid types (must be list of 1, 2, 3, 4)'
}), 400
if 'date_field' in data:
if data['date_field'] not in ['last_advert', 'lastmod']:
return jsonify({
'success': False,
'error': 'Invalid date_field (must be "last_advert" or "lastmod")'
}), 400
if 'days' in data:
if not isinstance(data['days'], int) or data['days'] < 0:
return jsonify({
'success': False,
'error': 'Invalid days (must be non-negative integer)'
}), 400
if 'enabled' in data:
if not isinstance(data['enabled'], bool):
return jsonify({
'success': False,
'error': 'Invalid enabled (must be boolean)'
}), 400
# Get current settings and merge with new values
current = get_cleanup_settings()
updated = {**current, **data}
# Save settings
if not save_cleanup_settings(updated):
return jsonify({
'success': False,
'error': 'Failed to save cleanup settings'
}), 500
# Update scheduler based on enabled state
from app.archiver.manager import schedule_cleanup
schedule_cleanup(enabled=updated.get('enabled', False))
return jsonify({
'success': True,
'message': 'Cleanup settings updated',
'settings': updated
}), 200
except Exception as e:
logger.error(f"Error updating cleanup settings: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
# =============================================================================
# Contact Management (Pending Contacts & Settings)
# =============================================================================

View File

@@ -51,6 +51,10 @@ let protectedContacts = []; // List of protected public_keys
let sortBy = 'last_advert'; // 'name' or 'last_advert'
let sortOrder = 'desc'; // 'asc' or 'desc'
// Auto-cleanup state
let autoCleanupSettings = null;
let cleanupSaveDebounceTimer = null;
// Map state (Leaflet)
let leafletMap = null;
let markersGroup = null;
@@ -168,6 +172,9 @@ function initManagePage() {
// Load contact counts for badges
loadContactCounts();
// Load cleanup settings (populates form and auto-cleanup status)
loadCleanupSettings();
// Attach event listeners for manage page
attachManageEventListeners();
}
@@ -190,6 +197,33 @@ function attachManageEventListeners() {
if (confirmCleanupBtn) {
confirmCleanupBtn.addEventListener('click', handleCleanupConfirm);
}
// Auto-cleanup toggle
const autoCleanupSwitch = document.getElementById('autoCleanupSwitch');
if (autoCleanupSwitch) {
autoCleanupSwitch.addEventListener('change', handleAutoCleanupToggle);
}
// Debounced auto-save for cleanup filter inputs
const cleanupNameFilter = document.getElementById('cleanupNameFilter');
if (cleanupNameFilter) {
cleanupNameFilter.addEventListener('input', debouncedSaveCleanupCriteria);
}
const cleanupDays = document.getElementById('cleanupDays');
if (cleanupDays) {
cleanupDays.addEventListener('input', debouncedSaveCleanupCriteria);
}
// Type filter checkboxes
document.querySelectorAll('.cleanup-type-filter').forEach(cb => {
cb.addEventListener('change', debouncedSaveCleanupCriteria);
});
// Date field radio buttons
document.querySelectorAll('input[name="cleanupDateField"]').forEach(radio => {
radio.addEventListener('change', debouncedSaveCleanupCriteria);
});
}
async function loadContactCounts() {
@@ -238,6 +272,221 @@ async function loadContactCounts() {
}
}
// =============================================================================
// Auto-Cleanup Settings Management
// =============================================================================
/**
* Load cleanup settings from server and apply to UI.
*/
async function loadCleanupSettings() {
const statusText = document.getElementById('autoCleanupStatusText');
if (statusText) statusText.textContent = 'Loading...';
try {
const response = await fetch('/api/contacts/cleanup-settings');
const data = await response.json();
if (data.success) {
autoCleanupSettings = data.settings;
applyCleanupSettingsToUI(autoCleanupSettings);
console.log('Loaded cleanup settings:', autoCleanupSettings);
} else {
console.error('Failed to load cleanup settings:', data.error);
if (statusText) statusText.textContent = 'Error loading settings';
}
} catch (error) {
console.error('Error loading cleanup settings:', error);
if (statusText) statusText.textContent = 'Network error';
}
}
/**
* Apply cleanup settings to form inputs.
* @param {Object} settings - Cleanup settings object
*/
function applyCleanupSettingsToUI(settings) {
// Name filter
const nameInput = document.getElementById('cleanupNameFilter');
if (nameInput) {
nameInput.value = settings.name_filter || '';
}
// Days
const daysInput = document.getElementById('cleanupDays');
if (daysInput) {
daysInput.value = settings.days || 0;
}
// Date field
const dateFieldValue = settings.date_field || 'last_advert';
const dateRadio = document.querySelector(`input[name="cleanupDateField"][value="${dateFieldValue}"]`);
if (dateRadio) {
dateRadio.checked = true;
}
// Contact types
const types = settings.types || [1, 2, 3, 4];
document.querySelectorAll('.cleanup-type-filter').forEach(cb => {
cb.checked = types.includes(parseInt(cb.value));
});
// Auto-cleanup switch and status
const autoCleanupSwitch = document.getElementById('autoCleanupSwitch');
const statusText = document.getElementById('autoCleanupStatusText');
if (autoCleanupSwitch) {
autoCleanupSwitch.checked = settings.enabled || false;
}
if (statusText) {
if (settings.enabled) {
statusText.textContent = 'Enabled (runs daily at 01:00 UTC)';
statusText.classList.remove('text-muted');
statusText.classList.add('text-success');
} else {
statusText.textContent = 'Disabled';
statusText.classList.remove('text-success');
statusText.classList.add('text-muted');
}
}
}
/**
* Handle auto-cleanup toggle change.
* Validates criteria before enabling.
*/
async function handleAutoCleanupToggle(event) {
const enabled = event.target.checked;
const statusText = document.getElementById('autoCleanupStatusText');
// Validate before enabling
if (enabled) {
const criteria = collectCleanupCriteria();
// Check if days > 0
if (criteria.days <= 0) {
showToast('Set "Days of Inactivity" > 0 before enabling auto-cleanup', 'warning');
event.target.checked = false;
return;
}
// Check if at least one type is selected
if (criteria.types.length === 0) {
showToast('Select at least one contact type before enabling auto-cleanup', 'warning');
event.target.checked = false;
return;
}
}
// Update status text while saving
if (statusText) {
statusText.textContent = 'Saving...';
statusText.classList.remove('text-success', 'text-muted');
}
// Save settings with new enabled state
const success = await saveCleanupSettings(enabled);
if (!success) {
// Revert switch on failure
event.target.checked = !enabled;
}
}
/**
* Debounced save for cleanup criteria changes.
* Only saves criteria, does not change enabled state.
*/
function debouncedSaveCleanupCriteria() {
// Clear existing timer
if (cleanupSaveDebounceTimer) {
clearTimeout(cleanupSaveDebounceTimer);
}
// Set new timer (500ms debounce)
cleanupSaveDebounceTimer = setTimeout(() => {
// Only save if auto-cleanup settings have been loaded
if (autoCleanupSettings !== null) {
// Preserve current enabled state
saveCleanupSettings(autoCleanupSettings.enabled);
}
}, 500);
}
/**
* Save cleanup settings to server.
* @param {boolean} enabled - Whether auto-cleanup should be enabled
* @returns {Promise<boolean>} True if save was successful
*/
async function saveCleanupSettings(enabled) {
const criteria = collectCleanupCriteria();
const statusText = document.getElementById('autoCleanupStatusText');
const settings = {
enabled: enabled,
types: criteria.types,
date_field: criteria.date_field,
days: criteria.days,
name_filter: criteria.name_filter
};
try {
const response = await fetch('/api/contacts/cleanup-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
const data = await response.json();
if (data.success) {
autoCleanupSettings = data.settings;
// Update status text
if (statusText) {
if (data.settings.enabled) {
statusText.textContent = 'Enabled (runs daily at 01:00 UTC)';
statusText.classList.remove('text-muted');
statusText.classList.add('text-success');
} else {
statusText.textContent = 'Disabled';
statusText.classList.remove('text-success');
statusText.classList.add('text-muted');
}
}
console.log('Cleanup settings saved:', data.settings);
return true;
} else {
console.error('Failed to save cleanup settings:', data.error);
showToast('Failed to save settings: ' + data.error, 'danger');
// Restore previous status
if (statusText && autoCleanupSettings) {
if (autoCleanupSettings.enabled) {
statusText.textContent = 'Enabled (runs daily at 01:00 UTC)';
} else {
statusText.textContent = 'Disabled';
}
}
return false;
}
} catch (error) {
console.error('Error saving cleanup settings:', error);
showToast('Network error saving settings', 'danger');
if (statusText) {
statusText.textContent = 'Save failed';
}
return false;
}
}
// Global variable to store preview contacts
let cleanupPreviewContacts = [];

View File

@@ -124,6 +124,21 @@
<input type="number" class="form-control" id="cleanupDays" value="0" min="0">
<small class="form-text text-muted">Contacts inactive for more than this many days will be selected</small>
</div>
<!-- Auto-Cleanup Toggle -->
<div class="mb-3 mt-4 pt-3 border-top">
<div class="form-check form-switch d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" role="switch" id="autoCleanupSwitch" style="cursor: pointer; min-width: 3rem; min-height: 1.5rem;">
<label class="form-check-label" for="autoCleanupSwitch" style="cursor: pointer; font-weight: 500;">
<span id="autoCleanupLabel">Enable Auto-Cleanup</span>
</label>
<i class="bi bi-info-circle info-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="When enabled, contacts matching the above criteria will be automatically deleted daily at 01:00 UTC"></i>
</div>
<small class="text-muted">Status: <span id="autoCleanupStatusText">Loading...</span></small>
</div>
</div>
<button class="btn btn-warning" id="cleanupPreviewBtn">