In this post, I will create a web application that stores locations and plots them on a map. I will be using Django as the web framework, PostgreSQL to store the data, and LeafletJS to plot the points.

Creating the base application
This app is based on a starter project using the steps in this post, so you’ll need to do that first!
Creating a Django ‘places’ app
First, create a new Django app called ‘places’ using the following command:
python manage.py startapp places
Next, let’s register these models within the admin console within admin.py.
from django.contrib import admin
from reversion.admin import VersionAdmin
from places.models import Place, Collection
@admin.register(Place)
class PlaceAdmin(VersionAdmin):
exclude = ['slug']
@admin.register(Collection)
class CollectionAdmin(VersionAdmin):
exclude = ['slug']
Now let’s create our models within models.py. First, let’s start with the imports:
from django.db import models
from django.template.defaultfilters import slugify
import uuid
The Places model
class Place(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
slug = models.CharField(max_length=255, unique=True)
name = models.CharField('Name', max_length=255)
description = models.CharField('Description', max_length=255, null=True, blank=True)
latitude = models.DecimalField(max_digits=11, decimal_places=8)
longitude = models.DecimalField(max_digits=11, decimal_places=8)
collection = models.ForeignKey("places.Collection", on_delete=models.CASCADE)
def full_clean(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().full_clean(*args, **kwargs)
def clean(self):
self.slug = slugify(self.name)
def save(self, *args, **kwargs):
self.full_clean()
super(Place, self).save(*args, **kwargs)
def __str__(self):
return self.name
class Meta:
verbose_name = "Place"
verbose_name_plural = "Places"
Here’s what this will end up looking like from the admin console:

The Collection model
class Collection(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
slug = models.CharField(max_length=255, unique=True)
name = models.CharField('Name', max_length=255)
description = models.CharField('Description', max_length=255, null=True, blank=True)
color = models.CharField(max_length=7, default="#3388ff", null=True, blank=True)
def full_clean(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().full_clean(*args, **kwargs)
def clean(self):
self.slug = slugify(self.name)
def save(self, *args, **kwargs):
self.full_clean()
super(Collection, self).save(*args, **kwargs)
def __str__(self):
return self.name
class Meta:
verbose_name = "Collection"
verbose_name_plural = "Collections"
Here’s what that will end up looking like in the admin console:

Now, let’s create forms for the models in a new file, called forms.py.
from django import forms
from django.forms.widgets import TextInput
from places.models import Collection, Place
class CollectionForm(forms.ModelForm):
class Meta:
model = Collection
exclude = []
widgets = {
'color': TextInput(attrs={'type': 'color'}),
}
def __init__(self, *args, **kwargs):
super(CollectionForm, self).__init__(*args, **kwargs)
class PlaceForm(forms.ModelForm):
class Meta:
model = Place
exclude = []
def __init__(self, *args, **kwargs):
super(PlaceForm, self).__init__(*args, **kwargs)
Creating views for the places app
Let’s create some views. We can start by defining the urls within urls.py.
from django.urls import path
from places.views import PlaceListView, PlaceDataView, PlaceDetailView, PlaceCreateView, PlaceUpdateView, PlaceDeleteView
urlpatterns = [
path("", PlaceListView.as_view(), name="place_list"),
path("json/", PlaceDataView.as_view(), name="place_data"),
path("create/", PlaceCreateView.as_view(), name="place_create"),
path("<slug:place_slug>/", PlaceDetailView.as_view(), name="place_detail"),
path("<slug:place_slug>/update/", PlaceUpdateView.as_view(), name="place_update"),
path("<slug:place_slug>/delete/", PlaceDeleteView.as_view(), name="place_delete"),
]
Next, let’s add our imports to views.py:
from django.views.generic import View, DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.shortcuts import get_object_or_404
from django.core import serializers
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.http import HttpResponse, Http404
from django.db.models import F
from places.models import Place, Collection
from places.forms import PlaceForm
import json
import random
Creating a places API
First, let’s make a view to serve as an API to feed data to the map. Add the following to views.py.
class PlaceDataView(LoginRequiredMixin, View):
def get(self, request):
places = [
place["fields"]
for place in serializers.serialize(
"python",
Place.objects.order_by('?')[:1000],
fields=("slug", "name","description","latitude","longitude","collection"),
)
]
response = []
for i in range(len(places)):
slug, name, description, latitude, longitude, collection = places[i].values()
color = Collection.objects.get(uuid=collection).color
response.append(
{
"slug":slug,
"name":name,
"color":color,
"description":description,
"latitude":float(latitude),
"longitude":float(longitude)
}
)
response = json.dumps(response, indent=4)
return HttpResponse(response, content_type="application/json")
Navigate to http://127.0.0.1:8000/places/json/, and you should see the following:

Places list view
Now let’s create a ListView to display all of our places. Put the following in views.py:
class PlaceListView(LoginRequiredMixin, ListView):
model = Place
template_name = "places/place_list.html"
def get_queryset(self):
places = Place.objects.all()
return places
Now let’s actually write some HTML to see all of our places! Start by putting the following in a file called places_list.html.
{% extends 'base.html' %}
{% block title %}Places{% endblock %}
{% block page_title %}Places{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'home' %}">Home</a></li>
<li class="breadcrumb-item"><a href="{% url 'place_list' %}">Places</a></li>
</ol>
</nav>
{% endblock %}
{% block style %}
#map {
height: 400px;
}
{% endblock %}
{% block content %}
<div id="map"></div>
{% endblock %}
Now, let’s generate our map using Mapbox, Openstreetmap, and LeafletJS:
{% block script %}
var map = L.map('map').setView([0, 0], 2);
L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', {
attribution: 'Map data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
maxZoom: 18,
id: 'mapbox/streets-v11',
tileSize: 512,
zoomOffset: -1,
accessToken: 'pk.eyJ1IjoibmF0aGFua2plciIsImEiOiJja3p1cXVncjMwNDF1MnVteXhidWo1b25kIn0.7M0acmAQvipqVmnfYSURhg'
}).addTo(map);
fetch('json')
.then(response => response.json())
.then(data => data.forEach((place) => {
var circle = L.circle([place.latitude,place.longitude], {
color: place.color,
fillcolor: place.color,
fillOpacity: 0.5,
radius: 1000
}).addTo(map);
}));
{% endblock %}
Here is what the result should look like:

Popups
Let’s add popups. Add the following within the data.forEach((place) => loop.
popup = '<b><a href="' + place.slug + '">' + place.name + '</a></b><br>'
if (place.description){
popup += place.description
}
circle.bindPopup(popup);

Places detail view
Put the following in views.json:
class PlaceDetailView(LoginRequiredMixin, DetailView):
model = Place
template_name = "places/place_detail.html"
def get_object(self):
return get_object_or_404(Place, slug=self.kwargs["place_slug"])
Put the setup in place_detail.html:
{% extends 'base.html' %}
{% block meta %}
{% endblock %}
{% block title %}{{ object }}{% endblock %}
{% block page_title %}{{ object }}{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'home' %}">Home</a></li>
<li class="breadcrumb-item"><a href="{% url 'place_list' %}">Places</a></li>
<li class="breadcrumb-item"><a href="{% url 'place_detail' object.slug %}">{{ object }}</a></li>
</ol>
</nav>
{% endblock %}
{% block style %}
#map {
height: 400px;
}
{% endblock %}
{% block content %}
<div id="map"></div>
{% endblock %}
Add the script:
{% block script %}
var map = L.map('map').setView([{{ object.latitude }}, {{ object.longitude }}], 8);
L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', {
attribution: 'Map data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
maxZoom: 18,
id: 'mapbox/streets-v11',
tileSize: 512,
zoomOffset: -1,
accessToken: 'pk.eyJ1IjoibmF0aGFua2plciIsImEiOiJja3p1cXVncjMwNDF1MnVteXhidWo1b25kIn0.7M0acmAQvipqVmnfYSURhg'
}).addTo(map);
var circle = L.circle([{{object.latitude}},{{object.longitude}}], {
color: "{{ object.collection.color }}",
fillcolor: "{{ object.collection.color }}",
fillOpacity: 0.5,
radius: 1000
}).addTo(map);
popup = "<b>" + "{{ object.name }}" + "</b><br>"
if ("{{ object.description }}" != "None"){
popup += {{ object.description }}
}
circle.bindPopup(popup);
{% endblock %}
Here is the result:

Places create, update, and delete views
Add the following to views.py:
class PlaceCreateView(LoginRequiredMixin, CreateView):
model = Place
form_class = PlaceForm
login_url = "/accounts/login/"
template_name = "places/place_create.html"
def get_form_kwargs(self):
kwargs = super(PlaceCreateView, self).get_form_kwargs()
kwargs.update(self.kwargs)
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
return context
def get_success_url(self):
return reverse_lazy("place_list")
class PlaceUpdateView(LoginRequiredMixin, UpdateView):
model = Place
form_class = PlaceForm
login_url = "/accounts/login/"
template_name = "places/place_update.html"
def get_form_kwargs(self):
kwags = super(PlaceUpdateView, self).get_form_kwargs()
kwags.update(self.kwargs)
return kwargs
def get_object(self):
return get_object_or_404(Place, uuid=self.kwargs["place_id"])
def get_success_url(self):
return reverse_lazy("place_list")
class PlaceDeleteView(LoginRequiredMixin, DeleteView):
model = Place
form_class = PlaceForm
login_url = "/accounts/login/"
template_name = "places/place_delete.html"
def get_object(self):
return get_object_or_404(Place, uuid=self.kwargs["place_id"])
def get_success_url(self):
return reverse_lazy("place_list")
Create a place_create.html template file:
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Create Place{% endblock %}
{% block page_title %}Create Place{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'home' %}">Home</a></li>
<li class="breadcrumb-item"><a href="{% url 'place_list' %}">Places</a></li>
<li class="breadcrumb-item active"><a href="{% url 'place_create' %}">Create Place</a></li>
</ol>
</nav>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="save btn btn-primary">Save</button>
</form>
{% endblock %}
Create a place_update.html template file:
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Update "{{ object }}"{% endblock %}
{% block page_title %}Update "{{ object }}"{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'home' %}">Home</a></li>
<li class="breadcrumb-item"><a href="{% url 'place_list' %}">Places</a></li>
<li class="breadcrumb-item active"><a href="{% url 'place_update' object.uuid %}">Update {{ object }}</a></li>
</ol>
</nav>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="save btn btn-primary">Save</button><a href="../delete/" class="btn btn-outline-danger mx-1">Delete</a>
</form>
{% endblock %}
Create a place_delete.html template file:
{% extends 'base.html' %}
{% block title %}Delete "{{ object }}"{% endblock %}
{% block page_title %}Delete "{{ object }}"{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'home' %}">Home</a></li>
<li class="breadcrumb-item"><a href="{% url 'place_list' %}">Places</a></li>
<li class="breadcrumb-item active"><a href="{% url 'place_delete' object.uuid %}">Delete {{ object }}</a></li>
</ol>
</nav>
{% endblock %}
{% block content %}
<form method="post">{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p>
<button type="submit" class="save btn btn-danger">Confirm</button>
</form>
{% endblock %}
Adding the data
Let’s start by putting a list of country gps coordinates from here and reformat it as follows. Put the result within myplaces/core/management/commands/helpers/static/countries.json:
[
{
"latitude": 42.546245,
"longitude": 1.601554,
"name": "Andorra"
},
{
"latitude": 23.424076,
"longitude": 53.847818,
"name": "United Arab Emirates"
},
{
"latitude": 33.93911,
"longitude": 67.709953,
"name": "Afghanistan"
},
...
]
Next, put the following within myplaces/core/management/commands/helpers/data.py:
import os
import json
def fetch_countries():
cache_file_path = './myplaces/core/management/commands/helpers/static/countries.json'
if os.path.isfile(cache_file_path):
with open(cache_file_path) as f:
return json.load(f)
Create a top level script to initialize the database in myplaces/core/management/commands/initialize.py:
from django.core.management.base import BaseCommand
from django.core import management
from django.contrib.sites.models import Site
from users.models import User
import logging
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Initialize the database with proper initial conditions."
def handle(self, *args, **options):
self.update_site_name()
self.create_superuser()
management.call_command("create_places")
def update_site_name(self):
sites = Site.objects.all()
if sites.count() != 1:
logger.warning("Multiple sites not currently supported")
return
site = sites.first()
if site.pk != 1:
logger.warning("Site ID must be 1")
site.domain = "127.0.0.1"
site.name = "My Places"
site.save()
def create_superuser(self):
if not User.objects.filter(is_superuser=True).count():
management.call_command("createsuperuser")