255f9e2576
Two bugs fixed: 1. calendar_manager and wireguard_manager (port-only) called _restart_container immediately in apply_config, bypassing the pending restart banner and restarting the container before the docker port binding in .env was updated — leaving the service broken until the banner was applied manually. apply_config now only updates the config file (radicale.conf / wg0.conf); the docker compose restart happens via the banner as intended. 2. Port change detection in update_config used `if old_val is not None` to guard against triggering on unchanged values. When a service's port was never explicitly saved (first time), old_val was None, so the pending restart was never queued. Fix: fall back to PORT_DEFAULTS[key] so the comparison is always against the effective current value. Add TestPortChangeDetection (5 tests) covering first-save and multi-service accumulation cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
555 lines
22 KiB
Python
555 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Calendar Manager for Personal Internet Cell
|
|
Handles calendar service configuration and user management
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import subprocess
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Any
|
|
from base_service_manager import BaseServiceManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class CalendarManager(BaseServiceManager):
|
|
"""Manages calendar service configuration and users"""
|
|
|
|
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config'):
|
|
super().__init__('calendar', data_dir, config_dir)
|
|
self.calendar_data_dir = os.path.join(data_dir, 'calendar')
|
|
self.calendar_dir = self.calendar_data_dir # alias used by tests
|
|
self.radicale_dir = os.path.join(config_dir, 'radicale')
|
|
self.users_file = os.path.join(self.calendar_data_dir, 'users.json')
|
|
self.calendars_file = os.path.join(self.calendar_data_dir, 'calendars.json')
|
|
self.events_file = os.path.join(self.calendar_data_dir, 'events.json')
|
|
|
|
self.safe_makedirs(self.calendar_data_dir)
|
|
self.safe_makedirs(self.radicale_dir)
|
|
|
|
def _get_configured_port(self) -> int:
|
|
cfg = self.get_config()
|
|
if isinstance(cfg, dict) and 'error' not in cfg:
|
|
return cfg.get('port', 5232)
|
|
return 5232
|
|
|
|
def get_status(self) -> Dict[str, Any]:
|
|
"""Get calendar service status"""
|
|
try:
|
|
port = self._get_configured_port()
|
|
# Check if we're running in Docker environment
|
|
import os
|
|
is_docker = os.path.exists('/.dockerenv') or os.environ.get('DOCKER_CONTAINER') == 'true'
|
|
|
|
if is_docker:
|
|
# Check if calendar container is actually running
|
|
container_running = self._check_calendar_container_status()
|
|
status = {
|
|
'running': container_running,
|
|
'status': 'online' if container_running else 'offline',
|
|
'port': port,
|
|
'users_count': 0,
|
|
'calendars_count': 0,
|
|
'events_count': 0,
|
|
'timestamp': datetime.utcnow().isoformat()
|
|
}
|
|
else:
|
|
# Check actual service status in production
|
|
service_running = self._check_calendar_status()
|
|
users = self._load_users()
|
|
calendars = self._load_calendars()
|
|
events = self._load_events()
|
|
|
|
status = {
|
|
'running': service_running,
|
|
'status': 'online' if service_running else 'offline',
|
|
'port': port,
|
|
'users_count': len(users),
|
|
'calendars_count': len(calendars),
|
|
'events_count': len(events),
|
|
'timestamp': datetime.utcnow().isoformat()
|
|
}
|
|
|
|
return status
|
|
except Exception as e:
|
|
return self.handle_error(e, "get_status")
|
|
|
|
def test_connectivity(self) -> Dict[str, Any]:
|
|
"""Test calendar service connectivity"""
|
|
try:
|
|
# Test if calendar service is accessible
|
|
service_test = self._test_service_connectivity()
|
|
|
|
# Test database connectivity
|
|
db_test = self._test_database_connectivity()
|
|
|
|
# Test web interface
|
|
web_test = self._test_web_interface()
|
|
|
|
results = {
|
|
'service_connectivity': service_test,
|
|
'database_connectivity': db_test,
|
|
'web_interface': web_test,
|
|
'success': service_test['success'] and db_test['success'] and web_test['success'],
|
|
'timestamp': datetime.utcnow().isoformat()
|
|
}
|
|
|
|
return results
|
|
except Exception as e:
|
|
return self.handle_error(e, "test_connectivity")
|
|
|
|
def _check_calendar_status(self) -> bool:
|
|
"""Check if calendar service is running"""
|
|
try:
|
|
# Check if port 5232 (Radicale) is listening
|
|
result = subprocess.run(['netstat', '-tuln'], capture_output=True, text=True)
|
|
return ':5232 ' in result.stdout
|
|
except Exception:
|
|
return False
|
|
|
|
def _check_calendar_container_status(self) -> bool:
|
|
"""Check if calendar Docker container is running"""
|
|
try:
|
|
import docker
|
|
client = docker.from_env()
|
|
containers = client.containers.list(filters={'name': 'cell-radicale'})
|
|
return len(containers) > 0
|
|
except Exception:
|
|
return False
|
|
|
|
def _test_service_connectivity(self) -> Dict[str, Any]:
|
|
"""Test calendar service connectivity via TCP socket to cell-radicale container."""
|
|
import socket
|
|
try:
|
|
with socket.create_connection(('cell-radicale', 5232), timeout=5):
|
|
pass
|
|
return {'success': True, 'message': 'Calendar service accessible'}
|
|
except Exception as e:
|
|
return {'success': False, 'message': f'Calendar service not accessible: {str(e)}'}
|
|
|
|
def _test_database_connectivity(self) -> Dict[str, Any]:
|
|
"""Test database connectivity — data dir must be writable; files are created on first use."""
|
|
try:
|
|
data_dir = os.path.dirname(self.users_file)
|
|
os.makedirs(data_dir, exist_ok=True)
|
|
accessible = os.access(data_dir, os.R_OK | os.W_OK)
|
|
return {
|
|
'success': accessible,
|
|
'message': 'Database directory accessible' if accessible else 'Database directory not accessible'
|
|
}
|
|
except Exception as e:
|
|
return {'success': False, 'message': f'Database test error: {str(e)}'}
|
|
|
|
def _test_web_interface(self) -> Dict[str, Any]:
|
|
"""Test Radicale web interface via HTTP to cell-radicale container."""
|
|
try:
|
|
import urllib.request
|
|
with urllib.request.urlopen('http://cell-radicale:5232', timeout=5) as r:
|
|
body = r.read(512).decode('utf-8', errors='ignore').lower()
|
|
success = r.status < 500
|
|
return {'success': success, 'message': 'Web interface accessible' if success else 'Web interface not accessible'}
|
|
except Exception as e:
|
|
return {'success': False, 'message': f'Web interface not accessible: {str(e)}'}
|
|
|
|
def _load_users(self) -> List[Dict[str, Any]]:
|
|
"""Load calendar users from file"""
|
|
try:
|
|
if os.path.exists(self.users_file):
|
|
with open(self.users_file, 'r') as f:
|
|
return json.load(f)
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Error loading calendar users: {e}")
|
|
return []
|
|
|
|
def _save_users(self, users: List[Dict[str, Any]]):
|
|
"""Save calendar users to file"""
|
|
try:
|
|
with open(self.users_file, 'w') as f:
|
|
json.dump(users, f, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error saving calendar users: {e}")
|
|
|
|
def _load_calendars(self) -> List[Dict[str, Any]]:
|
|
"""Load calendars from file"""
|
|
try:
|
|
if os.path.exists(self.calendars_file):
|
|
with open(self.calendars_file, 'r') as f:
|
|
return json.load(f)
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Error loading calendars: {e}")
|
|
return []
|
|
|
|
def _save_calendars(self, calendars: List[Dict[str, Any]]):
|
|
"""Save calendars to file"""
|
|
try:
|
|
with open(self.calendars_file, 'w') as f:
|
|
json.dump(calendars, f, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error saving calendars: {e}")
|
|
|
|
def _load_events(self) -> List[Dict[str, Any]]:
|
|
"""Load events from file"""
|
|
try:
|
|
if os.path.exists(self.events_file):
|
|
with open(self.events_file, 'r') as f:
|
|
return json.load(f)
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Error loading events: {e}")
|
|
return []
|
|
|
|
def _save_events(self, events: List[Dict[str, Any]]):
|
|
"""Save events to file"""
|
|
try:
|
|
with open(self.events_file, 'w') as f:
|
|
json.dump(events, f, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Error saving events: {e}")
|
|
|
|
def get_calendar_status(self) -> Dict[str, Any]:
|
|
"""Get detailed calendar service status"""
|
|
try:
|
|
status = self.get_status()
|
|
|
|
# Add user details
|
|
users = self._load_users()
|
|
user_details = []
|
|
|
|
for user in users:
|
|
user_detail = {
|
|
'username': user.get('username', ''),
|
|
'calendars_count': user.get('calendars_count', 0),
|
|
'events_count': user.get('events_count', 0),
|
|
'created_at': user.get('created_at', ''),
|
|
'last_login': user.get('last_login', ''),
|
|
'active': user.get('active', True)
|
|
}
|
|
user_details.append(user_detail)
|
|
|
|
status['users'] = user_details
|
|
return status
|
|
except Exception as e:
|
|
return self.handle_error(e, "get_calendar_status")
|
|
|
|
def get_calendar_users(self) -> List[Dict[str, Any]]:
|
|
"""Get all calendar users"""
|
|
try:
|
|
return self._load_users()
|
|
except Exception as e:
|
|
logger.error(f"Error getting calendar users: {e}")
|
|
return []
|
|
|
|
def create_calendar_user(self, username: str, password: str) -> bool:
|
|
"""Create a new calendar user"""
|
|
try:
|
|
users = self._load_users()
|
|
|
|
# Check if user already exists
|
|
for user in users:
|
|
if user.get('username') == username:
|
|
logger.warning(f"Calendar user {username} already exists")
|
|
return False
|
|
|
|
# Create new user
|
|
new_user = {
|
|
'username': username,
|
|
'password': password, # In production, this should be hashed
|
|
'calendars_count': 0,
|
|
'events_count': 0,
|
|
'created_at': datetime.utcnow().isoformat(),
|
|
'last_login': None,
|
|
'active': True
|
|
}
|
|
|
|
users.append(new_user)
|
|
self._save_users(users)
|
|
|
|
# Create user directory
|
|
user_dir = os.path.join(self.calendar_data_dir, 'users', username)
|
|
self.safe_makedirs(user_dir)
|
|
|
|
logger.info(f"Created calendar user: {username}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to create calendar user {username}: {e}")
|
|
return False
|
|
|
|
def delete_calendar_user(self, username: str) -> bool:
|
|
"""Delete a calendar user"""
|
|
try:
|
|
users = self._load_users()
|
|
|
|
# Find and remove user
|
|
for i, user in enumerate(users):
|
|
if user.get('username') == username:
|
|
del users[i]
|
|
self._save_users(users)
|
|
|
|
# Remove user directory
|
|
user_dir = os.path.join(self.calendar_data_dir, 'users', username)
|
|
if os.path.exists(user_dir):
|
|
import shutil
|
|
shutil.rmtree(user_dir)
|
|
|
|
logger.info(f"Deleted calendar user: {username}")
|
|
return True
|
|
|
|
logger.warning(f"Calendar user {username} not found")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete calendar user {username}: {e}")
|
|
return False
|
|
|
|
def create_calendar(self, username: str, calendar_name: str,
|
|
description: str = '', color: str = '#4285f4') -> bool:
|
|
"""Create a new calendar for a user"""
|
|
try:
|
|
if not username or not calendar_name:
|
|
return False
|
|
calendars = self._load_calendars()
|
|
|
|
# Check if calendar already exists for user
|
|
for calendar in calendars:
|
|
if calendar.get('username') == username and calendar.get('name') == calendar_name:
|
|
logger.warning(f"Calendar {calendar_name} already exists for user {username}")
|
|
return False
|
|
|
|
# Create new calendar
|
|
new_calendar = {
|
|
'username': username,
|
|
'name': calendar_name,
|
|
'description': description,
|
|
'color': color,
|
|
'created_at': datetime.utcnow().isoformat(),
|
|
'events_count': 0,
|
|
'active': True
|
|
}
|
|
|
|
calendars.append(new_calendar)
|
|
self._save_calendars(calendars)
|
|
|
|
# Update user's calendar count
|
|
users = self._load_users()
|
|
for user in users:
|
|
if user.get('username') == username:
|
|
user['calendars_count'] = user.get('calendars_count', 0) + 1
|
|
break
|
|
self._save_users(users)
|
|
|
|
# Create calendar directory
|
|
calendar_dir = os.path.join(self.calendar_data_dir, 'users', username, calendar_name)
|
|
self.safe_makedirs(calendar_dir)
|
|
|
|
logger.info(f"Created calendar {calendar_name} for user {username}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to create calendar {calendar_name} for user {username}: {e}")
|
|
return False
|
|
|
|
def get_calendar_events(self, username: str, calendar_name: str,
|
|
start_date: str = None, end_date: str = None) -> List[Dict[str, Any]]:
|
|
"""Get calendar events for a user and calendar"""
|
|
try:
|
|
events = self._load_events()
|
|
|
|
# Filter events by user and calendar
|
|
filtered_events = []
|
|
for event in events:
|
|
if (event.get('username') == username and
|
|
event.get('calendar_name') == calendar_name):
|
|
|
|
# Apply date filters if provided
|
|
if start_date and end_date:
|
|
event_start = event.get('start', '')
|
|
if start_date <= event_start <= end_date:
|
|
filtered_events.append(event)
|
|
else:
|
|
filtered_events.append(event)
|
|
|
|
return filtered_events
|
|
except Exception as e:
|
|
logger.error(f"Error getting calendar events: {e}")
|
|
return []
|
|
|
|
def create_calendar_event(self, username: str, calendar_name: str,
|
|
title: str, start: str, end: str,
|
|
description: str = '', location: str = '') -> bool:
|
|
"""Create a new calendar event"""
|
|
try:
|
|
events = self._load_events()
|
|
|
|
# Create new event
|
|
new_event = {
|
|
'id': f"event_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{username}",
|
|
'username': username,
|
|
'calendar_name': calendar_name,
|
|
'title': title,
|
|
'start': start,
|
|
'end': end,
|
|
'description': description,
|
|
'location': location,
|
|
'created_at': datetime.utcnow().isoformat(),
|
|
'updated_at': datetime.utcnow().isoformat()
|
|
}
|
|
|
|
events.append(new_event)
|
|
self._save_events(events)
|
|
|
|
# Update calendar's event count
|
|
calendars = self._load_calendars()
|
|
for calendar in calendars:
|
|
if calendar.get('username') == username and calendar.get('name') == calendar_name:
|
|
calendar['events_count'] = calendar.get('events_count', 0) + 1
|
|
break
|
|
self._save_calendars(calendars)
|
|
|
|
# Update user's event count
|
|
users = self._load_users()
|
|
for user in users:
|
|
if user.get('username') == username:
|
|
user['events_count'] = user.get('events_count', 0) + 1
|
|
break
|
|
self._save_users(users)
|
|
|
|
logger.info(f"Created calendar event {title} for user {username}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to create calendar event: {e}")
|
|
return False
|
|
|
|
def get_metrics(self) -> Dict[str, Any]:
|
|
"""Get calendar service metrics"""
|
|
try:
|
|
users = self._load_users()
|
|
calendars = self._load_calendars()
|
|
events = self._load_events()
|
|
|
|
total_events = sum(user.get('events_count', 0) for user in users)
|
|
total_calendars = sum(user.get('calendars_count', 0) for user in users)
|
|
|
|
return {
|
|
'service': 'calendar',
|
|
'timestamp': datetime.utcnow().isoformat(),
|
|
'status': 'online' if self._check_calendar_status() else 'offline',
|
|
'users_count': len(users),
|
|
'calendars_count': len(calendars),
|
|
'events_count': len(events),
|
|
'total_user_events': total_events,
|
|
'total_user_calendars': total_calendars,
|
|
'average_events_per_user': total_events / len(users) if users else 0,
|
|
'average_calendars_per_user': total_calendars / len(users) if users else 0
|
|
}
|
|
except Exception as e:
|
|
return self.handle_error(e, "get_metrics")
|
|
|
|
def restart_service(self) -> bool:
|
|
"""Restart calendar service"""
|
|
try:
|
|
logger.info('Calendar service restart requested')
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f'Failed to restart calendar service: {e}')
|
|
return False
|
|
|
|
def _ensure_config_exists(self):
|
|
"""Create radicale config file if it doesn't exist."""
|
|
self._generate_radicale_config()
|
|
|
|
def _generate_radicale_config(self):
|
|
"""Write a default radicale config to radicale_dir/config."""
|
|
config_file = os.path.join(self.radicale_dir, 'config')
|
|
config_content = (
|
|
'[server]\n'
|
|
'hosts = 0.0.0.0:5232\n'
|
|
'\n'
|
|
'[auth]\n'
|
|
'type = htpasswd\n'
|
|
'htpasswd_filename = /etc/radicale/users\n'
|
|
'htpasswd_encryption = md5\n'
|
|
'\n'
|
|
'[storage]\n'
|
|
'filesystem_folder = /data/collections\n'
|
|
)
|
|
with open(config_file, 'w') as f:
|
|
f.write(config_content)
|
|
|
|
def apply_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Update radicale config file. Port changes go through pending restart (docker binding)."""
|
|
restarted = []
|
|
warnings = []
|
|
if 'port' not in config:
|
|
return {'restarted': restarted, 'warnings': warnings}
|
|
try:
|
|
radicale_conf = os.path.join(self.radicale_dir, 'config')
|
|
if os.path.exists(radicale_conf):
|
|
with open(radicale_conf) as f:
|
|
lines = f.readlines()
|
|
lines = [
|
|
f"hosts = 0.0.0.0:{config['port']}\n" if l.strip().startswith('hosts =') else l
|
|
for l in lines
|
|
]
|
|
with open(radicale_conf, 'w') as f:
|
|
f.writelines(lines)
|
|
# No immediate restart — docker port binding must be updated first.
|
|
# The pending restart banner will run docker compose up with updated .env.
|
|
except Exception as e:
|
|
warnings.append(f"radicale config update failed: {e}")
|
|
return {'restarted': restarted, 'warnings': warnings}
|
|
|
|
def remove_calendar(self, username: str, calendar_name: str) -> bool:
|
|
"""Remove a calendar."""
|
|
try:
|
|
if not username or not calendar_name:
|
|
return False
|
|
calendars = self._load_calendars()
|
|
new_cals = [
|
|
c for c in calendars
|
|
if not (c.get('username') == username and c.get('name') == calendar_name)
|
|
]
|
|
self._save_calendars(new_cals)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f'remove_calendar failed: {e}')
|
|
return False
|
|
|
|
def add_event(self, username: str, calendar_name: str,
|
|
event_data: dict) -> bool:
|
|
"""Add an event to a calendar."""
|
|
try:
|
|
if not username or not calendar_name or event_data is None:
|
|
return False
|
|
events = self._load_events()
|
|
event_data = dict(event_data)
|
|
event_data.update({
|
|
'username': username,
|
|
'calendar': calendar_name,
|
|
'uid': event_data.get('uid', datetime.utcnow().isoformat()),
|
|
})
|
|
events.append(event_data)
|
|
self._save_events(events)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f'add_event failed: {e}')
|
|
return False
|
|
|
|
def remove_event(self, username: str, calendar_name: str, uid: str) -> bool:
|
|
"""Remove an event by UID."""
|
|
try:
|
|
if not username or not calendar_name or not uid:
|
|
return False
|
|
events = self._load_events()
|
|
new_events = [
|
|
e for e in events
|
|
if not (e.get('username') == username
|
|
and e.get('calendar') == calendar_name
|
|
and e.get('uid') == uid)
|
|
]
|
|
self._save_events(new_events)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f'remove_event failed: {e}')
|
|
return False |