Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add iframe chat feature with API key validation #8

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion django_polly/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django.contrib import admin
from .models import Parrot, Trick, Message, SmartConversation
from .models import Parrot, Trick, Message, SmartConversation, APIKey
from django.utils.html import format_html
from django.urls import reverse
from django import forms
from django.contrib.auth import get_user_model

User = get_user_model()

# Define a custom form for Parrot model
class ParrotAdminForm(forms.ModelForm):
Expand All @@ -26,6 +28,13 @@ class MessageInline(admin.TabularInline):
ordering = ("created_at",)


class APIKeyInline(admin.TabularInline):
model = APIKey
extra = 0
readonly_fields = ("key", "created_at")
can_delete = False


@admin.register(Parrot)
class ParrotAdmin(admin.ModelAdmin):
list_display = ('name', 'color', 'age', 'external_id', 'created_at', 'updated_at')
Expand Down Expand Up @@ -79,3 +88,16 @@ class MessageAdmin(admin.ModelAdmin):
search_fields = ("content",)
date_hierarchy = "created_at"
raw_id_fields = ("conversation",)


@admin.register(APIKey)
class APIKeyAdmin(admin.ModelAdmin):
list_display = ("key", "user", "created_at")
list_filter = ("user", "created_at")
search_fields = ("key", "user__username")
readonly_fields = ("key", "created_at")


@admin.register(User)
class CustomUserAdmin(admin.ModelAdmin):
inlines = [APIKeyInline]
9 changes: 9 additions & 0 deletions django_polly/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ class ConversationParty(models.TextChoices):
ASSISTANT = 'ASSISTANT', 'Assistant'


class APIKey(CommonFieldsModel):
key = models.CharField(max_length=255, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return self.key


class SmartConversation(CommonFieldsModel):
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="conversations"
Expand Down
49 changes: 49 additions & 0 deletions django_polly/static/css/custom.css
Original file line number Diff line number Diff line change
@@ -1 +1,50 @@
/* custom css goes here */

.fab {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: background-color 0.3s ease;
}

.fab:hover {
background-color: #4338ca;
}

.chat-container {
display: none;
position: fixed;
bottom: 80px;
right: 20px;
width: 300px;
height: 400px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
overflow: hidden;
z-index: 1000;
}

.chat-header {
background-color: #4f46e5;
color: white;
padding: 10px;
text-align: center;
}

.chat-body {
padding: 10px;
height: calc(100% - 40px);
overflow-y: auto;
}
176 changes: 176 additions & 0 deletions django_polly/templates/conversation/iframe_chat.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Smart Conversation</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
<style>
/* Custom scrollbar styles */
#message-list::-webkit-scrollbar {
width: 6px;
}

#message-list::-webkit-scrollbar-track {
background: #f1f1f1;
}

#message-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}

#message-list::-webkit-scrollbar-thumb:hover {
background: #555;
}

/* Floating action button styles */
.fab {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: background-color 0.3s ease;
}

.fab:hover {
background-color: #4338ca;
}

.chat-container {
display: none;
position: fixed;
bottom: 80px;
right: 20px;
width: 300px;
height: 400px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
overflow: hidden;
z-index: 1000;
}

.chat-header {
background-color: #4f46e5;
color: white;
padding: 10px;
text-align: center;
}

.chat-body {
padding: 10px;
height: calc(100% - 40px);
overflow-y: auto;
}
</style>
</head>
<body class="bg-gray-100">
<div class="fab" onclick="toggleChat()">💬</div>
<div class="chat-container" id="chat-container">
<div class="chat-header">Chat</div>
<div class="chat-body">
<div class="flex h-full flex-col bg-white overflow-hidden shadow-lg rounded-lg"
hx-ext="ws"
ws-connect="/polly/ws/smart-gpt-admin/?user_id={{ request.user.id }}&conversation_id={{ conversation_id }}">
<div id="message-list" class="p-6 flex-1 overflow-y-auto space-y-4">
<!-- Messages will be inserted here by the consumer -->
</div>

<form id="chat-form" class="border-t-2 border-gray-200 bg-gray-50 p-4" ws-send>
<div class="relative flex items-center">
<input type="text"
id="message-input"
placeholder="Type your message here..."
name="message"
class="pl-4 pr-10 py-3 rounded-full border-2 border-indigo-300 w-full focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 shadow-sm">
<button id="send-button" type="submit"
class="ml-2 bg-indigo-600 text-white rounded-full px-6 py-3 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition duration-150 ease-in-out shadow-md disabled:opacity-50 disabled:cursor-not-allowed">
Send
</button>
</div>
</form>
</div>
</div>
</div>

<script>
function toggleChat() {
const chatContainer = document.getElementById('chat-container');
chatContainer.style.display = chatContainer.style.display === 'none' ? 'block' : 'none';
}

const messageInput = document.getElementById('message-input');
const sendButton = document.getElementById('send-button');
const chatForm = document.getElementById('chat-form');
const messageList = document.getElementById('message-list');

function scrollToBottom() {
messageList.scrollTop = messageList.scrollHeight;
}

// Scroll to bottom initially
scrollToBottom();

document.body.addEventListener('htmx:wsAfterSend', function (event) {
console.log('Message sent, clearing input and disabling button');
messageInput.value = '';
sendButton.disabled = true;
scrollToBottom();
});

document.body.addEventListener('htmx:wsAfterMessage', function (event) {
console.log('Received message:', event.detail.message);
try {
const message = JSON.parse(event.detail.message);
if (message.type === "assistant_message_complete") {
console.log('Assistant message complete, enabling button');
sendButton.disabled = false;
}
} catch (error) {
console.error('Error parsing message:', error);
}
// Scroll to bottom after receiving any message
scrollToBottom();
});

chatForm.addEventListener('submit', function (event) {
if (messageInput.value.trim() === '') {
event.preventDefault();
}
});

// Additional event listener for any WebSocket message
document.body.addEventListener('htmx:wsMessage', function (event) {
console.log('WebSocket message received:', event.detail.message);
// Scroll to bottom after any WebSocket message
scrollToBottom();
});

// MutationObserver to watch for changes in the message list
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'childList') {
scrollToBottom();
}
});
});

// Configure the observer to watch for child changes in the message list
const config = {childList: true, subtree: true};
observer.observe(messageList, config);
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion django_polly/templates/conversation/single_chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,4 @@ <h2 class="text-xl font-bold">{{ conversation_title }}</h2>
observer.observe(messageList, config);
</script>
</body>
</html>
</html>
12 changes: 12 additions & 0 deletions django_polly/templates/example_iframe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example Iframe Chat</title>
</head>
<body>
<h1>Example Iframe Chat</h1>
<iframe src="/iframe-chat/?conversation_id=1&api_key=your_api_key_here" width="600" height="400"></iframe>
</body>
</html>
28 changes: 28 additions & 0 deletions django_polly/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,31 @@ def test_chat_view_with_unauthorized_user(self, client, conversation):
client.force_login(unauthorized_user)
response = client.get(reverse('django_polly:smart_gpt_chat', args=[conversation.id]))
assert response.status_code == 403


@pytest.mark.django_db
class TestIframeChatView:
@pytest.fixture
def user(self):
return User.objects.create_user(username='testuser', password='12345', is_staff=True, is_superuser=True)

@pytest.fixture
def conversation(self, user):
return SmartConversation.objects.create(user=user, title="Test Chat")

def test_iframe_chat_view_with_valid_api_key(self, client, user, conversation):
response = client.get(reverse('django_polly:iframe_chat'), {'api_key': 'your_api_key_here', 'conversation_id': conversation.id})
assert response.status_code == 200
assert 'conversation/single_chat.html' in [t.name for t in response.templates]

def test_iframe_chat_view_with_invalid_api_key(self, client, user, conversation):
response = client.get(reverse('django_polly:iframe_chat'), {'api_key': 'invalid_key', 'conversation_id': conversation.id})
assert response.status_code == 403

def test_iframe_chat_view_with_missing_conversation_id(self, client, user):
response = client.get(reverse('django_polly:iframe_chat'), {'api_key': 'your_api_key_here'})
assert response.status_code == 404

def test_iframe_chat_view_with_nonexistent_conversation(self, client, user):
response = client.get(reverse('django_polly:iframe_chat'), {'api_key': 'your_api_key_here', 'conversation_id': 999})
assert response.status_code == 404
2 changes: 1 addition & 1 deletion django_polly/urls.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

from django.urls import path
from django_polly import views, consumers, consumers_admin
from django_polly.views import DashboardView
Expand All @@ -8,4 +7,5 @@
urlpatterns = [
path('dashboard/', DashboardView.as_view(), name='dashboard'),
path('smart-gpt-chat/<int:conversation_id>/', views.smart_gpt_chat, name='smart_gpt_chat'),
path('iframe-chat/', views.iframe_chat, name='iframe_chat'),
]
24 changes: 23 additions & 1 deletion django_polly/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from django.views.generic import TemplateView
from django.shortcuts import render
from django.template.response import TemplateResponse
from django.http import HttpResponseForbidden, HttpResponseNotFound

from .models import Parrot, Trick, SmartConversation
from .models import Parrot, Trick, SmartConversation, APIKey


def smart_gpt_chat(request, conversation_id):
Expand All @@ -19,6 +20,27 @@ def smart_gpt_chat(request, conversation_id):
'conversation_title': conversation.title})


def iframe_chat(request):
api_key = request.GET.get('api_key')
conversation_id = request.GET.get('conversation_id')

if not api_key or not conversation_id:
return HttpResponseForbidden("API key and conversation ID are required")

try:
api_key_obj = APIKey.objects.get(key=api_key)
except APIKey.DoesNotExist:
return HttpResponseForbidden("Invalid API key")

try:
conversation = SmartConversation.objects.get(id=conversation_id, user=api_key_obj.user)
except SmartConversation.DoesNotExist:
return HttpResponseNotFound("Conversation not found")

return render(request, 'conversation/single_chat.html', {'conversation_id': conversation_id,
'conversation_title': conversation.title})


class DashboardView(TemplateView):
template_name = "django_polly/dashboard.html"

Expand Down
Loading
Loading