import logging import secrets from django.conf import settings from django.core.exceptions import ValidationError from rest_framework import status from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from rest_framework_simplejwt.authentication import JWTAuthentication from ai_chatbot.serializers import EnableAutoChatSerializer, PropertyCustomQASerializer from .models import MasterQuestion, PropertyCustomQA, PropertyQA, AgencyAutoChatSetting, PropertyAutoChatState, GlobalQA from .services import EmbeddingService logger = logging.getLogger(__name__) embedding_service = EmbeddingService() # FIX #8: Add serializer validation from rest_framework import serializers class QAAnswerSerializer(serializers.Serializer): master_question_id = serializers.UUIDField(required=True) answer = serializers.CharField(max_length=5000, required=True, allow_blank=False) class BulkQASerializer(serializers.Serializer): property_id = serializers.UUIDField(required=True) answers = QAAnswerSerializer(many=True, required=True) # FIX #3: Webhook authentication class WebhookSecretAuthentication: """Custom authentication for webhook endpoint""" def authenticate(self, request): secret = request.headers.get('X-Webhook-Secret') expected_secret = getattr(settings, 'AI_WEBHOOK_SECRET', None) if not expected_secret: logger.warning("AI_WEBHOOK_SECRET not set in settings") return None if secret and secrets.compare_digest(secret, expected_secret): return (None, None) # Valid return None def authenticate_header(self, request): return 'X-Webhook-Secret' @api_view(['GET']) @authentication_classes([JWTAuthentication]) @permission_classes([IsAuthenticated]) def get_master_questions(request): """Get all master questions for agency to answer""" questions = MasterQuestion.objects.filter(is_active=True).order_by('order') data = [{'id': str(q.id), 'question': q.question, 'order': q.order} for q in questions] return Response({'questions': data}) @api_view(['GET']) @authentication_classes([JWTAuthentication]) @permission_classes([IsAuthenticated]) def get_property_qa_status(request, property_id): """Get Q&A status for a property""" user = request.user master_questions = MasterQuestion.objects.filter(is_active=True).order_by('order') # Get existing answers existing_qa = { str(qa.master_question_id): { 'answer': qa.answer, 'qa_id': str(qa.id), 'updated_at': qa.updated_at } for qa in PropertyQA.objects.filter( property_id=property_id, agency=user ).select_related('master_question') } questions_data = [] for mq in master_questions: mq_id = str(mq.id) questions_data.append({ 'id': mq_id, 'question': mq.question, 'order': mq.order, 'is_answered': mq_id in existing_qa, 'answer': existing_qa[mq_id]['answer'] if mq_id in existing_qa else None, 'updated_at': existing_qa[mq_id]['updated_at'] if mq_id in existing_qa else None, }) # Get settings try: auto_chat_setting = AgencyAutoChatSetting.objects.get(agency=user) auto_chat_enabled = auto_chat_setting.is_enabled delay_seconds = auto_chat_setting.delay_seconds confidence_threshold = auto_chat_setting.confidence_threshold except AgencyAutoChatSetting.DoesNotExist: auto_chat_enabled = False delay_seconds = 30 confidence_threshold = 0.6 try: property_auto_state = PropertyAutoChatState.objects.get(property_id=property_id) property_auto_enabled = property_auto_state.is_auto_chat_enabled except PropertyAutoChatState.DoesNotExist: property_auto_enabled = False answered_count = sum(1 for q in questions_data if q['is_answered']) total_questions = len(questions_data) return Response({ 'property_id': property_id, 'master_questions': questions_data, 'stats': { 'total_questions': total_questions, 'answered_count': answered_count, 'completion_percentage': round((answered_count / total_questions) * 100, 1) if total_questions > 0 else 0, 'remaining_count': total_questions - answered_count }, 'auto_chat_settings': { 'is_enabled': auto_chat_enabled, 'property_auto_enabled': property_auto_enabled, 'delay_seconds': delay_seconds, 'confidence_threshold': confidence_threshold } }) @api_view(['POST']) @authentication_classes([JWTAuthentication]) @permission_classes([IsAuthenticated]) def save_property_qa_bulk(request): """ FIX #5 & #9: Optimized bulk save with proper auto-enable logic """ user = request.user # Check role if not hasattr(user, 'role') or getattr(user.role, 'role_type', None) != 'agency': return Response({'error': 'Only agencies can add Q&A'}, status=status.HTTP_403_FORBIDDEN) # FIX #8: Validate input serializer = BulkQASerializer(data=request.data) if not serializer.is_valid(): return Response({'error': 'Invalid data', 'details': serializer.errors}, status=status.HTTP_400_BAD_REQUEST) validated_data = serializer.validated_data property_id = validated_data['property_id'] answers = validated_data['answers'] if not answers: return Response({'error': 'No answers provided'}, status=status.HTTP_400_BAD_REQUEST) # FIX #5: Get existing answers to minimize database operations existing_qa = { str(qa.master_question_id): qa for qa in PropertyQA.objects.filter( property_id=property_id, agency=user ).select_related('master_question') } # Track which master questions were answered answered_master_ids = set() created_count = 0 updated_count = 0 errors = [] for item in answers: master_q_id = str(item['master_question_id']) answer = item['answer'].strip() if not answer: continue try: master_q = MasterQuestion.objects.get(id=master_q_id, is_active=True) answered_master_ids.add(master_q_id) # Generate embedding embedding = embedding_service.generate_embedding(master_q.question) # Update or create if master_q_id in existing_qa: # Update existing qa = existing_qa[master_q_id] qa.answer = answer qa.question_embedding = embedding qa.save(update_fields=['answer', 'question_embedding', 'updated_at']) updated_count += 1 else: # Create new PropertyQA.objects.create( agency=user, property_id=property_id, master_question=master_q, answer=answer, question_embedding=embedding ) created_count += 1 except MasterQuestion.DoesNotExist: errors.append(f'Master question not found: {master_q_id}') except Exception as e: errors.append(f'Error for question {master_q_id}: {str(e)}') # Delete answers that were removed (not in current submission) to_delete_ids = set(existing_qa.keys()) - answered_master_ids if to_delete_ids: deleted_count = PropertyQA.objects.filter( property_id=property_id, master_question_id__in=to_delete_ids ).delete()[0] logger.info(f"Deleted {deleted_count} removed answers for property {property_id}") # FIX #9: Auto-enable only when ALL ACTIVE master questions are answered total_active_questions = MasterQuestion.objects.filter(is_active=True).count() total_answered = PropertyQA.objects.filter( property_id=property_id, master_question__is_active=True ).count() auto_chat_enabled = False if total_answered >= total_active_questions and total_active_questions > 0: property_state, created = PropertyAutoChatState.objects.update_or_create( property_id=property_id, defaults={'is_auto_chat_enabled': True} ) auto_chat_enabled = True logger.info(f"✅ Auto-chat enabled for property {property_id} ({total_answered}/{total_active_questions} questions answered)") return Response({ 'message': f'Saved answers for property', 'created': created_count, 'updated': updated_count, 'total': created_count + updated_count, 'auto_chat_enabled': auto_chat_enabled, 'completion': { 'answered': total_answered, 'total': total_active_questions, 'percentage': round((total_answered / total_active_questions) * 100, 1) if total_active_questions > 0 else 0 }, 'errors': errors if errors else None }, status=status.HTTP_200_OK if (created_count + updated_count) > 0 else status.HTTP_400_BAD_REQUEST) @api_view(['POST']) @authentication_classes([JWTAuthentication]) @permission_classes([IsAuthenticated]) def toggle_auto_chat(request, property_id): """Enable/disable auto-chat for a specific property""" user = request.user if not hasattr(user, 'role') or getattr(user.role, 'role_type', None) != 'agency': return Response({'error': 'Only agencies can configure auto-chat'}, status=status.HTTP_403_FORBIDDEN) is_enabled = request.data.get('is_enabled', False) # Validate before enabling if is_enabled: total_required = MasterQuestion.objects.filter(is_active=True).count() total_answered = PropertyQA.objects.filter( property_id=property_id, agency=user, master_question__is_active=True ).count() if total_answered < total_required: return Response({ 'error': f'Please answer all {total_required} active questions first. Currently answered: {total_answered}/{total_required}' }, status=status.HTTP_400_BAD_REQUEST) # Update property auto-chat state property_state, created = PropertyAutoChatState.objects.get_or_create( property_id=property_id, defaults={'is_auto_chat_enabled': is_enabled} ) if not created: property_state.is_auto_chat_enabled = is_enabled property_state.save() # Also update agency-wide setting agency_setting, _ = AgencyAutoChatSetting.objects.get_or_create( agency=user, defaults={ 'is_enabled': is_enabled, 'delay_seconds': 30, 'confidence_threshold': 0.6 } ) if not agency_setting.is_enabled and is_enabled: agency_setting.is_enabled = True agency_setting.save() return Response({ 'property_id': property_id, 'is_enabled': property_state.is_auto_chat_enabled, 'message': f'Auto-chat {"enabled" if is_enabled else "disabled"} for this property' }) @api_view(['POST']) @authentication_classes([WebhookSecretAuthentication]) # FIX #3: Secure webhook @permission_classes([AllowAny]) def webhook_auto_reply(request): """ FIX #3: Secured webhook endpoint for Chat app to trigger auto-reply """ from .services import AutoChatService # Validate required fields required_fields = ['chat_id', 'property_id', 'message'] for field in required_fields: if field not in request.data: return Response({'error': f'Missing required field: {field}'}, status=status.HTTP_400_BAD_REQUEST) data = request.data chat_id = data.get('chat_id') property_id = data.get('property_id') client_message = data.get('message') last_agency_reply_at = data.get('last_agency_reply_at') last_client_message_at = data.get('last_client_message_at') auto_chat_service = AutoChatService() # Check if auto-reply should trigger if auto_chat_service.should_auto_reply(chat_id, property_id, last_agency_reply_at, last_client_message_at): reply = auto_chat_service.generate_auto_reply(client_message, property_id, chat_id) if reply: return Response(reply) return Response({'should_auto_reply': False, 'error': 'Failed to generate reply'}) return Response({'should_auto_reply': False}) @api_view(['POST']) @authentication_classes([JWTAuthentication]) @permission_classes([IsAuthenticated]) def enable_auto_chat_with_questions(request): """ Enable auto-chat for a property and add custom questions (max 5) """ user = request.user # Check if agency if not hasattr(user, 'role') or getattr(user.role, 'role_type', None) != 'agency': return Response({'error': 'Only agencies can enable auto-chat'}, status=status.HTTP_403_FORBIDDEN) # Validate input serializer = EnableAutoChatSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) validated_data = serializer.validated_data property_id = validated_data['property_id'] enable = validated_data['enable'] custom_questions = validated_data.get('custom_questions', []) # Verify property belongs to this agency from Property.models import Property try: property_obj = Property.objects.get(id=property_id, user=user) except Property.DoesNotExist: return Response({'error': 'Property not found or does not belong to you'}, status=status.HTTP_404_NOT_FOUND) # Delete existing custom questions for this property PropertyCustomQA.objects.filter(property_id=property_id).delete() # Add new custom questions with embeddings created_questions = [] for idx, q_data in enumerate(custom_questions): # Generate embedding for the question embedding = embedding_service.generate_embedding(q_data['question']) custom_qa = PropertyCustomQA.objects.create( agency=user, property=property_obj, question=q_data['question'], answer=q_data['answer'], question_embedding=embedding, order=idx + 1, # 1, 2, 3, 4, 5 is_active=True ) created_questions.append({ 'order': custom_qa.order, 'question': custom_qa.question, 'answer': custom_qa.answer }) # Enable or disable auto-chat property_state, created = PropertyAutoChatState.objects.update_or_create( property_id=property_id, defaults={'is_auto_chat_enabled': enable} ) # Also update agency settings agency_setting, _ = AgencyAutoChatSetting.objects.get_or_create( agency=user, defaults={ 'is_enabled': enable, 'delay_seconds': 30, 'confidence_threshold': 0.7 } ) if enable and not agency_setting.is_enabled: agency_setting.is_enabled = True agency_setting.save() return Response({ 'success': True, 'property_id': str(property_id), 'auto_chat_enabled': property_state.is_auto_chat_enabled, 'custom_questions_added': len(created_questions), 'custom_questions': created_questions, 'message': f'Auto-chat {"enabled" if enable else "disabled"} with {len(created_questions)} custom questions' }, status=status.HTTP_200_OK) @api_view(['GET']) @authentication_classes([JWTAuthentication]) @permission_classes([IsAuthenticated]) def get_custom_questions(request, property_id): """ Get custom questions for a property (for editing) """ user = request.user # Verify property belongs to agency from Property.models import Property try: property_obj = Property.objects.get(id=property_id, user=user) except Property.DoesNotExist: return Response({'error': 'Property not found'}, status=status.HTTP_404_NOT_FOUND) custom_questions = PropertyCustomQA.objects.filter( property_id=property_id, is_active=True ).order_by('order') serializer = PropertyCustomQASerializer(custom_questions, many=True) # Get auto-chat status auto_chat_state = PropertyAutoChatState.objects.filter(property_id=property_id).first() return Response({ 'property_id': str(property_id), 'auto_chat_enabled': auto_chat_state.is_auto_chat_enabled if auto_chat_state else False, 'custom_questions': serializer.data, 'max_allowed': 5, 'remaining_slots': 5 - len(serializer.data) }) import uuid from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAdminUser @api_view(['GET', 'POST', 'PUT', 'DELETE']) @permission_classes([IsAdminUser]) def admin_master_questions(request, question_id=None): """ Admin CRUD for master questions """ # ==================== GET ==================== if request.method == 'GET': if question_id: try: question = MasterQuestion.objects.get(id=question_id) data = { 'id': str(question.id), 'question': question.question, 'order': question.order, 'is_active': question.is_active, 'created_at': question.created_at } return Response(data) except MasterQuestion.DoesNotExist: return Response({'error': 'Question not found'}, status=404) else: questions = MasterQuestion.objects.all().order_by('order') data = [{ 'id': str(q.id), 'question': q.question, 'order': q.order, 'is_active': q.is_active } for q in questions] return Response({ 'count': len(data), 'questions': data }) # ==================== CREATE (Single) ==================== elif request.method == 'POST': question_text = request.data.get('question') order = request.data.get('order') is_active = request.data.get('is_active', True) if not question_text: return Response({'error': 'Question text is required'}, status=400) # Check duplicate if MasterQuestion.objects.filter(question=question_text).exists(): return Response({'error': 'Question already exists'}, status=400) # Auto assign order if not provided if order is None: order = MasterQuestion.objects.count() + 1 question = MasterQuestion.objects.create( id=uuid.uuid4(), question=question_text, order=order, is_active=is_active ) return Response({ 'id': str(question.id), 'question': question.question, 'order': question.order, 'is_active': question.is_active, 'message': 'Master question created successfully' }, status=201) # ==================== UPDATE ==================== elif request.method == 'PUT': if not question_id: return Response({'error': 'Question ID required'}, status=400) try: question = MasterQuestion.objects.get(id=question_id) except MasterQuestion.DoesNotExist: return Response({'error': 'Question not found'}, status=404) question_text = request.data.get('question', question.question) order = request.data.get('order', question.order) is_active = request.data.get('is_active', question.is_active) question.question = question_text question.order = order question.is_active = is_active question.save() return Response({ 'id': str(question.id), 'question': question.question, 'order': question.order, 'is_active': question.is_active, 'message': 'Master question updated successfully' }) # ==================== DELETE ==================== elif request.method == 'DELETE': if not question_id: return Response({'error': 'Question ID required'}, status=400) try: question = MasterQuestion.objects.get(id=question_id) question.delete() return Response({'message': 'Master question deleted successfully'}) except MasterQuestion.DoesNotExist: return Response({'error': 'Question not found'}, status=404) # ==================== NEW: BULK CREATE ==================== @api_view(['POST']) @permission_classes([IsAdminUser]) def admin_master_questions_bulk(request): """ Admin can create multiple master questions at once """ questions_data = request.data.get('questions', []) if not questions_data: return Response({'error': 'Questions list is required'}, status=400) created = [] errors = [] for idx, q_data in enumerate(questions_data): question_text = q_data.get('question') order = q_data.get('order', idx + 1) is_active = q_data.get('is_active', True) if not question_text: errors.append({'index': idx, 'error': 'Question text required'}) continue # Skip duplicates if MasterQuestion.objects.filter(question=question_text).exists(): errors.append({'index': idx, 'question': question_text, 'error': 'Already exists'}) continue try: question = MasterQuestion.objects.create( id=uuid.uuid4(), question=question_text, order=order, is_active=is_active ) created.append({ 'id': str(question.id), 'question': question.question, 'order': question.order }) except Exception as e: errors.append({'index': idx, 'question': question_text, 'error': str(e)}) return Response({ 'message': f'Created {len(created)} questions', 'created': created, 'errors': errors if errors else None }, status=201 if created else 400) # ==================== GET ACTIVE QUESTIONS FOR AGENCY ==================== @api_view(['GET']) @authentication_classes([JWTAuthentication]) @permission_classes([IsAuthenticated]) def get_master_questions(request): """ Get all active master questions for agency to answer """ questions = MasterQuestion.objects.filter(is_active=True).order_by('order') data = [{ 'id': str(q.id), 'question': q.question, 'order': q.order } for q in questions] return Response({ 'total': len(data), 'questions': data }) # ==================== GLOBAL Q&A CRUD ==================== @api_view(['GET', 'POST', 'PUT', 'DELETE']) @permission_classes([IsAdminUser]) def admin_global_qa(request, qa_id=None): """ Admin CRUD for Global Q&A (applies to ALL properties) """ # GET all or single if request.method == 'GET': if qa_id: try: qa = GlobalQA.objects.get(id=qa_id) data = { 'id': str(qa.id), 'question': qa.question, 'answer': qa.answer, 'language': qa.language, 'priority': qa.priority, 'is_active': qa.is_active } return Response(data) except GlobalQA.DoesNotExist: return Response({'error': 'Not found'}, status=404) else: qa_list = GlobalQA.objects.all().order_by('-priority', 'question') data = [{ 'id': str(q.id), 'question': q.question, 'answer': q.answer[:100], 'language': q.language, 'priority': q.priority, 'is_active': q.is_active } for q in qa_list] return Response({'total': len(data), 'global_qa': data}) # CREATE single elif request.method == 'POST': question = request.data.get('question') answer = request.data.get('answer') language = request.data.get('language', 'both') priority = request.data.get('priority', 0) is_active = request.data.get('is_active', True) if not question or not answer: return Response({'error': 'Question and answer required'}, status=400) if GlobalQA.objects.filter(question=question).exists(): return Response({'error': 'Question already exists'}, status=400) # Generate embedding embedding = embedding_service.generate_embedding(question) qa = GlobalQA.objects.create( id=uuid.uuid4(), question=question, answer=answer, question_embedding=embedding, language=language, priority=priority, is_active=is_active ) return Response({ 'id': str(qa.id), 'question': qa.question, 'answer': qa.answer, 'message': 'Global Q&A created successfully' }, status=201) # UPDATE elif request.method == 'PUT': if not qa_id: return Response({'error': 'ID required'}, status=400) try: qa = GlobalQA.objects.get(id=qa_id) except GlobalQA.DoesNotExist: return Response({'error': 'Not found'}, status=404) question = request.data.get('question', qa.question) answer = request.data.get('answer', qa.answer) language = request.data.get('language', qa.language) priority = request.data.get('priority', qa.priority) is_active = request.data.get('is_active', qa.is_active) # Update embedding if question changed if question != qa.question: embedding = embedding_service.generate_embedding(question) qa.question_embedding = embedding qa.question = question qa.answer = answer qa.language = language qa.priority = priority qa.is_active = is_active qa.save() return Response({'message': 'Global Q&A updated successfully'}) # DELETE elif request.method == 'DELETE': if not qa_id: return Response({'error': 'ID required'}, status=400) try: qa = GlobalQA.objects.get(id=qa_id) qa.delete() return Response({'message': 'Global Q&A deleted successfully'}) except GlobalQA.DoesNotExist: return Response({'error': 'Not found'}, status=404) # ==================== BULK CREATE GLOBAL Q&A ==================== @api_view(['POST']) @permission_classes([IsAdminUser]) def admin_global_qa_bulk(request): """ Admin can create multiple Global Q&A at once """ items = request.data.get('items', []) if not items: return Response({'error': 'Items list required'}, status=400) created = [] errors = [] for idx, item in enumerate(items): question = item.get('question') answer = item.get('answer') language = item.get('language', 'both') priority = item.get('priority', 0) if not question or not answer: errors.append({'index': idx, 'error': 'Question and answer required'}) continue if GlobalQA.objects.filter(question=question).exists(): errors.append({'index': idx, 'question': question, 'error': 'Already exists'}) continue try: embedding = embedding_service.generate_embedding(question) qa = GlobalQA.objects.create( id=uuid.uuid4(), question=question, answer=answer, question_embedding=embedding, language=language, priority=priority, is_active=True ) created.append({ 'id': str(qa.id), 'question': qa.question, 'answer': qa.answer[:50] }) except Exception as e: errors.append({'index': idx, 'question': question, 'error': str(e)}) return Response({ 'message': f'Created {len(created)} Global Q&A', 'created': created, 'errors': errors if errors else None }, status=201 if created else 400)