Geotagging with Django and LeafletJS

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 &copy; <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 &copy; <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")


About the author



Hi, I'm Nathan. Thanks for reading! Keep an eye out for more content being posted soon.


Leave a Reply

Your email address will not be published. Required fields are marked *