One SDK call hands you an AI brain. Django is everything around it — views, templates, routing, and a service layer that keeps your code clean.
google-genai SDK wraps all the HTTP for you. Import the client, call generate_content, read response.text.No boilerplate JSON bodies, no nested candidates digging — the SDK is one import away from clean, readable AI calls.
SDK setup, service layer, first call
text view, template, form
Pillow, multimodal, image form
Pydantic, structured output, API
Each module ends in a runnable simulation + a 15-question test (8 coding + 7 concept) on a 15-minute timer. Then: Bonus (memory, provider swap) → Capstone.
1 · Go to aistudio.google.com → "Get API key" (free, no card needed)
2 · pip install django python-dotenv google-genai pillow pydantic
3 · Create a .env file in your project root:
GEMINI_API_KEY=AIza...your-free-key
4 · Add .env to .gitignore — never commit keys
5 · Copy each simulation into ai_test.py and run: python ai_test.py
Every simulation in this deck is real, runnable code. Because the deck can't call the internet, green boxes show one example reply — your wording will differ (AI is non-deterministic).
genai.Client() reads GEMINI_API_KEY from the environment automatically — you never pass the key in code.Set up the project, load your key safely, and build the GeminiService class that the rest of the app will use.
core/ ← Django project (settings, urls) gemini_app/ ← your app services.py ← ALL AI logic lives here (GeminiService class) views.py ← thin: call service, render template — no AI code urls.py ← routes templates/
services.py — views never change.This is the same "keep AI code out of views" pattern used by every production Django+AI project.
python -m venv venv venv\Scripts\activate # Windows (source venv/bin/activate on Mac/Linux) pip install django python-dotenv google-genai pillow pydantic
One virtual environment per project — keeps your global Python tidy and lets you lock versions in requirements.txt.
google-genai is the official Google SDK. pillow handles images for multimodal calls. pydantic enforces the JSON schema.
django-admin startproject core .
python manage.py startapp gemini_app
# core/settings.py
INSTALLED_APPS = [
# ... default apps
'gemini_app',
]
The dot in startproject core . puts manage.py in the current directory instead of a subfolder — the standard layout for this course.
# .env (NEVER committed — add to .gitignore)
GEMINI_API_KEY=AIza...your-free-key
# core/settings.py (top of file)
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
# ...
INSTALLED_APPS = [
# ... default apps
'gemini_app',
]
load_dotenv(BASE_DIR / '.env') reads it from disk at startup — never hardcoded.genai.Client() then picks up GEMINI_API_KEY automatically from the environment — you never pass it explicitly.
GeminiService class
import os
from google import genai
from google.genai import types
from PIL import Image
from pydantic import BaseModel
class GeminiService:
def __init__(self):
# Automatically extracts GEMINI_API_KEY from the environment
self.client = genai.Client()
self.default_model = "gemini-2.0-flash"
genai.Client() reads GEMINI_API_KEY automatically — no explicit key passing needed. default_model is one place to change the model for all methods.
generate_text
def generate_text(self, prompt: str) -> str:
"""Beginner: Simple text-to-text generation."""
try:
response = self.client.models.generate_content(
model=self.default_model,
contents=prompt,
)
return response.text
except Exception as e:
return f"Error generating text: {str(e)}"
response.text → "Hello! It's great to connect with you today."
response.text is a clean string — no nested dicts. The try/except returns a friendly message instead of crashing the page.
import os
from dotenv import load_dotenv
from google import genai
load_dotenv() # reads GEMINI_API_KEY from .env
client = genai.Client() # key read automatically
response = client.models.generate_content(
model="gemini-2.0-flash",
contents="Say hello in one short sentence.",
)
print(response.text)
Hello! It's great to connect with you today.
If you see a sentence, your key works and you've called an AI with the SDK. 🎉
Wire GeminiService.generate_text into a Django view and a simple HTML form.
text_generator_view
from django.shortcuts import render
from .services import GeminiService
gemini_service = GeminiService() # instantiate once at module level
def text_generator_view(request):
context = {}
if request.method == "POST":
prompt = request.POST.get("prompt")
if prompt:
ai_response = gemini_service.generate_text(prompt)
context = {"prompt": prompt, "response": ai_response}
return render(request, "gemini_app/text_gen.html", context)
Instantiate GeminiService once at module level — not inside the view function — so the client is not recreated on every request.
services.py.text_gen.html
<form method="POST">
{% csrf_token %}
<textarea name="prompt" placeholder="Ask anything..."></textarea>
<button type="submit">Generate</button>
</form>
{% if response %}
<div class="response-box">
<h3>AI Response:</h3>
<p>{{ response }}</p>
</div>
{% endif %}
{% csrf_token %} is required for every Django POST form — it prevents cross-site request forgery attacks.{{ response }} renders the string returned by generate_text. Django auto-escapes it so user input can't inject HTML.
from dotenv import load_dotenv
from google import genai
load_dotenv()
class GeminiService:
def __init__(self):
self.client = genai.Client()
self.default_model = "gemini-2.0-flash"
def generate_text(self, prompt: str) -> str:
try:
response = self.client.models.generate_content(
model=self.default_model,
contents=prompt,
)
return response.text
except Exception as e:
return f"Error generating text: {str(e)}"
svc = GeminiService()
print(svc.generate_text("Explain what a Django view does in 2 sentences."))
A Django view is a Python function that receives a web request and returns a response. It contains the logic for what data to retrieve and which template to render.
Use Pillow to open an image and pass it directly to the SDK alongside a text prompt.
analyze_image — Pillow + SDK
def analyze_image(self, image_file, prompt: str) -> str:
"""Intermediate: Multimodal analysis using Pillow."""
try:
img = Image.open(image_file)
response = self.client.models.generate_content(
model=self.default_model,
contents=[img, prompt],
)
return response.text
except Exception as e:
return f"Error analyzing image: {str(e)}"
contents=[img, prompt] — passing a list mixes modalities. The SDK handles encoding the Pillow image; you never convert to base64 manually.
generate_content call, just a richer contents list.image_analyzer_view
def image_analyzer_view(request):
context = {}
if request.method == "POST" and request.FILES.get("image"):
image_file = request.FILES["image"]
prompt = request.POST.get("prompt", "Describe what you see in this image.")
ai_response = gemini_service.analyze_image(image_file, prompt)
context = {"response": ai_response, "prompt": prompt}
return render(request, "gemini_app/image_analyzer.html", context)
request.FILES["image"] is a Django InMemoryUploadedFile — Pillow can open it directly with Image.open(), no temp file needed.
image_analyzer.html — ⚠️ enctype
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<input type="file" name="image" accept="image/*">
<input type="text" name="prompt" placeholder="What do you want to know?">
<button type="submit">Analyze Image</button>
</form>
{% if response %}
<div class="response-box">
<p>{{ response }}</p>
</div>
{% endif %}
enctype="multipart/form-data" is mandatory for file uploads — without it, request.FILES will be empty and the view silently skips the upload.
def analyze_image(self, image_file, prompt: str) -> str:
img = Image.open(image_file)
img.thumbnail((1024, 1024)) # shrinks in-place, keeps aspect ratio
response = self.client.models.generate_content(
model=self.default_model,
contents=[img, prompt],
)
return response.text
Large images cost more tokens (and time). Calling img.thumbnail((1024, 1024)) before the SDK call reduces cost without losing enough detail for most analysis tasks.
from dotenv import load_dotenv
from google import genai
from PIL import Image
load_dotenv()
client = genai.Client()
img = Image.open("test_image.jpg") # any image file you have
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=[img, "Describe what you see in this image."],
)
print(response.text)
The image shows a sunny park with green trees, a winding path, and a few people walking in the distance.
Drop any .jpg or .png into the same folder, rename it test_image.jpg, and run. The model describes whatever is in the photo.
Use Pydantic to define a schema and guarantee the model returns JSON that exactly matches your data contract.
# Without a schema the model might return:
# '{"recipeName": ...}' ← wrong key name
# '```json\n{...}\n```' ← markdown fence you must strip
# '{ingredients: [...] }' ← invalid JSON (unquoted keys)
#
# With response_schema=RecipeSchema the SDK guarantees:
# - valid JSON
# - exact field names you declared
# - correct types (str, list[str], int)
GenerateContentConfig and the model is forced to conform — no parsing tricks needed.This is the difference between "ask nicely and hope" and a real data contract.
RecipeSchema
import json
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from pydantic import BaseModel, Field
class RecipeSchema(BaseModel):
recipe_name: str = Field(description="The name of the dish")
ingredients: list[str] = Field(description="List of ingredients with measurements")
prep_time_minutes: int = Field(description="Total prep and cooking time in minutes")
Three typed fields: a string, a list of strings, and an integer. The model must return all three with these exact names and types.
Field(description=...) gives the model a hint about what each field should contain — better descriptions lead to better AI output.generate_structured_json
def generate_structured_json(self, prompt: str, schema: type[BaseModel]) -> str:
"""Advanced: Guarantees JSON conforming to a Pydantic model."""
try:
response = self.client.models.generate_content(
model=self.default_model,
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=schema,
),
)
return response.text
except Exception as e:
return f"Error generating structured JSON: {str(e)}"
response_mime_type="application/json" + response_schema=schema in GenerateContentConfig — these two settings lock the output format.
api_generate_recipe
@csrf_exempt # demo only; secure via Token/Session auth in production
def api_generate_recipe(request):
if request.method != "POST":
return JsonResponse({"error": "Only POST requests are allowed"}, status=405)
try:
data = json.loads(request.body)
user_ingredient = data.get("ingredient", "eggs")
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON payload"}, status=400)
prompt = f"Give me a quick, creative recipe centered around: {user_ingredient}."
raw_json_string = gemini_service.generate_structured_json(prompt, RecipeSchema)
try:
clean_json_data = json.loads(raw_json_string)
return JsonResponse(clean_json_data, status=200)
except json.JSONDecodeError:
return JsonResponse({"error": "AI returned malformed JSON structure"}, status=500)
Parse JSON body → call the service → parse the string back to a dict → return as JsonResponse. Callers get clean, typed JSON.
import json
from dotenv import load_dotenv
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
load_dotenv()
client = genai.Client()
class RecipeSchema(BaseModel):
recipe_name: str = Field(description="The name of the dish")
ingredients: list[str] = Field(description="List of ingredients with measurements")
prep_time_minutes: int = Field(description="Total prep and cooking time in minutes")
response = client.models.generate_content(
model="gemini-2.0-flash",
contents="Give me a quick recipe centered around: eggs.",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=RecipeSchema,
),
)
recipe = json.loads(response.text)
print(recipe["recipe_name"])
print(recipe["ingredients"])
print(recipe["prep_time_minutes"], "minutes")
Scrambled Eggs Delight ['2 eggs', '1 tbsp butter', 'salt', 'pepper', '2 tbsp milk'] 10 minutes
Conversation memory, provider swapping, and production best practices — the extras that take your app from demo to deployable.
chats
# Conversation memory: the SDK keeps history for you
chat = client.chats.create(model="gemini-2.0-flash")
print(chat.send_message("What is a variable?").text)
print(chat.send_message("Give an example.").text) # remembers the first turn
Turn 1: A variable is a named container that holds a value in memory. Turn 2: Sure! x = 5 stores the integer 5 — you can use x anywhere in your code.
client.chats.create() returns a stateful session. The SDK automatically resends the conversation history on every send_message call — you don't manage the history list yourself.
# Swap provider — OpenAI
from openai import OpenAI
oai = OpenAI()
r = oai.chat.completions.create(model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}])
print(r.choices[0].message.content)
# Swap provider — Anthropic
from anthropic import Anthropic
ac = Anthropic()
m = ac.messages.create(model="claude-haiku-4-5", max_tokens=1024,
messages=[{"role": "user", "content": prompt}])
print(m.content[0].text)
Because all AI logic lives in services.py, changing providers means editing one file. Views and templates are completely untouched.
async def views for production traffic.gemini-2.0-flash for simple tasks — heavier models only when quality demands it.try/except. On 429 (rate-limit), back off and retry with exponential delay. Always return a friendly fallback string — never let an AI error reach the browser as a 500.
# gemini_app/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('text-gen/', views.text_generator_view, name='text_gen'),
path('image-analyze/', views.image_analyzer_view, name='image_analyze'),
path('api/recipe/', views.api_generate_recipe, name='api_recipe'),
]
# core/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('ai/', include('gemini_app.urls')),
]
Three endpoints under /ai/. The project urls include the app urls — the standard Django pattern for reusable apps.
python manage.py migrate
python manage.py runserver
# Endpoints now live at:
# http://127.0.0.1:8000/ai/text-gen/ ← text form
# http://127.0.0.1:8000/ai/image-analyze/ ← image upload form
# http://127.0.0.1:8000/ai/api/recipe/ ← JSON API (POST)
# Quick API test:
curl -X POST http://127.0.0.1:8000/ai/api/recipe/ \
-H "Content-Type: application/json" \
-d "{\"ingredient\": \"avocado\"}"
{"recipe_name": "Avocado Toast Delight",
"ingredients": ["1 avocado", "2 slices bread", "salt", "lemon"],
"prep_time_minutes": 5}form → view → GeminiService → SDK → response.text → template / JsonResponse
Three levels of intelligence in one Django app — text generation, multimodal vision, and structured JSON — all through the same GeminiService. Change the prompt or schema and you have a new product.
generate_text → view → templatecontents=[img, prompt]GenerateContentConfigsummarize(text) to GeminiService and wire it to a new view and URL.client.chats.create() to add conversation memory to the text generator view.RecipeSchema with a difficulty: str field and verify the JSON output changes.GeminiService to use the OpenAI SDK — the views should need zero changes.You can set up the google-genai SDK, build a service layer, generate text, analyse images with Pillow, and return Pydantic-validated JSON — all wired into a real Django app. That's the whole stack.
Take the final test → ask your instructor for the link.