Python Django PostgreSQL cat adoption

A cat adoption directory lets visitors search by name or temperament, browse shelter rooms, and compare each cat's status and personality. Tokay runs the Django project as one Web Service with PostgreSQL.

Last updated

This is a regular Django project with templates, static styles, models, migrations, and optional admin access. Tokay handles those familiar pieces together, so you do not need a separate asset service or database process.

Runtime
Python
Framework
Django
Service types
Web Service
Resources
PostgreSQL
Required secrets
SECRET_KEY

What it does

The directory introduces every cat with an age, temperament, room, adoption status, and a short note from the shelter.

Search for a name or a quality such as calm. Use Sunroom, Library, Garden room, or Quiet room to browse one part of the shelter.

The optional Django admin lets your team change a cat's details and status. Visitors see those updates as soon as they return to the directory.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Generate the required secret

Generate a value on your own computer.

python -c "import secrets; print(secrets.token_urlsafe(50))"

Keep the value ready for the setup step after Tokay creates the Service. Do not put it in this folder.

Finish setup in Tokay

Choose Environment on the directory Service.

  1. Under From your code, find SECRET_KEY. Choose Set shared value, paste the value you generated, and save it. Future deployments will use the same encrypted Project Secret.
  2. Find DATABASE_URL under From your infrastructure. Confirm the database, leave app as its name, and save the choice.
  3. Check that both rows say Good to go, then complete the deployment tracker. Use Go Live if prompted. The finished Service's live URL opens the directory.

Try it

  1. Search for calm, then open the Library room.
  2. Deploy the same code again after the directory is live.
  3. Repeat the search. The cats and their adoption status remain in PostgreSQL.

Turn on the admin

Open the Service's Environment page. Find ADMIN_PASSWORD under From your code, choose Set shared value, then deploy again. Sign in at /admin/ with username shelter-admin and the password you set. Change a cat's status, then return to the directory to see the update.

You choose the admin password. The app never generates or prints one in the logs.

How it works

Want to know where the directory comes from? A Django migration defines the database and a small seed step adds or updates the fictional cats. Tokay tests python manage.py migrate --noinput against a copy before the new version receives traffic.

Your CSS stays in Django's normal static file layout. Tokay collects and serves it with the directory, so the live page and its assets ship together.

Secrets

Name Required Purpose
SECRET_KEY Yes Signs Django sessions and security tokens. Store it as an encrypted Project Secret.
ADMIN_PASSWORD No Creates or updates the opt in shelter-admin account.

Grow from here

Try the Streamlit support dashboard when the app is only for your own team.

catadoption/__init__.py Raw

This file is empty.

catadoption/settings.py Raw

import os
from pathlib import Path
from urllib.parse import unquote, urlparse

BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = ["*"]

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "cats.apps.CatsConfig",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "catadoption.urls"
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [],
    "APP_DIRS": True,
    "OPTIONS": {
        "context_processors": [
            "django.template.context_processors.request",
            "django.contrib.auth.context_processors.auth",
            "django.contrib.messages.context_processors.messages",
        ],
    },
}]
WSGI_APPLICATION = "catadoption.wsgi.application"

database_url = urlparse(os.environ["DATABASE_URL"])
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": database_url.path.lstrip("/"),
        "USER": unquote(database_url.username or ""),
        "PASSWORD": unquote(database_url.password or ""),
        "HOST": database_url.hostname or "",
        "PORT": database_url.port or 5432,
    }
}

AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/New_York"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

catadoption/urls.py Raw

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("cats.urls")),
]

catadoption/wsgi.py Raw

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "catadoption.settings")
application = get_wsgi_application()

cats/migrations/0001_initial.py Raw

from django.db import migrations, models


class Migration(migrations.Migration):
    initial = True
    dependencies = []
    operations = [
        migrations.CreateModel(
            name="Cat",
            fields=[
                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
                ("name", models.CharField(max_length=80, unique=True)),
                ("age", models.PositiveSmallIntegerField()),
                ("temperament", models.CharField(max_length=160)),
                ("room", models.CharField(choices=[("sunroom", "Sunroom"), ("library", "Library"), ("garden", "Garden room"), ("quiet", "Quiet room")], max_length=20)),
                ("status", models.CharField(choices=[("available", "Ready to meet"), ("pending", "Meeting scheduled"), ("adopted", "Heading home")], default="available", max_length=20)),
                ("note", models.TextField()),
            ],
            options={"ordering": ["name"]},
        ),
    ]

cats/migrations/__init__.py Raw

This file is empty.

cats/static/cats/site.css Raw

:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #f3eee5; color: #292a25; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; }
header { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 24px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #c8bfae; }
.brand { font: 700 1.35rem Georgia, serif; text-decoration: none; }
nav { display: flex; flex-wrap: wrap; gap: 17px; }
nav a { color: #625e53; font-size: 0.88rem; }
main { width: min(1180px, calc(100% - 36px)); margin: 55px auto 90px; }
.intro { display: grid; grid-template-columns: 1.25fr 0.75fr; gap: 20px 70px; align-items: end; }
.kicker { grid-column: 1 / -1; margin: 0; color: #a24e39; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h1 { margin: 0; font: 500 clamp(3.8rem, 9vw, 8rem)/0.86 Georgia, serif; letter-spacing: -0.055em; }
.intro > p:not(.kicker) { margin: 0; color: #6b675d; font: 1.05rem/1.7 Georgia, serif; }
form { grid-column: 1 / -1; margin-top: 26px; }
label { display: block; margin-bottom: 8px; font-size: 0.84rem; font-weight: 800; }
form div { display: flex; max-width: 650px; }
input { flex: 1; min-width: 0; padding: 13px 15px; border: 1px solid #aca18f; border-radius: 10px 0 0 10px; background: #fffdf8; font: inherit; }
button { border: 0; border-radius: 0 10px 10px 0; padding: 0 20px; background: #294f43; color: white; font: inherit; font-weight: 800; }
.cats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; margin-top: 46px; }
article { padding: 17px; border: 1px solid #d0c7b7; border-radius: 18px; background: #fbf8f1; }
.portrait { min-height: 210px; display: grid; place-items: center; border-radius: 12px; color: #fffaf0; font: 500 4rem Georgia, serif; letter-spacing: -0.08em; }
.room-sunroom { background: #c57845; } .room-library { background: #526d63; } .room-garden { background: #778a4f; } .room-quiet { background: #6e6581; }
.card-head { display: flex; align-items: baseline; justify-content: space-between; margin-top: 17px; }
h2 { margin: 0; font: 500 2rem Georgia, serif; }
.card-head span { color: #817a6d; }
article p { color: #686358; line-height: 1.55; }
.temperament { min-height: 48px; color: #3f413a; font-weight: 700; }
article footer { display: flex; justify-content: space-between; gap: 10px; padding-top: 13px; border-top: 1px solid #ded7ca; color: #777064; font-size: 0.78rem; }
article footer strong { color: #2d6248; } article footer .pending { color: #936327; } article footer .adopted { color: #755a83; }
.empty { grid-column: 1 / -1; padding: 40px; background: #fbf8f1; border-radius: 16px; }
@media (max-width: 820px) { .intro { grid-template-columns: 1fr; } .cats { grid-template-columns: repeat(2, 1fr); } nav { display: none; } }
@media (max-width: 540px) { .cats { grid-template-columns: 1fr; } .portrait { min-height: 175px; } }

cats/templates/cats/directory.html Raw

{% load static %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Good Room Cat Shelter</title>
  <link rel="stylesheet" href="{% static 'cats/site.css' %}">
</head>
<body>
  <header>
    <a class="brand" href="/">Good Room</a>
    <nav><a href="/rooms/sunroom/">Sunroom</a><a href="/rooms/library/">Library</a><a href="/rooms/garden/">Garden room</a><a href="/rooms/quiet/">Quiet room</a></nav>
  </header>
  <main>
    <section class="intro">
      <p class="kicker">{{ room_name }}</p>
      <h1>Meet someone good.</h1>
      <p>Every cat here has been observed with care. Read slowly, visit twice, and choose the relationship that fits both of you.</p>
      <form method="get" action="/">
        <label for="q">Search by name or temperament</label>
        <div><input id="q" name="q" value="{{ query }}" placeholder="Try calm, playful, or Ada"><button>Search</button></div>
      </form>
    </section>
    <section class="cats">
      {% for cat in cats %}
        <article>
          <div class="portrait room-{{ cat.room }}" aria-hidden="true">{{ cat.initials }}</div>
          <div class="card-head"><h2>{{ cat.name }}</h2><span>{{ cat.age }} yr</span></div>
          <p class="temperament">{{ cat.temperament }}</p>
          <p>{{ cat.note }}</p>
          <footer><span>{{ cat.get_room_display }}</span><strong class="{{ cat.status }}">{{ cat.get_status_display }}</strong></footer>
        </article>
      {% empty %}
        <p class="empty">No cats match that search yet. Try a room or a gentler word.</p>
      {% endfor %}
    </section>
  </main>
</body>
</html>

cats/__init__.py Raw

This file is empty.

cats/admin.py Raw

from django.contrib import admin

from .models import Cat


@admin.register(Cat)
class CatAdmin(admin.ModelAdmin):
    list_display = ["name", "age", "room", "status"]
    list_filter = ["room", "status"]
    search_fields = ["name", "temperament", "note"]

cats/apps.py Raw

import sys
import threading
from pathlib import Path

from django.apps import AppConfig


class CatsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "cats"

    def ready(self):
        executable = Path(sys.argv[0]).name
        if "gunicorn" not in executable and "runserver" not in sys.argv:
            return
        from .seed import seed_with_retry

        threading.Thread(target=seed_with_retry, name="cat-seed", daemon=True).start()

cats/models.py Raw

from django.db import models


class Cat(models.Model):
    ROOM_CHOICES = [
        ("sunroom", "Sunroom"),
        ("library", "Library"),
        ("garden", "Garden room"),
        ("quiet", "Quiet room"),
    ]
    STATUS_CHOICES = [
        ("available", "Ready to meet"),
        ("pending", "Meeting scheduled"),
        ("adopted", "Heading home"),
    ]

    name = models.CharField(max_length=80, unique=True)
    age = models.PositiveSmallIntegerField()
    temperament = models.CharField(max_length=160)
    room = models.CharField(max_length=20, choices=ROOM_CHOICES)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="available")
    note = models.TextField()

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name

    @property
    def initials(self):
        return "".join(part[0] for part in self.name.split()[:2]).upper()

cats/seed.py Raw

import os
import time

from django.contrib.auth import get_user_model
from django.db import OperationalError, ProgrammingError

from .models import Cat

CATS = [
    ("Ada", 3, "Curious, composed, and fond of high shelves", "library", "available", "Ada likes a slow introduction and settles beside quiet readers."),
    ("Biscuit", 6, "Warm, chatty, and very food motivated", "sunroom", "available", "Biscuit greets familiar people with a small chirp."),
    ("Clover", 2, "Playful, quick, and confident", "garden", "pending", "Clover would enjoy a home with room for an evening chase."),
    ("Dashi", 8, "Gentle, observant, and unhurried", "quiet", "available", "Dashi prefers soft voices and a dependable windowsill."),
    ("Etta", 4, "Social, tidy, and interested in everything", "sunroom", "adopted", "Etta is preparing to leave for her new home."),
    ("Fig", 1, "Bold, bouncy, and always carrying a toy", "garden", "available", "Fig still has kitten energy and learns quickly."),
    ("Gus", 10, "Patient, affectionate, and excellent company", "library", "available", "Gus is happiest close to someone working at a desk."),
    ("Hazel", 5, "Independent, calm, and softly affectionate", "quiet", "pending", "Hazel chooses her people carefully and then stays close."),
    ("Iris", 7, "Friendly, adaptable, and fond of brushing", "sunroom", "available", "Iris has lived with another calm cat."),
    ("Juniper", 2, "Athletic, alert, and eager to play", "garden", "available", "Juniper turns cardboard into an obstacle course."),
    ("Kumo", 9, "Quiet, steady, and happiest near a blanket", "quiet", "available", "Kumo needs daily medicine hidden in a favorite treat."),
    ("Linden", 4, "Tender, shy at first, and deeply loyal", "library", "available", "Linden comes forward when the room becomes still."),
    ("Mabel", 11, "Dignified, sweet, and clear about supper", "sunroom", "adopted", "Mabel found a sunny retirement home."),
    ("Nori", 3, "Bright, busy, and fascinated by water", "garden", "available", "Nori likes puzzle feeders and a sturdy scratching post."),
    ("Olive", 6, "Easygoing, affectionate, and quietly comic", "library", "available", "Olive is an experienced lap cat."),
    ("Pip", 1, "Small, brave, and ready for a patient teacher", "garden", "pending", "Pip is learning that hands bring good things."),
    ("Quincy", 5, "Polite, thoughtful, and devoted to routine", "quiet", "available", "Quincy enjoys one person conversations."),
    ("Romy", 4, "Confident, expressive, and always nearby", "sunroom", "available", "Romy wants to supervise the household."),
    ("Sable", 8, "Soft spoken, affectionate, and settled", "library", "available", "Sable would suit a calm home beautifully."),
    ("Tansy", 2, "Cheerful, nimble, and generous with head bumps", "garden", "available", "Tansy is ready for play and company."),
]


def seed_once():
    for name, age, temperament, room, status, note in CATS:
        Cat.objects.update_or_create(
            name=name,
            defaults={"age": age, "temperament": temperament, "room": room, "status": status, "note": note},
        )

    # The admin is opt in. No password is generated or printed.
    admin_password = os.getenv("ADMIN_PASSWORD", "").strip()
    if admin_password:
        user, _created = get_user_model().objects.update_or_create(
            username="shelter-admin",
            defaults={"email": "admin@example.test", "is_staff": True, "is_superuser": True},
        )
        user.set_password(admin_password)
        user.save(update_fields=["password"])


def seed_with_retry():
    delay = 1
    for attempt in range(8):
        try:
            seed_once()
            return
        except (OperationalError, ProgrammingError):
            if attempt == 7:
                return
            time.sleep(delay)
            delay = min(delay * 2, 8)

cats/urls.py Raw

from django.urls import path

from . import views

urlpatterns = [
    path("", views.directory, name="directory"),
    path("rooms/<slug:room_slug>/", views.room, name="room"),
    path("health", views.health, name="health"),
]

cats/views.py Raw

from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import render

from .models import Cat


def directory(request):
    query = request.GET.get("q", "").strip()
    cats = Cat.objects.all()
    if query:
        cats = cats.filter(
            Q(name__icontains=query)
            | Q(temperament__icontains=query)
            | Q(note__icontains=query)
            | Q(room__icontains=query)
        )
    return render(request, "cats/directory.html", {"cats": cats, "query": query, "room_name": "All rooms"})


def room(request, room_slug):
    room_names = dict(Cat.ROOM_CHOICES)
    cats = Cat.objects.filter(room=room_slug)
    return render(request, "cats/directory.html", {"cats": cats, "query": "", "room_name": room_names.get(room_slug, "Room")})


def health(_request):
    return JsonResponse({"ok": True})

manage.py Raw

#!/usr/bin/env python
import os
import sys

if __name__ == "__main__":
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "catadoption.settings")
    from django.core.management import execute_from_command_line

    execute_from_command_line(sys.argv)

requirements.txt Raw

Django==6.0.7
gunicorn==26.0.0
psycopg[binary]==3.3.4

Built from revision 52c7b7f

Guide: Deploy a Django app