DEV Community

Michael Musatov
Michael Musatov

Posted on

A Toy Django REST API with an Admin Panel

As a developer working primarily with Next.js and NestJS, I recently reviewed a pull request to a Django project authored by another developer — and realized just how much the Python ecosystem has evolved since I last used it professionally. In this post, I’ll document the process of setting up a modern Python + Django environment from a Node.js developer’s perspective.

The Python Environment setup

Since I last used Python professionally, the ecosystem has changed. virtualenv is still around and doing fine, but venv has been part of the standard library since Python 3.3. And now there’s uv — a modern, fast tool trying to consolidates the functionality of multiple utilities.

Let's compare them across virtual environments, project management, and packaging. Spoiler: I'm currently preferring uv for new projects.

  • With virtualenv, installation is done via pip install virtualenv command (pipx is optional), and the setup usually looks like virtualenv venv && source venv/bin/activate. New packages are installed with pip install <package>, and dependencies are frozen with pip freeze > requirements.txt  installation of frozen dependencies is pip install -r requirements.txt. And a modern approach is to use pyproject.toml for projects management and dependencies management. Installing defined dependencies with pip install . or pip install -e . in development. Requires pip-tools or Pipenv for locking dependencies.
  • With venv, everything works almost the same, except there's no need to install an extra tool — just run python -m venv venv && source venv/bin/activate.
  • The uv tool takes it a bit differently. It's installed with curl -LsSf https://astral.sh/uv/install.sh | sh (or pipx install uv) and initialized using uv venv && source .venv/bin/activate. Packages are managed with uv add <package>, which automatically generates a pyproject.toml (if missing) and a uv.lock file for dependency locking. Dependencies for the entire project can be installed with uv sync.

In practice, uv replaces virtualenv/venv (environment creation), pip (package installation), pip-tools(dependency resolution), and pipx (tool installation via uv tool install), while partially covering poetry, pdm, and pyenv. The result is an opinionated but efficient workflow — fast, consistent, and simple.

If you’re coming from the Node.js world, the setup will feel somewhat familiar at first — though there are some fundamental differences. nvm manages different versions of the Node.js interpreter installed centrally under ~/.nvm, along with any global dependencies tied to those versions. In contrast, venv, virtualenv, and uv (which builds on venv) manage project-specific dependencies by creating an isolated Python environment. Each environment includes its own package directory and a dedicated copy (or symlinks) of the Python interpreter and its supporting files. When you activate it, your shell’s PATH is updated to use this local interpreter and its packages — effectively sandboxing everything to that single project.

Django Setup

With the environment ready, it's time to get to the point - setup Django. The official Django tutorial walks through the process in great detail, but here's the quick version.

First, install Django into your active environment with uv add django (or pip install django if you’re going old school). Then create a new project using django-admin startproject demo ., which generates the initial project structure and configuration.

Run the development server with python manage.py runserver and open the suggested URL in your browser — if everything works, you'll see Django's default "The install worked successfully! Congratulations!" page.

When you're building your Django app locally, you use the built-in development server python manage.py runserver. This is perfect for coding - it automatically reloads when you change files and shows detailed error pages if you mess up. But this server is slow and insecure. Dev server is WSGI, which you'll recognize from the wsgi.py file in your Django project.

When you're ready to show your app to the world, you need a production server that can handle real traffic. This is where ASGI servers like Daphne or Uvicorn come in - they're built for speed and security. You combine these with a proper web server like gunicorn, and suddenly your app can actually survive in the wild instead of just running on your laptop.

A toy API service

Let’s start by preparing the database and admin panel. We already have some pending migrations from the initial project scaffolding, so let’s apply them first. There’s no need to change any database settings — SQLite is a perfectly fine choice for a toy project. Run the migrations to create the database structures Django needs for the admin application with python manage.py migrate, and then create a superuser using python manage.py createsuperuser --username admin --email your@email.com.

Now we can set up the API service. We’ll use Django REST Framework (DRF) — because we’re lazy and smart enough not to reinvent the wheel. Add the package with uv add djangorestframework; it will immediately appear in your pyproject.toml. Then create a new API app with cd demo && django-admin startapp api.

Next, we’re ready to define some models. For the sake of the demo, let’s implement a toy CRUD structure: Category -> Product -> Review:

class Category(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name

class Product(models.Model):
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    name = models.CharField(max_length=100)
    description = models.TextField()
    price = models.DecimalField(max_digits=10, decimal_places=2)

    def __str__(self):
        return f"{self.name} ({self.category}) - ${self.price}"


class Review(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    rating = models.IntegerField()
    comment = models.TextField()

    def __str__(self):
        return f"Review for {self.product.name}: {self.rating} stars"
Enter fullscreen mode Exit fullscreen mode

Note this little override of the __str__ method — it’s going to be quite useful. In Django, this defines how an object is represented as text. By returning self.name, records of this model will display that name in the admin interface and anywhere else the object appears as a string.

With the models in place, we define serializers api/serializers.py and views api/views.py. DRF serializers describe how models are converted to and from JSON:

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ["name"]

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = ["category", "name", "description", "price"]

class ReviewSerializer(serializers.ModelSerializer):
    class Meta:
        model = Review
        fields = ["product", "rating", "comment"]
Enter fullscreen mode Exit fullscreen mode

By default, all model fields are automatically mapped to corresponding serializer fields. However, it’s usually better to be explicit and define them yourself — at least to avoid unintentional exposure of internal fields you didn’t mean to show to the world.

With views we are going with public CRUD for simplicity reasons:

class CategoriesViewSet(viewsets.ModelViewSet):
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    permission_classes = [permissions.AllowAny]

class ProductsViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    permission_classes = [permissions.AllowAny]

class ReviewsViewSet(viewsets.ModelViewSet):
    queryset = Review.objects.all()
    serializer_class = ReviewSerializer
    permission_classes = [permissions.AllowAny]
Enter fullscreen mode Exit fullscreen mode

With that set, we just need to configure routing inside our API app api/urls.py:

from django.urls import path, include
from rest_framework import routers
from . import views

router = routers.DefaultRouter(trailing_slash=False)
router.register(r"categories", views.CategoriesViewSet)
router.register(r"products", views.ProductsViewSet)
router.register(r"reviews", views.ReviewsViewSet)

urlpatterns = [
    path("", include(router.urls)),
]
Enter fullscreen mode Exit fullscreen mode

Don’t forget to add the demo.api app and rest_framework to the project’s INSTALLED_APPS (missing either of these will break things) settings.py:

INSTALLED_APPS = [
    ...
    "rest_framework",
    "demo.api",
]
Enter fullscreen mode Exit fullscreen mode

One more important step is to register our models with the admin so we can manage them through the UI api/admin.py:

from django.contrib import admin
from demo.api.models import Category, Product, Review

admin.site.register(Category)
admin.site.register(Product)
admin.site.register(Review)
Enter fullscreen mode Exit fullscreen mode

With all that in place, create migrations for the new models using python manage.py makemigrations api and apply them with python manage.py migrate.

At this point everything is wired up and we can start the application with python manage.py runserver. A quick curl http://localhost:8000/api/reviews should return an empty array, which is exactly what we expect for a fresh database. Now you can go to /admin/, log in with the superuser you created earlier, add a category, a product, and a review — and then hit the same API endpoint again to see the data flowing through the API.
Django Admin UI screenshot

If you’re a Node.js developer, Django’s automatic admin interface might feel almost magical — there’s really no direct equivalent. In the JavaScript world, you usually mix and match tools to get something similar.

For the data access layer, frameworks like Prisma or TypeORM can generate models, migrations, and typed queries from schema definitions — that’s about as close as it gets to Django’s ORM. For an admin panel, you’d typically reach for AdminJS, which can auto-generate CRUD UIs on top of your models. Another option is Strapi, which works more like a highly customizable headless CMS with a built-in admin interface.

While none of these provide the same tight, batteries-included integration Django offers out of the box, a setup using Prisma + AdminJS (or Strapi if you prefer a full CMS) can get you a comparable developer experience — but with a few more moving parts to glue together.

Conclusion

I had some fun setting up this little toy project and dusting off my long-forgotten Python knowledge. Turns out, Django can be a pretty nice tool for managing data in a database while also providing an extensible API layer.

Next, I want to explore adding filtering, sorting, and pagination for all entities through the API — to see if this setup could really serve as a replacement for a headless CMS.

Also, since I recently had to deal with monitoring Django services running in GCP, I’m planning to set up OpenTelemetry. Let’s see how long it takes me to turn those experiments into the next blog post (or a few of them). 😅

By the way, I’ve pushed this little toy project to GitHub — feel free to explore. And there is mirror of this article on my personal website.

Top comments (0)