I know there are a few new practices that I should adopt as I start new projects, but part of the purpose of this one is trying out nanodjango. So I’m keeping my other stuff the same, including pyenv, black, and poetry usage, for now in order to save my “innovation tokens.”

Some Quick Ceremony to Make a Comfortable Environment

cd fischer
git init .
gibo dump MacOS JetBrains python >.gitignore
pyenv local 3.12.7
poetry init

Then I set my development tools in pyproject.toml:

[tool.poetry]
name = "fischer"
version = "0.1.0"
description = "bookmark manager and 90s-style startpage"
authors = ["Geoff Beier <geoff@tuxpup.com>"]
license = "AGPL-3.0-only"
readme = "README.md"
package-mode = false

[tool.poetry.dependencies]
python = "^3.12"

[tool.poetry.group.dev.dependencies]
black = "^24.8.0"
pre-commit = "^3.8.0"
djhtml = "^3.0.6"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

It’s worth calling out package-mode = false; that tells poetry that I’m just using it to manage (and lock!) dependencies, not using it to package things for PyPA. Knowing that, it doesn’t complain about a set of things that are unimportant for a django application that will be packaged and distributed through other means.

Then I want to make sure black runs on commit for all my python files by setting it up in my .pre-commit-config.toml

default_language_version:
  python: python3.12
repos:
-   repo: https://github.com/psf/black-pre-commit-mirror
    rev: 24.8.0
    hooks:
    -   id: black

It’s easy, and by doing this at the beginning, I increase the chances that my git logs will be useful later.

I add a minimal README.md and LICENSE.md then make sure my pre-commit hooks are configured:

poetry run pre-commit install

Then I add it all, commit and make sure my remote is set up.

git add .
git commit -m "create a sandbox to work on fischer"
git remote add origin git@git.sr.ht:~tuxpup/fischer
git push --set-upstream origin main

Maybe that’s too much ceremony, but I’m ready to work on it now. I may add some more DX improvements later, like isort and djhtml, to the pre-commit hooks, but in my experience black is the one I really miss if I don’t have it for the first python code that I commit.

Creating my Project

Normally, here’s where I’d run django-admin startproject and django-admin startapp. But part of the fun of this project is seeing how I like the nanodjango workflow, so I’m going to skip that and go directly to nanodjango.

poetry add django nanodjango

I don’t really need to explicitly add django here. I’m only doing that so that poetry will explicity include it in my dependencies and lockfiles.

Using version ^5.1.1 for django
Using version ^0.9.1 for nanodjango

Updating dependencies
Resolving dependencies... (1.1s)

Package operations: 14 installs, 0 updates, 0 removals

  - Installing typing-extensions (4.12.2)
  - Installing annotated-types (0.7.0)
  - Installing asgiref (3.8.1)
  - Installing pydantic-core (2.23.4)
  - Installing sqlparse (0.5.1)
  - Installing django (5.1.1)
  - Installing h11 (0.14.0)
  - Installing pydantic (2.9.2)
  - Installing django-ninja (1.3.0)
  - Installing gunicorn (23.0.0)
  - Installing isort (5.13.2)
  - Installing uvicorn (0.31.0)
  - Installing whitenoise (6.7.0)
  - Installing nanodjango (0.9.1)

Writing lock file

Django and nanodjango are really the only ones I care about locking. The others can move along in any way that’s compatible with those two.

Side Note: Working around a Nanodjango Bug

Nanodjango’s view function wrapper does not currently pass kwargs appropriately to the underlying view functions. An issue has been opened and there’s a pending pull request that fixes it. While we’re waiting for that to land upstream, I’ve created my own fork with the fix applied and updated my pyproject.toml to point to that fork:

[tool.poetry.dependencies]
python = "^3.12"
django = "^5.1.1"
nanodjango = { git="https://github.com/geoffbeier/nanodjango.git", branch="fix-kwarg" }

To make sure it’s all working, I’m going to create a new app in fischer.py:

from nanodjango import Django

app = Django()

@app.route("/")
def index(request):
    return f"<h1>Hello, NanoDjango!"

Then I’ll activate a poetry shell in my project directory, and run my app with the command:

nanodjango run ./fischer.py

nanodjango automatically runs migrations, prompts me to create a superuser, complains that my static files location doesn’t exist yet, and starts my server. Visiting with a browser shows me that everything is working.

Hello, Nanodjango

PyCharm Setup

So far, I’ve been working in vim and my shell. But I prefer to work in pycharm now that this initial setup is working. So I open my directory in pycharm, tell it to use the poetry interpreter it detects, and set up a run configuration.

PyCharm run configuration for nanodjango

Add project files to git

At this point, to make my later change sets clearer, I like to commit my poetry and PyCharm project files:

git add poetry.lock pyproject.toml
git commit -m "Add nanodjango and django to project."
git add .idea
git commit -m "Add PyCharm project files."

Initial Models

Now that I know everything is working, it’s time to start adding models. Even though all of my instincts say to replace the default user model right now, I’m going to fight that. It’d be an unwelcome detour, and right now my goal is to get a bookmark manager that makes a 90s-style start page that I like as quickly as I can, so I can see if I still like the idea once I have something concrete in front of me. So I should start with bookmark models.

To get going, I add this to fischer.py:

@app.admin
class Bookmark(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='bookmarks')
    title = models.CharField(max_length=200)
    summary = models.TextField(blank=True)
    url = models.URLField()
    is_favorite = models.BooleanField(default=False)
    notes = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"[{self.title}]({self.url})"

    class Meta:
        ordering = ['-created_at']

That creates a model for a basic bookmark item that’s owned by a specifc user. The @app.admin decorator tells nanodjango to use a default ModelAdmin to make those editable in the django admin UI.

For now, I’m going to change my root view to redirect me to the admin UI:

@app.route("/")
def index(request):
    return HttpResponseRedirect("/admin")

Then I’ll run the server and make sure things work so far. I now do that using the run configuration I configured above in PyCharm, but poetry run nanodjango run ./fischer.py from a shell prompt in the project directory would work just fine. nanodjango detects that migrations need to be generated and applied, does so, then starts the server.

When I visit, I’m directed to log in to the admin UI, and I can use the autogenerated CRUD form to add some bookmarks.

Add bookmarks using the admin UI

Adding a Bookmark Listing Page

With the model started, it’s time to make a way to see bookmarks without going through django admin.

First I need a template or two. I’m just goint to use a templates/bookmarks directory at the root of my project to hold those.

mkdir -p templates/bookmarks
touch templates/bookmarks/base.html templates/bookmarks/user.html

I also know I’m going to want some minimal styling for my pages. Normally I prefer to use tailwind, but I’m going to start with pico.css. I don’t like using CDNs, even for development, I’m going to vendor pico.css, create CREDITS.md, and make a note of that:

mkdir -p static/css
curl -o static/css/pico.min.css https://raw.githubusercontent.com/picocss/pico/refs/heads/main/css/pico.min.css

Then create a base template that includes it, in templates/bookmarks/base.html:

{% load static %}
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <link rel="stylesheet" href="{% static 'css/pico.min.css' %}">
    <title>Fischer</title>
  </head>
  <body>
    <main class="container">
        {% block content %}
        <h1>Fischer</h1>
        {% endblock %}
    </main>
  </body>
</html>

and create a user template that extends the base and lists the user’s bookmarks:

{% extends "bookmarks/base.html" %}
{% block content %}
<h1>Bookmarks for {{ request.user.username }}</h1>
<ul>
    {% for bookmark in bookmarks %}
    <li><a href="{{bookmark.url}}">{{bookmark.title}}</a></li>
    {% endfor %}
</ul>
{% endblock %}

along with a basic view that renders that template if the right user comes along:

@app.route("/u/<username>")
def user_page(request, username: str):
    if not request.user.is_authenticated:
        return HttpResponseRedirect(f"/admin")
    if request.user.username != username:
        raise PermissionDenied(f"{request.user.username} may not view bookmarks for {username}")
    bookmarks = request.user.bookmarks.all()
    return render(request, "bookmarks/user.html", {"bookmarks": bookmarks})

Update the index to redirect logged in users to their own user page:

@app.route("/")
def index(request):
    if not request.user.is_authenticated:
        return HttpResponseRedirect("/admin")
    return redirect("user_page", request.user.username)

Obviously, there are more idomatic ways to require a logged in user and take unauthenticated users to a login page; for now these redirects to the admin page are a placeholder for a the real authentication pages we’ll put together later.

Now, when a logged-in user visits the index, they see their own bookmarks:

User page

That’s a lot working with very little code. Time to commit and push.

If you want to follow along with my progress, you can get the code here.

Return to the introduction

Read Part 2: Tagging