Backend: - Add HasQuota() permission factory for quota limits (resources, users, services, appointments, email templates, automated tasks) - Add HasFeaturePermission() factory for feature-based permissions (SMS, masked calling, custom domains, white label, plugins, webhooks, calendar sync, analytics) - Add has_feature() method to Tenant model for flexible permission checking - Add new tenant permission fields: can_create_plugins, can_use_webhooks, can_use_calendar_sync, can_export_data - Create Data Export API with CSV/JSON support for appointments, customers, resources, services - Create Analytics API with dashboard, appointments, revenue endpoints - Add calendar sync views and URL configuration Frontend: - Add usePlanFeatures hook for checking feature availability - Add UpgradePrompt components (inline, banner, overlay variants) - Add LockedSection wrapper and LockedButton for feature gating - Update settings pages with permission checks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
313 lines
10 KiB
Python
313 lines
10 KiB
Python
"""
|
|
Calendar Sync API Views
|
|
|
|
Provides endpoints for managing calendar integrations (Google Calendar, Outlook Calendar).
|
|
Handles OAuth credential creation, calendar selection, and event syncing.
|
|
|
|
Features:
|
|
- List connected calendars
|
|
- Sync calendar events
|
|
- Delete calendar integrations
|
|
- Permission checking via HasFeaturePermission
|
|
"""
|
|
|
|
import logging
|
|
from rest_framework import status, viewsets
|
|
from rest_framework.views import APIView
|
|
from rest_framework.response import Response
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.decorators import action
|
|
|
|
from core.models import OAuthCredential, Tenant
|
|
from core.permissions import HasFeaturePermission
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CalendarSyncPermission(IsAuthenticated):
|
|
"""
|
|
Custom permission that checks for calendar sync feature access.
|
|
Combines authentication check with feature permission check.
|
|
"""
|
|
def has_permission(self, request, view):
|
|
# First check authentication
|
|
if not super().has_permission(request, view):
|
|
return False
|
|
|
|
# Then check calendar sync feature permission
|
|
tenant = getattr(request, 'tenant', None)
|
|
if not tenant:
|
|
return False
|
|
|
|
return tenant.has_feature('can_use_calendar_sync')
|
|
|
|
|
|
class CalendarListView(APIView):
|
|
"""
|
|
List OAuth credentials for calendar sync.
|
|
|
|
GET /api/calendar/list/
|
|
|
|
Returns list of connected calendars (Google Calendar, Outlook, etc.)
|
|
"""
|
|
permission_classes = [CalendarSyncPermission]
|
|
|
|
def get(self, request):
|
|
"""
|
|
Get all calendar integrations for the current tenant.
|
|
|
|
Returns:
|
|
List of calendar credentials with details (tokens masked for security)
|
|
"""
|
|
try:
|
|
tenant = getattr(request, 'tenant', None)
|
|
if not tenant:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'No tenant found in request',
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Get calendar purpose OAuth credentials for this tenant
|
|
credentials = OAuthCredential.objects.filter(
|
|
tenant=tenant,
|
|
purpose='calendar',
|
|
).order_by('-created_at')
|
|
|
|
calendar_list = []
|
|
for cred in credentials:
|
|
calendar_list.append({
|
|
'id': cred.id,
|
|
'provider': cred.get_provider_display(),
|
|
'email': cred.email,
|
|
'is_valid': cred.is_valid,
|
|
'is_expired': cred.is_expired(),
|
|
'last_used_at': cred.last_used_at,
|
|
'created_at': cred.created_at,
|
|
})
|
|
|
|
return Response({
|
|
'success': True,
|
|
'calendars': calendar_list,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing calendars: {e}")
|
|
return Response({
|
|
'success': False,
|
|
'error': f'Error listing calendars: {str(e)[:100]}',
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
class CalendarSyncView(APIView):
|
|
"""
|
|
Sync events from a connected calendar.
|
|
|
|
POST /api/calendar/sync/
|
|
Body: {
|
|
"credential_id": <int>,
|
|
"calendar_id": "<optional google calendar id>",
|
|
"start_date": "2025-01-01",
|
|
"end_date": "2025-12-31"
|
|
}
|
|
|
|
Syncs events from the specified calendar into the schedule.
|
|
"""
|
|
permission_classes = [CalendarSyncPermission]
|
|
|
|
def post(self, request):
|
|
"""
|
|
Trigger calendar sync for a specific calendar credential.
|
|
|
|
Permission Check: Requires can_use_calendar_sync feature permission
|
|
"""
|
|
try:
|
|
credential_id = request.data.get('credential_id')
|
|
calendar_id = request.data.get('calendar_id')
|
|
start_date = request.data.get('start_date')
|
|
end_date = request.data.get('end_date')
|
|
|
|
if not credential_id:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'credential_id is required',
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
tenant = getattr(request, 'tenant', None)
|
|
if not tenant:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'No tenant found in request',
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Verify credential belongs to this tenant
|
|
try:
|
|
credential = OAuthCredential.objects.get(
|
|
id=credential_id,
|
|
tenant=tenant,
|
|
purpose='calendar',
|
|
)
|
|
except OAuthCredential.DoesNotExist:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'Calendar credential not found',
|
|
}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
# Check if credential is still valid
|
|
if not credential.is_valid:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'Calendar credential is no longer valid. Please reconnect.',
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# TODO: Implement actual calendar sync logic
|
|
# This would use the credential's access_token to fetch events
|
|
# from Google Calendar API or Microsoft Graph API
|
|
# and create Event records in the schedule
|
|
|
|
logger.info(
|
|
f"Calendar sync initiated for tenant {tenant.name}, "
|
|
f"credential {credential.email} ({credential.get_provider_display()})"
|
|
)
|
|
|
|
return Response({
|
|
'success': True,
|
|
'message': f'Calendar sync started for {credential.email}',
|
|
'credential_id': credential_id,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error syncing calendar: {e}")
|
|
return Response({
|
|
'success': False,
|
|
'error': f'Error syncing calendar: {str(e)[:100]}',
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
class CalendarDeleteView(APIView):
|
|
"""
|
|
Delete/disconnect a calendar integration.
|
|
|
|
DELETE /api/calendar/disconnect/
|
|
Body: { "credential_id": <int> }
|
|
|
|
Revokes the OAuth credential and removes the calendar integration.
|
|
"""
|
|
permission_classes = [CalendarSyncPermission]
|
|
|
|
def delete(self, request):
|
|
"""
|
|
Delete a calendar credential for the current tenant.
|
|
|
|
Permission Check: Requires can_use_calendar_sync feature permission
|
|
"""
|
|
try:
|
|
credential_id = request.data.get('credential_id')
|
|
|
|
if not credential_id:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'credential_id is required',
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
tenant = getattr(request, 'tenant', None)
|
|
if not tenant:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'No tenant found in request',
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# Verify credential belongs to this tenant
|
|
try:
|
|
credential = OAuthCredential.objects.get(
|
|
id=credential_id,
|
|
tenant=tenant,
|
|
purpose='calendar',
|
|
)
|
|
except OAuthCredential.DoesNotExist:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'Calendar credential not found',
|
|
}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
email = credential.email
|
|
provider = credential.get_provider_display()
|
|
|
|
# Delete the credential
|
|
credential.delete()
|
|
|
|
logger.info(
|
|
f"Calendar credential deleted for tenant {tenant.name}: "
|
|
f"{email} ({provider})"
|
|
)
|
|
|
|
return Response({
|
|
'success': True,
|
|
'message': f'Calendar integration for {email} has been disconnected',
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error deleting calendar credential: {e}")
|
|
return Response({
|
|
'success': False,
|
|
'error': f'Error deleting calendar: {str(e)[:100]}',
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
class CalendarStatusView(APIView):
|
|
"""
|
|
Get calendar sync status and information.
|
|
|
|
GET /api/calendar/status/
|
|
|
|
Returns information about calendar sync capability and connected calendars.
|
|
"""
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
"""
|
|
Get calendar sync status for the tenant.
|
|
|
|
Returns:
|
|
- can_use_calendar_sync: Boolean indicating if feature is enabled
|
|
- total_connected: Number of connected calendars
|
|
- total_synced_events: Count of events synced (placeholder)
|
|
"""
|
|
try:
|
|
tenant = getattr(request, 'tenant', None)
|
|
if not tenant:
|
|
return Response({
|
|
'success': False,
|
|
'error': 'No tenant found in request',
|
|
}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
has_permission = tenant.has_feature('can_use_calendar_sync')
|
|
|
|
if not has_permission:
|
|
return Response({
|
|
'success': True,
|
|
'can_use_calendar_sync': False,
|
|
'message': 'Calendar Sync feature is not available for your plan',
|
|
'total_connected': 0,
|
|
})
|
|
|
|
# Count connected calendars
|
|
total_connected = OAuthCredential.objects.filter(
|
|
tenant=tenant,
|
|
purpose='calendar',
|
|
is_valid=True,
|
|
).count()
|
|
|
|
return Response({
|
|
'success': True,
|
|
'can_use_calendar_sync': True,
|
|
'total_connected': total_connected,
|
|
'feature_enabled': True,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting calendar status: {e}")
|
|
return Response({
|
|
'success': False,
|
|
'error': f'Error getting status: {str(e)[:100]}',
|
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|