Real Estate Listings API with DRF
Build a filterable property listings API with JWT auth and image uploads.
Introduction
30 Second Summary
Every property listing site you scroll through is powered by a backend API that serves data, handles authentication, and filters results based on what you search for. Building one yourself means understanding how production APIs manage public access, user-specific content, and file uploads all at once.
In this project, you will build a real estate listings API with Django REST Framework and PostgreSQL. Your API will let anyone browse and filter property listings, while authenticated users can post their own properties with multiple photos.
What You'll Build
A REST API backend that serves property listings publicly and protects create, update, and delete operations behind JWT authentication.
By the end of this project, you'll have:
- A Django REST Framework API with public browsing endpoints and JWT-protected endpoints for managing your own listings.
- A multi-model architecture supporting multiple image uploads per listing, with PostgreSQL running in a Docker container.
- A filtering system that lets users search listings by city, property type, and price range.
- Secret Mission: Add a featured listings endpoint that returns recent listings with images, complete with custom pagination.
Are there any prerequisites?
This project assumes you have basic Python and Django experience. You should also have Docker and PyCharm installed on your machine.
Before We Start
Before We Start...
Set Up the Project
Your real estate listings API needs a solid foundation before you write any application code.
In this step, you will create a Python virtual environment, install the required packages, spin up a PostgreSQL database in Docker, and scaffold the Django project with all necessary configuration.
In this step, get ready to:
- Create a virtual environment and install all project dependencies.
- Start a PostgreSQL Docker container as the project database.
- Scaffold the Django project and configure settings for DRF, JWT, filtering, and media uploads.
Create the virtual environment and install dependencies
A virtual environment keeps this project's packages separate from your system Python. You will install six packages that handle everything from API serialization to image uploads.
- In PyCharm, create a new project. Click File in the top menu bar.
- Click New Project.
- Set the project location to a folder called realestate-api on your Desktop.
- Make sure New environment using Virtualenv is selected.
- Click Create.
Why a virtual environment?
Virtual environments isolate your project's packages from other Python projects on your machine. Without one, upgrading a package for one project could break another.
- Open the terminal inside PyCharm by clicking Terminal at the bottom of the window.
- Create the requirements.txt file by running this command:
cat > requirements.txt << 'EOF'
Django==5.2.5
djangorestframework==3.17.1
djangorestframework-simplejwt==5.5.1
django-filter==25.2
Pillow==12.2.0
psycopg2-binary==2.9.12
EOF
What are these packages?
- Django==5.2.5 is the web framework that handles routing, ORM, and project structure.
- djangorestframework==3.17.1 adds serializers, viewsets, and permission classes for building JSON APIs.
- djangorestframework-simplejwt==5.5.1 provides JWT token authentication (login, refresh).
- django-filter==25.2 lets you add query parameter filtering to your API endpoints.
- Pillow==12.2.0 is the Python imaging library required by Django's ImageField for handling uploaded photos.
- psycopg2-binary==2.9.12 is the PostgreSQL adapter that lets Django talk to your database.
- Install all the dependencies by running:
pip install -r requirements.txt
- Confirm the packages installed by running:
pip list
You should see all six packages listed with their pinned version numbers.
Seeing install errors?
If psycopg2-binary fails, make sure you are using Python 3.10 or higher. Check with python --version in your terminal.
If pip is not found, ensure the PyCharm terminal is using the virtual environment (you should see (venv) at the start of your prompt).
help me troubleshoot package installation errors in my virtual environment
Start the PostgreSQL Docker container
Your API needs a database to store property listings. Instead of installing PostgreSQL directly on your machine, you will run it inside a Docker container. This keeps your system clean and makes the database easy to start, stop, and delete.
- Start the PostgreSQL container by running this command in your PyCharm terminal:
docker run --name realestate-postgres -e POSTGRES_DB=realestate_db -e POSTGRES_USER=realestate_user -e POSTGRES_PASSWORD=realestate_pass -p 5432:5432 -d postgres:16
What does this command do?
- --name realestate-postgres gives the container a memorable name you can reference later.
- -e POSTGRES_DB=realestate_db creates a database called realestate_db when the container first starts.
- -e POSTGRES_USER=realestate_user creates a superuser with this name.
- -e POSTGRES_PASSWORD=realestate_pass sets the password for that user.
- -p 5432:5432 maps the container's port to your machine so Django can connect via localhost.
- -d postgres:16 runs PostgreSQL 16 in the background (detached mode).
- Verify the container is running by checking:
docker ps
You should see a row for realestate-postgres with status Up and port 5432 mapped.
Container not showing up?
Make sure Docker Desktop is running. If you see a "Cannot connect to the Docker daemon" message, open Docker Desktop and wait for it to finish starting.
If port 5432 is already in use, you may have another PostgreSQL instance running. Stop it first, or use a different host port like -p 5433:5432 (and update the Django settings PORT later).
help me fix my Docker container that won't start
Create and configure the Django project
Now you will scaffold the Django project and wire up all the settings. The project structure uses a config directory for top-level project files, keeping the layout clean as you add apps later.
- Create the Django project by running:
django-admin startproject config .
Why the dot at the end?
The dot tells Django to create the project files in the current directory instead of a nested subfolder. This avoids a double-nested structure like realestate-api/config/config/.
- Confirm the project was created by running:
ls
You should see manage.py, a config/ folder, and your requirements.txt.
- Open config/settings.py in PyCharm by double-clicking it in the Project panel on the left.
- Replace the entire contents of config/settings.py with the following code:
from pathlib import Path
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-change-this-in-production"
DEBUG = True
ALLOWED_HOSTS = []
# Register DRF, django-filter, and our custom apps
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"django_filters",
"users",
"listings",
]
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 = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
# Connect to the PostgreSQL Docker container
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "realestate_db",
"USER": "realestate_user",
"PASSWORD": "realestate_pass",
"HOST": "localhost",
"PORT": "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 = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
# Media files (uploaded images)
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# DRF configuration: JWT auth and filtering backends
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_FILTER_BACKENDS": (
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
),
}
# JWT token lifetimes
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
}
What's happening in this configuration?
- INSTALLED_APPS registers DRF (rest_framework), the filter library (django_filters), and the two apps you will create in later steps (users and listings).
- DATABASES points Django at the PostgreSQL container you just started. The credentials match the POSTGRES_* environment variables from the docker run command.
- MEDIA_URL and MEDIA_ROOT configure where uploaded property images are stored on disk and how they are served.
- REST_FRAMEWORK sets JWT as the default authentication method and enables filtering on all viewsets.
- SIMPLE_JWT gives access tokens a 60-minute lifetime and refresh tokens a 7-day lifetime.
- Now open config/urls.py and replace its contents with:
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("api/users/", include("users.urls")),
path("api/", include("listings.urls")),
]
# Serve uploaded media files during development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
What does this URL configuration do?
- api/users/ routes all user registration and authentication endpoints to the users app.
- api/ routes all listing endpoints to the listings app.
- The static() call at the bottom tells Django's development server to serve uploaded images from the media/ folder.
- Save both config/settings.py and config/urls.py.
Don't run the server yet
Django will throw errors if you try to run the server now because the users and listings apps do not exist yet. You will create those in the next steps.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete config/settings.py:
from pathlib import Path
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-change-this-in-production"
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"django_filters",
"users",
"listings",
]
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 = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "realestate_db",
"USER": "realestate_user",
"PASSWORD": "realestate_pass",
"HOST": "localhost",
"PORT": "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 = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_FILTER_BACKENDS": (
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
),
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
}
Here is the complete config/urls.py:
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("api/users/", include("users.urls")),
path("api/", include("listings.urls")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Here is the complete requirements.txt:
Django==5.2.5
djangorestframework==3.17.1
djangorestframework-simplejwt==5.5.1
django-filter==25.2
Pillow==12.2.0
psycopg2-binary==2.9.12
Your foundation is set. You have a running database, a configured Django project, and all the packages you need. Next up, you will build the Listing and Image models that define how property data is stored.
Build the Listing and Image Models
Your Django project is configured and connected to PostgreSQL. Now you need a data structure that represents property listings and their associated images.
In this step, you'll create Django models that define the database schema for your listings. Each listing stores property details like price, type, and location. A separate model handles images so that each listing can have multiple photos attached.
In this step, get ready to:
- Create the listings app and define the Listing model with property fields, choices, and relationships.
- Define the ListingImage model for multiple image uploads per listing.
- Run migrations to create the database tables in PostgreSQL.
Create the listings app and define the Listing model
Django organizes code into apps. Each app handles a specific domain of your project. Your listings app will own everything related to property listings and their images.
- Create the listings app by running this command:
python manage.py startapp listings
- Confirm the app was created by running:
ls listings
You should see files like __init__.py, admin.py, apps.py, models.py, and a migrations/ directory listed in the output.
Why is listings already in INSTALLED_APPS?
In Step 1, you pre-registered the listings app in config/settings.py. Now that you've actually created the app directory, Django can find and load it.
The Listing model represents a single property advertisement. It needs fields for the property details, the owner who posted it, and metadata like timestamps.
- Open listings/models.py in PyCharm and replace its contents with the following code:
from django.db import models
from django.contrib.auth.models import User
class Listing(models.Model):
# Choices restrict property_type to a fixed set of options
PROPERTY_TYPE_CHOICES = [
("apartment", "Apartment"),
("house", "House"),
("villa", "Villa"),
("studio", "Studio"),
]
# Source tracks where the listing came from (user or future scraper)
SOURCE_CHOICES = [
("user_posted", "User Posted"),
("dubizzle", "Dubizzle"),
("facebook", "Facebook"),
]
title = models.CharField(max_length=200)
description = models.TextField()
price = models.DecimalField(max_digits=12, decimal_places=2)
property_type = models.CharField(max_length=20, choices=PROPERTY_TYPE_CHOICES)
address = models.CharField(max_length=300)
city = models.CharField(max_length=100)
phone_number = models.CharField(max_length=20)
source = models.CharField(
max_length=20, choices=SOURCE_CHOICES, default="user_posted"
)
# Each listing belongs to one user
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="listings")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
What does this model do?
PROPERTY_TYPE_CHOICES and SOURCE_CHOICES define fixed options for dropdown-style fields. Django validates that only these values can be stored.
The owner field is a ForeignKey linking each listing to the user who created it. on_delete=models.CASCADE means if a user is deleted, all their listings are deleted too.
The source field defaults to user_posted for listings created through your API. The other options prepare the architecture for future scraper integrations without needing schema changes later.
Meta ordering = ["-created_at"] makes queries return newest listings first by default.
Don't save the file yet. You'll add the second model next.
Define the ListingImage model
A single property listing can have multiple photos. Storing images in a separate model with a ForeignKey back to the listing creates a clean one-to-many relationship. This lets you upload, list, and delete images independently.
- Add the following class below the Listing model in listings/models.py:
class ListingImage(models.Model):
# Each image belongs to one listing
listing = models.ForeignKey(
Listing, on_delete=models.CASCADE, related_name="images"
)
# Images stored in media/listings/YYYY/MM/DD/ for organized file storage
image = models.ImageField(upload_to="listings/%Y/%m/%d/")
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Image for {self.listing.title}"
What does this model do?
The related_name="images" lets you access all images for a listing with listing.images.all(). This is the reverse accessor Django creates automatically.
ImageField stores the file path in the database while saving the actual file to disk. The upload_to="listings/%Y/%m/%d/" pattern organizes uploads into date-based folders so your media directory stays manageable.
- Save listings/models.py.
Seeing a red underline on ImageField?
PyCharm may show a warning if it hasn't detected Pillow in your environment. Make sure your virtual environment is selected as the project interpreter in PyCharm. The Pillow package you installed in Step 1 provides the image processing that ImageField requires.
Help me fix my PyCharm interpreter settings for this Django project.
✔️ Awesome, I've got everything!
Great. Double check you've saved listings/models.py with both models included.
ⓧ I'd like to double check the full code
from django.db import models
from django.contrib.auth.models import User
class Listing(models.Model):
PROPERTY_TYPE_CHOICES = [
("apartment", "Apartment"),
("house", "House"),
("villa", "Villa"),
("studio", "Studio"),
]
SOURCE_CHOICES = [
("user_posted", "User Posted"),
("dubizzle", "Dubizzle"),
("facebook", "Facebook"),
]
title = models.CharField(max_length=200)
description = models.TextField()
price = models.DecimalField(max_digits=12, decimal_places=2)
property_type = models.CharField(max_length=20, choices=PROPERTY_TYPE_CHOICES)
address = models.CharField(max_length=300)
city = models.CharField(max_length=100)
phone_number = models.CharField(max_length=20)
source = models.CharField(
max_length=20, choices=SOURCE_CHOICES, default="user_posted"
)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="listings")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
class ListingImage(models.Model):
listing = models.ForeignKey(
Listing, on_delete=models.CASCADE, related_name="images"
)
image = models.ImageField(upload_to="listings/%Y/%m/%d/")
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Image for {self.listing.title}"
Run migrations and verify the database
Django uses migrations to translate your model definitions into actual database tables. You need to generate the migration file first, then apply it to PostgreSQL.
- Generate the migration file by running:
python manage.py makemigrations listings
You should see output indicating a new migration was created with CreateModel operations for both Listing and ListingImage.
- Apply the migration to your PostgreSQL database by running:
python manage.py migrate
What does this command do?
The makemigrations command reads your model definitions and generates a Python file describing the schema changes. The migrate command executes those changes against your PostgreSQL database, creating the actual tables.
Since this is the first migration for the project, Django also creates its built-in tables for authentication, sessions, and the admin site.
- Verify the tables were created by running this command to connect to your PostgreSQL container:
docker exec -it realestate_db psql -U realestate_user -d realestate_db -c "\dt listings_*"
You should see listings_listing and listings_listingimage listed as tables in the output.
Don't see the tables?
Make sure your PostgreSQL Docker container is still running. You can check with docker ps and look for the container name.
If the migrate command failed, check that the database credentials in config/settings.py match the ones you used when creating the Docker container (realestate_db, realestate_user, realestate_pass).
Help me troubleshoot why my Django migration failed to connect to PostgreSQL.
Your data models are in place. Next up, you'll build the authentication system so users can register, log in, and manage their own listings.
Set Up JWT Authentication
Your models are in place and your database is ready to store listings. Before users can create their own listings, the API needs a way to identify who is making a request.
In this step, you'll build a registration endpoint and wire up JWT (JSON Web Token) authentication. This gives your API a secure handshake: users register, receive a token, and include that token in future requests to prove their identity.
In this step, get ready to:
- Build a user registration endpoint with password validation.
- Configure login and token refresh routes using SimpleJWT.
- Test the full authentication flow from registration to token issuance.
Create the users app and registration serializer
The users app handles everything related to authentication. You already registered it in settings.py during setup. Now you need to create the actual app files.
- Create the users app by running this command:
python manage.py startapp users
- Confirm the app folder was created by running:
ls users/
You should see files like __init__.py, apps.py, models.py, and views.py listed in the output.
A serializer in Django REST Framework converts data between JSON and Python objects. The registration serializer validates incoming user data and creates the account.
- Create a new file called users/serializers.py and add the following code:
from rest_framework import serializers
from django.contrib.auth.models import User
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
password_confirm = serializers.CharField(write_only=True, min_length=8)
class Meta:
model = User
fields = ("id", "username", "email", "password", "password_confirm")
def validate(self, attrs):
if attrs["password"] != attrs["password_confirm"]:
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
return attrs
def create(self, validated_data):
validated_data.pop("password_confirm")
user = User.objects.create_user(
username=validated_data["username"],
email=validated_data.get("email", ""),
password=validated_data["password"],
)
return user
What does this code do?
This serializer handles three responsibilities for user registration:
- write_only=True on both password fields means they are accepted in the request but never returned in the response. This prevents passwords from leaking in API output.
- The validate method checks that password and password_confirm match before proceeding.
- The create method uses Django's create_user() helper, which hashes the password before saving. Never store plain-text passwords.
- Save users/serializers.py.
Seeing an import error for rest_framework?
Make sure your virtual environment is activated. The djangorestframework package is installed inside the venv, not globally.
Help me fix this import error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved users/serializers.py before moving on.
ⓧ I'd like to double check the full code
from rest_framework import serializers
from django.contrib.auth.models import User
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
password_confirm = serializers.CharField(write_only=True, min_length=8)
class Meta:
model = User
fields = ("id", "username", "email", "password", "password_confirm")
def validate(self, attrs):
if attrs["password"] != attrs["password_confirm"]:
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
return attrs
def create(self, validated_data):
validated_data.pop("password_confirm")
user = User.objects.create_user(
username=validated_data["username"],
email=validated_data.get("email", ""),
password=validated_data["password"],
)
return user
Build the registration view and URL routes
The view connects your serializer to an HTTP endpoint. DRF's CreateAPIView handles POST requests automatically, so your view only needs to declare the serializer and permissions.
- Open users/views.py and replace its contents with:
from rest_framework import generics, permissions
from django.contrib.auth.models import User
from users.serializers import RegisterSerializer
class RegisterView(generics.CreateAPIView):
queryset = User.objects.all()
permission_classes = (permissions.AllowAny,)
serializer_class = RegisterSerializer
What does this code do?
- CreateAPIView provides a built-in POST handler. When a request arrives, DRF passes the data to your serializer, validates it, and calls create() automatically.
- permissions.AllowAny means anyone can hit this endpoint without authentication. Registration must be open to unauthenticated users by definition.
- Save users/views.py.
✔️ Awesome, I've got everything!
Great. Make sure you have saved users/views.py before moving on.
ⓧ I'd like to double check the full code
from rest_framework import generics, permissions
from django.contrib.auth.models import User
from users.serializers import RegisterSerializer
class RegisterView(generics.CreateAPIView):
queryset = User.objects.all()
permission_classes = (permissions.AllowAny,)
serializer_class = RegisterSerializer
Now you need URL routes that map HTTP paths to your view and to SimpleJWT's built-in token views.
- Create a new file called users/urls.py and add the following code:
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from users.views import RegisterView
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
What does this code do?
- register/ maps to your custom RegisterView for creating new user accounts.
- login/ uses SimpleJWT's TokenObtainPairView which accepts a username and password, then returns an access token and a refresh token.
- token/refresh/ uses TokenRefreshView to issue a new access token when the current one expires, without requiring the user to log in again.
- Save users/urls.py.
Where does config/urls.py fit in?
Your project's config/urls.py already includes path("api/users/", include("users.urls")) from the setup step. This means your three new routes are already accessible under /api/users/register/, /api/users/login/, and /api/users/token/refresh/.
Seeing a ModuleNotFoundError for users.urls?
Make sure you created the file at users/urls.py (inside the users folder, not the config folder). Also confirm the users app was created with startapp and has an __init__.py file.
Help me resolve this ModuleNotFoundError.
✔️ Awesome, I've got everything!
Great. Make sure you have saved users/urls.py before moving on.
ⓧ I'd like to double check the full code
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from users.views import RegisterView
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
Test the authentication flow
Everything is wired up. Time to prove it works by registering a user, logging in to get a token, and refreshing that token.
- Start the development server by running:
python manage.py runserver
- Open a second terminal window (keep the server running in the first one).
- Register a new user by running this command:
curl -X POST http://localhost:8000/api/users/register/ \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "email": "test@example.com", "password": "testpass123", "password_confirm": "testpass123"}'
You should see a JSON response containing the new user's id, username, and email. No password is returned because of the write_only setting.
- Log in with the new user to obtain a JWT token by running:
curl -X POST http://localhost:8000/api/users/login/ \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "testpass123"}'
You should see a JSON response with two keys: access and refresh. The access token is what you include in future requests. The refresh token lets you get a new access token when the current one expires.
- Copy the refresh value from the response above.
- Test the token refresh endpoint by running this command (paste your refresh token in place of the placeholder):
curl -X POST http://localhost:8000/api/users/token/refresh/ \
-H "Content-Type: application/json" \
-d '{"refresh": "your-refresh-token-here"}'
You should see a new access token in the response. This confirms your full JWT flow is working: register, login, and refresh.
Why two tokens?
The access token is short-lived (60 minutes in your config). The refresh token lasts 7 days. This pattern limits damage if an access token is stolen, since it expires quickly.
The refresh token lets users stay logged in without re-entering credentials every hour. Your React frontend will handle this exchange automatically.
Getting a 401 or invalid credentials error?
Make sure you registered the user first before trying to log in. Double-check that the username and password in your login request match exactly what you used during registration.
Help me debug this login error.
Your authentication layer is locked in. Next up, you'll build the listings CRUD endpoints that use these tokens to control who can create, update, and delete property listings.
Build Listings CRUD with Image Upload
You have your data models and authentication system ready. Now you need to expose those models as API endpoints that a React frontend can consume.
In this step, you will build serializers that convert your Django models to JSON, create viewsets that handle all CRUD operations, and configure permissions so only listing owners can modify their own posts. You will also add a dedicated image upload endpoint for attaching photos to listings.
In this step, get ready to:
- Build serializers for listings and listing images.
- Create viewsets with owner-based permission controls.
- Wire up URL routes for all CRUD and image upload endpoints.
Build the listing serializers
Serializers translate between Django model instances and the JSON data your API sends and receives. You need two serializers: one for images (simple) and one for listings (which nests images inside it).
- Create a new file called listings/serializers.py in your listings app folder.
- Add the ListingImageSerializer by pasting the following code:
from rest_framework import serializers
from listings.models import Listing, ListingImage
class ListingImageSerializer(serializers.ModelSerializer):
class Meta:
model = ListingImage
fields = ("id", "image", "uploaded_at")
read_only_fields = ("id", "uploaded_at")
What does this code do?
The ListingImageSerializer is a ModelSerializer that automatically generates fields from your ListingImage model. It exposes id, image (the file URL), and uploaded_at.
The read_only_fields setting means id and uploaded_at are auto-generated by Django and cannot be set by the client.
- Now add the ListingSerializer below the image serializer in the same file:
class ListingSerializer(serializers.ModelSerializer):
# Nest all images for this listing in the response
images = ListingImageSerializer(many=True, read_only=True)
# Show the owner's username instead of just their ID
owner_username = serializers.ReadOnlyField(source="owner.username")
class Meta:
model = Listing
fields = (
"id",
"title",
"description",
"price",
"property_type",
"address",
"city",
"phone_number",
"source",
"owner",
"owner_username",
"images",
"created_at",
"updated_at",
)
read_only_fields = ("id", "owner", "source", "created_at", "updated_at")
What does this code do?
The ListingSerializer converts your Listing model to JSON. The images field uses the ListingImageSerializer to nest all associated images inside the listing response.
The owner_username field pulls the username from the related User object using source="owner.username". This gives the frontend a human-readable name instead of just a numeric user ID.
Fields like owner, source, and created_at are read-only because they are set automatically by the server, not by the client.
- Save listings/serializers.py.
Seeing an import error?
Make sure you have from rest_framework import serializers at the top of the file. Also check that from listings.models import Listing, ListingImage matches your model class names exactly.
help me debug an import error in my serializers file
Create the permission class and listing viewset
Your API needs to let anyone browse listings, but only let the listing owner edit or delete their own posts. DRF's permission classes handle this logic cleanly at the view level.
- Create a new file called listings/permissions.py and add the following code:
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# Allow GET, HEAD, OPTIONS requests for any user
if request.method in permissions.SAFE_METHODS:
return True
# Only the owner can modify or delete
return obj.owner == request.user
What is a permission class?
A permission class is a reusable rule that DRF checks before allowing access to an endpoint. IsOwnerOrReadOnly checks two things: if the request is read-only (GET, HEAD, OPTIONS), it allows anyone through. If the request modifies data (PUT, PATCH, DELETE), it only allows the object's owner.
DRF calls has_object_permission automatically when you retrieve, update, or delete a specific listing.
- Save listings/permissions.py.
- Now create a new file called listings/filters.py. This is a placeholder for the filtering system you will build in the next step. Add this minimal code:
import django_filters
from listings.models import Listing
class ListingFilter(django_filters.FilterSet):
class Meta:
model = Listing
fields = []
Why create this file now?
Your viewset will reference ListingFilter in its configuration. Creating a minimal placeholder now avoids an import error when you start the server. You will flesh out the filter fields in the next step.
- Save listings/filters.py.
- Now create a new file called listings/views.py and add the ListingViewSet:
from rest_framework import viewsets, permissions, parsers, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from listings.models import Listing, ListingImage
from listings.serializers import ListingSerializer, ListingImageSerializer
from listings.permissions import IsOwnerOrReadOnly
from listings.filters import ListingFilter
class ListingViewSet(viewsets.ModelViewSet):
queryset = Listing.objects.all()
serializer_class = ListingSerializer
filterset_class = ListingFilter
filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ["title", "description"]
def get_permissions(self):
# Public access for browsing, authenticated for everything else
if self.action in ["list", "retrieve"]:
permission_classes = [permissions.AllowAny]
else:
permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
return [permission() for permission in permission_classes]
def perform_create(self, serializer):
# Automatically set the owner and source when creating
serializer.save(owner=self.request.user, source="user_posted")
What does this viewset do?
A ModelViewSet automatically provides list, create, retrieve, update, and destroy actions for your model.
The get_permissions method dynamically sets permissions based on the action. Browsing (list and retrieve) is open to everyone. Creating, updating, and deleting require authentication plus ownership.
The perform_create method injects the logged-in user as the owner and sets source to "user_posted" automatically. The client never needs to send these values.
- Save listings/views.py.
Getting a ModuleNotFoundError?
Check that listings/permissions.py and listings/filters.py both exist in your listings folder. The viewset imports from both of these files.
help me fix a ModuleNotFoundError in my listings views
Create the image viewset and wire up URL routes
Images are uploaded separately from listings. A user first creates a listing, then uploads images to it via a dedicated endpoint. This keeps the listing creation simple while allowing multiple images per property.
- In listings/views.py, add the ListingImageViewSet class below the ListingViewSet:
class ListingImageViewSet(viewsets.ModelViewSet):
serializer_class = ListingImageSerializer
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
# Only return images for the specific listing in the URL
return ListingImage.objects.filter(listing_id=self.kwargs["listing_pk"])
def perform_create(self, serializer):
# Verify the user owns this listing before allowing image upload
listing = Listing.objects.get(pk=self.kwargs["listing_pk"])
if listing.owner != self.request.user:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("You can only add images to your own listings.")
serializer.save(listing=listing)
What does this viewset do?
The ListingImageViewSet handles file uploads using MultiPartParser and FormParser. These parsers know how to process binary file data sent from HTML forms or API clients.
The get_queryset method filters images to only those belonging to the listing specified in the URL (listing_pk). The perform_create method checks that the authenticated user owns the listing before allowing them to upload an image to it.
- Save listings/views.py.
- Now create a new file called listings/urls.py and add the URL routing:
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from listings.views import ListingViewSet, ListingImageViewSet
# Register the listing viewset with DRF's router for automatic URL generation
router = DefaultRouter()
router.register(r"listings", ListingViewSet, basename="listing")
urlpatterns = [
path("", include(router.urls)),
# Manual routes for nested image endpoints
path(
"listings/<int:listing_pk>/images/",
ListingImageViewSet.as_view({"get": "list", "post": "create"}),
name="listing-images",
),
path(
"listings/<int:listing_pk>/images/<int:pk>/",
ListingImageViewSet.as_view({"delete": "destroy"}),
name="listing-image-detail",
),
]
Why mix a router with manual routes?
The DefaultRouter automatically generates standard CRUD URLs for ListingViewSet (like /api/listings/ and /api/listings/1/).
Image endpoints are nested under a specific listing (/api/listings/1/images/), which requires a custom URL pattern. The listing_pk capture group passes the parent listing ID into the image viewset.
- Save listings/urls.py.
- Test that the server starts without errors by running:
python manage.py runserver
You should see the development server start successfully with no import errors or exceptions.
- Open a new terminal tab and test that listing creation works by running:
curl -X POST http://127.0.0.1:8000/api/listings/ \
-H "Content-Type: application/json" \
-d '{"title": "Test", "description": "Test", "price": "100.00", "property_type": "apartment", "address": "123 Main St", "city": "Dubai", "phone_number": "555-0100"}'
You should see a 401 Unauthorized response. This confirms your permission system is working correctly.
- Now test that the public list endpoint works by running:
curl http://127.0.0.1:8000/api/listings/
You should see a 200 response with an empty list ([]). This confirms unauthenticated users can browse listings.
Server not starting?
Make sure your virtual environment is activated and that all files (serializers.py, permissions.py, filters.py, views.py, urls.py) are saved inside the listings/ folder. Also confirm your PostgreSQL Docker container is still running.
help me fix my Django server startup error
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in your listings/ folder.
ⓧ I'd like to double check the full code
Here is the full code for each file you created in this step.
from rest_framework import serializers
from listings.models import Listing, ListingImage
class ListingImageSerializer(serializers.ModelSerializer):
class Meta:
model = ListingImage
fields = ("id", "image", "uploaded_at")
read_only_fields = ("id", "uploaded_at")
class ListingSerializer(serializers.ModelSerializer):
images = ListingImageSerializer(many=True, read_only=True)
owner_username = serializers.ReadOnlyField(source="owner.username")
class Meta:
model = Listing
fields = (
"id",
"title",
"description",
"price",
"property_type",
"address",
"city",
"phone_number",
"source",
"owner",
"owner_username",
"images",
"created_at",
"updated_at",
)
read_only_fields = ("id", "owner", "source", "created_at", "updated_at")
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner == request.user
from rest_framework import viewsets, permissions, parsers, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from listings.models import Listing, ListingImage
from listings.serializers import ListingSerializer, ListingImageSerializer
from listings.permissions import IsOwnerOrReadOnly
from listings.filters import ListingFilter
class ListingViewSet(viewsets.ModelViewSet):
queryset = Listing.objects.all()
serializer_class = ListingSerializer
filterset_class = ListingFilter
filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ["title", "description"]
def get_permissions(self):
if self.action in ["list", "retrieve"]:
permission_classes = [permissions.AllowAny]
else:
permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
return [permission() for permission in permission_classes]
def perform_create(self, serializer):
serializer.save(owner=self.request.user, source="user_posted")
class ListingImageViewSet(viewsets.ModelViewSet):
serializer_class = ListingImageSerializer
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ListingImage.objects.filter(listing_id=self.kwargs["listing_pk"])
def perform_create(self, serializer):
listing = Listing.objects.get(pk=self.kwargs["listing_pk"])
if listing.owner != self.request.user:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("You can only add images to your own listings.")
serializer.save(listing=listing)
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from listings.views import ListingViewSet, ListingImageViewSet
router = DefaultRouter()
router.register(r"listings", ListingViewSet, basename="listing")
urlpatterns = [
path("", include(router.urls)),
path(
"listings/<int:listing_pk>/images/",
ListingImageViewSet.as_view({"get": "list", "post": "create"}),
name="listing-images",
),
path(
"listings/<int:listing_pk>/images/<int:pk>/",
ListingImageViewSet.as_view({"delete": "destroy"}),
name="listing-image-detail",
),
]
Your CRUD endpoints are live. Anyone can browse listings, but only authenticated owners can create, update, or delete them. Next up, you will build a powerful filtering system so users can search by city, property type, and price range.
Add Filtering and Search
Your API can create, read, update, and delete listings. But a real estate platform with hundreds of properties is useless if users can't narrow down results to what they actually want.
In this step, you'll build a filtering system that lets anyone search listings by city, property type, and price range. You'll use django-filter to declare filters as a class, then wire them into your viewset alongside DRF's built-in search.
In this step, get ready to:
- Create a ListingFilter class with city, property type, and price range filters.
- Wire up filtering and full-text search on the ListingViewSet.
- Test filtering by querying listings with different parameters.
Create the ListingFilter class
A FilterSet is a class that declares which model fields are filterable and how each filter should match. Instead of writing manual query logic in your view, you declare the filters once and django-filter handles the rest.
- Create a new file called filters.py inside your listings app folder.
- Add the following code to define filters for city, property type, and price range:
import django_filters
from listings.models import Listing
class ListingFilter(django_filters.FilterSet):
# Case-insensitive partial match on city name
city = django_filters.CharFilter(lookup_expr="icontains")
# Exact match on property type (apartment, house, villa, studio)
property_type = django_filters.CharFilter(lookup_expr="exact")
# Price range: minimum price (greater than or equal)
min_price = django_filters.NumberFilter(field_name="price", lookup_expr="gte")
# Price range: maximum price (less than or equal)
max_price = django_filters.NumberFilter(field_name="price", lookup_expr="lte")
class Meta:
model = Listing
fields = ["city", "property_type", "min_price", "max_price"]
What does this code do?
Each filter declaration maps a query parameter to a database lookup:
- city uses icontains so searching for "dubai" matches "Dubai Marina" and "Old Dubai" alike.
- property_type uses exact because types are predefined choices with no room for fuzzy matching.
- min_price and max_price both point to the same price field using field_name, with gte (greater than or equal) and lte (less than or equal) to form a price range.
- Save listings/filters.py.
Seeing an ImportError?
Make sure the file is named exactly filters.py (not filter.py) and that it's inside the listings/ folder alongside models.py. The viewset imports from listings.filters, so any mismatch in name or location breaks the import.
Help me debug my django-filter ImportError
Verify the viewset is wired up for filtering
The filter class is ready, but it won't do anything until the viewset knows to use it. Your ListingViewSet already has the wiring in place from the previous step.
- Open listings/views.py and confirm the following lines exist inside ListingViewSet:
filterset_class = ListingFilter
filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ["title", "description"]
What do these three lines do?
- filterset_class = ListingFilter tells the viewset which FilterSet to apply when the list action runs.
- filter_backends enables both DjangoFilterBackend (field-level filtering) and SearchFilter (full-text search) on this viewset.
- search_fields controls which model fields the SearchFilter searches through when a user passes a ?search= query parameter.
Why declare filter_backends on the viewset?
You already set default filter backends in settings.py under REST_FRAMEWORK. Declaring them again on the viewset is explicit and makes the viewset self-documenting. If you ever remove the global default, this viewset still works.
Test the filter endpoints
To test filtering, you need a few listings with different cities, types, and prices. You'll seed some test data via the Django shell, then hit the API with filter query parameters.
- Open the Django shell by running:
python manage.py shell
- Create sample listings by pasting the following into the shell:
from django.contrib.auth.models import User
from listings.models import Listing
# Get or create a test user
user = User.objects.first()
if not user:
user = User.objects.create_user(username="testuser", password="testpass123")
# Create listings with different cities, types, and prices
Listing.objects.create(title="Modern Studio in Dubai Marina", description="Fully furnished studio with sea view", price=85000, property_type="studio", address="Marina Walk 12", city="Dubai", phone_number="0501234567", owner=user)
Listing.objects.create(title="Spacious Villa in Abu Dhabi", description="5 bedroom villa with private pool", price=3500000, property_type="villa", address="Saadiyat Island 5", city="Abu Dhabi", phone_number="0507654321", owner=user)
Listing.objects.create(title="Cozy Apartment in Dubai", description="2 bedroom apartment near metro", price=650000, property_type="apartment", address="JLT Cluster D", city="Dubai", phone_number="0509876543", owner=user)
Listing.objects.create(title="Family House in Sharjah", description="3 bedroom house with garden", price=1200000, property_type="house", address="Al Nahda Street 8", city="Sharjah", phone_number="0501112233", owner=user)
print(f"Created {Listing.objects.count()} listings")
What does this code do?
This creates four listings spread across different cities (Dubai, Abu Dhabi, Sharjah), property types (studio, villa, apartment, house), and price points (85K to 3.5M). This variety lets you test each filter independently and in combination.
- Exit the shell by typing exit().
- Start the development server by running:
python manage.py runserver
Now test each filter type. All filter endpoints are public, so no authentication token is needed.
- Filter by city. Open a new terminal and run:
curl http://127.0.0.1:8000/api/listings/?city=dubai
- You should see only the two Dubai listings in the response. The icontains lookup means the search is case-insensitive.
- Filter by property type by running:
curl http://127.0.0.1:8000/api/listings/?property_type=villa
- You should see only the Abu Dhabi villa listing.
- Filter by price range by running:
curl "http://127.0.0.1:8000/api/listings/?min_price=100000&max_price=1500000"
- You should see the apartment (650,000) and the house (1,200,000). The studio (85,000) and villa (3,500,000) fall outside the range.
- Combine multiple filters by running:
curl "http://127.0.0.1:8000/api/listings/?city=dubai&property_type=apartment"
- You should see only the apartment in Dubai. Combining filters narrows results further.
- Test full-text search on title and description by running:
curl http://127.0.0.1:8000/api/listings/?search=pool
- You should see only the villa listing, because its description contains "private pool".
How does search differ from filtering?
Filters target specific fields with specific match types (exact, range, contains). Search scans across multiple text fields for any substring match. A frontend would use filters for structured dropdowns and search for a free-text search bar.
Getting an empty results array?
Check that you created the sample listings in the Django shell. Run curl http://127.0.0.1:8000/api/listings/ with no filters to confirm listings exist.
If listings appear without filters but not with them, double check your filter field names match the query parameters exactly (e.g. property_type not propertyType).
Help me debug why my listing filters return empty results
✔️ Awesome, I've got everything!
Great. Double check you've saved listings/filters.py and that your dev server is running without errors.
ⓧ I'd like to double check the full code
Here is the complete listings/filters.py file:
import django_filters
from listings.models import Listing
class ListingFilter(django_filters.FilterSet):
city = django_filters.CharFilter(lookup_expr="icontains")
property_type = django_filters.CharFilter(lookup_expr="exact")
min_price = django_filters.NumberFilter(field_name="price", lookup_expr="gte")
max_price = django_filters.NumberFilter(field_name="price", lookup_expr="lte")
class Meta:
model = Listing
fields = ["city", "property_type", "min_price", "max_price"]
And the complete listings/views.py (unchanged from the previous step, but confirming the filter wiring):
from rest_framework import viewsets, permissions, parsers, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.filters import SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from listings.models import Listing, ListingImage
from listings.serializers import ListingSerializer, ListingImageSerializer
from listings.permissions import IsOwnerOrReadOnly
from listings.filters import ListingFilter
class ListingViewSet(viewsets.ModelViewSet):
queryset = Listing.objects.all()
serializer_class = ListingSerializer
filterset_class = ListingFilter
filter_backends = [DjangoFilterBackend, SearchFilter]
search_fields = ["title", "description"]
def get_permissions(self):
if self.action in ["list", "retrieve"]:
permission_classes = [permissions.AllowAny]
else:
permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
return [permission() for permission in permission_classes]
def perform_create(self, serializer):
serializer.save(owner=self.request.user, source="user_posted")
class ListingImageViewSet(viewsets.ModelViewSet):
serializer_class = ListingImageSerializer
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return ListingImage.objects.filter(listing_id=self.kwargs["listing_pk"])
def perform_create(self, serializer):
listing = Listing.objects.get(pk=self.kwargs["listing_pk"])
if listing.owner != self.request.user:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("You can only add images to your own listings.")
serializer.save(listing=listing)
Your API now supports filtering by city, property type, and price range, plus full-text search across titles and descriptions. Anyone can browse and search listings without needing to log in. Next up, you'll tackle the Secret Mission and build a featured listings endpoint with pagination.
Secret mission
Add a Featured Listings Endpoint with Pagination
Your API can list and filter properties, but a real frontend needs a curated "featured" section. Build a dedicated endpoint that highlights recent listings with photos, complete with paginated results for smooth infinite scrolling.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container running on port 5432.
- Media files stored in the media/ directory.
- Project files on your filesystem.
Keep everything running
No action needed. Choose this if you're still actively building on this project or plan to connect a React frontend to the API soon.
- Your PostgreSQL Docker container stays running and retains all data.
- Your Django development server can be restarted at any time with python manage.py runserver.
- Uploaded media files remain in the media/ directory.
Pause - I'll come back to this later
Stop the Docker container to free up system resources. Your data persists in the volume so you can pick back up later.
- Find your running PostgreSQL container by running:
docker ps
- Stop the container by running:
docker stop [[CONTAINER_NAME="your PostgreSQL container name"]]
- When you're ready to resume, start it again by running:
docker start [[CONTAINER_NAME="your PostgreSQL container name"]]
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database data, media files, and project code.
- Stop and remove the PostgreSQL Docker container and its volume by running:
docker stop [[CONTAINER_NAME="your PostgreSQL container name"]]
docker rm -v [[CONTAINER_NAME="your PostgreSQL container name"]]
- Navigate to your Desktop and remove the project folder by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
Windows users
Replace the rm -rf command with:
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
- Confirm the folder is gone by running:
ls
Nice Work!
Nice Work!
Nice work! You just built a production-ready real estate listings API with Django REST Framework and PostgreSQL.
You've learned how to:
- Build a complete REST API with Django REST Framework and PostgreSQL, including JWT authentication for user registration, login, and token refresh.
- Create a multi-model architecture with ForeignKey relationships, supporting multiple image uploads per listing using Django's ImageField with filesystem storage.
- Build a filtering system with django-filter for browsing properties by city, property type, and price range, with custom permission classes to protect owner-only actions.
- Secret Mission: Added a featured listings endpoint with annotation-based filtering and custom pagination.
Ready to quiz yourself?