Files
smoothschedule/smoothschedule/schedule/calendar_sync_views.py
poduck e4ad7fca87 feat: Plan-based feature permissions and quota enforcement
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>
2025-12-02 11:21:11 -05:00

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)