Files
pic/api/calendar_manager.py
T
roof 87ff50c378 feat: Settings changes now apply to real service config files and restart containers
Each service manager now has apply_config() that writes to the actual config:
- network: dhcp_range → dnsmasq.conf (reload cell-dhcp), ntp_servers → chrony.conf
  (restart cell-ntp), domain → dnsmasq.conf domain= line
- email: domain → mailserver.env OVERRIDE_HOSTNAME + POSTMASTER_ADDRESS,
  restart cell-mail
- wireguard: port/address/private_key → wg0.conf ListenPort/Address/PrivateKey,
  restart cell-wireguard
- calendar: port → radicale config hosts=, restart cell-radicale

PUT /api/config now calls apply_config() after persisting JSON, and returns
{restarted: [...], warnings: [...]} so Settings UI can show which containers
were restarted. _restart_container() helper added to BaseServiceManager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 04:27:22 -04:00

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 port and restart cell-radicale."""
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)
self._restart_container('cell-radicale')
restarted.append('cell-radicale')
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