← All courses

Tutorial · Intermediate

Persist chatbot history

By Ian Freitz de la Cernaianfreitz.com

Replace Django's session storage with a Message model so your Gemini chatbot's conversations survive cookie clears, server restarts, and redeploys.

IntermediateDjangoSQLitePythonGemini~10 minFree tier

What you’ll become

A developer who can persist chatbot conversations to a real database instead of a session cookie.

  • Model a chat turn as a Django ORM row with role, content, and timestamp
  • Generate and apply a schema migration for SQLite
  • Swap session-based history for database reads and writes
  • Render prior conversation history on page load

Recommended first

Take Add a web UI to your chatbot first — this course builds on what you made there.

Step 01

Before you start

This course picks up exactly where Add a web UI to your chatbot ended. You’ll reuse the same chatsite Django project, the same chat app, and the same .venv. Right now conversation history lives in request.session— which means it vanishes the moment the session cookie clears. We’ll move history into a Django model and let SQLite keep it for us.

  • A working gemini-chatbot/ folder from the previous course, with chatsite/ and chat/ inside it.
  • The .venv virtual environment still present, and GEMINI_API_KEY in .env.
  • Comfort running python manage.py commands.

What you'll build

A new Message row in db.sqlite3for every chat turn, keyed by the browser’s Django session key. Reload the page and every turn you’ve ever sent replays straight from the database — no session cookie needed to remember the content.

Step 02

Model a chat turn

A chat turn has four things worth remembering: who said it (user or model), what they said, when it happened, and which browser it belongs to. That maps cleanly to four fields on a single model.

chat/models.py

# chat/models.py
from django.db import models


class Message(models.Model):
    USER = "user"
    MODEL = "model"
    ROLE_CHOICES = [
        (USER, "User"),
        (MODEL, "Model"),
    ]

    session_key = models.CharField(max_length=40, db_index=True)
    role = models.CharField(max_length=8, choices=ROLE_CHOICES)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]

    def __str__(self):
        return f"{self.role}: {self.content[:40]}"

Two details worth pausing on. session_key is indexed because every page load filters on it — it becomes the equivalent of a user id for anonymous visitors. And ordering = ["created_at"] paired with auto_now_add means every queryset replays turns in chronological order without any manual order_by() call.

Step 03

Generate and run the migration

A new model means a new table. Django writes the SQL for you — makemigrations generates a file describing the change, and migrate applies it to db.sqlite3.

terminal

(.venv) $ python manage.py makemigrations chat
Migrations for 'chat':
  chat/migrations/0001_initial.py
    + Create model Message

(.venv) $ python manage.py migrate
Running migrations:
  Applying chat.0001_initial... OK

Note

This is the same migrate you ran at the end of the previous course — Django just has a new migration to apply. The generated chat/migrations/0001_initial.py should be committed to git alongside the model.

Step 04

Rewrite the views

The new views.py replaces every request.sessionread and write with an ORM call. The shape of the response JSON doesn’t change, so the template JavaScript you wrote in the previous course keeps working untouched.

chat/views.py

# chat/views.py
import json
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from . import gemini_client
from .models import Message


def _session_key(request):
    """Force-create a session key on first visit so anon users are stable."""
    if not request.session.session_key:
        request.session.save()
    return request.session.session_key


def index(request):
    key = _session_key(request)
    history = Message.objects.filter(session_key=key)
    return render(request, "chat/index.html", {"history": history})


@require_http_methods(["POST"])
def send_message(request):
    data = json.loads(request.body)
    message = data.get("message", "").strip()

    if not message:
        return JsonResponse({"error": "empty message"}, status=400)

    key = _session_key(request)
    prior = Message.objects.filter(session_key=key)
    history = [{"role": m.role, "parts": [m.content]} for m in prior]

    reply = gemini_client.get_reply(message, history)

    Message.objects.create(session_key=key, role="user", content=message)
    Message.objects.create(session_key=key, role="model", content=reply)

    return JsonResponse({"reply": reply})

Three things changed. _session_key() calls request.session.save() on first visit so anonymous users get a stable id immediately. The history passed to Gemini is built by iterating the queryset into the {"role", "parts": [...]} shape the client expects. And the two Message.objects.create() calls at the bottom replace the old session-append pair.

Step 05

Show prior history on load

index now passes a history queryset into the template. Render it inside #log so a refresh immediately shows every prior turn — no JavaScript hydration needed.

chat/templates/chat/index.html

{# chat/templates/chat/index.html — replace the empty <div id="log"></div> #}
<div id="log">
  {% for msg in history %}
    <p class="msg {% if msg.role == 'user' %}user{% else %}bot{% endif %}">
      {% if msg.role == 'user' %}You: {% else %}Gemini: {% endif %}{{ msg.content }}
    </p>
  {% endfor %}
</div>

Bonus

Register the model in chat/admin.py and create a superuser with python manage.py createsuperuser to browse conversations at /admin/.

chat/admin.py

# chat/admin.py
from django.contrib import admin
from .models import Message


@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
    list_display = ("created_at", "session_key", "role", "content")
    list_filter = ("role",)
    search_fields = ("content", "session_key")

Step 06

Run it and verify

Start the server, chat for a few turns, then stop it with Ctrl-C and start it again. Refresh http://127.0.0.1:8000/ — the whole conversation replays from SQLite.

terminal

(.venv) $ python manage.py runserver
# ...chat for a few turns in the browser, then stop the server with Ctrl-C

(.venv) $ python manage.py runserver
# Refresh http://127.0.0.1:8000/ — your earlier conversation is still there.

To see the session_keyscoping in action, open your browser’s devtools, delete the sessionid cookie, and reload. The log is empty because the new session has no rows — the old conversation still exists in the database, just under a different key.

Step 07

Where to take it next

  • Add accounts with django.contrib.auth and swap session_key for a user = ForeignKey(User) column so history follows a person across browsers and devices.
  • Stream the replywith Server-Sent Events so tokens appear as they’re generated.
  • Export or clear a conversation with a small admin-only view that filters Message by session_key.
  • Deploy to Vercel — a full follow-up course walks through shipping this Django project live. SQLite is fine for a demo, but Postgres is the natural next step for anything that sees real traffic.

Discussion

0
Sign in to join the conversation.

No comments yet. Start the conversation.