""" Notifications views for the Tenhal Multidisciplinary Healthcare Platform. This module contains views for notification management including: - Message dashboard with statistics - Message list and detail views - Template management (CRUD) - Bulk messaging interface - Analytics and reports """ from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Q, Count, Avg from django.http import JsonResponse, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views import View from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView from django.urls import reverse_lazy from django.contrib import messages from datetime import timedelta import json import csv from core.mixins import ( TenantFilterMixin, RolePermissionMixin, AuditLogMixin, HTMXResponseMixin, SuccessMessageMixin, PaginationMixin, ) from core.models import User, Patient from .models import MessageTemplate, Message, NotificationPreference, MessageLog from .forms import ( MessageTemplateForm, MessageFilterForm, BulkMessageForm, TestTemplateForm, MessageRetryForm, BroadcastNotificationForm, ) # ============================================================================ # Dashboard Views # ============================================================================ class MessageDashboardView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, View): """ Message dashboard with statistics and overview. Features: - Key metrics (total sent, success rate, etc.) - Recent messages - Charts for delivery rates - Quick actions """ allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get(self, request): """Display dashboard.""" tenant = request.user.tenant # Get date range (default: last 7 days) days = int(request.GET.get('days', 7)) since = timezone.now() - timedelta(days=days) # Get statistics stats = self._get_statistics(tenant, since) # Get recent messages recent_messages = Message.objects.filter( tenant=tenant ).select_related('template').order_by('-created_at')[:10] # Get failed messages that can be retried failed_messages = Message.objects.filter( tenant=tenant, status=Message.Status.FAILED, retry_count__lt=3 ).count() context = { 'stats': stats, 'recent_messages': recent_messages, 'failed_messages_count': failed_messages, 'days': days, } return render(request, 'notifications/dashboard.html', context) def _get_statistics(self, tenant, since): """Calculate messaging statistics.""" messages_qs = Message.objects.filter(tenant=tenant, created_at__gte=since) total = messages_qs.count() # Count by status sent = messages_qs.filter(status=Message.Status.SENT).count() delivered = messages_qs.filter(status=Message.Status.DELIVERED).count() failed = messages_qs.filter(status=Message.Status.FAILED).count() queued = messages_qs.filter(status=Message.Status.QUEUED).count() # Calculate success rate success_rate = 0 if total > 0: successful = messages_qs.filter( status__in=[Message.Status.DELIVERED, Message.Status.READ] ).count() success_rate = round((successful / total) * 100, 1) # Count by channel by_channel = {} for channel in Message.Channel: count = messages_qs.filter(channel=channel.value).count() by_channel[channel.value] = count # Get daily breakdown for chart daily_stats = [] for i in range(7): date = timezone.now().date() - timedelta(days=6-i) day_messages = messages_qs.filter(created_at__date=date) daily_stats.append({ 'date': date.strftime('%Y-%m-%d'), 'total': day_messages.count(), 'delivered': day_messages.filter(status=Message.Status.DELIVERED).count(), 'failed': day_messages.filter(status=Message.Status.FAILED).count(), }) return { 'total': total, 'sent': sent, 'delivered': delivered, 'failed': failed, 'queued': queued, 'success_rate': success_rate, 'by_channel': by_channel, 'daily_stats': daily_stats, } # ============================================================================ # Message List and Detail Views # ============================================================================ class MessageListView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, PaginationMixin, HTMXResponseMixin, ListView): """ Message list view with filtering and search. Features: - Filter by channel, status, date range, template - Search by recipient or content - Export to CSV - Bulk retry failed messages """ model = Message template_name = 'notifications/message_list.html' htmx_template_name = 'notifications/partials/message_list_partial.html' context_object_name = 'messages' paginate_by = 25 allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get_queryset(self): """Get filtered queryset.""" queryset = super().get_queryset() # Apply search search_query = self.request.GET.get('search', '').strip() if search_query: queryset = queryset.filter( Q(recipient__icontains=search_query) | Q(body__icontains=search_query) | Q(subject__icontains=search_query) ) # Apply filters channel = self.request.GET.get('channel') if channel: queryset = queryset.filter(channel=channel) status = self.request.GET.get('status') if status: queryset = queryset.filter(status=status) template_id = self.request.GET.get('template') if template_id: queryset = queryset.filter(template_id=template_id) date_from = self.request.GET.get('date_from') if date_from: queryset = queryset.filter(created_at__date__gte=date_from) date_to = self.request.GET.get('date_to') if date_to: queryset = queryset.filter(created_at__date__lte=date_to) return queryset.select_related('template').order_by('-created_at') def get_context_data(self, **kwargs): """Add filter form and statistics.""" context = super().get_context_data(**kwargs) # Add filter form context['filter_form'] = MessageFilterForm( self.request.GET, tenant=self.request.user.tenant ) # Add current filters context['current_filters'] = { 'search': self.request.GET.get('search', ''), 'channel': self.request.GET.get('channel', ''), 'status': self.request.GET.get('status', ''), 'template': self.request.GET.get('template', ''), 'date_from': self.request.GET.get('date_from', ''), 'date_to': self.request.GET.get('date_to', ''), } # Add quick stats queryset = self.get_queryset() context['total_count'] = queryset.count() context['failed_count'] = queryset.filter(status=Message.Status.FAILED).count() return context class MessageDetailView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, DetailView): """ Message detail view. Features: - Full message details - Delivery timeline - Provider response - Retry history - Related logs """ model = Message template_name = 'notifications/message_detail.html' context_object_name = 'message' allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get_context_data(self, **kwargs): """Add related data.""" context = super().get_context_data(**kwargs) message = self.object # Get message logs context['logs'] = message.logs.all().order_by('created_at') # Check if can retry context['can_retry'] = message.can_retry # Get timeline events context['timeline'] = self._build_timeline(message) return context def _build_timeline(self, message): """Build timeline of message events.""" timeline = [] # Created timeline.append({ 'timestamp': message.created_at, 'event': 'Created', 'icon': 'fa-plus-circle', 'color': 'info' }) # Sent if message.sent_at: timeline.append({ 'timestamp': message.sent_at, 'event': 'Sent', 'icon': 'fa-paper-plane', 'color': 'primary' }) # Delivered if message.delivered_at: timeline.append({ 'timestamp': message.delivered_at, 'event': 'Delivered', 'icon': 'fa-check-circle', 'color': 'success' }) # Failed if message.status == Message.Status.FAILED: timeline.append({ 'timestamp': message.updated_at, 'event': 'Failed', 'icon': 'fa-exclamation-circle', 'color': 'danger', 'details': message.error_message }) return sorted(timeline, key=lambda x: x['timestamp']) class MessageExportView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, View): """Export messages to CSV.""" allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get(self, request): """Export filtered messages to CSV.""" # Get filtered queryset (reuse MessageListView logic) queryset = Message.objects.filter(tenant=request.user.tenant) # Apply same filters as list view search_query = request.GET.get('search', '').strip() if search_query: queryset = queryset.filter( Q(recipient__icontains=search_query) | Q(body__icontains=search_query) ) channel = request.GET.get('channel') if channel: queryset = queryset.filter(channel=channel) status = request.GET.get('status') if status: queryset = queryset.filter(status=status) # Create CSV response response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="messages_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"' writer = csv.writer(response) writer.writerow([ 'Date', 'Channel', 'Recipient', 'Status', 'Template', 'Sent At', 'Delivered At', 'Error Message' ]) for message in queryset.select_related('template').order_by('-created_at'): writer.writerow([ message.created_at.strftime('%Y-%m-%d %H:%M:%S'), message.get_channel_display(), message.recipient, message.get_status_display(), message.template.name if message.template else '', message.sent_at.strftime('%Y-%m-%d %H:%M:%S') if message.sent_at else '', message.delivered_at.strftime('%Y-%m-%d %H:%M:%S') if message.delivered_at else '', message.error_message ]) return response class MessageRetryView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, AuditLogMixin, View): """Retry failed message.""" allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def post(self, request, pk): """Retry sending a failed message.""" from integrations.messaging_service import MessagingService message = get_object_or_404(Message, pk=pk, tenant=request.user.tenant) if not message.can_retry: messages.error(request, 'This message cannot be retried.') return redirect('notifications:message_detail', pk=pk) # Retry message service = MessagingService() result = service.retry_failed_message(str(message.id)) if result['success']: messages.success(request, 'Message retry initiated successfully!') else: messages.error(request, f'Retry failed: {result.get("error")}') return redirect('notifications:message_detail', pk=pk) # ============================================================================ # Template Management Views # ============================================================================ class TemplateListView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, PaginationMixin, ListView): """ Template list view. Features: - List all templates - Filter by channel, active status - Quick activate/deactivate """ model = MessageTemplate template_name = 'notifications/template_list.html' context_object_name = 'templates' paginate_by = 20 allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get_queryset(self): """Get filtered queryset.""" queryset = super().get_queryset() # Apply filters channel = self.request.GET.get('channel') if channel: queryset = queryset.filter(channel=channel) is_active = self.request.GET.get('is_active') if is_active: queryset = queryset.filter(is_active=is_active == 'true') return queryset.order_by('channel', 'name') def get_context_data(self, **kwargs): """Add filter options.""" context = super().get_context_data(**kwargs) context['channel_choices'] = MessageTemplate.Channel.choices return context class TemplateDetailView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, DetailView): """Template detail view with preview.""" model = MessageTemplate template_name = 'notifications/template_detail.html' context_object_name = 'template' allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get_context_data(self, **kwargs): """Add usage statistics.""" context = super().get_context_data(**kwargs) template = self.object # Get usage statistics context['usage_stats'] = { 'total_sent': template.messages.count(), 'successful': template.messages.filter( status__in=[Message.Status.DELIVERED, Message.Status.READ] ).count(), 'failed': template.messages.filter(status=Message.Status.FAILED).count(), } # Get recent messages using this template context['recent_messages'] = template.messages.all().order_by('-created_at')[:5] return context class TemplateCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, SuccessMessageMixin, CreateView): """Create new message template.""" model = MessageTemplate form_class = MessageTemplateForm template_name = 'notifications/template_form.html' success_message = "Template created successfully!" allowed_roles = [User.Role.ADMIN] def form_valid(self, form): """Set tenant.""" form.instance.tenant = self.request.user.tenant return super().form_valid(form) def get_success_url(self): """Redirect to template detail.""" return reverse_lazy('notifications:template_detail', kwargs={'pk': self.object.pk}) def get_context_data(self, **kwargs): """Add form title.""" context = super().get_context_data(**kwargs) context['form_title'] = 'Create Message Template' context['submit_text'] = 'Create Template' return context class TemplateUpdateView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, AuditLogMixin, SuccessMessageMixin, UpdateView): """Update message template.""" model = MessageTemplate form_class = MessageTemplateForm template_name = 'notifications/template_form.html' success_message = "Template updated successfully!" allowed_roles = [User.Role.ADMIN] def get_success_url(self): """Redirect to template detail.""" return reverse_lazy('notifications:template_detail', kwargs={'pk': self.object.pk}) def get_context_data(self, **kwargs): """Add form title.""" context = super().get_context_data(**kwargs) context['form_title'] = f'Update Template: {self.object.name}' context['submit_text'] = 'Update Template' return context class TemplateDeleteView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, AuditLogMixin, DeleteView): """Delete message template.""" model = MessageTemplate template_name = 'notifications/template_confirm_delete.html' success_url = reverse_lazy('notifications:template_list') allowed_roles = [User.Role.ADMIN] def delete(self, request, *args, **kwargs): """Add success message.""" messages.success(request, 'Template deleted successfully!') return super().delete(request, *args, **kwargs) class TemplateToggleView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, AuditLogMixin, View): """Toggle template active status.""" allowed_roles = [User.Role.ADMIN] def post(self, request, pk): """Toggle is_active status.""" template = get_object_or_404(MessageTemplate, pk=pk, tenant=request.user.tenant) template.is_active = not template.is_active template.save() status = 'activated' if template.is_active else 'deactivated' messages.success(request, f'Template {status} successfully!') return redirect('notifications:template_detail', pk=pk) class TemplateTestView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, View): """Test message template.""" allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get(self, request): """Display test form.""" form = TestTemplateForm(tenant=request.user.tenant) return render(request, 'notifications/template_test.html', {'form': form}) def post(self, request): """Send test message.""" from integrations.messaging_service import MessagingService form = TestTemplateForm(request.POST, tenant=request.user.tenant) if form.is_valid(): template = form.cleaned_data['template'] recipient = form.cleaned_data['test_recipient'] language = form.cleaned_data['language'] variables = form.cleaned_data['variables'] # Send test message service = MessagingService() result = service.send_from_template( template_code=template.code, recipient_phone=recipient, channel=template.channel, context=variables, tenant_id=str(request.user.tenant.id), language=language ) if result['success']: messages.success(request, f'Test message sent successfully! Message ID: {result["message_id"]}') else: messages.error(request, f'Failed to send test message: {result.get("error")}') return render(request, 'notifications/template_test.html', {'form': form}) # ============================================================================ # Bulk Messaging Views # ============================================================================ class BulkMessageView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, View): """ Bulk messaging interface. Features: - Select recipients (all, by tags, custom list) - Use template or custom message - Preview before sending - Track progress """ allowed_roles = [User.Role.ADMIN, User.Role.FRONT_DESK] def get(self, request): """Display bulk message form.""" form = BulkMessageForm(tenant=request.user.tenant) return render(request, 'notifications/bulk_message.html', {'form': form}) def post(self, request): """Process bulk message.""" from integrations.messaging_service import MessagingService form = BulkMessageForm(request.POST, tenant=request.user.tenant) if form.is_valid(): # Get recipients recipients = self._get_recipients(form.cleaned_data) if not recipients: messages.error(request, 'No recipients found.') return render(request, 'notifications/bulk_message.html', {'form': form}) # Get message content channel = form.cleaned_data['channel'] use_template = form.cleaned_data['use_template'] if use_template: template = form.cleaned_data['template'] # TODO: Implement template-based bulk sending messages.info(request, 'Template-based bulk sending not yet implemented.') else: message_body = form.cleaned_data['message'] # Send bulk messages service = MessagingService() result = service.send_bulk_messages( recipients=recipients, message=message_body, channel=channel, tenant_id=str(request.user.tenant.id) ) messages.success( request, f'Bulk send completed! Sent: {result["sent"]}, Failed: {result["failed"]}' ) return redirect('notifications:message_list') return render(request, 'notifications/bulk_message.html', {'form': form}) def _get_recipients(self, data): """Get list of recipients based on filter.""" recipient_filter = data['recipient_filter'] if recipient_filter == 'all': # Get all patients with phone numbers patients = Patient.objects.filter( tenant=self.request.user.tenant, phone__isnull=False ).exclude(phone='') return [p.phone for p in patients] elif recipient_filter == 'tags': # Get patients by tags tags = [t.strip() for t in data['tags'].split(',')] patients = Patient.objects.filter( tenant=self.request.user.tenant, tags__name__in=tags, phone__isnull=False ).exclude(phone='').distinct() return [p.phone for p in patients] elif recipient_filter == 'custom': # Parse custom recipient list recipients = data['recipients'].strip().split('\n') return [r.strip() for r in recipients if r.strip()] return [] # ============================================================================ # Analytics Views # ============================================================================ class MessageAnalyticsView(LoginRequiredMixin, RolePermissionMixin, TenantFilterMixin, View): """ Message analytics and reports. Features: - Delivery rate charts - Channel comparison - Cost analysis - Trend analysis """ allowed_roles = [User.Role.ADMIN] def get(self, request): """Display analytics dashboard.""" tenant = request.user.tenant # Get date range days = int(request.GET.get('days', 30)) since = timezone.now() - timedelta(days=days) # Get analytics data analytics = self._get_analytics(tenant, since, days) context = { 'analytics': analytics, 'days': days, } return render(request, 'notifications/analytics.html', context) def _get_analytics(self, tenant, since, days): """Calculate analytics data.""" messages_qs = Message.objects.filter(tenant=tenant, created_at__gte=since) # Overall statistics total = messages_qs.count() successful = messages_qs.filter( status__in=[Message.Status.DELIVERED, Message.Status.READ] ).count() # Channel breakdown channel_stats = [] for channel in Message.Channel: channel_messages = messages_qs.filter(channel=channel.value) channel_total = channel_messages.count() channel_success = channel_messages.filter( status__in=[Message.Status.DELIVERED, Message.Status.READ] ).count() channel_stats.append({ 'channel': channel.label, 'total': channel_total, 'successful': channel_success, 'success_rate': round((channel_success / channel_total * 100), 1) if channel_total > 0 else 0 }) # Daily trend daily_trend = [] for i in range(days): date = timezone.now().date() - timedelta(days=days-1-i) day_messages = messages_qs.filter(created_at__date=date) daily_trend.append({ 'date': date.strftime('%Y-%m-%d'), 'total': day_messages.count(), 'successful': day_messages.filter( status__in=[Message.Status.DELIVERED, Message.Status.READ] ).count(), }) # Top templates top_templates = MessageTemplate.objects.filter( tenant=tenant, messages__created_at__gte=since ).annotate( usage_count=Count('messages') ).order_by('-usage_count')[:5] return { 'total': total, 'successful': successful, 'success_rate': round((successful / total * 100), 1) if total > 0 else 0, 'channel_stats': channel_stats, 'daily_trend': daily_trend, 'top_templates': top_templates, } # ============================================================================ # Notification Center Views (In-App Notifications) # ============================================================================ class NotificationListView(LoginRequiredMixin, ListView): """ List all notifications for the current user. Features: - Show unread notifications first - Filter by type - Mark as read/unread - Pagination - Includes personal, general, and role-based notifications """ model = None # Will be set in get_queryset template_name = 'notifications/notification_list.html' context_object_name = 'notifications' paginate_by = 20 def get_queryset(self): """Get notifications for current user (personal, general, and role-based).""" from .models import Notification queryset = Notification.get_for_user(self.request.user) # Filter by read status filter_type = self.request.GET.get('filter', 'all') if filter_type == 'unread': queryset = queryset.filter(is_read=False) elif filter_type == 'read': queryset = queryset.filter(is_read=True) # Filter by notification type notif_type = self.request.GET.get('type') if notif_type: queryset = queryset.filter(notification_type=notif_type) return queryset.order_by('-created_at') def get_context_data(self, **kwargs): """Add unread count and filter info.""" context = super().get_context_data(**kwargs) from .models import Notification context['unread_count'] = Notification.get_unread_count(self.request.user) context['current_filter'] = self.request.GET.get('filter', 'all') context['current_type'] = self.request.GET.get('type', '') return context class NotificationMarkReadView(LoginRequiredMixin, View): """Mark a notification as read.""" def post(self, request, pk): """Mark notification as read.""" from .models import Notification notification = get_object_or_404(Notification, pk=pk, user=request.user) notification.mark_as_read() # Return JSON for AJAX requests if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ 'success': True, 'unread_count': Notification.get_unread_count(request.user) }) # Redirect to next URL or notification list next_url = request.GET.get('next', 'notifications:notification_list') return redirect(next_url) class NotificationMarkAllReadView(LoginRequiredMixin, View): """Mark all notifications as read for current user.""" def post(self, request): """Mark all notifications as read.""" from .models import Notification Notification.mark_all_as_read(request.user) # Return JSON for AJAX requests if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ 'success': True, 'unread_count': 0 }) messages.success(request, 'All notifications marked as read.') return redirect('notifications:notification_list') class NotificationUnreadCountView(LoginRequiredMixin, View): """Get unread notification count (for AJAX polling).""" def get(self, request): """Return unread count as JSON.""" from .models import Notification unread_count = Notification.get_unread_count(request.user) return JsonResponse({ 'unread_count': unread_count }) class NotificationDropdownView(LoginRequiredMixin, View): """Get recent notifications for dropdown (AJAX).""" def get(self, request): """Return recent notifications as JSON.""" from .models import Notification notifications = Notification.get_for_user(request.user).order_by('-created_at')[:10] data = { 'unread_count': Notification.get_unread_count(request.user), 'notifications': [ { 'id': str(n.id), 'title': n.title, 'message': n.message[:100] + '...' if len(n.message) > 100 else n.message, 'type': n.notification_type, 'is_read': n.is_read, 'created_at': n.created_at.isoformat(), 'action_url': n.action_url or '#', } for n in notifications ] } return JsonResponse(data) class BroadcastNotificationCreateView(LoginRequiredMixin, RolePermissionMixin, AuditLogMixin, View): """ Create broadcast notifications (general or role-based). Only admins can create broadcast notifications. """ allowed_roles = [User.Role.ADMIN] def get(self, request): """Display broadcast notification form.""" form = BroadcastNotificationForm() return render(request, 'notifications/broadcast_notification_form.html', { 'form': form, 'form_title': 'Create Broadcast Notification', }) def post(self, request): """Create broadcast notification.""" from .models import Notification form = BroadcastNotificationForm(request.POST) if form.is_valid(): broadcast_type = form.cleaned_data['broadcast_type'] title = form.cleaned_data['title'] message = form.cleaned_data['message'] notification_type = form.cleaned_data['notification_type'] action_url = form.cleaned_data['action_url'] if broadcast_type == 'general': # Create general notification notification = Notification.create_general( title=title, message=message, notification_type=notification_type, action_url=action_url ) messages.success( request, f'General notification created successfully! All users will see this notification.' ) else: # Create role-based notification target_roles = form.cleaned_data['target_roles'] notification = Notification.create_role_based( roles=target_roles, title=title, message=message, notification_type=notification_type, action_url=action_url ) role_names = ', '.join([dict(User.Role.choices).get(r, r) for r in target_roles]) messages.success( request, f'Role-based notification created successfully! Visible to: {role_names}' ) return redirect('notifications:notification_list') return render(request, 'notifications/broadcast_notification_form.html', { 'form': form, 'form_title': 'Create Broadcast Notification', })