Files
pic/api/calendar_manager.py
T
roof a5381b2ebc fix: health history all-down — connectivity checks and UI data path
Service manager fixes (connectivity tests):
- email_manager: replace telnet with socket.create_connection for SMTP/IMAP;
  replace nslookup with socket.getaddrinfo for DNS; exclude unconfigured domain
  from success (email healthy=False now correctly means ports refused, not missing domain)
- calendar_manager: replace localhost:5232 with cell-radicale:5232;
  fix database check to test dir writability instead of file existence (files created on demand)
- file_manager: replace localhost:8080 with cell-webdav:80; add top-level success key
- network_manager: replace nslookup with socket.getaddrinfo;
  add success key to dhcp_test and ntp_test return values
- routing_manager: exclude iptables_access from success
  (iptables runs in cell-wireguard, not API container)
- wireguard_manager: add success key to no-arg test_connectivity result

Health history UI:
- SvcCol reads data?.status?.running || data?.status?.status — handles nested health check shape

Result: network/wireguard/calendar/files/routing/vault all healthy=True.
Email healthy=False is correct — mail server needs ≥1 account before Dovecot starts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 02:18:23 -04:00

523 lines
20 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_status(self) -> Dict[str, Any]:
"""Get calendar service status"""
try:
# 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',
'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',
'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 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